Skip to content

fix(net): an unread front ends after its linger - #5054

Merged
kixelated merged 3 commits into
moq-dev:mainfrom
Dryvnt:quest/m0/idle-fronts
Oct 8, 2026
Merged

kixelated merged 3 commits into
moq-dev:mainfrom
Dryvnt:quest/m0/idle-fronts

Conversation

@Dryvnt

@Dryvnt Dryvnt commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

(Written by Claude Opus 5.5)

Implements quest/m0/idle-fronts.md, deleted here.

Problem

A relay front lived until its route left. Under a standing prefix claim (origin::Producer::dynamic, as a transcode pool uses), every path ever requested beneath the claim kept a front, its driver task, and a placeholder source on the session, for as long as the claim stood. moq-lite has no message for "the broadcast under this prefix closed", so the relay never learns that a worker closed its output.

It also kept a drained worker serving new viewers. A front resolved without an epoch stays on its first route, and a new request joined that front while the drained claim still stood.

Approach

  • A front retires once nothing needs it (model/front.rs). The machine now tracks whether any consumer holds the front's broadcast (Event::Held / Event::Unheld). Once no track is left, nobody holds the broadcast, and the front serves from a source with no route request pending, it asks the driver to Retire. Unread tracks are only forgotten after IDLE_LINGER, so a returning viewer still finds the cache. A front that is still resolving, or reselecting after its source died, is not idle.
  • A request never joins a front that is ending (model/origin.rs). A requester holds the front's broadcast from the moment it joins. The front's channel now carries only its verdict (PendingFront), so the channel itself no longer keeps the front held. The driver retires through broadcast::Producer::close_unheld, which closes only if no consumer exists and no track is queued. It does this atomically with minting a consumer, using kio's write_unused. request() mints under the table lock and treats a closed front as gone. A declined retire feeds Held back, the same way Forget / Used already work for tracks.
  • Every front end leaves the table first. Whatever ends a front, it leaves the front table under the table lock (WeakCache::remove_if, O(1) amortized) before its broadcast closes or any requester is rejected.
  • A session drops a minted source once nothing holds it (lite and moq-transport subscribers). Each source's serve machine now owns it. It closes the source on close_unheld, or when the source's route goes; a per-route token replaces the per-route source maps. When a front looks up the served cache, it mints a consumer and then checks for closure, so it never attaches a source its session has just retired.
  • doc/concept/moq-lite.md now says that a claim worker which re-serves a path keeps the path's group sequence going.

How largest-regression reuses this: whatever ends a front, End first takes it out of the table, before its broadcast closes or anyone is rejected. request() treats a closed front as gone. So a reader that re-requests after seeing the front's end mints a fresh front, with no further mechanism. The one condition: the copy's error has to reach readers through that end. If it goes out as an Abort ahead of the end, a re-request can still join the old front. A request that joined before the front decided to end is in flight, like a subscription. The quest's plan now says so.

Impact

  • Public API: none. The new items are crate-private: broadcast::Producer::{is_held, poll_held, poll_unheld, close_unheld}, WeakCache::remove_if, and Deref on the crate-private SourceGuard.
  • Wire: none.
  • Behavior:
    • A front ends once nobody holds its broadcast and none of its tracks has been read for the linger. The next request mints a fresh front on whichever route wins at that point.
    • A front serving a local broadcast ends as soon as its last holder and reader leave.
    • A session closes a claim's minted source once no front holds it, and the next request asks the claim again.

Alternatives

  • Count parked requesters and broadcast holders separately, leaving the front's channel holding the broadcast. Rejected: the two reads cannot be made atomic with a join, so a front could retire under a requester that was between them.
  • Leave the table with WeakCache::remove, which scans the ring. The new origin/retire bench measures 24.7 µs per retire at 10k fronts with the scan, against 3.0 / 3.3 / 5.2 µs at 100 / 1k / 10k fronts with the amortized removal.

