Skip to content

Fix server streaming handler not cancelled on client disconnect#175

Merged
stefanvanburen merged 4 commits intomainfrom
svanburen/client-disconnect
Mar 18, 2026
Merged

Fix server streaming handler not cancelled on client disconnect#175
stefanvanburen merged 4 commits intomainfrom
svanburen/client-disconnect

Conversation

@stefanvanburen
Copy link
Member

When a client disconnects during a server streaming RPC, the async generator continued yielding indefinitely because receive() was never consulted after the initial request was consumed.

Fix this by spawning a background task in the EndpointServerStream case that monitors receive() for http.disconnect. When detected, an Event is set; the streaming loop checks it between yields and raises ConnectError(CANCELED) if set. The response stream is also explicitly aclose()'d in the finally block so generator finally-clauses run promptly rather than being deferred to GC.

Fixes #174.

When a client disconnects during a server streaming RPC, the async
generator continued yielding indefinitely because receive() was never
consulted after the initial request was consumed.

Fix this by spawning a background task in the `EndpointServerStream`
case that monitors receive() for http.disconnect. When detected, an
Event is set; the streaming loop checks it between yields and raises
ConnectError(CANCELED) if set. The response stream is also explicitly
`aclose()`'d in the finally block so generator finally-clauses run
promptly rather than being deferred to GC.

Fixes #174.

Signed-off-by: Stefan VanBuren <svanburen@buf.build>
@stefanvanburen stefanvanburen force-pushed the svanburen/client-disconnect branch from 8774a61 to 4df01d1 Compare March 17, 2026 19:57
@stefanvanburen
Copy link
Member Author

FWIW, this is vaguely similar to how Starlette handles this, but they're using anyio: https://github.com/Kludex/starlette/blob/9ee951980bae776103715b66305f807d9e8245da/starlette/responses.py#L273

Signed-off-by: Stefan VanBuren <svanburen@buf.build>
@stefanvanburen stefanvanburen marked this pull request as ready for review March 17, 2026 20:26
@stefanvanburen stefanvanburen requested a review from a team March 17, 2026 20:26
# run promptly (Python defers async-generator cleanup to GC otherwise).
aclose = getattr(response_stream, "aclose", None)
if aclose is not None:
await aclose()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC it's possible for this to throw since it's user code and then monitor task would be leaked. When thinking about letting it propagate vs catch and maybe log, I guess a finally in a user generator is still part of the request handler and makes sense to allow to affect the response. How about just reordering then if that makes sense?

await aclose()
if monitor_task is not None:
monitor_task.cancel()
with contextlib.suppress(CancelledError, Exception):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we know monitor task never raises, maybe can keep it simply canceled error

request_bytes = Size(inches=10).SerializeToString()
request_body = struct.pack(">BI", 0, len(request_bytes)) + request_bytes

disconnect_trigger = asyncio.Event()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a client with a timeout of some tens of Ms should be non-flaky, but maybe the windows runner would try extra hard to prove otherwise.

Manually invoking the app seems fine too but let's add a comment then about potential flakiness if using a client with a timeout (we need it to happen after the request has been fully read)?

Signed-off-by: Stefan VanBuren <svanburen@buf.build>
@stefanvanburen
Copy link
Member Author

@anuraaga I think c46b74e should address your comments above, but LMK if I misunderstood!

@stefanvanburen stefanvanburen requested a review from anuraaga March 18, 2026 13:46
;)

Signed-off-by: Stefan VanBuren <svanburen@buf.build>
@stefanvanburen stefanvanburen requested a review from anuraaga March 18, 2026 13:58
@stefanvanburen stefanvanburen merged commit edfb1e9 into main Mar 18, 2026
39 of 41 checks passed
@stefanvanburen stefanvanburen deleted the svanburen/client-disconnect branch March 18, 2026 14:47
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.

Server streaming handler not cancelled on client disconnect

2 participants