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
@@ -272,6 +273,7 @@ def __init__(
272273 peer_cancel_mode : PeerCancelMode = "interrupt" ,
273274 raise_handler_exceptions : bool = False ,
274275 inline_methods : frozenset [str ] = frozenset (),
276+ close_write_stream_on_read_close : bool = True ,
275277 on_stream_exception : Callable [[Exception ], Awaitable [None ]] | None = None ,
276278 ) -> None :
277279 """Wire a dispatcher over a transport's `SessionMessage` stream pair.
@@ -284,6 +286,10 @@ def __init__(
284286 inline_methods: Methods awaited in the read loop before the next
285287 message is dequeued (e.g. `initialize`); an inline handler
286288 that awaits the peer deadlocks the parked loop.
289+ close_write_stream_on_read_close: Close the write stream when the
290+ read stream closes. Full-duplex transports may set this to
291+ false so in-flight handlers can finish writing responses after
292+ input EOF.
287293 on_stream_exception: Observer for `Exception` items on the read
288294 stream; without it they are debug-logged and dropped. Awaited
289295 inline in the read loop, so a slow observer stalls dispatch.
@@ -298,6 +304,7 @@ def __init__(
298304 )
299305 self ._peer_cancel_mode : PeerCancelMode = peer_cancel_mode
300306 self ._raise_handler_exceptions = raise_handler_exceptions
307+ self ._close_write_stream_on_read_close = close_write_stream_on_read_close
301308 self ._inline_methods = inline_methods
302309 self .on_stream_exception = on_stream_exception
303310 """Observer for ``Exception`` items on the read stream. Mutable so a session can
@@ -487,33 +494,36 @@ async def run(
487494 Single-shot: once the loop ends the dispatcher stays closed and cannot be restarted.
488495 """
489496 self ._on_notify_intercept = on_notify_intercept
497+ normal_eof = False
490498 try :
491- # LIFO exits: the write stream closes only after the task-group join, so teardown writes still land.
492- async with self ._write_stream :
493- async with anyio .create_task_group () as tg :
494- self ._tg = tg
495- self ._running = True
496- task_status .started ()
497- try :
498- async with self ._read_stream :
499- try :
500- async for item in self ._read_stream :
501- # Duck-typed: only `ContextReceiveStream` carries the
502- # sender's per-message contextvars snapshot.
503- sender_ctx : contextvars .Context | None = getattr (
504- self ._read_stream , "last_context" , None
505- )
506- await self ._dispatch (item , on_request , on_notify , sender_ctx )
507- except anyio .ClosedResourceError :
508- # Receive end closed under us (stateless SHTTP teardown); same as EOF.
509- logger .debug ("read stream closed by transport; treating as EOF" )
510- # EOF: wake blocked `send_raw_request` waiters with CONNECTION_CLOSED.
511- self ._running = False
512- self ._closed = True
513- self ._fan_out_closed ()
514- finally :
515- # Cancel in-flight handlers; otherwise the task-group join
516- # waits on handlers whose callers are already gone.
499+ async with anyio .create_task_group () as tg :
500+ self ._tg = tg
501+ self ._running = True
502+ task_status .started ()
503+ try :
504+ async with AsyncExitStack () as stack :
505+ await stack .enter_async_context (self ._read_stream )
506+ if self ._close_write_stream_on_read_close :
507+ await stack .enter_async_context (self ._write_stream )
508+ try :
509+ async for item in self ._read_stream :
510+ # Duck-typed: only `ContextReceiveStream` carries the
511+ # sender's per-message contextvars snapshot.
512+ sender_ctx : contextvars .Context | None = getattr (
513+ self ._read_stream , "last_context" , None
514+ )
515+ await self ._dispatch (item , on_request , on_notify , sender_ctx )
516+ except anyio .ClosedResourceError :
517+ # Receive end closed under us (stateless SHTTP teardown); same as EOF.
518+ logger .debug ("read stream closed by transport; treating as EOF" )
519+ # EOF: wake blocked `send_raw_request` waiters with CONNECTION_CLOSED.
520+ self ._running = False
521+ self ._fan_out_closed ()
522+ normal_eof = True
523+ finally :
524+ if not normal_eof :
525+ # Cancel on crash/cancel paths. On normal EOF, let
526+ # already received handlers drain their responses.
517527 tg .cancel_scope .cancel ()
518528 finally :
519529 # Covers cancel/crash paths that skip the inline fan-out; idempotent.
0 commit comments