ASGI lifespan policy, failure, and state — 2026-07-11¶
Status: accepted in production ASGI serving. The default remains compatibility-
oriented auto; strict and disabled modes are explicit operator choices.
Problem and specification boundary¶
Servery previously started one best-effort lifespan task, waited up to five
seconds, and bound the listener even after an explicit
lifespan.startup.failed. It also ignored lifespan.shutdown.failed and did not
provide the optional lifespan state namespace to HTTP or WebSocket scopes.
That conflated three different cases:
- an application that does not implement lifespan;
- an application that explicitly reports failed initialization; and
- an application that claims lifespan but never completes a phase.
The ASGI lifespan 2.0 specification requires a server to wait for startup completion before accepting connections, exit after explicit startup failure, wait for shutdown completion, and terminate after explicit shutdown failure. It deliberately permits an initial application exception to mean “lifespan unsupported.” It also defines a server-managed state namespace that is shallow-copied into request scopes.
Uvicorn exposes auto, on, and off lifespan policy. Servery now uses the
same operator vocabulary while retaining its existing bounded wait as an
explicit setting.
Accepted policy¶
New configuration:
| CLI | Config.create() |
Default | Behavior |
|---|---|---|---|
--lifespan auto\|on\|off |
lifespan |
auto |
detect support, require support, or skip the protocol |
--lifespan-timeout SECONDS |
lifespan_timeout |
5.0 |
positive per-phase startup/shutdown wait |
The modes are deliberately distinct:
autotreats an exception or invalid non-lifespan response before startup completion as unsupported and proceeds without lifespan state. This preserves compatibility with simple ASGI callables.ontreats the same behavior as startup failure. Use it when application resources must be initialized before readiness.offnever creates the lifespan task and never adds request-state copy work. It is appropriate only for an application known not to need lifespan.
In both auto and on, explicit lifespan.startup.failed prevents socket bind.
An explicit failure message reaches the CLI as a clean nonzero error without a
traceback. Startup or shutdown timeout is also fatal. Explicit
lifespan.shutdown.failed, premature exit after supported startup, malformed
message ordering, and shutdown timeout are surfaced while the server terminates.
The timeout stays configurable because application initialization and cleanup budgets are deployment-specific. Removing it would allow a broken application to hang process startup or shutdown indefinitely; silently continuing after it would advertise false readiness.
State ownership and cost¶
The lifespan scope receives one server-owned state dictionary. After
successful startup, each HTTP and WebSocket scope gets a shallow copy:
- top-level request mutations do not leak into later scopes;
- nested values remain shared, as required by the ASGI specification; and
offor auto-detected unsupported applications omitstateentirely and do not allocate a per-request copy.
This makes the correctness/performance choice explicit. Frameworks that support lifespan get spec-shaped state; minimal callables retain their prior hot path.
If listener bind fails after successful startup, servery sends shutdown before propagating the bind error. This avoids leaking resources initialized during startup.
Tests¶
Direct and wire tests cover:
- auto-detected unsupported applications;
- strict
onrejection of the same callable; offbypass of an application that would explicitly fail startup;- startup failure preventing the ready/bind callback;
- bounded startup hangs;
- explicit shutdown failure;
- clean CLI failure text and exit status;
- top-level isolation plus nested sharing across HTTP request state copies; and
- lifespan state in WebSocket scopes.
Existing ASGI callables that try to send an HTTP response for every scope remain
compatible in auto; they fail in on, as intended.
Performance evidence¶
The candidate image is
sha256:17d323d8f0768de58315b80419654472928bad52141a8fd99d237ba1164a6eb1
with product-tree hash e3e64c86.... The frozen pre-lifespan-state image is
sha256:d2d79e7b... with product-tree hash 28c8123e....
Artifacts:
benchmarks/artifacts/lifespan-state-auto-final-2026-07-11.json;benchmarks/artifacts/lifespan-off-control-final-2026-07-11.json; andlifespan-state-source-smoke-2026-07-11.json.
Five five-second trials use one server CPU, four client processes, 64 connections, exact response validation, deterministic order rotation, and zero timed errors.
| Workload/policy | RPS change | RPS ratio MAD | p99 change | p99 ratio MAD | Decision |
|---|---|---|---|---|---|
| Minimal ASGI, auto unsupported | +3.58% | 1.00 points | -2.63% | 2.33 points | neutral; no state copy |
| FastAPI JSON, auto supported | -1.78% | 1.55 points | +0.94% | 0.82 points | neutral |
| Starlette JSON, auto supported | -0.74% | 7.10 points | -1.34% | 8.61 points | noisy neutral |
| Starlette JSON, off | +1.22% | 5.65 points | -0.45% | 5.88 points | noisy neutral control |
Cgroup peak memory is unchanged within measurement resolution. The source gate does not justify making state nonconformant or disabling lifespan by default.
Remaining lifecycle gap¶
This closes startup/shutdown message semantics and state propagation, not the
whole graceful process-shutdown problem. asyncio.Server stops accepting new
connections before lifespan shutdown, but servery does not yet maintain a
global registry that drains active HTTP/WebSocket application tasks and
post-response background work before sending lifespan.shutdown.
The next lifecycle slice must add bounded connection/task drain with an operator-configurable graceful-shutdown deadline, cancellation behavior, and tests for in-flight responses and background tasks. It should not claim production parity from lifespan messages alone.
Verification¶
- 848 functional tests pass on CPython 3.15 with the GIL, with four optional integration skips.
- The same 848 tests pass on free-threaded CPython 3.15, with the same four skips.
- Focused ASGI/CLI and comparison tests cover lifecycle behavior, state, explicit adapters, and the frozen framework baseline.
- Ruff, formatting, type checks,
git diff --check, and a strict documentation build pass after the lifecycle changes. - Wheel and sdist builds succeed; both install outside the repository, and the installed CLI/API expose and validate the new lifespan settings with zero unconditional runtime dependencies.