@@ -257,6 +257,7 @@ def __init__(
257257 raise_handler_exceptions : bool = False ,
258258 inline_methods : frozenset [str ] = frozenset (),
259259 on_stream_exception : Callable [[Exception ], Awaitable [None ]] | None = None ,
260+ drain_inbound_on_read_eof : bool = False ,
260261 ) -> None :
261262 """Wire a dispatcher over a transport's `SessionMessage` stream pair.
262263
@@ -271,6 +272,10 @@ def __init__(
271272 on_stream_exception: Observer for `Exception` items on the read
272273 stream; without it they are debug-logged and dropped. Awaited
273274 inline in the read loop, so a slow observer stalls dispatch.
275+ drain_inbound_on_read_eof: Let already-accepted inbound request
276+ response writes finish after read EOF before cancelling the run
277+ task group. Intended for stdio EOF after redirected input;
278+ default transport-close semantics remain immediate cancellation.
274279 """
275280 self ._read_stream = read_stream
276281 self ._write_stream = write_stream
@@ -287,6 +292,8 @@ def __init__(
287292 """Observer for ``Exception`` items on the read stream. Mutable so a session can
288293 bind it after the dispatcher is built (e.g. ``ClientSession`` routing into
289294 ``message_handler``); only consulted inside ``run()`` so pre-enter assignment is safe."""
295+ self ._drain_inbound_on_read_eof = drain_inbound_on_read_eof
296+
290297
291298 self ._next_id = 0
292299 self ._pending : dict [RequestId , _Pending ] = {}
@@ -478,7 +485,8 @@ async def run(
478485 self ._running = False
479486 self ._closed = True
480487 self ._fan_out_closed ()
481- await self ._drain_active_inbound_requests ()
488+ if self ._drain_inbound_on_read_eof :
489+ await self ._drain_active_inbound_requests ()
482490 finally :
483491 # Cancel in-flight handlers; otherwise the task-group join
484492 # waits on handlers whose callers are already gone.
@@ -533,17 +541,24 @@ async def _dispatch_request(
533541 sender_ctx : contextvars .Context | None ,
534542 ) -> None :
535543 progress_token = progress_token_from_params (req .params )
544+ self ._active_inbound_requests += 1
536545 try :
537546 transport_ctx = self ._transport_builder (metadata )
538547 except Exception :
539548 # A raising builder must cost only this message, not the connection.
549+ # Track its spawned error response so stdio EOF drain waits for this
550+ # already-accepted request outcome too.
540551 logger .exception ("transport_builder raised; rejecting request %r" , req .id )
541- self ._spawn (
542- self ._write_error ,
543- req .id ,
544- ErrorData (code = INTERNAL_ERROR , message = "transport context unavailable" ),
545- sender_ctx = sender_ctx ,
546- )
552+
553+ async def _reject_builder_failure () -> None :
554+ try :
555+ await self ._write_error (
556+ req .id , ErrorData (code = INTERNAL_ERROR , message = "transport context unavailable" )
557+ )
558+ finally :
559+ self ._active_inbound_requests = max (0 , self ._active_inbound_requests - 1 )
560+
561+ self ._spawn (_reject_builder_failure , sender_ctx = sender_ctx )
547562 return
548563 dctx = _JSONRPCDispatchContext (
549564 transport = transport_ctx ,
@@ -553,7 +568,6 @@ async def _dispatch_request(
553568 _progress_token = progress_token ,
554569 )
555570 scope = anyio .CancelScope ()
556- self ._active_inbound_requests += 1
557571 # TODO(maxisbey): duplicate ids blind-overwrite (v1/TS parity); revisit
558572 # rejecting with INVALID_REQUEST. Key coerced so a stringified
559573 # `notifications/cancelled` id still correlates.
0 commit comments