Repository navigation
fix(hang,moq-audio): the join's backlog no longer holds the playout target - #8730
Conversation
Quest-Claim: 75c8b5d9c4f36e33
…t target An interval holding a frame due before the subscription's backlog finished arriving measured the join, not the path, so it folds nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…klog as a follow-up The two replay rows whose first interval now folds nothing move within half a hop. The 1% fast clock test grades until its last arrival, not the run-out after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou'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 2 minutes. View limit details
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. |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
I reviewed the join-backlog rule in all three jitter estimators, the new join corpus case, the regenerated corpus files, and the replay test and budget updates.
- Join detection:
startis the first arrival.joinedadvances only for frames wheremedia + fastest < start, and it is updated before the unmeasured early return, so unmeasured backlog still extends the join. The reference,js/hangJitter, andmoq-audioEstimatordo this identically. - Interval skip: at close, an interval with
oldest + fastest < joinedfolds nothing and adds 1 topending.pending = …changed topending += …becauseclose()no longer always runs first to zero it. A skipped interval on the declared-silence path keeps itspendingpastsettle()and folds later as idle, which matches "like an interval with no arrival". fork(): the interval in progress is judged by the outgoing track's join, and the fork starts a freshstartandjoined.History.fastest(): this isreference(+∞). The Rustexpectcannot fire. The first observation always pushes, becauseneweststarts at −∞ andmediais finite sinceTimescaleisNonZero. That first observation also takes thestartbranch, so it never callsfastest().- Corpus: the new
joincase comes fromrelay-mic-rtt40's first 30 s.backlog,batches,reorder, andtuneinwere regenerated. - Tests and budgets: the 1% fast-clock replay test now grades only until the last arrival, and the
relay-micandrelay-mic-stallA/V lag budgets were re-measured. - Quest:
join-hold.mdis removed, and the follow-upjoin-backlog.mdcovers backlog that the consumers skip without reading.
Edge cases I worked through:
- Fast publisher clock: only the first interval is skipped.
- Forward timestamp jump: one interval is skipped.
- Outages: they never extend
joined. - Publisher that keeps sending stale frames: it can keep skipping its own intervals, but the work stays bounded and other broadcasts are unaffected. That fits the rule in
AGENTS.md.
Tests run locally: bun test doc/concept/audio-jitter/corpus.test.ts js/hang/src/container/jitter.test.ts (99 pass) and cargo test -p moq-audio --lib jitter (7 pass, including conforms_to_the_corpus).
claude-opus-5-5 | 𝕏

