Skip to content
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

Merged
merged 14 commits into from Mar 19, 2024
Merged

Conversation

maxfischer2781
Copy link
Contributor

@maxfischer2781 maxfischer2781 commented Aug 10, 2022

  • I understand that this PR may be closed in case there was no previous discussion. (This doesn't apply to typos!)
  • I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
  • I've updated the documentation accordingly.

This PR adjust the signal handling of uvicorn.server.Server to integrate with asyncio applications by not suppressing shutdown signals. Major changes include:

  • Original signal handlers are restored before Server.serve finishes
  • When terminated by a signal, the appropriate original signal handler is invoked if possible

In 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.

@maxfischer2781

This comment was marked as outdated.

@maxfischer2781

This comment was marked as outdated.

@maxfischer2781 maxfischer2781 marked this pull request as draft August 12, 2022 16:35
@maxfischer2781 maxfischer2781 marked this pull request as ready for review August 15, 2022 13:42
@JoanFM
Copy link

JoanFM commented Nov 18, 2022

Hey @maxfischer2781 ,

I wonder if your PR would make this PR #1768 unnecessary?

@JoanFM
Copy link

JoanFM commented Nov 18, 2022

Hey @maxfischer2781 ,

I wonder if your PR would make this PR #1768 unnecessary?

Yes, I think ur implementation is better and involves less magic

@JoanFM
Copy link

JoanFM commented Nov 18, 2022

It may be nice to add a test covering SIGTERM signal handling?

@maxfischer2781
Copy link
Contributor Author

maxfischer2781 commented Nov 18, 2022

Hey @maxfischer2781 ,

I wonder if your PR would make this PR #1768 unnecessary?

@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.

@JoanFM
Copy link

JoanFM commented Nov 18, 2022

Yes, plus your PR does not use any private function and would work with more loops.

One question I have, will this PR respect the signals passed to an event loop as:

loop.add_signal_handler(...)

instead of

signal.signal(...)

?

@maxfischer2781
Copy link
Contributor Author

One question I have, will this PR respect the signals passed to an event loop as:

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.

@JoanFM
Copy link

JoanFM commented Nov 18, 2022

One question I have, will this PR respect the signals passed to an event loop as:

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?

@Kludex
Copy link
Sponsor Member

Kludex commented Nov 18, 2022

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.

@JoanFM
Copy link

JoanFM commented Nov 18, 2022

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

Copy link
Sponsor Member

@Kludex Kludex left a 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! 😎

setup.cfg Outdated Show resolved Hide resolved
try:
return asyncio.run(self.serve(sockets=sockets))
except KeyboardInterrupt:
pass

async def serve(self, sockets: Optional[List[socket.socket]] = None) -> None:
Copy link
Sponsor Member

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.

Suggested change
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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

Copy link
Sponsor Member

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.

Copy link
Contributor Author

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 Show resolved Hide resolved
Comment on lines 325 to 327
# 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.
Copy link
Sponsor Member

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?

Copy link
Contributor Author

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?

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:
Copy link
Sponsor Member

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?

Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

uvicorn/server.py Outdated Show resolved Hide resolved
@Kludex Kludex added the waiting author Waiting for author's reply label Dec 31, 2022
@Kludex
Copy link
Sponsor Member

Kludex commented Jan 1, 2023

Relevant: #1708

@maxfischer2781
Copy link
Contributor Author

@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.

Copy link
Sponsor Member

@Kludex Kludex left a 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.

setup.cfg Outdated Show resolved Hide resolved
try:
return asyncio.run(self.serve(sockets=sockets))
except KeyboardInterrupt:
pass

async def serve(self, sockets: Optional[List[socket.socket]] = None) -> None:
Copy link
Sponsor Member

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.

uvicorn/server.py Outdated Show resolved Hide resolved
@maxfischer2781
Copy link
Contributor Author

@Kludex Do you still see any required changes? As far as I can tell I should have everything covered.

@DrInfiniteExplorer
Copy link

@Kludex It would be super to see this merged! 🙏

@br3ndonland
Copy link
Contributor

br3ndonland commented Jun 4, 2023

@Kludex Do you still see any required changes? As far as I can tell I should have everything covered.

@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:

  • I understand that this PR may be closed in case there was no previous discussion. (This doesn't apply to typos!)
  • I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
  • I've updated the documentation accordingly.

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.

@maxfischer2781
Copy link
Contributor Author

maxfischer2781 commented Jun 11, 2023

@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:

  • I think Deployment > Running programmatically would be a more suitable place. The exact behaviour is only relevant for people that may fiddle with signals, to everyone else it'll "just work" now.
    • Would this location be fine?
  • If this gets documented as something programmers can rely on, I would prefer to make it portable and consistent. Right now the Windows and Unix versions behave slightly differently: the Unix version uses the asyncio signal interface but does not use its capabilities. This makes recovering some signal handlers impossible, but technically some user code may rely on the asyncio capabilites.
    • Would consistently using the plain signal package be fine? This is technically a breaking change, though not for documented behaviour.

@br3ndonland
Copy link
Contributor

I think Deployment > Running programmatically would be a more suitable place.

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.

If this gets documented as something programmers can rely on, I would prefer to make it portable and consistent. Right now the Windows and Unix versions behave slightly differently: the Unix version uses the asyncio signal interface but does not use its capabilities. This makes recovering some signal handlers impossible, but technically some user code may rely on the asyncio capabilites.

Would consistently using the plain signal package be fine? This is technically a breaking change, though not for documented behaviour.

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.

@tesioai
Copy link

tesioai commented Aug 16, 2023

Has it been decided to leave the broken behaviour?

@maxfischer2781
Copy link
Contributor Author

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.

@maxfischer2781
Copy link
Contributor Author

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.

  • simpler than original code because signal.raise_signal works on every supported version now
  • use signal.signal on any platform since asyncio…add_signal_handler does not allow restoring handlers
    • this has no downside since the handler does not interact with the event loop in any case
  • the "main" signal handler method is now called capture_signals instead of install_signal_handlers since changes are not backwards compatible
  • the test setup is inverted to test third-party asyncio…add_signal_handler as well

The behaviour is documented on "Index" -> "Running programmatically" since this is the only place where the server.serve method is shown.

@Kludex
Copy link
Sponsor Member

Kludex commented Mar 11, 2024

Oh! You shortened a lot of the diff here. So cool! I'll check in some hours.

docs/index.md Outdated Show resolved Hide resolved
Comment on lines +316 to +318
# 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}
Copy link
Sponsor Member

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?

Copy link
Contributor Author

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.


@pytest.mark.anyio
@pytest.mark.skipif(sys.platform == "win32", reason="require unix-like signal handling")
@pytest.mark.parametrize("exception_signal", [signal.SIGTERM, signal.SIGINT])
Copy link
Sponsor Member

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.

Copy link
Contributor Author

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.

docs/index.md Outdated Show resolved Hide resolved
@Kludex Kludex enabled auto-merge (squash) March 19, 2024 08:35
@Kludex Kludex removed the waiting author Waiting for author's reply label Mar 19, 2024
@Kludex
Copy link
Sponsor Member

Kludex commented Mar 19, 2024

Thanks for not giving up @maxfischer2781 🙏

I'll make a release shortly.

@Kludex Kludex mentioned this pull request Mar 19, 2024
1 task
@Kludex Kludex disabled auto-merge March 19, 2024 08:45
@Kludex Kludex merged commit 9e32e8e into encode:master Mar 19, 2024
15 checks passed
@vytas7
Copy link
Contributor

vytas7 commented Mar 20, 2024

In Falcon's test suite, we used to check the return code for functional tests of ASGI servers. Since we terminate the server via SIGTERM, Uvicorn now returns -15 which is a proper way in Unix. However, when running Actions on Windows, we are getting codes like 3221225786 == 2**31 + 2**30 + 314. Is this expected on Windows, and how to interpret these values? @Kludex

@maxfischer2781
Copy link
Contributor Author

3221225786 is the unsigned interpretation of Windows' 0xC000013A / STATUS_CONTROL_C_EXIT. This is a magic value just like signal 15 on UNIX.

@vytas7
Copy link
Contributor

vytas7 commented Mar 20, 2024

I see, thanks @maxfischer2781!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

uvicorn eats SIGINTs, does not propagate exceptions
10 participants