Skip to content

Respect graceful_timeout and fix HTTP/2 shutdown cancellation - #374

Open
emicuencac wants to merge 9 commits into
pgjones:mainfrom
emicuencac:fix-308-graceful-timeout-drain
Open

Respect graceful_timeout and fix HTTP/2 shutdown cancellation#374
emicuencac wants to merge 9 commits into
pgjones:mainfrom
emicuencac:fix-308-graceful-timeout-drain

Conversation

@emicuencac

@emicuencac emicuencac commented Sep 10, 2026

Copy link
Copy Markdown

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 and graceful_timeout is never reached.

Fix 2

Once Server.wait_closed() is removed, the forced cancel at graceful_timeout becomes reachable on 3.12.1+, and it exposes a second problem in HTTP/2, that was masked by the aforementioned wait_closed() change, but already existed before.

When the last stream on a terminated connection closes:

  • H2Protocol sends GOAWAY and then Updated(idle=True)
  • that makes the asyncio TCPServer restart its idle task inside a TaskGroup that is already being cancelled
  • create_task raises RuntimeError
  • the connection task fails instead of cancelling
  • worker_serve raises
  • and the non-zero exit makes run.py shut down the other workers too.

H11Protocol already sends Closed() in this situation, so we did the same in H2Protocol.

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 at graceful_timeout and then waits for the cancellation to complete, which an application that swallows CancelledError never does. The drain now uses asyncio.wait() in two bounded phases: wait graceful_timeout for the connections to finish, cancel the rest, and wait graceful_timeout again 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_STREAM and drops the stream. The client may have already sent the DATA frame, so DataReceived arrives for a stream id that is no longer in self.streams and raises KeyError, which fails the whole connection. _handle_events now ignores DataReceived for 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:

Each fix has a regression test that fails without it.

emicuencac and others added 4 commits September 9, 2026 15:04
`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>
…unded

Bound the graceful drain and ignore DataReceived for refused streams
emicuencac and others added 3 commits September 10, 2026 15:00
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

graceful_timeout not respected on Python 3.12

6 participants