Skip to content

fix(net): backport JS requester FIN semantics - #5213

Merged
kixelated merged 7 commits into
releasefrom
quest/m1/release-backports/js-fin
Oct 11, 2026
Merged

kixelated merged 7 commits into
releasefrom
quest/m1/release-backports/js-fin

Conversation

@kixelated

@kixelated kixelated commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Release's JS publisher cancels draft-19+ subscriptions after a clean requester FIN, losing media sent afterward. The backported update loop also acknowledged unsupported location and authorization-token parameters, lost UPDATE_FAILED when an update failed while the last group drained, could interleave an update reply with PUBLISH_DONE on the request stream, and let streams a failed subscription had opened finish cleanly after PUBLISH_DONE.

Approach

Backport the publisher half of #4658: earlier drafts retain FIN cancellation, while modern drafts serve until reset or STOP_SENDING. Reject unsupported LOCATION_FILTER, LARGEST_OBJECT and AUTHORIZATION_TOKEN updates with NOT_SUPPORTED and UPDATE_FAILED, matching Rust's request_stream.rs, and retain update failures through queued-group draining.

REQUEST_OK, REQUEST_ERROR and PUBLISH_DONE go through one per-subscription reply chain, so each message is written whole and in order, nothing follows PUBLISH_DONE, and a refused update queued ahead of PUBLISH_DONE still reports UPDATE_FAILED.

When the subscription ends with an error of our own (a refused or malformed update, a failed track), the publisher aborts the track subscriber with it and awaits the open groups and fill before PUBLISH_DONE. Truncated groups reset instead of claiming completion, and the stream count stays final. A StreamError (the requester resetting or stopping the request) keeps the existing cancel shortcut.

Impact

  • Public API: no consumer signatures change. The internal status constants include UPDATE_FAILED, and the internal Parameters gains an authorizationToken getter.
  • Wire: apply existing draft-defined FIN, request-error, and publish-done semantics without changing framing.
  • No package version or release change.

Decisions

Confirmed by the maintainer:

  • Serialize replies on release now and forward-port to main as an [XS] follow-up. ✅ Fix here, forward-port to main / Fix main first, then backport.
  • Codex security finding (O(subscribers) recompute per priority REQUEST_UPDATE): pre-existing on release through moq-lite SUBSCRIBE_UPDATE and on main through both wires, and quest/m1/perf/demand-aggregate.md keeps the JS fold. ✅ No action / Bound updates in this PR.

Alternatives

Selected: preserve the complete FIN backport and explicitly refuse unsupported updates. Applying new filters would add range-update semantics beyond this backport; silently acknowledging them is incorrect. A promise chain local to the subscription was preferred over a general serialized writer in Writer, which would touch every stream for a problem only this request stream has.

Validation

  • The original FIN regression fails before the backport on drafts 19–22.
  • Refusal regressions cover LOCATION_FILTER, LARGEST_OBJECT and AUTHORIZATION_TOKEN on drafts 17–22; the token cases fail without the new predicate.
  • PUBLISH_DONE waits for an update reply that is partly written fails without serialization (unexpected end of stream).
  • a refused/malformed update resets an open group instead of finishing it saw a FIN before the teardown fix.
  • All 104 publisher tests and Nix just check against release pass. CI is green, and Codex found no issues at 35479b6.

Follow-ups

(Written by Claude Opus 5.5)

kixelated and others added 2 commits October 10, 2026 09:31
Co-Authored-By: GPT-6 <noreply@openai.com>
Co-Authored-By: GPT-6 <noreply@openai.com>
@kixelated
kixelated marked this pull request as ready for review October 10, 2026 22:40
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-11T15:52:53.254427Z 35479b6 Manual request
🔒 Security Review ✅ Completed 2026-10-10T23:41:49.352264Z 8fc14fe Draft marked ready

Security findings

Advisory findings (1)

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c082edaa6c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +452 to +456
params.forward === false ||
params.bytes.size > 0 ||
params.rangeFilters ||
params.trackPropertyFilter ||
[...params.vars.keys()].some((key) => key !== 0x10n && key !== 0x20n);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refuse location-filter updates instead of acknowledging them

When a draft-17+ REQUEST_UPDATE carries LOCATION_FILTER, Parameters.decode stores it in the private #filter field (parameters.ts:666-668), so none of these checks sees it. The publisher consequently sends REQUEST_OK while ignoring the new filter and can continue delivering objects outside the acknowledged range, contrary to the documented requirement that non-priority updates receive NOT_SUPPORTED and UPDATE_FAILED. Check params.subscriptionFilter here as unsupported.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

const served = Symbol("served");
ended =
(await race([Promise.all([serving, filling]).then(() => served), stream.reader.closed])) === served;
ended = (await race([Promise.all([serving, filling]).then(() => served), requestEnded])) === served;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Observe update failures until queued groups finish

When the producer ends while a group stream is still draining, serving wins this race and permanently sets ended before the later Promise.all(groups) wait. If the requester sends an unsupported update before receiving PUBLISH_DONE, requestEnded still rejects and cancels the groups, but its error is no longer captured in publishError; the publisher therefore emits TRACK_ENDED rather than the required UPDATE_FAILED. Keep observing or recording requestEnded until the queued groups have drained.