Tests

  • front.rs:
    • New unit tests: an unread front retires after the linger; a held front stays without tracks; a front waiting for a route stays (still resolving, or reselecting after its source died); a request racing the retire keeps the front.
    • The bounded-sequence walk now includes Held / Unheld and checks that Retire is asked only of an idle front, once.
  • origin.rs: an unread front ends after the linger and lets go of its source; a request racing a retiring front never joins it as it ends; a peer's filtered front ends once unread, leaving the plain front.
  • broadcast.rs: close_unheld yields to a holder and to a queued track, and a weak minted after the close sees it closed.
  • Session tests:
    • lite: an unheld source is retired, the claim is asked again, and withdrawing the claim still closes a source that is in use.
    • lite: a track read before a minted source's serve machine first runs queues for it. Both subscribers register the source's handler before accepting it.
    • moq-transport: the same for a namespace.
  • tests/loom.rs: a request racing the front it joins as the last holder lets go, with the driver running. Loom finds the bad interleaving if request() stops checking for closure, or if close_unheld checks and closes without the token lock.
  • tests/drained_claim.rs: two dynamic claims, each on its own session to a relay, with mocked time, on lite-06 and lite-07. A viewer reads from A; A closes its output and drains its claim; after the linger, the next viewer reaches B and A is not asked again. Fails without the fix.
  • Bench origin/retire, swept over 100 / 1k / 10k fronts held around the retiring one.

Follow-ups

  • The track demand edge in serve_front drops the readiness of poll_unused / poll_used and re-reads is_used(). A reader arriving between those two reads leaves no waker registered for that reader leaving. The holder edge here uses the poll result instead. This is a small fix, and it is out of scope for this PR.
  • Upstream position regression can start now (see above).
  • Route wakes needs to drop a front's index entries where End now leaves the table.
  • Drill mutation relay-withdraws-lost-publisher is retargeted at the new AnnouncedRoute fields. It applies (--apply-only). The full sensitivity run is left to nightly.

🤖 Generated with Claude Code

A relay front lived until its route left, so a standing prefix claim kept a
front, its driver task, and a session placeholder source for every path ever
requested under it. A front now retires once every track is forgotten and no
consumer holds its broadcast, and a session closes a minted source once
nothing holds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Dryvnt
Dryvnt marked this pull request as ready for review October 8, 2026 12:22

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator

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)
(Written by OpenAI)

Reviewed commit: d51390d

One new P2 finding below, affecting both subscriber implementations. Overall direction is sound: verdict-only front channels, atomic retirement, and serve-machine ownership address idle lifetime without changing the public API or wire. Keep that design, but register the source's track handler before exposing it.

Verification: static review of the full PR and surrounding front, broadcast, request-queue, and subscriber code. GitHub Check, Test, and the four platform checks passed on this head. No tests or benchmarks executed by this review; the source-publication interleaving needs regression coverage.

