Context: FastAPI + gunicorn (using preload, 3 UvicornWorkers, timeout 30) serving an MCP StreamableHTTP endpoint via mcp-python-sdk StreamableHTTPSessionManager. Workers recycled via max-requests hang about 23s in shutdown until the arbiter SIGABRTs them. 85% hard-kill rate observed over 24h (128 SIGABRT out of 151 recycles).
The paradox: the session manager run() has correct cleanup in its finally block, calling tg.cancel_scope.cancel() to cancel all session tasks, then clearing server instances. This code never executes during a gunicorn recycle.
Mechanism: gunicorn sends SIGTERM to the worker. Uvicorn sets should_exit=True and begins a two-phase shutdown: (1) drain in-flight requests, then (2) run ASGI lifespan shutdown. MCP StreamableHTTP sessions are long-lived HTTP connections (SSE GET streams). From uvicorn's perspective, these are in-flight requests. Without a graceful shutdown timeout, uvicorn waits indefinitely for them to complete. The lifespan shutdown, where the task group cancellation would fire, never begins. The arbiter heartbeat timer fires first and SIGABRTs the worker.
The fix is NOT in the MCP layer. Subclass UvicornWorker and set the graceful shutdown timeout in CONFIG_KWARGS (the config key is the words timeout, graceful, and shutdown joined by underscores) to a value below gunicorn's timeout (e.g. 15 < 30). This caps the drain phase so uvicorn proceeds to lifespan shutdown, the MCP task group cancels cleanly, and the worker exits before the arbiter fires. The default is None (wait forever), which is correct for normal HTTP but wrong for any transport holding long-lived connections inside the request lifecycle.
Diagnostic signature: in gunicorn logs, every hard-killed worker shows the max-requests exceeded message followed 20-30s later by WORKER TIMEOUT + SIGABRT. The gap matches the timeout value minus the time spent finishing the last normal request before the drain stalls on SSE sessions.
Generalization: any ASGI app serving long-lived HTTP connections (SSE, long-polling, WebSocket upgrades handled as HTTP) behind gunicorn needs the graceful shutdown timeout set below the arbiter timeout, or the cleanup code in the lifespan finally block is dead code during recycling.