Repository: encode/uvicorn

Issue:
uvicorn eats SIGINTs, does not propagate exceptions
The following snippet cannot be killed with a SIGINT (ctrl+c):

```python
import asyncio

from starlette.applications import Starlette
from uvicorn import Config, Server

async def web_ui():
    await Server(Config(Starlette())).serve()

async def task():
    await asyncio.sleep(100000000000000)

async def main():
    await asyncio.gather(web_ui(), task())

if __name__ == "__main__":
    asyncio.run(main())
```

It appears that uvicorn is eating SIGINTs and does not propagate the KeyboardInterrupt and/or Cancelled exceptions.   Thanks for having a look.

<!-- POLAR PLEDGE BADGE START -->
> [!IMPORTANT]
> - We're using [Polar.sh](https://polar.sh/encode) so you can upvote and help fund this issue.
> - We receive the funding once the issue is completed & confirmed by you.
> - Thank you in advance for helping prioritize & fund our backlog.

<a href="https://polar.sh/encode/uvicorn/issues/1579">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/encode/uvicorn/issues/1579/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/encode/uvicorn/issues/1579/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
I saw this getting flagged by SonarQube.

```python
#  h11_impl.py line 401
 async def run_asgi(self, app: "ASGI3Application") -> None:
        try:
            result = await app(self.scope, self.receive, self.send)
        except BaseException as exc:
            msg = "Exception in ASGI application\n"
            self.logger.error(msg, exc_info=exc)
            if not self.response_started:
                await self.send_500_response()
            else:
                self.transport.close()
```

There are multiple instances of it in other files as well. 

Sonar:
```
"SystemExit" should be re-raised
 
Code smell
 
Critical
python:S5754

```
I'm having trouble with the '--reload' option inside docker. Nothing reloads. I suspect it might be related to this. I've tried running uvicorn via the watchfiles cli, and SIGINTs get swallowed.
Here's a *very* hacky workaround

```python
from uvicorn import Server

class MyCustomUvicornServer(Server):
    def install_signal_handlers(self):
        pass
```


Well, for my problem I've uninstalled watchfiles so uvicorn falls back to polling which works.
This has turned out to be a real problem for running an optional `uvicorn` based server inside an existing `asyncio` application. Since `Server.serve` finishes gracefully and the original signal handlers are never restored there is no way to ^C the application itself.

It looks okay'ish for `Server.serve` to capture signals for graceful shutdown, but it should at least restore the original signal handlers and ideally also reproduce the original behaviour as well. From the looks of it, `Server.serve` should raise `KeyboardInterrupt` when done and only `Server.run` should suppress it.
> It looks okay'ish for Server.serve to capture signals for graceful shutdown, but it should at least restore the original signal handlers and ideally also reproduce the original behaviour as well. From the looks of it, Server.serve should raise KeyboardInterrupt when done and only Server.run should suppress it.

Yes. Running into this exact scenario at the moment: I'm wrapping `Server.serve` in an asyncio task (to gather with other tasks) and the signal swallowing makes it impossible for me to catch a server shutdown to properly cancel the other tasks.
This has caused us some weird problems at Prefect as well. Most recently, I've worked around it with funky usage of a [cancel scope](https://github.com/PrefectHQ/prefect/pull/7334/files#diff-b39d26165689b953feaede20079f706aa1e2af8e5ba77a8cc012540641a6255dR99-R116) but this took a lot of trial and error to get working at all. Would it be feasible to merge something like #1600 or is there interest in a different `serve` API that is context managed and doesn't need to use signals for shutdown?
^ courtesy ping for @Kludex , who seems to be the most active maintainer
I have the same problem, I wonder if `UviCorn` could respect previously added signal handlers and combine them, I have a draft proposal in #1768 . This is just a draft to spark discussion
What's the problem with the following?

