Skip to content

fix(watch): count a stall only while a rendition plays - #8209

Merged
kixelated merged 3 commits into
mainfrom
quest/m1/explicit-state/watch-playback
Nov 8, 2026
Merged

kixelated merged 3 commits into
mainfrom
quest/m1/explicit-state/watch-playback

Conversation

@kixelated

@kixelated kixelated commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

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 echo stalls/stalled feedback (which @moq/hang's health grades on) in states where nothing was playing. Each decoder kept its playback state as loose flags:

  • Audio: #retire() cleared the consumer but kept #playing (the rendition key) and #played, so a switch to delay="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.timestamp and out.stalled also outlived the ring that fed them.
  • Video: the min-delay decoder shows a poster before the clock reaches it, and #runActive set #played for any frame, poster included. With a delay over the 500 ms buffering timer, every cold start or resume counted a stall. #runBuffering also returned early when disabled and left stalled set, so the spinner could stay on with visible="never".
  • Video switching kept two copies of "pending" (a #pending signal and a closure-local pending), plus #selected and #failed.
  • Sync: started was only set by buffered startup, so turning on lookahead mid-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:

  • Audio #phase: stopped | running { key, consumer? } | failed { failure }. #retire(failure?) is the only way out of running. #runStalls counts only inside a running run, and each run starts out waiting for its first audio, so the wait is never a stall. A rendition swap stays in running. Underruns still count in the totals, but go to a rendition's feedback only while it runs. The ring's timestamp and stalled reset when the ring closes.
  • Video #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. #runStalls gives each active rendition a starting | playing | stalled run driven by Rendition.timestamp, which stays undefined while the picture is only a poster, so it counts only a picture that played and then froze. While disabled, #runBuffering sets stalled false.
  • Sync Clock.phase: idle | starting | playing replaces started plus the playing signal. playing is now derived (phase !== "starting"). A live timeline that anchors becomes playing, so a later lookahead doesn't start it over.

I designed the audio phase around moq-dev/moq#8058: its new #lateness reset goes in #retire, and its skips counter needs no key. Expect a small conflict in #retire and in the underrun counter.

Choices made without asking

  • An outgoing video rendition's failure no longer latches its source. Before, a failure of the rendition being replaced during a handover set the same #failed latch as the selected one's. Now it only shows in out.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.
  • A restarted video timeline is a startup. The run goes back to starting while the new timeline shows only a poster.
  • A rendition that starts a new run (promoted, or after re-enable) waits for its first presented picture. A normal promotion has already presented, so it plays at once.
  • No change to video's declared-end freeze. A picture held through a publisher's declared end longer than 500 ms still counts as a stall, as on main. The quest didn't list it.

Impact

  • No public API or wire change. The exported Sync, Decoder, and stats types keep their shape.
  • Behavior: audio.out.timestamp is undefined and audio.out.stalled is true while no ring exists, and video.out.stalled is false while the video decoder is disabled.
  • Sync.playing (internal) is now a derived getter over the new internal Clock.phase.

Caused by

  • moq-dev/moq#7222: its #played/#playing flags counted stalls after a rendition stopped, and counted the video poster's wait.
  • moq-dev/moq#6609: it set started only in buffered mode, so turning on lookahead mid-stream re-ran startup.
  • The disabled-spinner bug is as old as #runBuffering's early return; no single PR introduced it.

Validation

  • bun test in js/watch: 542 pass. The new tests are in audio/decoder.test.ts ("Decoder stalls"), video/decoder.test.ts ("Decoder stalls"), and sync.test.ts ("keeps playing when lookahead turns on mid-stream"). Each failed on main before the fix.
  • just fix and just check pass.
  • I did not test in a real browser.

Alternatives

  • Making audio.out.stalled false outside running. That would let the buffering indicator drop its own audio gating. Not chosen: #idle reads the ring-level stall to suspend a drained tail, and the output's meaning would change for callers.
  • Marking the audio ring stalled on reset, so a run that starts within one render quantum of an instant switch sees the flush. Not chosen: it is a race only a fake worklet hits, and js/watch: time-stretch the audio ring onto its target #8058 rewrites buffer.ts.

Follow-ups

  • Video's declared-end freeze counts as a stall (see above). Recommend: plan it. Audio already treats a declared silence as no stall, so video is inconsistent and users see a wrong count.
  • video/decoder.test.ts logs "Once has already settled" from #waitStartup on main too (the refill Once is 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 review after each round.

  • Reviewer: Codex, gpt-6.1-sol, xhigh effort
  • Session: 01a1b83c-3f46-70a3-91c8-519ef8a38155, round 2
  • Head: 05e9aaa
  • Base: origin/main at 3ce26d1
  • Score: 10/10, Source inspection confirms the prior finding is fixed, with no additional issues found in the incremental changes; tests were not run.
  • Findings: none
  • The last round's findings, at 005a25f:
    • Fixed: medium, 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.

kixelated and others added 2 commits November 7, 2026 14:38
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>
@kixelated kixelated changed the title Watch playback phases fix(watch): count a stall only while a rendition plays Nov 7, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review November 7, 2026 23:13
@coderabbitai

coderabbitai Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You'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.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3a7cde5b-a2a8-4efa-8021-dc32b279b17f
Commits

Reviewing files that changed from the base of the PR and between d389cfb and 05e9aaa.

Files selected for processing: 8

  • js/watch/src/audio/decoder.test.ts
  • js/watch/src/audio/decoder.ts
  • js/watch/src/sync.test.ts
  • js/watch/src/sync.ts
  • js/watch/src/video/decoder.test.ts
  • js/watch/src/video/decoder.ts
  • quest/m1/explicit-state/README.md
  • quest/m1/explicit-state/watch-playback.md
  • Autofix · 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

@kixelated kixelated left a comment

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.

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.

@pullfrog

pullfrog Bot commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

This run hit its 1h time limit while still working. Give runs longer with --timeout=2h (or --notimeout) in this repo's instructions, or raise the workflow's timeout input if it sets one, since that input wins. A smaller PR or a lower effort also finishes sooner.

Repo settings → · Flags →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS)

@kixelated
kixelated added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit cb3d764 Nov 8, 2026
9 of 10 checks passed
@kixelated
kixelated deleted the quest/m1/explicit-state/watch-playback branch November 8, 2026 00:40
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