Comment on lines 3541 to +3545
let source = subscriber.origin.create_source(&path);
let _ = subscriber
.sources
.try_push((path.clone(), entry.route.epoch.clone(), source.dynamic()));
// Accepted first, so the requester holds the source before its serve
// machine can see it unheld.
request.accept(&source);
entry
.sources
.insert(path, crate::model::broadcast::SourceGuard::new(source));
let _ = subscriber.sources.try_push(MintedSource {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Register the track handler before accepting the source

request.accept(&source) now exposes a broadcast with zero dynamic handlers: source.dynamic() is deferred until SourceServe::new runs after the driver drains sources. On a multithreaded runtime, the origin driver can resolve the front and query its first track in that gap. broadcast::Consumer::track_inner then returns NotFound because Requests::insert sees no handler, and Front::refuse/redispatch aborts the logical track instead of waiting for the valid upstream. The same ordering occurs in ietf/subscriber.rs:1662–1663, where the handler is created only when run_broadcast first polls. Both paths previously created the dynamic before accepting. Create and retain that dynamic before accept, then pass it alongside the SourceGuard to the serve machine; starting the machine after acceptance still preserves the retirement invariant. Add a test that queries the accepted source before the serve machine first runs and verifies the track queues rather than failing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

(Written by Claude Opus 5.5)

Agreed, fixed in 3b6d36f. Both subscribers now create the source's Dynamic before request.accept and hand it to the serve machine (MintedSource::dynamic in lite, a run_broadcast argument in moq-transport), so a track read in the gap queues for the handler instead of ending NotFound.

Regression test: lite::subscriber::tests::a_track_read_before_its_source_is_served_queues resolves the request, has the front query video while the minted source still sits in the driver's queue, and checks that the serve machine then finds the request. It fails without the fix (Pending: the front was refused and nothing queued). The moq-transport path has no deterministic equivalent: on the single-threaded sim runtime run_route polls the new run_broadcast in the same pass that accepted it, so the gap opens only across threads. It uses the same ordering, though.

@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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: 7a0f1c7b-fa49-41ae-9629-434ecaae8857
📥 Commits

Reviewing files that changed from the base of the PR and between d51390d and 3b6d36f.

📒 Files selected for processing (2)
  • rs/moq-net/src/ietf/subscriber.rs
  • rs/moq-net/src/lite/subscriber.rs

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


Walkthrough

The change adds conditional source closure to broadcast producers and subscriber serving tasks. Origin fronts now track broadcast holders and can retire after their tracks are forgotten, when a source is serving and no upstream request is pending. Origin request handling avoids joining closed fronts and removes ending fronts before closing their broadcasts. The change also updates related plans and protocol documentation, adds a drained-claim integration test and a Loom race test, and adds a retirement benchmark.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 3b6d3

This change retires idle relay fronts and closes sessions' unheld sources. The serving-loop stall and track-handler ordering concerns raised earlier have both been addressed. No known blocking risk remains in the reviewed changes.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.03% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 74 functions across 9 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely describes the main change: unread relay fronts now end after their linger period.
Description check ✅ Passed The description is directly related to the changeset and explains front retirement, race handling, source cleanup, tests, and known follow-ups.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
✨ Simplify code
  • Create a new PR
  • Autopilot · 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.

A minted source's track handler was created only when its serve machine
first ran, after the requester already held the source. A front that read
a track in that gap found no handler and ended the track `NotFound`. Both
subscribers now create the handler before the accept and hand it to the
serve machine.

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

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator

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)
(Written by OpenAI)

Reviewed commit: 3b6d36f

No new actionable findings in the one-commit update since d51390d; no rebase or base change.

The prior P2 is fixed: lite/subscriber.rs:3593–3604 and ietf/subscriber.rs:1662–1684 register the track handler before accepting the source and retain it through the serve-machine handoff. Acceptance still precedes serving, preserving the retirement invariant. The new lite test at lite/subscriber.rs:2736–2784 exercises the read-before-serve gap and checks that the track queues.

Overall direction remains sound: this is a small ownership/order correction to the idle-retirement design, with no public API or wire change. No additional mechanism is needed.

Verification: static review of the update and surrounding request, handler, and lifetime code. No tests or benchmarks executed by this review; the IETF path has no equivalent regression test. GitHub Check/Test and platform CI are still running on this head.

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

Copy link
Copy Markdown
Collaborator

Rebase note from the 2026-10-08 quest audit: removing the Idle fronts line leaves quest/m0/README.md (~:19, "Relay hardening left in m0 is idle fronts", and ~:27-29) stale; fix those here. Also moq.pro links this quest (m2/wildcard/transcode.md, m3/transcription/worker.md); moq.pro#2274 rewords them to cite this PR.

(Written by Claude Opus 5.5)

@kixelated

Copy link
Copy Markdown
Collaborator

Merged main into this branch (fa1a100). Only quest docs conflicted: main's audit moved claim-epochs and largest-regression to m1 and linked them to the idle-fronts quest this PR deletes, so those links are dropped (including largest-regression's Required blocker, which this PR finishes). Rust merged without conflicts against #4929/#4974. just check (moq-uring memlock tests excluded as environmental), just test interop --all, and CI all pass. The description's largest-regression link now points at /quest/m1/.

OpenAI reviewed 3b6d36f with no open findings; only the main merge came after, and the maintainer accepted that review as covering this head. Enabling auto-merge pinned to fa1a100.

(Written by Claude Opus 5.5)

@kixelated
kixelated merged commit 0014821 into moq-dev:main Oct 8, 2026
8 checks passed
kixelated added a commit to Dryvnt/moq that referenced this pull request Oct 8, 2026
moq-dev#5054 and moq-dev#5053 merged, so demand-lost-wake no longer requires
idle-fronts and the m0 README keeps only this PR's two quests.

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.

2 participants