Respect graceful_timeout and fix HTTP/2 shutdown cancellation - #374
Open
emicuencac wants to merge 9 commits into
Open
Respect graceful_timeout and fix HTTP/2 shutdown cancellation#374emicuencac wants to merge 9 commits into
emicuencac wants to merge 9 commits into
Conversation
`Server.wait_closed()` returned as soon as the listening socket was closed until Python 3.12.1, where it changed to wait until every active connection has finished (python/cpython#79033, python/cpython#104344). The asyncio worker awaits it before the `wait_for(..., graceful_timeout)` that is meant to bound the drain, so on 3.12.1+ a single connection that never finishes holds the worker in `worker_serve` forever and `graceful_timeout` is never reached. `Server.close()` already stops serving on the listening sockets, and every connection task is tracked in `server_tasks`, which the following `wait_for` gathers and cancels. Dropping the `wait_closed()` call restores the documented behaviour without losing anything. This also unwedges `max_requests` recycling: a worker that trips its limit while a request is still in flight never exits, and `run.py` only spawns the replacement once the old process has exited, so the arbiter is left holding the listening socket with no worker accepting on it. The trio worker is unaffected — it bounds the same drain with `server_nursery.cancel_scope.deadline`.
…idle task When the last stream on an HTTP/2 connection closes after the worker has been told to terminate, `H2Protocol` sends GOAWAY and then emits `Updated(idle=True)`. The asyncio `TCPServer` reacts to that by restarting the idle task inside the connection's `TaskGroup`. If the stream was closed because `graceful_timeout` expired and the connection task is being cancelled, that `TaskGroup` is already shutting down and `create_task` raises `RuntimeError`. The error replaces the `CancelledError`, escapes `_server_callback` as an `ExceptionGroup`, is not matched by the `except asyncio.TimeoutError` in `worker_serve`, and the worker exits non-zero. `run.py` treats any non-zero worker exit as fatal and shuts down every other worker, so a single long-lived stream (e.g. a Restate invocation) still open at `graceful_timeout` could take the whole server down on a `max_requests` recycle. `H11Protocol._maybe_recycle` already sends `Closed()` rather than `Updated(idle=True)` once terminated; do the same in `H2Protocol`. The bytes on the wire are unchanged (GOAWAY, then FIN) and, since an idle connection has no live streams, nothing is skipped by closing directly rather than via the idle task. This has been reachable since ab98383 moved the idle task into the per-connection `TaskGroup`, but on Python 3.12.1+ was masked by the unbounded `Server.wait_closed()` removed in aceaa79. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three tests that fail on fix-308-graceful-timeout-drain: - test_protocol_terminated_data_for_refused_stream: DataReceived for a stream that was refused after termination raises KeyError in H2Protocol._handle_events. - test_request_during_drain_does_not_crash_worker: that KeyError now propagates through wait_for(gather(...)) in worker_serve, so the worker exits non-zero and the master shuts every worker down. - test_graceful_timeout_bounds_app_that_swallows_cancellation: an app that swallows CancelledError keeps worker_serve stuck after graceful_timeout, because wait_for awaits the cancellation without a bound. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two changes that make the new tests pass: - H2Protocol._handle_events: look the stream up with .get() for DataReceived. The stream is gone when the request was refused as it arrived (connection terminated) or the response was sent before the full request was received. The window credit is still returned. - worker_serve: replace wait_for(gather(*server_tasks)) with asyncio.wait with a timeout, cancel what is still pending, then wait for the cancellation with a second timeout. gather() re-raised the first connection error into worker_serve, and wait_for() awaited the cancellation forever when an app swallowed CancelledError. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
franauto
approved these changes
Sep 10, 2026
…unded Bound the graceful drain and ignore DataReceived for refused streams
juanpicr92
approved these changes
Sep 10, 2026
joaquinmoine
approved these changes
Sep 10, 2026
torresrodrigoe
approved these changes
Sep 10, 2026
StreamWriter.drain() and wait_closed() re-raise whatever exception connection_lost() received. Only ConnectionError subclasses were handled, so an ssl.SSLError (application data after close notify), a TimeoutError (SSL shutdown timed out) or a plain OSError such as EHOSTUNREACH escaped protocol_send() and _close(). From an app task that becomes an ExceptionGroup, which TCPServer.run() does not catch, and the whole connection fails with "Unhandled exception in client_connected_cb". These cluster during the graceful_timeout drain, when many connections close at once against peers that may be gone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
restart() and stop() cancel the previous idle task and await it, ignoring the CancelledError that the await raises. That await also raises CancelledError when the calling task is itself being cancelled, for example when the connection TaskGroup is torn down at graceful_timeout. Swallowing it let restart() continue and call create_task() on a TaskGroup that is already shutting down, which raises RuntimeError and fails the connection. Wait for the cancelled task with asyncio.wait(), which does not raise its CancelledError but still lets the caller's own cancellation through. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When _send_data() hits a closed stream it closes and removes the stream's buffer and priority entry. If either is already gone, the cleanup itself raises a second KeyError or MissingStreamError, which kills the connection's send task and with it every stream on the connection. Resets are frequent while a terminated connection drains, so make the cleanup idempotent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
avilaton
approved these changes
Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #308.
In the recent weeks our team faced several incidents related to this issue. When a worker has an HTTP/2 persistent connection the main process is unable to kill it.
Fix 1
Server.wait_closed()changed in Python 3.12.1 to wait for every active connection rather than just the listening socket (python/cpython#104344) –which was the original intended behavior– so a single connection that never finishes keeps the worker alive indefinitely andgraceful_timeoutis never reached.Fix 2
Once
Server.wait_closed()is removed, the forced cancel atgraceful_timeoutbecomes reachable on 3.12.1+, and it exposes a second problem in HTTP/2, that was masked by the aforementionedwait_closed()change, but already existed before.When the last stream on a terminated connection closes:
H2Protocolsends GOAWAY and thenUpdated(idle=True)TCPServerrestart its idle task inside aTaskGroupthat is already being cancelledcreate_taskraisesRuntimeErrorworker_serveraisesrun.pyshut down the other workers too.H11Protocolalready sendsClosed()in this situation, so we did the same inH2Protocol.Fix 3
Testing the branch against a real client showed that the drain still hung, one line further down.
wait_for()cancels the gathered connection tasks atgraceful_timeoutand then waits for the cancellation to complete, which an application that swallowsCancelledErrornever does. The drain now usesasyncio.wait()in two bounded phases: waitgraceful_timeoutfor the connections to finish, cancel the rest, and waitgraceful_timeoutagain for the cancellation.asyncio.wait()also does not propagate a connection task's exception, so one failing connection no longer takes the worker (and with it the other workers) down.Fix 4
A terminated connection answers new streams with
RST_STREAMand drops the stream. The client may have already sent theDATAframe, soDataReceivedarrives for a stream id that is no longer inself.streamsand raisesKeyError, which fails the whole connection._handle_eventsnow ignoresDataReceivedfor an unknown stream, while still acknowledging the data so the flow-control window is returned.Fix 5
The forced cancel path had not run on 3.12.1+ for two years, so a few more latent problems surfaced once it did, all of them failing a connection during the drain:
TCPServer.protocol_send()and_close()only handledConnectionError.drain()andwait_closed()re-raise whateverconnection_lost()received, which is alsossl.SSLError(SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY #261,ssl.SSLError: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2696)#291),TimeoutErrorfrom the SSL shutdown (SSL shutdown timed out #202) or a plainOSErrorsuch asEHOSTUNREACH(Plain OSError (EHOSTUNREACH) escapes TCPServer._close() on abrupt client disconnect — unhandled exception in client_connected_cb + propagates into the ASGI app #361). They now catchOSError, as_read_data()already did.AsyncioSingleTask.restart()/stop()swallowed the caller's ownCancelledErrorwhile awaiting the cancelled idle task, sorestart()went on tocreate_task()in aTaskGroupthat was already shutting down. They now useasyncio.wait(), which does not raise the handle'sCancelledErrorbut lets the caller's through.H2Protocol._send_data()cleanup indexedstream_buffersandpriorityunguarded, so a stream that was already removed raised a second time and killed the connection's send task.Each fix has a regression test that fails without it.