Serve subscriptions/listen over stdio, with era-scoped 2026 cancel silence#3120
Draft
maxisbey wants to merge 7 commits into
Draft
Serve subscriptions/listen over stdio, with era-scoped 2026 cancel silence#3120maxisbey wants to merge 7 commits into
maxisbey wants to merge 7 commits into
Conversation
…tcher Two per-request facts on the dispatch context, both defaulting to the existing behavior: - cancel_answer: the error written when a peer cancel interrupts the handler. Defaults to the legacy code-0 "Request cancelled" compat answer; None means total silence (the 2026 rule that nothing follows notifications/cancelled). Handler-origin error responses now also honor the silence commitment, so an error computed after the cancel landed (shielded handler, synchronous raise) never reaches the wire. - on_success_frame: an observer invoked immediately before each non-error frame written for the request (request-scoped notifications, progress, the success result), with no checkpoint before the write. Loop layers use it to commit connection state no later than the request's first client-visible output. Configured via suppress_cancel_answer / observe_success_frames, which no-op on dispatch contexts that are already structurally silent after a cancel (single-exchange HTTP, direct dispatch). The dispatcher stays era-ignorant; the era knowledge lives in whoever calls the setters.
Remove the transport-level rejection of subscriptions/listen on the stream-pair modern path: the method now dispatches through serve_one to the registered ListenHandler like every other modern request. Over stdio the listen request simply stays pending - the ack and every event are request-scoped notifications on the duplex pipe, and the response arrives only when the stream ends gracefully. server/discover's subscription capability advertisement is now truthful on this transport. Two era-scoped generalizations ride the dispatcher's new per-request seams, fixing the class rather than the listen instance: - Era lock at first client-visible success frame. A classified-modern request commits the modern lock immediately before its first non-error frame is written (notification, progress, or response), not when its dispatch returns. Plain requests lock at their response exactly as before; a listen locks at its ack, so an initialize pipelined behind a live stream is rejected with -32022 instead of flipping the connection legacy mid-stream. Classification failures and pre-frame cancellations still never lock, and progress written for a request that later fails locks correctly (the client that stamped a valid envelope already committed to modern). - Quiet cancellation for every classified-modern request. After an inbound notifications/cancelled the server sends nothing further for that request - no result, no error - matching the transport spec's MUST NOT. Legacy-era requests keep the byte-identical code-0 "Request cancelled" answer released 2025 clients unblock on. The single-exchange HTTP and direct in-process paths need no seam: cancellation is structurally silent there (stream close / caller's own scope).
A peer cancel that lands at the first success frame's own transport-write checkpoint - after the era lock committed, before the frame delivered - leaves a dual-era connection locked modern with ZERO frames on the wire. era_settles already re-checks cancel_requested at observer fire time, so this one-checkpoint window is the entire residue, and it exists only for mid-handler frames (the listen ack, progress): a plain request's response cannot be cancelled away because its in-flight entry is removed, with no checkpoint in between, before the response write starts. This is a deliberate, documented deviation from the no-frame-no-lock doctrine: closing it fully requires committing the lock only after the write is known delivered, which reopens the worse pipelined-initialize hijack the at-first-frame lock exists to prevent. The orphaned state is self-consistent and client-recoverable - only a validly-classified modern envelope reaches this path, a follow-up initialize is answered with -32022 naming the modern versions (released auto-negotiating clients treat that as modern evidence and re-probe), and the connection keeps serving modern traffic. Document the corner on serve_dual_era_loop and commit_modern, and pin it with raw-frame adjacency tests: the orphan itself (locked, silent, recoverable, slot freed), the ack beating an adjacent pipelined initialize, and the plain-request path that cannot orphan.
The dispatcher's shutdown arm wrote the CONNECTION_CLOSED drain answer for every in-flight request it caught unwinding, including one whose peer had already cancelled it and whose loop layer had committed it to 2026 cancel semantics - where no frame of any kind may follow notifications/cancelled. A silence-committed peer-cancelled request caught at loop shutdown now stays silent. The rule lives in one predicate on the dispatch context, must_stay_silent, consulted by both frame-suppressing sites (_answer_error and the shutdown drain) so it cannot drift between them; the interrupt arm remains its complementary write side. Requests the peer did NOT cancel keep the drain answer regardless of the commitment - the EOF-drain doctrine is unchanged.
The silence fact attached only after classification succeeded, so a request rejected before classification (envelope-less, malformed envelope) on a modern-locked connection could still answer after a peer cancel: the reject runs on a task spawned after the read loop may have already consumed an adjacent cancel. Unreachable under asyncio's FIFO scheduling - the reject's answer write always starts before the cancel is consumed, which is the legitimate already-answering case - but reachable under trio's randomized scheduling and under any future checkpoint on the reject path, where the frame written would have been the legacy code-0 answer or a post-cancel rejection error. Attach the fact at the top of the modern-locked branch instead, rejections included: a rejection error is a legitimate answer, only the post-cancel frame must be silent. On a still-unlocked connection the rule stays classification-scoped (an unclassifiable request has not proven modern semantics and keeps the legacy answer), initialize has no window at all (inline dispatch parks the read loop), and a legacy straggler cancelled after the lock keeps its legacy answer - scope documented at both attachment sites and on serve_dual_era_loop. With the locked branch owning the connection-scoped fact, serve_modern now attaches its two per-request facts only while the era is unlocked: once locked, the silence fact is already set and the era-lock observer is a permanent no-op, so post-lock requests skip the partial allocation and the per-frame observer invocation.
Four small hardenings around the per-request seams: - on_success_frame is invoked through fire_success_frame, which contains a raising observer (log and continue, the same boundary as the subscription bus's listeners) so an observer bug cannot convert the success it is observing into an error response. The only observer today cannot raise; this makes the contract structural. - suppress_cancel_answer / observe_success_frames now reach through NoServerRequestsDispatchContext instead of silently no-oping on it: the wrapper delegates wire I/O to the loop context it wraps, so per-request facts attached through it must land there. The wrapper moves from server/runner.py to shared/dispatcher.py - it was never server-specific, and the shared setters can only name it without a layering violation from there. The no-op remains for genuinely structurally-silent contexts (single-exchange HTTP, direct dispatch). - The legacy code-0 cancel answer template is never written to the wire: the one site that writes a cancel answer sends a fresh model_copy. ErrorData is mutable and frames cross in-memory transports by reference, so sharing the instance would let a consumer's mutation corrupt every later cancel answer process-wide. - ListenHandler's class docstring states the actual transport requirement (any dispatch context that forwards request-scoped notifications: HTTP SSE and the stream-pair dual-era loop) instead of naming SSE alone.
…tests Falsy request id 0 through the full listen lifecycle (ack, era lock, and graceful-close result all stamped with the literal 0), double-cancel and stale-cancel idempotence with the max_subscriptions slot recycled, event fan-out surviving the cancellation of a sibling stream, and a plain async-function listen handler whose schema-invalid result fails loudly without locking the era while a valid one serves and locks.
Contributor
📚 Documentation preview
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Serves
subscriptions/listenover stdio (and every stream-pair transport riding the dual-era loop), and generalizes two 2026-era behaviors the fix depends on: the era lock now commits at a request's first client-visible frame, and cancelled modern requests go silent per the transport spec.Motivation and Context
Over stdio,
subscriptions/listenwas rejected at the transport level with METHOD_NOT_FOUND whileserver/discoveron the same connection still advertisedresources.subscribeand thelistChangedflags — a 2026 client that trusted the advertisement got an error on the exact method it names. The rejection was quickest-path containment from the original stdio-2026 work, guarding against a wedge that no longer exists: the dual-era loop dispatches handlers concurrently,ListenHandleris transport-neutral (the ack and events are request-scoped notifications, which ride the duplex pipe with the in-bandsubscriptionIdstamp the stdio spec requires), and the client-side listen driver already speaks this shape. With the guard gone, listen dispatches throughserve_oneto the registered handler exactly like every other modern request — the same code path streamable HTTP uses — and the capability advertisement becomes truthful with no further change.Two era-scoped fixes ride along because listen exposes them, but they fix the class rather than the instance:
initializebehind a live stream could lock the connection legacy mid-stream. The lock now commits immediately before a classified-modern request's first non-error frame (notification, progress, or response). Plain requests lock at their response exactly as before; a listen locks at its ack; classification failures and cancelled-away requests still never lock.notifications/cancelled, a classified-modern request now sends nothing — no result, no error — including at loop shutdown. Legacy-era requests keep the byte-identical code-0"Request cancelled"answer that released 2025 clients unblock on.Completes the stdio half of subscriptions serving (#3035 / #3047 shipped the HTTP server half and the client driver; #3038 / #3040 shipped stdio dual-era serving) — the follow-up named when #2901 closed.
How Has This Been Tested?
strict-no-cover; pyright clean.initialize; a cancelled-away or rejected listen never locks; the accepted lock-without-delivered-frame corner is pinned — see below), cancel silence on modern for both listen and plain requests with the legacy answer pinned byte-identical, the shutdown drain honoring the silence commitment, request id0through the full lifecycle,max_subscriptionsslot recycling, and a custom listen handler registered on the lowlevel server.-32022era-boundary answers, EOF behavior unchanged for non-cancelled in-flight requests, legacy cancel answer byte-identical.2.0.0-beta.4over stdio in both roles: the ts client opens a subscription against this server, receives events, and both close directions behave (client-side close leaves the connection healthy; server-side close delivers the graceful result). Legacy-mode ts connect still handshakes. A stdio subscriptions checklist derived from the spec (ack-first per subscription, in-band id stamping, filter enforcement at the wire level, cancel silence windows, graceful close, capability honesty, EOF exit) passes fully against this branch and against the ts beta.4 stdio server as a control.Breaking Changes
None for the 2025 era: the handshake path is untouched and the legacy cancel answer is pinned byte-identical. Two behavior changes on the 2026 surface (unreleased protocol): cancelled modern requests no longer receive a late error response, and
subscriptions/listennow serves over stdio instead of rejecting.docs/whats-new.mdand the subscriptions handler docs are updated.Types of changes
Checklist
Additional context
Design note on the lock timing (reviewer sign-off wanted). The lock commits immediately before the first frame's write. A peer cancel landing exactly at that write's checkpoint can leave the connection locked modern with zero delivered frames. This is a deliberate, documented deviation from lock-on-delivered-success: it exists only for mid-handler frames (a listen ack, progress), a plain request's response cannot hit it, and the resulting state is self-consistent and recoverable — only a validly-classified modern envelope reaches this path, and a follow-up
initializegets-32022naming the modern versions, which auto-negotiating clients treat as modern evidence and re-probe. Committing only after delivery would reopen the pipelined-initialize hijack the first-frame lock exists to prevent. Documented onserve_dual_era_loopandcommit_modern, pinned by three adjacency tests.The dispatcher itself stays era-ignorant: it gains two per-request facts (
cancel_answer, defaulting to the legacy answer, and anon_success_frameobserver), both defaulting to existing behavior, and the era knowledge lives in the loop layer that sets them. The single-exchange HTTP and direct in-process paths need no seam — cancellation is structurally silent there already.AI Disclaimer