1010import contextvars
1111import logging
1212from collections .abc import Awaitable , Callable , Mapping
13+ from contextlib import AsyncExitStack
1314from dataclasses import dataclass , field
1415from functools import partial
1516from typing import Any , Generic , Literal , cast
@@ -250,6 +251,7 @@ def __init__(
250251 peer_cancel_mode : PeerCancelMode = "interrupt" ,
251252 raise_handler_exceptions : bool = False ,
252253 inline_methods : frozenset [str ] = frozenset (),
254+ close_write_stream_on_read_close : bool = True ,
253255 on_stream_exception : Callable [[Exception ], Awaitable [None ]] | None = None ,
254256 ) -> None :
255257 """Wire a dispatcher over a transport's `SessionMessage` stream pair.
@@ -262,6 +264,10 @@ def __init__(
262264 inline_methods: Methods awaited in the read loop before the next
263265 message is dequeued (e.g. `initialize`); an inline handler
264266 that awaits the peer deadlocks the parked loop.
267+ close_write_stream_on_read_close: Close the write stream when the
268+ read stream closes. Full-duplex transports may set this to
269+ false so in-flight handlers can finish writing responses after
270+ input EOF.
265271 on_stream_exception: Observer for `Exception` items on the read
266272 stream; without it they are debug-logged and dropped. Awaited
267273 inline in the read loop, so a slow observer stalls dispatch.
@@ -276,6 +282,7 @@ def __init__(
276282 )
277283 self ._peer_cancel_mode : PeerCancelMode = peer_cancel_mode
278284 self ._raise_handler_exceptions = raise_handler_exceptions
285+ self ._close_write_stream_on_read_close = close_write_stream_on_read_close
279286 self ._inline_methods = inline_methods
280287 self .on_stream_exception = on_stream_exception
281288 """Observer for ``Exception`` items on the read stream. Mutable so a session can
@@ -447,33 +454,36 @@ async def run(
447454 `task_status.started()` fires once `send_raw_request` is usable.
448455 Single-shot: once the loop ends the dispatcher stays closed and cannot be restarted.
449456 """
457+ normal_eof = False
450458 try :
451- # LIFO exits: the write stream closes only after the task-group join, so teardown writes still land.
452- async with self ._write_stream :
453- async with anyio .create_task_group () as tg :
454- self ._tg = tg
455- self ._running = True
456- task_status .started ()
457- try :
458- async with self ._read_stream :
459- try :
460- async for item in self ._read_stream :
461- # Duck-typed: only `ContextReceiveStream` carries the
462- # sender's per-message contextvars snapshot.
463- sender_ctx : contextvars .Context | None = getattr (
464- self ._read_stream , "last_context" , None
465- )
466- await self ._dispatch (item , on_request , on_notify , sender_ctx )
467- except anyio .ClosedResourceError :
468- # Receive end closed under us (stateless SHTTP teardown); same as EOF.
469- logger .debug ("read stream closed by transport; treating as EOF" )
470- # EOF: wake blocked `send_raw_request` waiters with CONNECTION_CLOSED.
471- self ._running = False
472- self ._closed = True
473- self ._fan_out_closed ()
474- finally :
475- # Cancel in-flight handlers; otherwise the task-group join
476- # waits on handlers whose callers are already gone.
459+ async with anyio .create_task_group () as tg :
460+ self ._tg = tg
461+ self ._running = True
462+ task_status .started ()
463+ try :
464+ async with AsyncExitStack () as stack :
465+ await stack .enter_async_context (self ._read_stream )
466+ if self ._close_write_stream_on_read_close :
467+ await stack .enter_async_context (self ._write_stream )
468+ try :
469+ async for item in self ._read_stream :
470+ # Duck-typed: only `ContextReceiveStream` carries the
471+ # sender's per-message contextvars snapshot.
472+ sender_ctx : contextvars .Context | None = getattr (
473+ self ._read_stream , "last_context" , None
474+ )
475+ await self ._dispatch (item , on_request , on_notify , sender_ctx )
476+ except anyio .ClosedResourceError :
477+ # Receive end closed under us (stateless SHTTP teardown); same as EOF.
478+ logger .debug ("read stream closed by transport; treating as EOF" )
479+ # EOF: wake blocked `send_raw_request` waiters with CONNECTION_CLOSED.
480+ self ._running = False
481+ self ._fan_out_closed ()
482+ normal_eof = True
483+ finally :
484+ if not normal_eof :
485+ # Cancel on crash/cancel paths. On normal EOF, let
486+ # already received handlers drain their responses.
477487 tg .cancel_scope .cancel ()
478488 finally :
479489 # Covers cancel/crash paths that skip the inline fan-out; idempotent.
0 commit comments