Skip to content

fix(net): forget spliced tracks unread for the linger, finished ones included - #4361

Merged
kixelated merged 4 commits into
mainfrom
claude/forget-unread-spliced-tracks
Sep 28, 2026
Merged

kixelated merged 4 commits into
mainfrom
claude/forget-unread-spliced-tracks

Conversation

@kixelated

@kixelated kixelated commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A relay's front (origin::run_front) removed a logical track from its state machine as soon as it finished or aborted. The idle linger (TRACK_IDLE_LINGER, 30s) only runs for tracks in the machine, so a finished track stayed in the broadcast's spliced.tracks map, and kept the source copy's cache pinned through its segments, for the life of the front. A long-lived broadcast that churns short-lived track names grew without bound. Aborted tracks were only reclaimed if someone looked the name up again, and tracks that were parked and released, or never read at all, left a stub per name.

Found while reviewing how ended and aborted tracks are cached after #4351.

Approach

One rule: a track nobody reads for the linger is forgotten, meaning removed from the broadcast along with everything behind it.

  • front.rs: finished and aborted tracks stay in the machine with an ended flag, so they are never spliced again but still follow the demand edges. Every unread state goes to Parked { since }, including a freshly assigned track, which now starts unread until Used arrives. The deadline emits Action::Forget (replacing Release), and the driver confirms with Event::Forgotten. A local source's unread track is forgotten at once, as it was released at once before.
  • origin.rs: Finish and Abort now drop the track's source handles (TrackIo::end) but keep the entry. Forget calls broadcast::Producer::forget_spliced, which removes the entry only if it has no reader. Lookups hand out readers under the same lock, so a reader arriving after the decision keeps the track. The driver feeds that reader's Used edge itself, since the level-triggered demand poll would miss a reader gone before the next poll and leave the track with no linger armed.
  • Within the linger, a returning reader still gets a finished track from the front's cache without asking the source again.

Tests

  • origin_impl::tests::finished_track_is_forgotten_after_the_linger: a finished track is served from the front within the linger (no new source request), and after the linger the next reader asks the source again. It fails on main.
  • front unit tests: a finished track lingers, is not re-spliced on failover, then is forgotten; an aborted track lingers then is forgotten; a reader racing the forget keeps the track; a local unread track is forgotten at once; a never-read assigned track is forgotten. The exhaustive walk test now includes Forgotten.
  • just check passes.

Impact

  • Public API: none. resume::Producer::is_clone and broadcast::Producer::forget_spliced are pub(crate).
  • Wire: none.
  • Behavior: a relay forgets finished, aborted, parked, and never-read spliced tracks 30s after their last reader leaves. The next reader asks the source afresh instead of being served the old finished track forever.
  • JS: not affected (no spliced front).

Follow-ups

  • The front machine's next_deadline and the driver's per-wake poll both scan every track, so each step is O(tracks within the linger). Needs a churn benchmark swept over tracks per front, then an expiry index and per-track wakes.
  • A reader that comes and goes between driver polls does not restart the linger (level-only demand in kio), for parked tracks on main as well. Costs one extra source request; a demand generation in kio with a loom model would close it.

Refs #4351.

🤖 Generated with Claude Code

(Written by Claude Opus 5.5)

…included