Useful? React with 👍 / 👎.

@kixelated
kixelated marked this pull request as draft October 10, 2026 22:45
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
@kixelated
kixelated marked this pull request as ready for review October 10, 2026 23:33

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by review (OpenAI)

Reviewed commit: 8fc14fe.

[P2] Serialize update responses with PUBLISH_DONE. The update task's REQUEST_OK writes at publisher.ts:469–472 run independently of the serving task's PUBLISH_DONE writes at 577–597. If the track ends while an update response is in progress, the type, length and body writes can interleave, corrupting the control stream: Message.encode writes the length/body separately and does not serialize complete messages. REQUEST_ERROR has the same exposure during normal completion. Coordinate terminal completion with in-flight responses through one serialized message-writing path. Add a deterministic regression that pauses an update-response write after its type, ends the track, and verifies both messages decode in order.

The prior location-filter finding and queued-group UPDATE_FAILED finding are addressed by explicit parameter checks and retained request errors, with targeted regression coverage. The version-gated FIN fix and refusal of unsupported updates are appropriate for this backport; implementing dynamic range changes would expand its scope.

Verification: static review of all four changed files and supporting stream/parameter paths; no tests independently executed. The PR reports 88 publisher tests, Nix just check, and all-language interop passing.

(Written by OpenAI)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8fc14fe87a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const served = Symbol("served");
ended =
(await race([Promise.all([serving, filling]).then(() => served), stream.reader.closed])) === served;
ended = (await race([Promise.all([serving, filling]).then(() => served), requestEnded])) === served;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for in-flight update replies before PUBLISH_DONE

When the producer ends while a REQUEST_UPDATE reply is still being encoded, serving can win this race even though updates is midway through an iteration, because requestEnded only settles when the entire update loop ends. Both paths then write to the same stream concurrently, so a REQUEST_OK or REQUEST_ERROR type can be followed by PUBLISH_DONE before its length and body, corrupting the control-stream framing; an unsupported update can also be reported as TRACK_ENDED if the done status is selected first. Ensure the current update and its response finish before finalizing the subscription.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e8941e0: REQUEST_OK, REQUEST_ERROR and PUBLISH_DONE share one per-subscription reply chain, so each message is written whole and in order and nothing follows PUBLISH_DONE. A refused update records UPDATE_FAILED before queueing its reply, so a PUBLISH_DONE behind it reports that status. Regression: PUBLISH_DONE waits for an update reply that is partly written.

