Lazy ASGI peer-disconnect delivery — 2026-07-11¶
Problem¶
The ASGI HTTP adapter returned http.disconnect immediately after delivering
the final http.request event, even while the peer remained connected and before
a response existed. That made a long-poll or streaming application believe its
client had gone away. Conversely, a peer that actually closed while an
application waited after the request body had no end-to-end notification path.
Reading from StreamReader merely to observe EOF is unsafe. The byte may be the
first byte of a pipelined next request, and consuming it in a background listener
would fork parser ownership. Polling reader.at_eof() adds timer activity and
disconnect latency. A permanent watcher task or event on every request also
violates the measured minimal-ASGI hot-path budget.
Accepted design¶
_AsyncBody.receive() still delivers request-body chunks exactly as before. Once
the final request event has been delivered, an additional receive() now blocks
until real stream EOF or connection loss. A disconnect during a declared or
chunked body is converted from IncompleteReadError/ConnectionError into an
ASGI http.disconnect event.
Observation is deliberately lazy:
serve_forever()continues to use the originalasyncio.start_server()path.- The accepted stream protocol is passed through the connection exchange, but no event, future, set, watcher task, or wrapper is created for ordinary apps.
- Only when an app calls
receive()after the final body event does_DisconnectStatetemporarily wrap that protocol instance'seof_received()andconnection_lost()callbacks. - The first real EOF wakes all registered receives without reading any bytes.
- If an application cancels its listener first, the original protocol methods are restored. The keep-alive connection and pipelined bytes remain owned by the normal HTTP parser.
This closes real peer-disconnect delivery. Synthetic request-scope completion was subsequently closed without reviving the eager designs by ASGI post-response disconnect.
Rejected shapes¶
The first production shape replaced start_server() with a permanent
StreamReaderProtocol subclass and allocated two asyncio.Event objects per
request. A three-trial A/B measured -8.1% keep-alive RPS and -5.2% churn RPS.
A persistent queue-style disconnect state removed events and batched wakeups, but still wired response completion into every response. Follow-up probes stayed roughly 5–9% behind on keep-alive and increased churn p99 by 18–36%. A five-trial gate for the best eager-response shape measured -5.7% RPS/+9.9% p99 on keep-alive and -4.2% RPS/+29.8% p99 on churn. These variants are rejected.
The final lazy protocol hook removes all normal-close callbacks and all response-state work. It pays for a future and temporary wrappers only in the capability path that asks to observe disconnect.
Correctness evidence¶
Direct tests prove that:
- the final request event is delivered once and a following receive remains pending while the peer is connected;
- closing the peer produces
http.disconnectend to end; - truncating a declared request body produces
http.disconnect, not an internal read exception delivered past the adapter; - cancelling a disconnect listener restores the original protocol methods;
- after that cancellation, two pipelined requests are both parsed and answered;
- existing keep-alive, TLS, body framing, streaming, timeout, and WebSocket tests continue to use the same adapter.
The tests run under both normal-GIL and free-threaded CPython 3.15. No application polling interval or public configuration is introduced.
Performance gate¶
The harness adds opt-in asgi-churn-1k, matching the asgi-1k response while
using one request per connection and a 32-connection cap. The final A/B uses one
server CPU, disjoint client CPUs, two client processes, one-second warmup, five
five-second trials, balanced order rotation, the same mounted application
fixture, and an immediately preceding frozen image.
Final artifact (gitignored):
benchmarks/artifacts/asgi-disconnect-final-2026-07-11.json.
| scenario | adapter | median RPS | RPS MAD | p99 | peak MiB |
|---|---|---|---|---|---|
asgi-1k, 64 connections |
lazy disconnect | 32,191 | 4.5% | 2.69 ms | 26.0 |
asgi-1k, 64 connections |
frozen baseline | 31,476 | 3.7% | 2.69 ms | 26.0 |
asgi-churn-1k, 32 connections |
lazy disconnect | 10,621 | 0.3% | 3.51 ms | 26.3 |
asgi-churn-1k, 32 connections |
frozen baseline | 10,641 | 0.5% | 3.53 ms | 26.4 |
The median paired keep-alive change is +4.1% RPS/+0.1% p99, but ratio MADs are 8.1 and 4.4 percentage points, so the decision is neutral rather than a gain. Churn is tightly neutral at -0.3% RPS/-0.3% p99 with 0.6/0.5-point ratio MAD. One frozen-baseline churn sample was an obvious high-throughput/high-p99 outlier; medians, MAD, and paired ranges remain in the artifact rather than being removed. All timed samples recorded zero errors and client CPU remained below saturation.
The artifact records candidate image ID sha256:3fc70039..., frozen baseline
image ID sha256:587b8eff..., and product-tree hash a2b2bf5d....
Decision and remaining work¶
Accept lazy real-peer disconnect delivery because it fixes a false-disconnect bug, preserves parser ownership, restores hooks on cancellation, and is neutral on both protected workloads. Keep the eager response-event designs rejected.
Before claiming full ASGI lifecycle parity, test Starlette/FastAPI cancellation patterns, TLS EOF/half-close behavior, server-initiated shutdown, and HTTP/2-to-ASGI if that adapter is added. Large streaming request bodies, native Uvicorn cohorts, framework compatibility, and sustained lifecycle tests also remain open roadmap items.
Verification¶
- 800 functional tests pass under CPython 3.15 normal-GIL and free-threaded builds. The normal environment has 40 optional-dependency skips; the populated free-threaded environment has four.
- Targeted ASGI tests also pass on normal-GIL CPython 3.14, including plaintext and TLS disconnect delivery.
- Repository-wide Ruff format/lint, Bandit, strict MkDocs, and
git diff --checkgates pass. Ty reports only two pre-existing unused-suppression warnings. - The final benchmark artifact's product-tree and harness-file hashes match the current source.
- Wheel and source distribution build, the wheel has no unconditional runtime dependencies, and installed import/CLI smoke passes outside the source tree.