A relay's front removed a finished or aborted logical track from its
machine, so the idle linger never ran for it and the broadcast kept it
(and the source copy's cache) for the life of the front. Tracks now
stay in the machine until unread for TRACK_IDLE_LINGER, then are
removed from the broadcast. The same covers parked tracks and lookups
whose reader left before the front saw them.

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

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 0cf985ea-7957-472e-b396-42d0af6c885a

📥 Commits

Reviewing files that changed from the base of the PR and between a91bcb3 and b8bfa11.

📒 Files selected for processing (4)
  • rs/moq-net/src/model/broadcast.rs
  • rs/moq-net/src/model/front.rs
  • rs/moq-net/src/model/origin.rs
  • rs/moq-net/src/model/resume.rs

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 0 remain after this review.


Walkthrough

Track assignment now records its time and starts unread tracks in a parked state. Finished and aborted tracks remain available during the linger period. When a track is due for forgetting, the front requests removal and waits for confirmation; if a reader arrives first, the track remains. The origin clears ended source state and uses producer identity and reader state when processing forget requests.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to b8bfa

Track forgetting has no established merge-blocking issue; merge after normal checks.

Security Architecture Review

Security architecture risk: 🔵 Low · up to b8bfa

The new cleanup flow changes when cached tracks are retained, but the inspected reader safeguards and event ordering limit the apparent security risk. Production behavior and rollout coverage remain unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — A reader can influence demand for track names within an authorized broadcast path. The inspected lookup uses that broadcast’s state rather than selecting another broadcast by track name.

Trust Boundaries and Controls

  • observed — Broadcast requests check consumer scope before joining or creating a front. Within a front, a reader present at the forget decision prevents removal; a later lookup can create a new track.

Resilience and Maintainability Implications

  • observed — The origin drains queued confirmations before polling another assignment. This ordering prevents a normal subsequent same-name assignment from being removed by the preceding forget confirmation.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 30 functions across 4 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description clearly explains the track cleanup problem, implementation, tests, impact, and follow-ups. It directly matches the changeset.
Title check ✅ Passed The title clearly identifies the primary change: forgetting spliced tracks after the unread linger, including finished tracks.
✨ Finishing Touches
✨ Simplify code
  • Commit to this branch
  • Create a new PR

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 28, 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-09-28T19:45:51.599057Z b8bfa11 New commits
ℹ️ 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.

@kixelated

Copy link
Copy Markdown
Collaborator Author

MERGE

Positive improvement, worth the complexity, and the approach looks right.

What this fixes. Finished and aborted tracks used to leave the front machine immediately, so TRACK_IDLE_LINGER never applied to them. They stayed in spliced.tracks (and pinned segment cache) for the life of the front. On a long-lived broadcast that churns short-lived track names, that is unbounded growth. Keeping them in the machine with ended and applying the same unread linger is the correct fix.

Why this shape. Renaming Release → Forget and making forget remove the broadcast entry (not just the warm copy) unifies parked, finished, aborted, and never-read assigned tracks under one rule: unread for the linger → gone. Starting TrackAssigned as Parked { since } closes the stub-per-name hole for lookups whose reader never stuck. The forget_spliced / Forgotten handshake under the same lock as lookups is the right race story for a reader arriving after the machine decided; the unit test for that path and the integration test (finished_track_is_forgotten_after_the_linger, fails on main) make the intent concrete.

Complexity. +317/−55 across four files is real, but most of it is the ended flag, the confirmation event, and tests. No public API or wire change. A separate GC over spliced.tracks would be worse: it would duplicate demand/linger logic the front already owns. Forgetting finished tracks immediately on last-reader-leave would regress the within-linger cache hit the linger exists for (HLS poll cadence).

Nits (non-blocking). CI was still pending when this review ran — worth a glance once Check/Test finish. The aborted-track unit test stops at Forget and does not assert Forgotten / map removal the way the finished-track test does; tightening that would match the claimed symmetry. Extremely tight TOCTOU where is_used flips true then false between forget_spliced and the next demand wait could leave a track Idle with no deadline until the next reader; that only delays forget, does not reintroduce unbounded churn, and is acceptable.

Recommendation: MERGE once CI is green.

This is an automated review, not the maintainer's decision
(Written by Grok)

kixelated and others added 2 commits September 28, 2026 11:29
The demand poll is level-triggered, so a reader that blocked the forget and
left before the next poll left the track unread with no linger armed.
Also assert the aborted track leaves the machine once forgotten.

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: 3155fc8c74

ℹ️ 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 +538 to +539
track.state = TrackState::Idle;
track.ended = true;

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 Avoid rescanning every lingered track per event

When a relay churns short-lived tracks, retaining each finished track here for 30 seconds makes self.tracks proportional to the recent completion rate, while every Front::step calls next_deadline(), which linearly scans that entire map. A workload completing R tracks per interval therefore turns subsequent assignments, demand edges, and completions into O(R²) work during the linger window, whereas finished tracks previously left the map immediately. Maintain the next expiry without a full-table scan, and add a churn benchmark that sweeps the track count to expose the slope.

AGENTS.md reference: AGENTS.md:L35-L36

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.

Agreed the scan is linear, but it predates this PR and is not the dominant term: the driver in origin::run_front also polls every track in tracks on each wake, so both are O(tracks) per step. This PR bounds that table to tracks read within the linger; before it, finished tracks left, but aborted, released, and never-read ones stayed for the life of the front, so the table was unbounded. Fixing only next_deadline would leave the same slope in the driver.

Leaving this for a follow-up that sweeps a churn benchmark over tracks per front and replaces both scans (an expiry index in the machine, per-track wakes in the driver).

(Written by 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: b8bfa11cf0

ℹ️ 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 +2472 to +2473
if let Some(io) = tracks.get_mut(&name)
&& !broadcast.forget_spliced(&name, &io.resume)

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 Restart the linger after transient readers

When a cached or finished track is read and fully dropped between driver polls, which is possible because cached reads can complete without yielding to run_front, poll_used only observes the current level and no Used/Unused event refreshes Parked::since. At the old deadline this check sees is_used() == false and forgets the track even if its last reader left moments ago, so the next reader unnecessarily requests the source instead of receiving the promised 30-second cache linger. Preserve a demand generation or otherwise restart the linger after this transient-use race, and cover the concurrent handoff in loom.

AGENTS.md reference: rs/moq-net/AGENTS.md:L14-L15

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.

Real, but not new here and not a correctness issue. Parked tracks on main have the same gap: a reader that comes and goes between driver polls does not refresh Parked::since, so Release drops the warm copy at the old deadline. This PR only extends the same linger to finished and aborted tracks. The cost is one extra source request after a sub-poll read, and the next reader still gets correct data.

Closing it properly needs a demand generation on the channel itself (the consumer count in kio is level-only), which is a change to the kio primitive plus a loom model, so it belongs in its own PR. Leaving it as a follow-up.

(Written by Opus 5.5)

@kixelated
kixelated merged commit 166f9e8 into main Sep 28, 2026
5 checks passed
@kixelated
kixelated deleted the claude/forget-unread-spliced-tracks branch September 28, 2026 20:17
@kixelated

kixelated commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Summary

Landed at b8bfa11.

Changes since review

  • Driver: when forget_spliced is refused because a reader holds the track, feed Used directly. The demand poll is level-triggered, so a reader gone before the next poll would otherwise leave the track Idle with no linger armed (the TOCTOU the Grok review noted; it could leave a stub per churned name).
  • Test: the aborted-track unit test now asserts Forgotten removes the track, matching the finished-track test.
  • Brought in origin/main twice; just check and cargo nextest run -p moq-net pass locally, CI green.

Codex findings (replied, not fixed)

  • next_deadline linear scan: pre-existing, and the driver per-wake poll has the same slope; this PR bounds the table rather than growing it. Follow-up with a churn benchmark.
  • Transient reader between polls does not restart the linger: same on main for parked tracks, costs one extra source request, and needs a demand generation in kio. Follow-up.

main later gained #4363, a test-only addition to the origin.rs tests with no interaction here, so it was not brought in again.

Edited: this comment first went up with another PR's summary by mistake.

(Written by Opus 5.5)

This was referenced Sep 28, 2026
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