@@ -610,6 +610,56 @@ async def caller() -> None:
610610 assert scopes [0 ].cancelled_caught
611611
612612
613+ @pytest .mark .anyio
614+ async def test_notification_handler_exception_is_contained (caplog : pytest .LogCaptureFixture ):
615+ """A raising notification handler costs only that notification, never the connection.
616+
617+ The handler runs as a bare task in the dispatcher's task group; without containment its
618+ exception would cancel the read loop and every in-flight request. The TypeScript, C#, and
619+ Go engines all contain notification-handler failures the same way.
620+ """
621+
622+ async def server_on_notify (ctx : DCtx , method : str , params : Mapping [str , Any ] | None ) -> None :
623+ raise RuntimeError ("notify boom" )
624+
625+ async with running_pair (jsonrpc_pair , server_on_notify = server_on_notify ) as (client , * _ ):
626+ with anyio .fail_after (5 ):
627+ await client .notify ("boom" , None )
628+ # The connection survived: a full round-trip still works.
629+ result = await client .send_raw_request ("ping" , None )
630+ assert result == {"echoed" : "ping" , "params" : {}}
631+ assert "notification handler for 'boom' raised" in caplog .text
632+
633+
634+ @pytest .mark .anyio
635+ async def test_spawned_notification_handlers_run_concurrently ():
636+ """Notification handlers are spawned, not serialized: a parked one does not block the next.
637+
638+ The first handler waits for the second to have started - serialized dispatch would deadlock
639+ here. This matches the TypeScript and C# engines (fire-and-forget); handlers needing
640+ mutual ordering must coordinate themselves.
641+ """
642+ second_started = anyio .Event ()
643+ completed : list [str ] = []
644+ done = anyio .Event ()
645+
646+ async def server_on_notify (ctx : DCtx , method : str , params : Mapping [str , Any ] | None ) -> None :
647+ if method == "first" :
648+ await second_started .wait ()
649+ else :
650+ second_started .set ()
651+ completed .append (method )
652+ if len (completed ) == 2 :
653+ done .set ()
654+
655+ async with running_pair (jsonrpc_pair , server_on_notify = server_on_notify ) as (client , * _ ):
656+ with anyio .fail_after (5 ):
657+ await client .notify ("first" , None )
658+ await client .notify ("second" , None )
659+ await done .wait ()
660+ assert completed == ["second" , "first" ]
661+
662+
613663@pytest .mark .anyio
614664async def test_ctx_message_metadata_carries_inbound_request_metadata ():
615665 """Transport-attached metadata (HTTP request, SSE close hooks) is readable off the dispatch context."""
0 commit comments