Skip to content

fix(net): a resumed group keeps the head the replaced route received - #5958

Merged
kixelated merged 13 commits into
mainfrom
quest/m1/resume-prefix
Oct 19, 2026
Merged

kixelated merged 13 commits into
mainfrom
quest/m1/resume-prefix

Conversation

@kixelated

@kixelated kixelated commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Plans and finishes quest/m1/resume-prefix.md (added and removed in this PR).

Problem

After a route change mid-group, a front's reader resumed group S at frame N by fetching N onward from the new route. The new route's cache then held S from N only: the replaced copy's commit_abort released frames 0..N-1 before anything could take them, and Recover held no frames. A later reader or fetch of S from frame 0 missed the cache and went upstream again, so a relay that saw the route change behaved differently from one that did not. This affects every group, not only compressed ones.

Change

  • Hand-off (group::Handoff): a group handed out from a logical track shares one hand-off among the readers that can resume it (created on first hand_out, held weakly by the group). It reads the copy's frames while the copy has them. If the copy is released for any reason other than cache policy (a failed or given-up abort, or a producer dropped unfinished), release moves the committed frames into the hand-off instead of dropping them. Old, Timeout, Evicted and GroupTooLarge still free everything.
  • Stale consumers pin nothing: a plain consumer of the aborted copy still reads the abort, and the group state is still cleared. The moved frames live only as long as a resuming reader holds the hand-off. A reader that adopts a replacement switches to that copy's hand-off.
  • Adoption (group::Producer::adopt_head, through track::Consumer::adopt_head): before Recover checks a candidate replacement (delivered by subscription or fetched), it lends the head to the serving copy's cached copies of that group. Each one that starts above the head and is reached by it takes the frames below its start. The adopted frames are billed and checked against the group budget (if they do not fit, nothing is adopted). IETF object fields stay aligned, and the wire header's first_object/first_id/last covers the head, including a header that arrives after the adoption.
  • The fetch still asks from the reader's frame, so request shapes, handlers and the wire are unchanged.

Hook for #5928: group::Producer::adopt_head is where a group gains frames below its start. A compressed group would rebuild its inflater there from the adopted head, rather than widening resume to frame 0.

