@@ -307,6 +307,7 @@ def __init__(
307307 self ._next_id = 0
308308 self ._pending : dict [RequestId , _Pending ] = {}
309309 self ._in_flight : dict [RequestId , _InFlight [TransportT ]] = {}
310+ self ._active_requests : set [anyio .Event ] = set ()
310311 self ._on_notify_intercept : OnNotifyIntercept | None = None
311312 self ._tg : anyio .abc .TaskGroup | None = None
312313 self ._running = False
@@ -480,12 +481,15 @@ async def run(
480481 on_notify_intercept : OnNotifyIntercept | None = None ,
481482 * ,
482483 task_status : anyio .abc .TaskStatus [None ] = anyio .TASK_STATUS_IGNORED ,
484+ graceful_shutdown_timeout : float = 0 ,
483485 ) -> None :
484486 """Drive the receive loop until the read stream closes.
485487
486488 `task_status.started()` fires once `send_raw_request` is usable.
487489 Single-shot: once the loop ends the dispatcher stays closed and cannot be restarted.
488490 """
491+ if graceful_shutdown_timeout < 0 :
492+ raise ValueError ("graceful_shutdown_timeout must be non-negative" )
489493 self ._on_notify_intercept = on_notify_intercept
490494 try :
491495 # LIFO exits: the write stream closes only after the task-group join, so teardown writes still land.
@@ -511,6 +515,9 @@ async def run(
511515 self ._running = False
512516 self ._closed = True
513517 self ._fan_out_closed ()
518+ if graceful_shutdown_timeout and self ._active_requests :
519+ with anyio .move_on_after (graceful_shutdown_timeout ):
520+ await self ._wait_for_active_requests ()
514521 finally :
515522 # Cancel in-flight handlers; otherwise the task-group join
516523 # waits on handlers whose callers are already gone.
@@ -523,6 +530,15 @@ async def run(
523530 self ._fan_out_closed ()
524531 await resync_tracer ()
525532
533+ async def _wait_for_active_requests (self ) -> None :
534+ """Wait for requests already accepted from a transport before cancellation."""
535+ events = tuple (self ._active_requests )
536+ if not events :
537+ return
538+ async with anyio .create_task_group () as tg :
539+ for event in events :
540+ tg .start_soon (event .wait )
541+
526542 async def _dispatch (
527543 self ,
528544 item : SessionMessage | Exception ,
@@ -585,6 +601,8 @@ async def _dispatch_request(
585601 _progress_token = progress_token ,
586602 )
587603 scope = anyio .CancelScope ()
604+ completion = anyio .Event ()
605+ self ._active_requests .add (completion )
588606 # TODO(maxisbey): duplicate ids blind-overwrite (v1/TS parity); revisit
589607 # rejecting with INVALID_REQUEST. Key coerced so a stringified
590608 # `notifications/cancelled` id still correlates.
@@ -596,14 +614,28 @@ async def _dispatch_request(
596614
597615 async def _run_inline () -> None :
598616 try :
599- await self ._handle_request (req , dctx , scope , on_request )
617+ await self ._run_request (req , dctx , scope , on_request , completion )
600618 finally :
601619 done .set ()
602620
603621 self ._spawn (_run_inline , sender_ctx = sender_ctx )
604622 await done .wait ()
605623 else :
606- self ._spawn (self ._handle_request , req , dctx , scope , on_request , sender_ctx = sender_ctx )
624+ self ._spawn (self ._run_request , req , dctx , scope , on_request , completion , sender_ctx = sender_ctx )
625+
626+ async def _run_request (
627+ self ,
628+ req : JSONRPCRequest ,
629+ dctx : _JSONRPCDispatchContext [TransportT ],
630+ scope : anyio .CancelScope ,
631+ on_request : OnRequest ,
632+ completion : anyio .Event ,
633+ ) -> None :
634+ try :
635+ await self ._handle_request (req , dctx , scope , on_request )
636+ finally :
637+ self ._active_requests .discard (completion )
638+ completion .set ()
607639
608640 def _dispatch_notification (
609641 self ,
0 commit comments