Summary
Finishes quest/m2/audio-playout/join-hold.md: after a subscription's join, the playout target (and video's delay with it) no longer holds 100 to 460 ms for a 20 s half-life before falling to the path's own.
The rule (
doc/concept/audio-jitter.md, new "The join's backlog" section):fastest, the window's smallestarrival - media, says when a frame was due.start, the first arrival, was produced before the subscription began: it is backlog.joinedis the latest arrival of a backlog frame.joinedmeasured the join, not the path. Its frames are the backlog or queued behind it, so it folds nothing, as if it held no arrival.The test runs when the interval closes, because a join reveals itself late. The fastest path moves 1.3 to 2 s earlier over the first 400 to 800 ms as the backlog drains, so a frame that looks punctual against the backlog before it can turn out to be due before
start. Only backlog movesjoined, so lateness after the join (an outage, a publisher flushing seconds at once) never extends it.Implemented in the reference,
js/hangJitter(includingfork(), judged by the outgoing track's join), andmoq-audiojitter.rs, bit-identical on all 15 corpus cases. The newjoincase replays the first 30 s of the checked-inrelay-mic-rtt40recording through the public relay: today's estimator holds 780 ms there for 20 s, and this one goes from the prior to 60 to 100 ms.backlog,reorder,tuneinandbatcheseach converge one interval later, because each opens with frames due before its first arrival.batchesstill reaches its 1100 ms, so 1 s flushes don't chain the join.How it was picked
I replayed the 42 browser arrival captures of the 11-11, 11-12 and 11-13 Nightlies and the 6 replay traces through the reference, comparing variants. The quest's recommended candidate (measure a reordered frame against the window's fastest frame when its own reference lags it, and skip it when far behind) cleared the joins whose reference was itself stale. It left every
widerow and the 11-12 Opus congestion row holding: there the chunk's reference is the fastest frame so far, and only the live frames that follow show the chunk was backlog. Per-frame "due before start" had the same blind spot. Measuring at interval close, and extending through the backlog's last arrival, cleared them all.Then, through the player's real consumer (each capture converted to a replay trace and run through
replay.ts,main's estimator against this one), mean target over 3 to 25 s:mainwidePlayer underruns stayed at 29 in total across the 42 rows, and trimmed audio fell slightly (52975 → 52782 ms/min summed). On the congestion rows the join's residue no longer lets the 25 s outage tip the target back up (11-12 Opus: 340 at 40 s on
main, 60 here).Decisions
Each was taken by the agent under /quest-afk, for the maintainer's review.
wideand 11-12 congestion, above); the same per-frame without hindsight; a per-delay-bucket record that dropped only backlog frames from an interval (more state and spec for the same Nightly result, and it still left the live frames queued behind the backlog); extendingjoinedby every skipped interval (a publisher whose lateness exceeds an interval would chain it forever and never be measured).relay-mic-rtt40stays at 320 ms in the player, filed as a follow-up. ✅ The browser consumer closes groups below its cursor unread, so its 267 stale backlog groups arriving 1.0 to 1.3 s into the join never reach the estimator, and the 300 ms they delay the live frames reads as an ordinary stall.moq-mux's consumer does the same. Rejected: fixing it here, since it changes both consumers' read loops and public API.replay.test.ts) now grades until its last arrival. ✅ A fast publisher clock makes the first frame slower than the path after it, so that first interval folds nothing and the target settles 500 ms later. That shifts the phase of the drift's splice sawtooth. Through the steady state both estimators show the same 5 to 11 ms; only the last 25 ms, after the final arrival, where the bridge slows audio as it runs out, differ: 29.9 ms here against 13.9 onmain. The test's intent is the steady state. Rejected: keeping the run-out in the window and loosening the bound.Validation
js/hang(bun test src/container/jitter.test.ts) andmoq-audio(cargo test -p moq-audio --lib jitter) pass all 15 cases;corpus.test.tsconfirms the files match the reference.just test audio-quality --replays --enforcepasses on the merge withmainafter Audio follows the clock by its lag alone #8722 (77c9ca5), with its exact channel start. Againstmain's estimator on the same tree, onlyrelay-mic(A/V lag p50 1.8 → -0.3 ms, p95 6.9 → 9) andrelay-mic-stall(p50 3.1 → 1.7) moved, each within the half hop the lag rule leaves alone, re-measured inbudgets.jsonwith a note.mainand this branch on the same tree.just checkpasses.Public API and wire
No API or wire change. Behavior change: the audio playout target in
@moq/hangandmoq-audio.Caused by
No single PR: the cold start's
FRESH_MAXweight on the join's intervals is as old as the estimator (#4162).Follow-ups
relay-mic-rtt40's replay target p95 falls from 320 ms. Recommended: aJitter.backlog(arrival)and Rust counterpart, called for groups dropped below where delivery started, not below a cursor a later gap moved. No open decisions.Decided 2026-11-14 (maintainer, /quest-complete): ✅ keep in m2 (rejected: drop it, move to m3).
Models
Size: M
Implementer: Claude Code, claude-opus-5-5, unknown effort
🤖 Generated with Claude Code
(Written by Claude Opus 5.5)
Review
Rewritten by
quest reviewafter each round.Head: 03ef20e
Base: origin/main at 4fec611
Reviewer: Codex, gpt-6.1-sol, high effort
Session:
01a1da71-12c1-77a1-80f9-ce8188dd3a6c, round 1Score: 8/10,
No actionable regressions were found and the implementations agree, with the skipped-backlog limitation documented, but builds and tests were not run as instructed.Rounds: 1; findings 0: fixed 0, refuted 0, open 0
Findings: none