```python
import asyncio

from starlette.applications import Starlette
from uvicorn import Config, Server


async def web_ui():
    print("Inside web_ui")
    await Server(Config(Starlette())).serve()


async def sleep():
    try:
        print("Inside task")
        await asyncio.sleep(100000000000000)
    except asyncio.CancelledError:
        print("I can handle the cancellation here.")


async def main():
    task = asyncio.create_task(sleep())
    web_ui_task = asyncio.create_task(web_ui())
    await asyncio.wait([web_ui_task, task], return_when=asyncio.FIRST_COMPLETED)


if __name__ == "__main__":
    asyncio.run(main())
    print("It finished!")
```
@Kludex Even when adding such code to manually handle the exit, the exit code is wrong for any naive handling. The program shown exits with `0`; [it should propagate the SIGINT if killed by that, though](https://www.cons.org/cracauer/sigint.html). A proper exit is via `KeyboardInterrupt` *iff* the application was killed by SIGINT.

That needs quite some extra scaffolding to do right.
I'm trying to assess the issue.

> It appears that uvicorn is eating SIGINTs and does not propagate the KeyboardInterrupt and/or Cancelled exceptions.

With the above, the `CancelledError` happens.



> @Kludex Even when adding such code to manually handle the exit, the exit code is wrong for any naive handling. The program shown exits with `0`; [it should propagate the SIGINT if killed by that, though](https://www.cons.org/cracauer/sigint.html). A proper exit is via `KeyboardInterrupt` _iff_ the application was killed by SIGINT.
> 
> That needs quite some extra scaffolding to do right.

What is the issue you are trying to solve? I mean real case scenario.
Hey @Kludex — a use case I have is to run an API server temporarily. For example, to facilitate a browser-based login experience:

1. The user runs `prefect cloud login`
2. We start an API server from our CLI
3. We open the browser to perform login 
4. The browser then sends credentials to the API server
5. The API server is shutdown

Right now, there are a lot of problems with using Uvicorn for a feature like this:

1. When the server is misconfigured, `serve` will raise a `SystemExit` exception; this makes displaying errors to users complex and a `SystemExit` does not make sense when Uvicorn is not the owner of the process
2. A SIGINT must be raised to stop the server; raising a SIGINT in the main thread to stop a single async task does not make sense
3. After the server exits, all future keyboard interrupts will have no effect; this breaks our CLI user experience and could have more detrimental effects in an application

Does that make sense? I definitely could be doing it wrong, but it feels like the `serve` interface is designed to take control of a process rather than to be a first-class way to run a server programmatically. It'd be great to:

1. Use a context manager to start and stop the server
2. Only handle signals during the server runtime, propagate any received signal after handling
3. Avoid assuming process level ownership (like calling `sys.exit`) when not started via the CLI entrypoint
> With the above, the `CancelledError` happens.

This is not sufficient for an `asyncio` application. `KeyboardInterrupt` is needed to signal to the event loop to shut down *all* tasks, not just those manually tied to the `uvicorn` server; an application is impossible to shut down gracefully otherwise. It is also needed to signal to the orchestration of the program (be this a shell, `systemd`, or similar) that shutdown occurred properly.

> What is the issue you are trying to solve? I mean real case scenario.

I'm not sure what kind of answer you are expecting to this. "run `uvicorn` as part of a larger `async` application" is a realistic use-case in my book. This requires being able to shut down the application using standard means; right now `uvicorn` prevents this (at least without extensive workarounds).

In our specific case, we use `uvicorn` to implement an (optional) REST API of a highly concurrent resource management application. Mishandling SIGINT means that the application could not properly shut down and needed a hard kill, leaking other resources.
Hi all,

To chime in with another example.

I have a fairly trivial application where in a websocket handler, I'm using an async queue to push messages to clients. On shutdown, I'm using an event and a `None` message to signal we are closing down. But the fastapi shutdown handler is never applied and I cannot trigger these sentinel messages. It's ina deadlock position as far as I can tell.
I don't know if this could help but this is my workaround for my use case:

```python
import signal
from types import FrameType

from fastapi import FastAPI 

app = FastAPI()


@app.on_event("startup")
async def startup_event() -> None:
    default_sigint_handler = signal.getsignal(signal.SIGINT)
    def terminate_now(signum: int, frame: FrameType = None):
        # do whatever you need to unblock your own tasks

        default_sigint_handler(signum, frame)
    signal.signal(signal.SIGINT, terminate_now)
 ```

You can just override install_signal_handlers function:

```
# FastAPI Uvicorn override
class Server(uvicorn.Server):

    # Override
    def install_signal_handlers(self) -> None:

        # Do nothing
        pass
```
And then when you catch interrupt elsewhere you can just signal server to stop:

`self.server.should_exit = True`
> You can just override install_signal_handlers function:
> 
> ```
> # FastAPI Uvicorn override
> class Server(uvicorn.Server):
> 
>     # Override
>     def install_signal_handlers(self) -> None:
> 
>         # Do nothing
>         pass
> ```
> 
> And then when you catch interrupt elsewhere you can just signal server to stop:
> 
> `self.server.should_exit = True`

Nice!
one more option is to override the `handle_exit`:

```
on_exit = lambda: logging.getLogger(__name__).info("Exiting...") 
class Server(uvicorn.Server):
        """Override uvicorn.Server to handle exit."""

        def handle_exit(self, sig, frame):
            # type: (int, Optional[FrameType]) -> None
            """Handle exit."""
            on_exit()
            super().handle_exit(sig=sig, frame=frame)
```


Resolving patch:
diff --git a/uvicorn/server.py b/uvicorn/server.py
--- a/uvicorn/server.py
+++ b/uvicorn/server.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import asyncio
+import contextlib
 import logging
 import os
 import platform
@@ -11,7 +12,7 @@
 import time
 from email.utils import formatdate
 from types import FrameType
-from typing import TYPE_CHECKING, Sequence, Union
+from typing import TYPE_CHECKING, Generator, Sequence, Union
 
 import click
 
@@ -57,11 +58,17 @@ def __init__(self, config: Config) -> None:
         self.force_exit = False
         self.last_notified = 0.0
 
+        self._captured_signals: list[int] = []
+
     def run(self, sockets: list[socket.socket] | None = None) -> None:
         self.config.setup_event_loop()
         return asyncio.run(self.serve(sockets=sockets))
 
     async def serve(self, sockets: list[socket.socket] | None = None) -> None:
+        with self.capture_signals():
+            await self._serve(sockets)
+
+    async def _serve(self, sockets: list[socket.socket] | None = None) -> None:
         process_id = os.getpid()
 
         config = self.config
@@ -70,8 +77,6 @@ async def serve(self, sockets: list[socket.socket] | None = None) -> None:
 
         self.lifespan = config.lifespan_class(config)
 
-        self.install_signal_handlers()
-
         message = "Started server process [%d]"
         color_message = "Started server process [" + click.style("%d", fg="cyan") + "]"
         logger.info(message, process_id, extra={"color_message": color_message})
@@ -302,22 +307,28 @@ async def _wait_tasks_to_complete(self) -> None:
         for server in self.servers:
             await server.wait_closed()
 
-    def install_signal_handlers(self) -> None:
+    @contextlib.contextmanager
+    def capture_signals(self) -> Generator[None, None, None]:
+        # Signals can only be listened to from the main thread.
         if threading.current_thread() is not threading.main_thread():
-            # Signals can only be listened to from the main thread.
+            yield
             return
-
-        loop = asyncio.get_event_loop()
-
+        # 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}
         try:
-            for sig in HANDLED_SIGNALS:
-                loop.add_signal_handler(sig, self.handle_exit, sig, None)
-        except NotImplementedError:  # pragma: no cover
-            # Windows
-            for sig in HANDLED_SIGNALS:
-                signal.signal(sig, self.handle_exit)
+            yield
+        finally:
+            for sig, handler in original_handlers.items():
+                signal.signal(sig, handler)
+        # If we did gracefully shut down due to a signal, try to
+        # trigger the expected behaviour now; multiple signals would be
+        # done LIFO, see https://stackoverflow.com/questions/48434964
+        for captured_signal in reversed(self._captured_signals):
+            signal.raise_signal(captured_signal)
 
     def handle_exit(self, sig: int, frame: FrameType | None) -> None:
+        self._captured_signals.append(sig)
         if self.should_exit and sig == signal.SIGINT:
             self.force_exit = True
         else:


Test patch:
diff --git a/tests/test_server.py b/tests/test_server.py
new file mode 100644
--- /dev/null
+++ b/tests/test_server.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import signal
+import sys
+from typing import Callable, ContextManager, Generator
+
+import pytest
+
+from uvicorn.config import Config
+from uvicorn.server import Server
+
+
+# asyncio does NOT allow raising in signal handlers, so to detect
+# raised signals raised a mutable `witness` receives the signal
+@contextlib.contextmanager
+def capture_signal_sync(sig: signal.Signals) -> Generator[list[int], None, None]:
+    """Replace `sig` handling with a normal exception via `signal"""
+    witness: list[int] = []
+    original_handler = signal.signal(sig, lambda signum, frame: witness.append(signum))
+    yield witness
+    signal.signal(sig, original_handler)
+
+
+@contextlib.contextmanager
+def capture_signal_async(sig: signal.Signals) -> Generator[list[int], None, None]:  # pragma: py-win32
+    """Replace `sig` handling with a normal exception via `asyncio"""
+    witness: list[int] = []
+    original_handler = signal.getsignal(sig)
+    asyncio.get_running_loop().add_signal_handler(sig, witness.append, sig)
+    yield witness
+    signal.signal(sig, original_handler)
+
+
+async def dummy_app(scope, receive, send):  # pragma: py-win32
+    pass
+
+
+if sys.platform == "win32":
+    signals = [signal.SIGBREAK]
+    signal_captures = [capture_signal_sync]
+else:
+    signals = [signal.SIGTERM, signal.SIGINT]
+    signal_captures = [capture_signal_sync, capture_signal_async]
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize("exception_signal", signals)
+@pytest.mark.parametrize("capture_signal", signal_captures)
+async def test_server_interrupt(
+    exception_signal: signal.Signals, capture_signal: Callable[[signal.Signals], ContextManager[None]]
+):  # pragma: py-win32
+    """Test interrupting a Server that is run explicitly inside asyncio"""
+
+    async def interrupt_running(srv: Server):
+        while not srv.started:
+            await asyncio.sleep(0.01)
+        signal.raise_signal(exception_signal)
+
+    server = Server(Config(app=dummy_app, loop="asyncio"))
+    asyncio.create_task(interrupt_running(server))
+    with capture_signal(exception_signal) as witness:
+        await server.serve()
+    assert witness
+    # set by the server's graceful exit handler
+    assert server.should_exit