Repository navigation
fix(watch): count a stall only while a rendition plays - #8209
Conversation
Quest-Claim: 9d92216918c1d1b8
Each decoder's playback and rendition-switching state is now one value: audio has a stopped/running/failed phase, video a selection (none/selected/failed) beside the active rendition and a per-run starting/playing/stalled playback, and the sync clock an idle/starting/playing phase. - Audio stops counting stalls once its rendition is retired, removed, failed, or switched to instant, and clears its playhead and stall state with the ring that reported them. - Video no longer counts the poster's wait for the clock as a stall, and clears its buffering state while disabled. - Turning on lookahead mid-stream no longer re-runs startup anchored at the stream's first picture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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. |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed head 05e9aaa.
No actionable findings. Scoping audio stall accounting to a running phase, distinguishing video's poster from a presented picture, and retaining a live clock's playing phase when lookahead changes address the reported failures directly. Deriving pending video from selected versus active removes the duplicated ownership state while preserving handover; the outgoing-error correction and its regression cover selection changing before replacement playback.
Verification limits: GitHub-only static review of all eight changed files and surrounding decoder, rendition, subscription and clock lifecycle paths. I inspected the added audio/video stall and mid-stream lookahead regressions but did not run tests or browser/worklet checks. The declared-end video freeze remains explicitly out of scope. Rechecked open/non-draft state, full head/base and published reviews immediately before posting.
|
This run hit its 1h time limit while still working. Give runs longer with
|

Implements quest/m1/explicit-state/watch-playback.md from the explicit-state epic, and deletes it.
Problem
<moq-watch>counted stalls, stalled time, and the echostalls/stalledfeedback (which@moq/hang's health grades on) in states where nothing was playing. Each decoder kept its playback state as loose flags:#retire()cleared the consumer but kept#playing(the rendition key) and#played, so a switch todelay="instant", a decode failure, or the rendition leaving the catalog each counted a stall that kept growing for as long as that state lasted.out.timestampandout.stalledalso outlived the ring that fed them.#runActiveset#playedfor any frame, poster included. With a delay over the 500 ms buffering timer, every cold start or resume counted a stall.#runBufferingalso returned early when disabled and leftstalledset, so the spinner could stay on withvisible="never".#pendingsignal and a closure-localpending), plus#selectedand#failed.startedwas only set by buffered startup, so turning onlookaheadmid-stream re-ran cold startup, anchored at the stream's first picture: the playhead jumped back to it and froze until media caught up.Each bug has a test that fails on
main.Approach
Each lifecycle is now one value:
#phase:stopped | running { key, consumer? } | failed { failure }.#retire(failure?)is the only way out ofrunning.#runStallscounts only inside arunningrun, and each run starts out waiting for its first audio, so the wait is never a stall. A rendition swap stays inrunning. Underruns still count in the totals, but go to a rendition's feedback only while it runs. The ring'stimestampandstalledreset when the ring closes.#selection:none | selected { rendition } | failed { failure }, next to#active. The pending rendition is computed from the two (selected but not yet active), which drops the second copy,#selected, and#failed.#runStallsgives each active rendition astarting | playing | stalledrun driven byRendition.timestamp, which stays undefined while the picture is only a poster, so it counts only a picture that played and then froze. While disabled,#runBufferingsetsstalledfalse.Clock.phase:idle | starting | playingreplacesstartedplus theplayingsignal.playingis now derived (phase !== "starting"). A live timeline that anchors becomesplaying, so a later lookahead doesn't start it over.I designed the audio phase around moq-dev/moq#8058: its new
#latenessreset goes in#retire, and its skips counter needs no key. Expect a small conflict in#retireand in the underrun counter.Choices made without asking
#failedlatch as the selected one's. Now it only shows inout.error, until the selected rendition plays or the selection changes. That latch was cleared on almost every rerun anyway, since the outgoing rendition's track or instance differs from the selected one's. The one difference: a rendition that failed while outgoing gets retried if it is selected again.startingwhile the new timeline shows only a poster.main. The quest didn't list it.Impact
Sync,Decoder, and stats types keep their shape.audio.out.timestampis undefined andaudio.out.stalledis true while no ring exists, andvideo.out.stalledis false while the video decoder is disabled.Sync.playing(internal) is now a derived getter over the new internalClock.phase.Caused by
#played/#playingflags counted stalls after a rendition stopped, and counted the video poster's wait.startedonly in buffered mode, so turning onlookaheadmid-stream re-ran startup.#runBuffering's early return; no single PR introduced it.Validation
bun testinjs/watch: 542 pass. The new tests are inaudio/decoder.test.ts("Decoder stalls"),video/decoder.test.ts("Decoder stalls"), andsync.test.ts("keeps playing when lookahead turns on mid-stream"). Each failed onmainbefore the fix.just fixandjust checkpass.Alternatives
audio.out.stalledfalse outsiderunning. That would let the buffering indicator drop its own audio gating. Not chosen:#idlereads the ring-level stall to suspend a drained tail, and the output's meaning would change for callers.buffer.ts.Follow-ups
video/decoder.test.tslogs "Once has already settled" from#waitStartuponmaintoo (the refillOnceis set twice). Recommend: drop. It is log noise with no behavior change, and fits a nearby video-startup change.Models
Size: M
Implementer: Claude Code, claude-opus-5-5, high effort
🤖 Generated with Claude Code
(Written by Claude Opus 5.5 with high effort)
Review
Rewritten by
quest reviewafter each round.01a1b83c-3f46-70a3-91c8-519ef8a38155, round 2Source inspection confirms the prior finding is fixed, with no additional issues found in the incremental changes; tests were not run.js/watch/src/video/decoder.ts:418:Clear outgoing errors on a later source switch;js/watch/src/video/decoder.ts:256 now clears outgoing errors on selection reruns without a latched failure, and js/watch/src/video/decoder.test.ts:873 covers clearing before replacement playback for both legacy and CMAF.