Skip to content

BaseReload.restart() / shutdown() hang indefinitely when worker holds a long-lived connection #2943

Description

@idletimes

Bug

BaseReload.restart() and BaseReload.shutdown() in uvicorn/supervisors/basereload.py call self.process.join() without a timeout:

self.process.terminate()
self.process.join()  # blocks forever if worker has open connections

If any client holds a long-lived connection (SSE, WebSocket, chunked streaming), the worker will not exit after receiving SIGTERM — it waits for those connections to close first. process.join() then blocks indefinitely, the reload path never reaches get_subprocess(...), and no new worker is ever started. From the outside, hot-reload silently hangs.

Affected versions

0.30.6 through 0.47.0 (current main as of May 2026).

Reproduction

  1. Create a minimal FastAPI/Starlette app with an SSE or WebSocket endpoint that holds a connection open.
  2. Connect a client so the connection stays open.
  3. Edit any watched file to trigger a reload.
  4. Observe: WatchFiles detected changes is logged, the worker receives SIGTERM and logs its shutdown, but no new worker ever appears.

Expected behaviour

The old worker is given a grace period to finish. If it has not exited by then it is force-killed, and the new worker is started immediately afterwards.

Actual behaviour

process.join() never returns. The supervisor thread blocks forever. No new worker is started.

Proposed fix

Reuse timeout_graceful_shutdown (already a config option) as the join timeout, falling back to 5s, then force-kill with SIGKILL if the process is still alive:

def restart(self) -> None:
    if sys.platform == "win32":
        self.is_restarting = True
        assert self.process.pid is not None
        os.kill(self.process.pid, signal.CTRL_C_EVENT)
        sys.stdout.flush()
    else:
        self.process.terminate()
        timeout = self.config.timeout_graceful_shutdown or 5
        self.process.join(timeout=timeout)
        if self.process.is_alive():
            logger.warning("Worker did not exit after %ss, sending SIGKILL", timeout)
            self.process.kill()
            self.process.join()
    self.process = get_subprocess(config=self.config, target=self.target, sockets=self.sockets)
    self.process.start()

Apply the same pattern to shutdown().

timeout_graceful_shutdown is already the configured grace period for lifespan shutdown, so reusing it here is semantically consistent and requires no new config surface.

Workaround

Add a threading.Timer in your app's lifespan shutdown handler that calls os._exit(0) after N seconds. This should not be necessary.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions