11from __future__ import annotations
22
33import base64
4+ import contextlib
45import functools
56import inspect
67from abc import ABC , abstractmethod
7- from asyncio import CancelledError , sleep
8+ from asyncio import CancelledError , Event , create_task , sleep
89from dataclasses import replace
910from http import HTTPStatus
1011from typing import TYPE_CHECKING , Generic , TypeVar , cast
@@ -387,6 +388,9 @@ async def _handle_stream(
387388 self ._read_max_bytes ,
388389 )
389390
391+ disconnect_detected : Event | None = None
392+ monitor_task = None
393+
390394 match endpoint :
391395 case EndpointUnary ():
392396 request = await _consume_single_request (request_stream )
@@ -398,22 +402,50 @@ async def _handle_stream(
398402 case EndpointServerStream ():
399403 request = await _consume_single_request (request_stream )
400404 response_stream = endpoint .function (request , ctx )
405+
406+ # The request has been fully consumed; monitor receive() for a
407+ # client disconnect so we can stop streaming promptly.
408+ disconnect_detected = Event ()
409+
410+ async def _watch_for_disconnect () -> None :
411+ while True :
412+ msg = await receive ()
413+ if msg ["type" ] == "http.disconnect" :
414+ disconnect_detected .set ()
415+ return
416+
417+ monitor_task = create_task (_watch_for_disconnect ())
401418 case EndpointBidiStream ():
402419 response_stream = endpoint .function (request_stream , ctx )
403420
404- async for message in response_stream :
405- # Don't send headers until the first message to allow logic a chance to add
406- # response headers.
407- if not sent_headers :
408- await _send_stream_response_headers (
409- send , protocol , codec , resp_compression .name (), ctx
421+ try :
422+ async for message in response_stream :
423+ if disconnect_detected is not None and disconnect_detected .is_set ():
424+ raise ConnectError (Code .CANCELED , "Client disconnected" )
425+ # Don't send headers until the first message to allow logic a chance to add
426+ # response headers.
427+ if not sent_headers :
428+ await _send_stream_response_headers (
429+ send , protocol , codec , resp_compression .name (), ctx
430+ )
431+ sent_headers = True
432+
433+ body = writer .write (message )
434+ await send (
435+ {"type" : "http.response.body" , "body" : body , "more_body" : True }
410436 )
411- sent_headers = True
412-
413- body = writer .write (message )
414- await send (
415- {"type" : "http.response.body" , "body" : body , "more_body" : True }
416- )
437+ finally :
438+ # Cancel the monitor first so a throwing generator finally-block
439+ # doesn't leak the task.
440+ if monitor_task is not None :
441+ monitor_task .cancel ()
442+ with contextlib .suppress (CancelledError ):
443+ await monitor_task
444+ # Explicitly close the stream so that any generator finally-blocks
445+ # run promptly (Python defers async-generator cleanup to GC otherwise).
446+ aclose = getattr (response_stream , "aclose" , None )
447+ if aclose is not None :
448+ await aclose ()
417449 except CancelledError as e :
418450 raise ConnectError (Code .CANCELED , "Request was cancelled" ) from e
419451 except Exception as e :
0 commit comments