Repository navigation
fix(audio): hold a latest join's whole budget from the start - #6990
Conversation
Quest-Claim: ff98f3c2265d96c7
A `Start::Latest` join with a catalog warmup subscribed with the warmup alone as its max delay and widened to `max_delay + warmup` only once it read its first packet. The transport enforces that budget too, so a reader starved between subscribing and its first read had the groups queued behind it reset as `Old`, and its first frames were followed by a gap. moq-c decodes audio this way, which is why only the `-> c` interop cells failed the tone check. Subscribe with the whole budget, move the local cursor to the live edge as a join without warmup already did, and prime the decoder by trimming the warmup from that group, as every other join does. The gap-checked interop readers also give themselves the 5 s budget the native JS audio reader has, since a reader starved past 1 s skips on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: b9e7507.
No actionable bugs found. Holding max_delay + warmup from subscription creation removes the narrow transport-budget window; moving only the local cursor to the reported live-edge group preserves shared subscription ranges. Existing warmup trimming, gap re-priming and declared-end handling remain consistent with the chosen live-edge priming policy.
Direction: deleting the temporary join state is simpler than another budget update. The 5 s interop reader budgets address scheduler stalls without removing gap assertions. The regression checks both the initial budget and output start; the documented transport-backpressure regression and GStreamer budget remain separate follow-ups.
Verification: static review of the changes and surrounding audio/container/subscriber behavior. No tests, builds, stall reproductions or interop runs performed; PR-reported results were not independently reproduced.
|
No actionable comments were generated in the recent review.
|
|
Merge summary: head (Written by Claude Opus 5.5) |
…ts first frame #6990 already subscribes a Start::Latest join with its whole budget from the start, so the remaining work in quest/m2/audio-join-budget.md was the session-level reproduction its Plan asks for. The test bursts one-packet groups over a memory session before the first decode and fails with a 320 ms gap if the wire budget is narrowed to the warmup again. Finishes and deletes the quest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finishes
quest/m0/test-flakes-2/ffi-audio-gap.md(deleted here, with its link in the epic README).Problem
The interop tone check failed with a timestamp gap for the
python -> candgo -> ccells, and somecpp ->cells, under full-matrix load. One example from moq-dev/moq#6464: 500000us after a frame ending at 393500us. The shape fits a declared gap at group 20 (400 ms) followed by the decoder's warmup trim. Back then the C client didn't fail on a decoderGapread itself (moq-dev/moq#6745 added that check), so tone.py caught the result instead.Every failing cell has a C subscriber. The one C-specific path is moq-c's
moq_decode_audio, which joins withStart::Latest.Cause
A
Start::Latestjoin on a rendition with a catalogwarmupsubscribed with only the warmup (80 ms for Opus) as its max delay. It widened tomax_delay + warmuponly once it read its first packet. The transport enforces that budget too. So when a reader was starved between subscribing and its first read, the relay reset the groups queued behind it asOld, and its first few frames were followed by a gap. The relay log of a reproduction shows the audio SUBSCRIBE carryingmax_delay: 80msalthough the client asked for 1 s (and later 5 s), followed by a burst ofOldresets once the client resumes.There's a second, smaller cause: the gap-checked interop readers ask for a 1 s budget. A reader starved for longer than that skips on its own, the same race moq-dev/moq#6658 fixed for the native JS reader. Since moq-dev/moq#6923 the C client also fails on a video gap, and it keeps reading video for the whole audio decode, so this now hits its 1 s video budget as well.
Fix
Start::Latestjoin now subscribes with its whole budget (max_delay + warmup) from the start. It moves the local cursor to the live-edge group, as a join without warmup already did. It primes the decoder by trimming the warmup from that group, as every other join does. Thejoining_latestnarrowing and widening is deleted. This mirrors moq-video, which already subscribes with its full budget and only moves the local cursor.Reproduction
A loop outside the harness, in this checkout's ignored
.tmp/: an anonymous relay, a fresh Python publisher per iteration (ffmpeg H.264 plus the Opus tone, as ininterop.sh), and the C subscriber started with it, judged by tone.py. I SIGSTOP the subscriber 0.4 s after it starts and resume it 1.5 s later, which lands the stall in its join window.main(1 s budgets)Without stalls, 40 iterations under 48 busy loops passed both before and after. CPU contention alone doesn't stall a process long enough. Late joins (1.2 to 2.5 s) and publisher stalls during the join passed both before and after, so the trigger is a stalled reader.
Impact
decode::Start::Latestwith a catalogwarmup): the consumer starts at the live-edge group instead of up to one warmup before it, and holdsmax_delay + warmupon the subscription from the start. Latency to live is unchanged. The first audible sample can come up to one warmup later (80 ms for Opus), since priming now decodes new media rather than cached media. Callers: moq-cmoq_decode_audio,moq play, moq-scene, and moq-studio.Decisions
max_delay + warmup) from the start. moq-dev/moq#5569 had narrowed the join budget so a join could prime from cached groups up to one warmup before the live edge. Option (a) gives up that head start, costing up to one warmup of time-to-first-audio and nothing in latency. It deletes code and matches moq-video. The alternatives were (b), narrowing only the local container's budget during the join, which needs a new moq-mux API, and (c), widening once the subscription is established, which only shrinks the window.Interaction with push-to-talk and always-on encoding
quest/m1/push-to-talk/): the first-audio cost of up to 80 ms applies once per RustStart::Latestjoin. Listeners held on a disabled rendition (moq-dev/moq#7156) don't rejoin on unmute. Utterance boundaries take the declared-endrestartpath, which doesn't re-arm warmup.quest/m1/eager-encode/): holding the whole budget suits always-on caches. Those are exactly where the narrowed join budget gotOldresets.Choices made without asking
--max-age 5s, which already covers the native JS reader. The FFI publishers keep hang's 30 s.max_delay + warmupand joins at the live edge. Reproducing relay-sideOldresets needs real transport backpressure, so the test doesn't do that.Checks
just check(rs, js, markdown, gh, shell, quest, ...): passed, 1534 nextest tests.just fix: no changes.just test interop --publishers rust,python,go,cpp --subscribers rust,python,go,cpp,c,js-native-node,gst: all checks passed (122).mainagain:cargo nextest run -p moq-audio -p moq-cpassed (332 tests), andquest checkpasses.Suggested follow-ups
moqsrcbudget.rs/moq-gst/src/source/imp.rssubscribes every rendition with a hard-coded 1 s max delay, and thegstcells run tone.py's contiguity check. So a starved GStreamer subscriber can fail the same way, and there's no knob to widen it. The fix is either amax-delayproperty onmoqsrcthatinterop.shsets to 5 s, or deriving the budget from the track's max-age. This is a public element property, so it's the maintainer's call.Oldresets within its budget. It would guard the relay path this bug went through, beyond the subscription-budget invariant tested here. It could use the moq-net mock harness with a capped stream count.(Written by Claude Opus 5.5)
🤖 Generated with Claude Code