(Written by Claude Opus 5.5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Codex Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: 8fc14fe87a

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment on lines +469 to +470
if (params.subscriberPriority !== undefined)
track.update({ ...track.subscription.peek(), priority: fromWire(params.subscriberPriority) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Codex Security Review · Automatically triggered

P2 Badge Security: Bound the work performed by priority updates

When a remote peer can subscribe to a high-fanout track served by the JS server API, it can alternate the priority in tiny REQUEST_UPDATEs. Each changed call to track.update makes the producer recompute the combined subscription by scanning every live sink (track.ts:164-190, 612-618); the awaited ACK lets that Signal flush before the next update, so microtask coalescing does not help. At 10,000 sinks, the target-tree reproduction measured 0.72-0.78 ms per update, enough for about 1,300 low-bandwidth updates/s to occupy one JS core and delay every session in the process. Maintain an incremental O(1) aggregate or bound updates. (Written by gpt-5.6-sol)

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining here. This is pre-existing: release already calls track.update for every moq-lite SUBSCRIBE_UPDATE without waiting on an ack, and main has the same fold under both wires. quest/m1/perf/demand-aggregate.md makes the counted aggregate Rust-only and keeps the JS fold, since no JS relay fans one track out to thousands of readers. If JS servers become a high-fanout target, that decision should be revisited in a quest rather than a backport.

(Written by Claude Opus 5.5)

kixelated and others added 2 commits October 11, 2026 08:16
REQUEST_OK and REQUEST_ERROR come from the update loop while PUBLISH_DONE comes
from the serving task, and each message is written in several pieces. Chain the
writes so each message goes out whole and in order, nothing follows PUBLISH_DONE,
and an update refused ahead of it still reports UPDATE_FAILED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e8941e03e4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

});
const unsupported =
params.forward === false ||
params.bytes.size > 0 ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refuse authorization-token updates instead of acknowledging them

When a draft-17+ REQUEST_UPDATE carries an authorization token, Parameters.decode stores parameter 0x03 in its private repeatable-parameter map rather than bytes. Consequently params.bytes.size remains zero and none of the other predicates detects the token, so the publisher ignores it and sends REQUEST_OK. Expose/check this repeatable parameter and return NOT_SUPPORTED plus UPDATE_FAILED, as is already done for the other unsupported update fields.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1fc8cc0: Parameters.authorizationToken exposes the repeatable 0x03, and the update loop refuses it with NOT_SUPPORTED and UPDATE_FAILED, matching Rust's !token.is_empty() in request_stream.rs. The refusal tests now cover a token on drafts 17–22.

(Written by Claude Opus 5.5)

Comment on lines +503 to +506
// Updates can fail after the serving loop ends while its last groups drain.
(err: unknown) => {
publishError = error(err);
unsubscribe();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Settle open group streams before sending PUBLISH_DONE

If an unsupported update arrives while a group stream is already open and still reading a live group, this unsubscribe() resolves the shortcut raced at line 573, but #runGroup only uses that promise while opening the stream and does not cancel an already-open one. The publisher can therefore send PUBLISH_DONE, return, and leave the group task transmitting or pinned indefinitely afterward, contrary to this function's requirement that every opened data stream be closed first. Either cancel active group streams on update failure or keep awaiting their tasks before finalizing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly agreed, fixed in 1fc8cc0. The task was not pinned indefinitely: the track.close() in finally ended it right after PUBLISH_DONE. But it ended with a clean FIN, claiming a complete group, and after PUBLISH_DONE. A refused update now closes the track subscriber with the failure, so open group streams reset, and waits for them before PUBLISH_DONE. Regression: a refused update resets an open group instead of finishing it (it saw a FIN before the fix). Peer cancellation keeps the existing shortcut, since that requester has already left.

(Written by Claude Opus 5.5)

A REQUEST_UPDATE carrying AUTHORIZATION_TOKEN decoded into the repeatable
parameter map, so the update loop acknowledged it; refuse it like every other
non-priority update, as Rust does. A refused update also left open group streams
to finish cleanly after PUBLISH_DONE, claiming a complete group; abort them with
the failure and wait for them before PUBLISH_DONE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

(Written by Claude Opus 5.5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1fc8cc0ffd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread js/net/src/ietf/publisher.ts Outdated
Comment on lines +578 to +580
if (publishError instanceof UpdateFailed) {
track.close(publishError);
await Promise.all(groups);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for the active fill before sending PUBLISH_DONE

When a draft-20+ subscription has a live FILL stream and the requester sends an unsupported update, unsubscribed wins the race at line 574, but this branch waits only for groups; the separate filling promise can still be unwinding and resetting its fetch stream. PUBLISH_DONE can therefore be sent before that data stream closes, contrary to the invariant documented immediately above, and its stream count may be read before a concurrently opening fill increments it. Await filling as well before finalizing a publisher-ended subscription.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 35479b6: a subscription we end with our own error now awaits the fill alongside the groups before PUBLISH_DONE, so its stream count is final. No dedicated fill regression, since holding a live FILL open deterministically isn't cheap in this harness. The group case is covered.

(Written by Claude Opus 5.5)

Comment thread js/net/src/ietf/publisher.ts Outdated
Comment on lines +578 to +581
if (publishError instanceof UpdateFailed) {
track.close(publishError);
await Promise.all(groups);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Settle group streams after malformed updates too

If a requester sends a malformed REQUEST_UPDATE, such as an unexpected message type or truncated parameter block, updates rejects, the observer resolves unsubscribed, and the group wait at line 574 returns immediately. Because this new guard handles only UpdateFailed, the publisher then emits PUBLISH_DONE(INTERNAL_ERROR) while open group streams are still running and closes them later from finally. Fresh evidence beyond the earlier refused-update finding is that the final tree's instanceof UpdateFailed guard leaves all decoding and framing errors on this path; locally generated request errors must also close and await the data streams before the terminal message, or abort the request instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 35479b6: the guard now covers any error of our own rather than only UpdateFailed, including malformed updates and a failed track. It aborts the subscriber and awaits the groups and the fill before PUBLISH_DONE. A StreamError (the requester resetting or stopping the request) keeps the existing cancel shortcut. Regression: a malformed update resets an open group instead of finishing it, which saw a FIN under the narrower guard.

(Written by Claude Opus 5.5)

A malformed update ends the subscription with an error of our own just as a
refused one does, and a live fill is a data stream too. Abort and await both
groups and the fill before PUBLISH_DONE whenever the error is ours, leaving the
requester-cancel shortcut for stream errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

(Written by Claude Opus 5.5)

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 35479b6954

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kixelated

Copy link
Copy Markdown
Collaborator Author

Merge summary

Head 35479b6954 backports the JS requester FIN semantics from #4658 to release. Four follow-up fixes came out of review:

  • Update replies and PUBLISH_DONE are serialized through one per-subscription reply chain.
  • AUTHORIZATION_TOKEN updates are refused, matching Rust.
  • A subscription ended by our own error (a refused or malformed update, a failed track) resets its open groups and fill and waits for them before PUBLISH_DONE.
  • Merged latest release to clear the test-only conflict.

Confirmed decisions:

  • Fix the reply race here on release, with an [XS] forward-port to main as a follow-up.
  • No action on the Codex update-storm finding: it is pre-existing on both branches, and quest/m1/perf/demand-aggregate.md keeps the JS fold.

CI is green, and Codex found no issues at this head. Enabling auto-merge.

(Written by Claude Opus 5.5)

@kixelated
kixelated merged commit ec21e99 into release Oct 11, 2026
5 checks passed
@kixelated
kixelated deleted the quest/m1/release-backports/js-fin branch October 11, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant