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
- Create a minimal FastAPI/Starlette app with an SSE or WebSocket endpoint that holds a connection open.
- Connect a client so the connection stays open.
- Edit any watched file to trigger a reload.
- 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.
Bug
BaseReload.restart()andBaseReload.shutdown()inuvicorn/supervisors/basereload.pycallself.process.join()without a timeout: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 reachesget_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
mainas of May 2026).Reproduction
WatchFiles detected changesis 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:Apply the same pattern to
shutdown().timeout_graceful_shutdownis 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.Timerin your app's lifespan shutdown handler that callsos._exit(0)after N seconds. This should not be necessary.