JS: js/net has no route resume on main (it is in #5836 / quest/m1/js-group-handover.md), so there is nothing to mirror here yet. That port should carry this contract.

Public API and wire impact

None. Everything new is pub(super). No wire, moq-ffi or binding changes, so the interop matrix was not run.

Decisions (settled by the maintainer)

  1. ✅ A held head is billed and bounded like cached content. The maintainer: unbilled is fine only if it is not abusable. It was abusable. An audit of the egress found nothing that caps the group consumers a stalled downstream can hold: lite serve() pushes a GroupServe per group with no in-flight cap (rs/moq-net/src/lite/publisher.rs:3733-3754), IETF says "one machine per group, with unlimited concurrency" (rs/moq-net/src/ietf/publisher.rs:4007), the max-delay budget defaults to about 2^53 ms (lite/publisher.rs:90-104), and an aborted group waiting in Open never expires. So an upstream forcing route changes, plus a downstream withholding credit, could pin failed groups' heads without limit; before this PR, the abort released them. Now, as the second commit:
    • The head is billed to its track's pool for as long as it is held (cache::Bill, released on drop). It takes the place of the group's own charge, which the release just cleared.
    • It is not held when that would take the pool past its capacity (cache::Track::hold).
    • The pool sweep drops it once the pool's idle window passes (cache::Track::expire_held, registered only after the head is stored, so a concurrent sweep cannot forget it). That is 30 s by default on a relay, the same window that reclaims idle cached groups.
    • Worst case: held heads are bounded like the cache itself, by the capacity when one is set and otherwise by ingress over the idle window. Only an inert pool, with no capacity and no expiry, leaves them unbounded, as it leaves the cache.
  2. ✅ A resuming reader that stops polling keeps the head until it drops, now within the bounds of 1.
  3. ✅ Sparse groups lend and adopt nothing, and adoption is skipped when it does not fit the group budget. The budget counts an open frame's unwritten remainder, which its open already admitted.

Locking: Handoff::head takes the group's lock before the salvage, in release's order, so it cannot deadlock against a concurrent release or see the head in neither place.

Tests

  • resume: a_failed_route_hands_its_head_to_the_replacement, a_stalled_route_lends_its_head_to_the_replacement (both failed on main: the later fetch from frame 0 was a cache miss), and an_evicted_group_hands_nothing_to_the_replacement.
  • group: an_abort_hands_the_head_to_its_resumers (the frames move only for a non-policy abort, are billed while held, and are freed and unbilled with the last resumer), a_held_head_expires_with_the_pool_window and a_full_pool_holds_no_head (both failed without the second commit), an_open_frame_reserves_its_remainder_from_adoption (failed without the third commit), and an_adopted_head_keeps_fields_and_the_wire_aligned (fields and wire header, whether the header was set before or after adoption, billing, and reader positions).
  • Plain #[test]s, no wall clock.

Benchmark: origin/relay (tracks by readers through a front, the hand_out path that now takes the hand-off) against a main baseline on the same machine shows no regression: every shape measured faster, which is within the noise of the baseline run. Adoption runs only on a route change.

Checks: just check passes (exit 0). cargo test -p moq-net --all-features passes. quest check passes. Not run: just test interop --all (no wire or ffi change), just rs loom.

Follow-ups

🤖 Generated with Claude Code

(Written by Claude Opus 5.5)

kixelated and others added 2 commits October 19, 2026 09:26
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a front resumes a group on another route, the replacement copy now
adopts frames 0..N-1 the replaced copy held and fetches only N onward, so
the serving route caches the group whole, as a cold relay would.

A group handed out from a logical track shares a hand-off with its readers.
A release that is no cache policy moves the committed frames into it rather
than dropping them, so a plain consumer still reads the abort and pins
nothing, and the frames go with the last resuming reader.

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

chatgpt-codex-connector Bot commented Oct 19, 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-19T21:22:57.493025Z 659248e Manual request
ℹ️ 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: 79396ca4f7

ℹ️ 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 rs/moq-net/src/model/group.rs Outdated
Comment thread rs/moq-net/src/model/group.rs Outdated
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 25 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 63e538b1-2451-4322-b0bf-b16819a0da0d
📥 Commits

Reviewing files that changed from the base of the PR and between 25532ea and 659248e.

📒 Files selected for processing: 4
  • rs/moq-net/src/model/cache.rs
  • rs/moq-net/src/model/group.rs
  • rs/moq-net/src/model/resume.rs
  • rs/moq-net/src/model/track.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Nothing bounds how many group consumers a stalled downstream egress
holds, so an unbilled hand-off let a peer that forces route changes pin
failed groups' heads without limit. A held head is now billed to its
track's pool, refused when the pool has no room for it, and dropped by
the sweep once the pool's idle window passes, the same bounds as cached
content.

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

Copy link
Copy Markdown
Collaborator Author

Decisions on the three internal choices, now recorded in the description:

  1. ✅ Unbilled is fine only if it can't be abused. It could be: nothing caps the group consumers a stalled downstream egress holds (lite serve() has no in-flight cap; IETF runs one machine per group with unlimited concurrency; the default max-delay is about 2^53 ms; an aborted group waiting in Open never expires). So an upstream forcing route changes, plus a downstream withholding credit, could pin heads without limit. Fixed in 307d50c: a held head is billed to its track's pool, not held when the pool has no room for it, and dropped by the sweep once the pool's idle window passes (30 s by default). Held heads now share the cache's bounds. Regression tests a_held_head_expires_with_the_pool_window and a_full_pool_holds_no_head fail without the commit.
  2. ✅ A resuming reader that stops polling keeps the head until it drops, within the bounds above.
  3. ✅ Sparse groups adopt nothing, and adoption is skipped when it does not fit the group budget.

just check passes on 307d50c.

(Written by Claude Opus 5.5)

@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: 307d50c8f3

ℹ️ 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 rs/moq-net/src/model/group.rs Outdated
Comment thread rs/moq-net/src/model/group.rs
kixelated and others added 3 commits October 19, 2026 11:12
…e from adoption

- Handoff::head takes the group's lock before the salvage, as release does, so it
  neither deadlocks against a concurrent release nor sees the head in neither place.
- A held head registers for expiry only once stored, so a sweep in between cannot
  forget it.
- Adoption counts the open frame's unwritten remainder against the group budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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: edf62b2ec8

ℹ️ 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 rs/moq-net/src/model/group.rs Outdated
kixelated and others added 3 commits October 19, 2026 11:40
A GroupTooLarge from set_end_object reached commit_abort through abort and was
salvaged; abort_too_large now shares commit_abort, which frees it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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: f8a7e7c243

ℹ️ 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 rs/moq-net/src/model/cache.rs Outdated
kixelated and others added 2 commits October 19, 2026 12:59
Every failed group in an expiry-enabled pool registered its hand-off for
the sweep, even when recovery adopted the head at once, and the sweep's
retain kept the vector's peak allocation. A burst of failed groups could
pin memory outside the pool's accounting. The registry now forgets dead
hand-offs before it grows, and the sweep gives back what a burst of held
heads grew it to.

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: 7ab1757490

ℹ️ 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 rs/moq-net/src/model/cache.rs
kixelated and others added 2 commits October 19, 2026 14:12
A held hand-off head sits outside every track's eviction order, and
only an expiry window ever dropped it. In a capacity-only pool, a shrink
below the held bills left a stalled resumer keeping them while ordinary
groups were evicted. The held registry now lives on the pool, which
drops the oldest held heads first whenever a track charges debt over
capacity, and on each sweep.

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. Another round soon, please!

Reviewed commit: 659248e388

ℹ️ 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
kixelated enabled auto-merge October 19, 2026 21:26
@kixelated
kixelated added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit eb6ac57 Oct 19, 2026
11 checks passed
@kixelated
kixelated deleted the quest/m1/resume-prefix branch October 19, 2026 21:56
kixelated added a commit that referenced this pull request Oct 19, 2026
Main's #5958 adds a handoff field to Recover where this branch adds backups;
the fetch's Recover literal conflicted and takes both fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated added a commit that referenced this pull request Oct 19, 2026
The branch's held() path continues a failed group from another copy before
poll_serving runs, so #5958's lend_head never ran for it and that copy's cache
missed the head the failed route received.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated added a commit that referenced this pull request Oct 19, 2026
Resolve against main's head hand-off (#5958) and key-logging (#5907):
- MIGRATE: keep both Unreleased entries.
- resume: the replacement lends the failed copy's head before the
  compressed-group prefix check.
- cache: main's held-head Bill keeps its name; the inflated copy's
  growable bill becomes cache::Extra.
- group: a held Head carries a compressed group's inflated sizes, and a
  compressed group adopts only a compressed head, with its sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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