@@ -415,6 +415,7 @@ def __init__(
415415 self ._negotiated_version : str | None = None
416416 self ._stamp : Callable [[dict [str , Any ], CallOptions ], None ] = _preconnect_stamp
417417 self ._task_group : anyio .abc .TaskGroup | None = None
418+ self ._owned_stream_exception_hook : Callable [[Exception ], Any ] | None = None
418419 # subscriptions/listen demux routes; membership decides ack consumption (raw listens are never registered)
419420 self ._listen_routes : dict [RequestId , ListenRoute ] = {}
420421 if dispatcher is not None :
@@ -424,11 +425,11 @@ def __init__(
424425 if isinstance (dispatcher , JSONRPCDispatcher ) and dispatcher .on_stream_exception is None :
425426 # Route transport-level Exception items into message_handler — only
426427 # stream-backed dispatchers carry these; DirectDispatcher has none.
427- # Don't clobber a caller-supplied hook.
428- # TODO(L78): this leaves a bound-method ref on the dispatcher after the
429- # session exits (memory pin) and a second wrap of the same dispatcher would
430- # skip install. The Transport-as-Dispatcher rework (L77) removes this seam.
431- dispatcher . on_stream_exception = self . _on_stream_exception
428+ # Don't clobber a caller-supplied hook, and remember the exact bound
429+ # method object so shutdown can remove only our own installation.
430+ hook = self . _on_stream_exception
431+ dispatcher . on_stream_exception = hook
432+ self . _owned_stream_exception_hook = hook
432433 else :
433434 if read_stream is None or write_stream is None :
434435 raise ValueError ("read_stream and write_stream are required when no dispatcher is given" )
@@ -465,6 +466,7 @@ async def __aenter__(self) -> Self:
465466 await task_group .__aexit__ (None , None , None )
466467 finally :
467468 self ._close_binding_queues ()
469+ self ._remove_owned_stream_exception_hook ()
468470 raise
469471 return self
470472
@@ -482,9 +484,18 @@ async def __aexit__(
482484 finally :
483485 self ._close_binding_queues ()
484486 self ._settle_listen_routes_closed ()
487+ self ._remove_owned_stream_exception_hook ()
485488 await resync_tracer ()
486489 return result
487490
491+ def _remove_owned_stream_exception_hook (self ) -> None :
492+ """Remove the stream hook only if this session still owns the dispatcher slot."""
493+ hook = self ._owned_stream_exception_hook
494+ if hook is not None and isinstance (self ._dispatcher , JSONRPCDispatcher ):
495+ if self ._dispatcher .on_stream_exception is hook :
496+ self ._dispatcher .on_stream_exception = None
497+ self ._owned_stream_exception_hook = None
498+
488499 def _close_binding_queues (self ) -> None :
489500 # Unclosed memory object streams warn at garbage collection; close is idempotent.
490501 for send , receive in self ._binding_queues .values ():
0 commit comments