1212from collections .abc import Awaitable , Callable , Mapping
1313from dataclasses import dataclass , field
1414from functools import partial
15- from typing import Any , Generic , Literal , cast
15+ from typing import Any , Final , Generic , Literal , cast
1616
1717import anyio
1818import anyio .abc
6464 "JSONRPCDispatcher" ,
6565 "cancelled_request_id_from_params" ,
6666 "handler_exception_to_error_data" ,
67+ "observe_success_frames" ,
6768 "progress_token_from_params" ,
69+ "suppress_cancel_answer" ,
6870]
6971
7072logger = logging .getLogger (__name__ )
@@ -131,6 +133,15 @@ class _InFlight(Generic[TransportT]):
131133 dctx : _JSONRPCDispatchContext [TransportT ]
132134
133135
136+ _LEGACY_CANCEL_ANSWER : Final [ErrorData ] = ErrorData (code = 0 , message = "Request cancelled" )
137+ """The answer a peer-cancelled request gets by default.
138+
139+ TODO(L38): the spec says receivers SHOULD NOT respond after a cancel; the
140+ existing server always has, so this pins released-client compat. Never
141+ mutated - one shared instance is safe.
142+ """
143+
144+
134145@dataclass
135146class _JSONRPCDispatchContext (Generic [TransportT ]):
136147 """Concrete `DispatchContext` produced for each inbound JSON-RPC message."""
@@ -143,6 +154,22 @@ class _JSONRPCDispatchContext(Generic[TransportT]):
143154 _progress_token : ProgressToken | None = None
144155 _closed : bool = False
145156 cancel_requested : anyio .Event = field (default_factory = anyio .Event )
157+ cancel_answer : ErrorData | None = field (default_factory = lambda : _LEGACY_CANCEL_ANSWER )
158+ """The error written when a peer cancel interrupts this request's handler.
159+
160+ `None` means silence: no frame at all follows the cancel. The default is
161+ the legacy compat answer; loop layers that know a request is governed by
162+ 2026-era wire rules (which forbid any frame for a request after
163+ `notifications/cancelled`) clear it via `suppress_cancel_answer`.
164+ """
165+ on_success_frame : Callable [[], None ] | None = None
166+ """Observer invoked synchronously immediately before each non-error frame
167+ written for this request: a request-scoped notification (progress rides
168+ those) or the success result. Never invoked for error responses, nor for
169+ notifications dropped because the context closed. Set via
170+ `observe_success_frames`; loop layers use it to commit connection state no
171+ later than the request's first client-visible output.
172+ """
146173
147174 @property
148175 def request_id (self ) -> RequestId | None :
@@ -156,6 +183,8 @@ async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Call
156183 if self ._closed :
157184 logger .debug ("dropped %s: dispatch context closed" , method )
158185 return
186+ if self .on_success_frame is not None :
187+ self .on_success_frame ()
159188 await self ._dispatcher .notify (method , params , opts , _related_request_id = self ._request_id )
160189
161190 async def send_raw_request (
@@ -182,6 +211,41 @@ def close(self) -> None:
182211 self ._closed = True
183212
184213
214+ def suppress_cancel_answer (dctx : DispatchContext [Any ]) -> None :
215+ """Commit `dctx`'s request to silence on peer cancellation.
216+
217+ 2026-era wire rule: once the peer sends `notifications/cancelled` for a
218+ request, the server MUST NOT send any further frame for it - no result,
219+ no error. Era-aware loop layers call this the moment a request commits to
220+ those semantics; do so before the request's first checkpoint, so a racing
221+ cancel can never observe the legacy default answer.
222+
223+ No-op for dispatch contexts that are already structurally silent after a
224+ cancel (single-exchange HTTP closes the response stream; direct dispatch
225+ unwinds into the caller's own cancelled scope) - only the JSON-RPC loop
226+ context writes a late answer to suppress.
227+ """
228+ if isinstance (dctx , _JSONRPCDispatchContext ):
229+ dctx .cancel_answer = None
230+
231+
232+ def observe_success_frames (dctx : DispatchContext [Any ], observer : Callable [[], None ]) -> None :
233+ """Invoke `observer` immediately before each non-error frame written for `dctx`'s request.
234+
235+ Fires for request-scoped notifications (progress rides those) and for the
236+ success result, in the writing task with no checkpoint before the write -
237+ so state the observer commits is settled before the peer can read the
238+ frame. It does not fire for error responses or for notifications dropped
239+ because the request already finished. `observer` must not raise.
240+
241+ No-op for dispatch contexts other than the JSON-RPC loop's: the only
242+ caller is the dual-era loop's era lock, which has no analogue on the
243+ single-exchange or direct paths.
244+ """
245+ if isinstance (dctx , _JSONRPCDispatchContext ):
246+ dctx .on_success_frame = observer
247+
248+
185249def _default_transport_builder (_meta : MessageMetadata ) -> TransportContext :
186250 return TransportContext (kind = "jsonrpc" , can_send_request = True )
187251
@@ -717,15 +781,18 @@ async def _handle_request(
717781 # peers drop late responses, while a second answer for one id
718782 # would break JSON-RPC.
719783 answer_write_started = True
784+ if dctx .on_success_frame is not None :
785+ dctx .on_success_frame ()
720786 await self ._write_result (req .id , result )
721- if scope .cancelled_caught :
787+ if scope .cancelled_caught and dctx . cancel_answer is not None :
722788 # anyio absorbs the scope's own cancel at __exit__, and
723789 # `cancelled_caught` (unlike `cancel_called`) guarantees the
724790 # result write above did not happen - no double response.
725- # TODO(L38): spec says SHOULD NOT respond after cancel;
726- # the existing server always has, so match that for now.
791+ # `cancel_answer` is the legacy compat answer unless the loop
792+ # layer committed this request to 2026 cancel semantics
793+ # (silence) via `suppress_cancel_answer`.
727794 answer_write_started = True
728- await self ._write_error (req .id , ErrorData ( code = 0 , message = "Request cancelled" ) )
795+ await self ._write_error (req .id , dctx . cancel_answer )
729796 except anyio .get_cancelled_exc_class ():
730797 # Shutdown: answer the request so the peer isn't left waiting - unless
731798 # an answer write already started (it may have reached the transport;
@@ -742,12 +809,12 @@ async def _handle_request(
742809 except Exception as e :
743810 error = handler_exception_to_error_data (e )
744811 if error is not None :
745- await self ._write_error ( req .id , error )
812+ await self ._answer_error ( dctx , req .id , error )
746813 else :
747814 logger .exception ("handler for %r raised" , req .method )
748815 # TODO(L58): code=0 pins existing-server compat; JSON-RPC says
749816 # INTERNAL_ERROR. Revisit per the suite's divergence entry.
750- await self ._write_error ( req .id , ErrorData (code = 0 , message = str (e )))
817+ await self ._answer_error ( dctx , req .id , ErrorData (code = 0 , message = str (e )))
751818 if self ._raise_handler_exceptions :
752819 raise
753820 # No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id.
@@ -771,6 +838,21 @@ async def _write_error(self, request_id: RequestId, error: ErrorData) -> None:
771838 except (anyio .BrokenResourceError , anyio .ClosedResourceError ):
772839 logger .debug ("dropped error for %r: write stream closed" , request_id )
773840
841+ async def _answer_error (
842+ self , dctx : _JSONRPCDispatchContext [TransportT ], request_id : RequestId , error : ErrorData
843+ ) -> None :
844+ """Write a handler-origin error response, honoring the request's cancel-silence commitment.
845+
846+ A request committed to silence via `suppress_cancel_answer` whose peer
847+ has already cancelled gets NO frame at all - even when the handler's
848+ failure was computed before the cancellation interrupt could land (a
849+ synchronous raise after the cancel arrived, or a shielded handler).
850+ """
851+ if dctx .cancel_answer is None and dctx .cancel_requested .is_set ():
852+ logger .debug ("dropped error for %r: peer cancelled a silence-committed request" , request_id )
853+ return
854+ await self ._write_error (request_id , error )
855+
774856 async def _final_write (
775857 self ,
776858 write : Callable [[], Awaitable [None ]],
0 commit comments