New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Cooperative signal handling #1600
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
Hey @maxfischer2781 , I wonder if your PR would make this PR #1768 unnecessary? |
Yes, I think ur implementation is better and involves less magic |
It may be nice to add a test covering |
@JoanFM Possibly, but the two changes have slightly different behaviour. This PR will trigger the original handler after the univocrn shutdown procedure. #1768 will trigger the original handler before the uvicorn shutdown procedure. As a default, I think triggering the original handlers late is better – many handlers shutdown quickly (namely, the default handlers) and would skip the uvicorn shutdown entirely. It might be worth having a method to chain handlers explicitly, though. |
Yes, plus your PR does not use any private function and would work with more One question I have, will this PR respect the signals passed to an loop.add_signal_handler(...) instead of signal.signal(...) ? |
Sadly it won't. There is no clean way to query the loop for registered handlers – handlers are stored in a private attribute of the loop and methods allow only to set or delete handlers by signal number. |
Okey, but signal.signal should work the same except if I need to interact with event loop itself. Any idea when this might be merged? |
Is it me, or this question sounds a bit presumptious? 😅 Thanks for the PR @maxfischer2781 , and sorry the delay. This requires a bit of my focus, as there are many ideias related around. I'll check the whole signal thingy for 0.23.0. I'm about to release 0.22.0. No estimation can be provided, since I work on uvicorn on my free time. |
Sorry @Kludex, No bad intentions in this question. I really appreciate the work you guys do and understand it is not easy to dedicate time. I just do not know how this specific project worked. Thanks for everything |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this solve #1301 as well?
Ok, review done. Happy new year! 😎
uvicorn/server.py
Outdated
try: | ||
return asyncio.run(self.serve(sockets=sockets)) | ||
except KeyboardInterrupt: | ||
pass | ||
|
||
async def serve(self, sockets: Optional[List[socket.socket]] = None) -> None: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we use the _capture_exit_signals
as decorator instead? It would generate less footprint.
async def serve(self, sockets: Optional[List[socket.socket]] = None) -> None: | |
@capture_signals | |
async def serve(self, sockets: Optional[List[socket.socket]] = None) -> None: |
In the suggestion, capture_signals
= _capture_exit_signals
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's doable but would create a similar/larger footprint at another place.
In principle it is possible to refactor _capture_exit_signals
to work as a decorator as well: a contextlib.contextmanager
decorated function can be used as a decorator. However, this does not work for methods out of the box; I would have to write a new contextlib.contextmanager
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've experimented a bit with this but ultimately decided to drop my sketch for a context manager method. Stacking a context manager on top of a descriptor (for the self
binding) on top of async def
is much more complexity than a regular contextmanager
.
Let me know if you would prefer the decorator variant anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm thinking if we should do something like:
async def serve(self, sockets):
with self._capture_exit_signals():
self._serve(sockets)
This is not a suggestion, it's a thought.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good thought. The diff is much more on point like this.
uvicorn/server.py
Outdated
# Windows Caveat: We have no guarantee that we can correctly send | ||
# the signal (due to CTRL+C as SIGINT) to the process (due to groups)! | ||
# For now, do not do anything in this situation. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we have some reference instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added a reference to an Stack Overflow description of Windows signal handling and (re-)raising SIGINT/CTRL+C. Should I remove the other comments in the method?
uvicorn/server.py
Outdated
signal.signal(sig, handler) | ||
# if we did gracefully shut down due to a signal, try to | ||
# trigger the expected behaviour for this signal now | ||
if self._delayed_signal is not None: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you help me understand a bit better why this is needed, and the scenario on which this is triggered?
If I got it right, this is triggered on SIGINT
or SIGTERM
, due to handle_exit
, and it's triggered after the server is already down. But... Why do we need to call the original handlers? Why the os.kill
below is not enough?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The scenario is as you understood.
Calling the original signal handler directly allows avoiding the non-portability of os.kill
, i.e. it works the same on Windows and others.
Would you prefer the code to unconditionally use os.kill
on non-Windows? That would probably be more in line with what people are used to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've changed the order so that the common UNIX case is handled first. The workarounds are only tried on Windows-like platform. This should make it clearer what is the UNIX case and what are the workarounds.
Relevant: #1708 |
@Kludex I've addressed/implemented all suggestions now. Please take another look once you find the time. I don't think an approach as suggested in #1708 makes sense in uvicorn: Practically, signal handling is a two-step approach of 1) handling the signal and setting the shutdown flag and 2) actually shutting down the server. That means one cannot run a "regular" signal handler before or after the uvicorn signal handler (1), since one needs to wait for the server to shut down (2) first. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll check this better later on.
uvicorn/server.py
Outdated
try: | ||
return asyncio.run(self.serve(sockets=sockets)) | ||
except KeyboardInterrupt: | ||
pass | ||
|
||
async def serve(self, sockets: Optional[List[socket.socket]] = None) -> None: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm thinking if we should do something like:
async def serve(self, sockets):
with self._capture_exit_signals():
self._serve(sockets)
This is not a suggestion, it's a thought.
5255717
to
aa44f36
Compare
@Kludex Do you still see any required changes? As far as I can tell I should have everything covered. |
@Kludex It would be super to see this merged! 🙏 |
@maxfischer2781 thanks for your work on this. Since you opened the PR, we've added a PR template with a checklist. For your PR, I think it would currently look like this:
So it seems like what's missing is some documentation of the server's signal handling behavior. We have a docs page on server behavior that could work for this (it's here in the repo code). If you could add a section there and clearly explain how signal handling will work after this PR, I think it would help everyone understand this better. After some docs are added to explain the signal handling behavior, I'm happy to give this PR a review as well. |
@br3ndonland Thanks for the reply, I'll prepare proper docs. Can you give me some input on things I would change for making this "official" via documentation:
|
Sure! This PR is about running the server programmatically, so either the server behavior page or the deployment docs on running programmatically could work. Thanks for the suggestion.
You make a good point here. I agree that the implementation should be as portable and consistent as possible, but given the long discussion in this PR and in #1579, it might be most pragmatic to keep the existing behavior as-is and just document it. I'm happy to take a look at the code either way. |
Has it been decided to leave the broken behaviour? |
I'm still committed to finishing this but seeing how long this dragged on my initial time window for working on it is gone. It may take about 1-4 weeks until I have time to work on this again. |
I've recreated the branch from scratch now since the original state was heavily outdated and had a nasty git history. The changes are simpler now as Windows/Unix and Python version special cases are gone.
The behaviour is documented on "Index" -> "Running programmatically" since this is the only place where the |
Oh! You shortened a lot of the diff here. So cool! I'll check in some hours. |
# always use signal.signal, even if loop.add_signal_handler is available | ||
# this allows to restore previous signal handlers later on | ||
original_handlers = {sig: signal.signal(sig, self.handle_exit) for sig in HANDLED_SIGNALS} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why did we use loop.add_signal_handler
before?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't found a technical reason for it. It seems to have been added in #141 for no specific and then copied over the years. I assume it was used because the asyncio
docs promote it as a Linux feature that is better in unspecified ways.
On the technical side the only advantage of loop.add_signal_handler
is that it allows synchronously triggering async code (e.g. event.set()
), and the handlers don't use that. Since the code has to be compatible with Windows, handlers cannot rely on being invoked by the loop anyways.
tests/test_server.py
Outdated
|
||
@pytest.mark.anyio | ||
@pytest.mark.skipif(sys.platform == "win32", reason="require unix-like signal handling") | ||
@pytest.mark.parametrize("exception_signal", [signal.SIGTERM, signal.SIGINT]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens with signal.SIGBREAK
on Windows?
We added this to HANDLED_SIGNALS
some months ago.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added that case now.
Thanks for not giving up @maxfischer2781 🙏 I'll make a release shortly. |
In Falcon's test suite, we used to check the return code for functional tests of ASGI servers. Since we terminate the server via |
3221225786 is the unsigned interpretation of Windows' |
I see, thanks @maxfischer2781! |
This PR adjust the signal handling of
uvicorn.server.Server
to integrate withasyncio
applications by not suppressing shutdown signals. Major changes include:Server.serve
finishesIn the usual case, this allows CTRL+C to end both
uvicorn
and the containing async application. Closes #1579.There is a caveat for tests in that signal handling on Windows is not as precise as on Unix (see e.g. Stackoverflow: How to handle a signal.SIGINT on a Windows OS machine?). For testing, I could not setup a situation where the tests receive a signal without the test environment also being interrupted by it.