Skip to content

dispatcher's second streaming site answers a write-less transport res.json(result.result) — the SSE descriptor serializes with its AsyncIterable dropped, silent total event loss #9961

Closed

Description

@huangyiirene

Found while running the #9936 reader/reachability sweep (recording only, not starting). Measured on origin/main at 5989b0de90.

Blocked-by: #9936 — the correct shape for this body follows the same fork ruling that card carries; #9936 remains open and is not addressed here.

The site

packages/runtime/src/dispatcher-plugin.ts, in the dispatch-result writer (locate by text const isStream = r && typeof r === 'object', around line 351 on the measured ref; numbers drift). This is a DIFFERENT site from the mountRouteOnServer fallback #9936 names — that one is the enumerated unenveloped: 1 in check-route-envelope.mjs; this body is a relayed res.json(result.result) and is invisible to the gate's counters by design.

When a dispatch result carries the SSE/streaming descriptor and the transport's res HAS write/end, the events stream correctly. When res lacks them, the guard fails and control falls through to:

res.status(200);
applySecurityHeaders();
res.json(result.result);

result.result is the stream descriptor itself: { type: 'stream', contentType, events, vercelDataStream, headers } where events is an AsyncIterable. JSON.stringify serializes an async generator object to {} — the caller receives the descriptor's metadata with the payload gone, HTTP 200, and the iterable is never drained. Unlike #9936's site (which at least collects and returns the events), this one loses every event silently.

Reachability

Same population as #9936's site: both in-repo http.server providers (plugin-hono-server/src/adapter.ts, qa/http-conformance/src/adapter.ts) construct write/end unconditionally, so no shipped composition reaches this branch. It is reachable only through an externally supplied Runtime({ server }) transport that omits the optional write/end — a composition packages/spec/src/contracts/http-server.ts explicitly permits, and for which its #3607 / ADR-0076 OQ#10 contract text prescribes falling back to buffered send(). No in-repo test covers either streaming branch of dispatcher-plugin.

Why filed separately

#9936's scope is explicitly its one enumerated { events } literal body. This site needs the same ruling applied (buffered send() per the contract text, an envelope, or retirement), so it should be routed after that fork is decided rather than fixed to a shape the ruling might contradict.


Generated by Claude Code

Activity

  1. self-assigned this
    on Aug 19, 2026
  2. huangyiirene commented on Aug 19, 2026

    @huangyiirene
    CollaboratorAuthor

    Claim: PM loop round 25 — family member of chain-head #9936
    Session: session_01WeN7F6jQFpcqW2BN56RdPa
    Branch: claude/issue-9936-sse-fallback-envelope (⚠️ the chain-head's branch — this card does not get its own)
    Worktree: objectstack-issue-9936
    Domain: domain:cli
    File surface: packages/runtime/src/dispatcher-plugin.ts (the dispatch-result writer's write-less fallback), plus a pin and .changeset/**
    Clause-②: yes — same fork, same contract as #9936
    Serial constraints: 🔒 same file as #9936, which is precisely why this is a family dispatch and not a queued sibling. Same-file work is hard serial in this lane (maintainer-reaffirmed 2026-08-19), so shipping these as two PRs would serialise them for no benefit and risk two shapes for one contract.

    Ruling — inherited from #9936, ⛔ not re-litigable

    Option B, ruled on #9936 (comment 5340848774): implement the contract's own prescription. packages/spec/src/contracts/http-server.ts, the JSDoc on IHttpResponse.write (:86-96 on 5989b0de90), states it verbatim:

    CONTRACT (#3607, ADR-0076 OQ#10): consumers that emit streaming results … feature-detect this member and fall back to buffered send() when absent.

    I verified that text myself. Today's body contradicts a recorded contract rather than awaiting a new one, which is why this was not escalated to the maintainer.

    ⚠️ This site is the WORSE of the two — do not copy-paste #9936's fix onto it

    #9936's site at least collects the events into an array before answering. This one loses them entirely: res.json(result.result) serializes the stream descriptor with its events AsyncIterable dropped to {} by JSON.stringify, the caller gets HTTP 200 with the payload gone, and the iterable is never drained.

    ⇒ The fix must actually drain the iterable and emit the frames, not re-serialize the descriptor into a better-shaped envelope.

    What rides the family PR

    One branch, per-member commits, one PR carrying Fixes #9936 and Fixes #9961. Both sites take the same shape — that is the point of ruling them together.

    ⛔ packages/spec/** untouched: you are implementing the contract, not amending it. ⛔ Never weaken a gate. ⚠️ Note this body is a relayed res.json(...) and is invisible to check-route-envelope's counters by design — so unlike #9936's site, no gate number will move for this half. That makes the pin the only evidence it was fixed; state plainly what it would catch.

    Report on the chain-head card #9936 with the os-dev-report marker, covering both members.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions