Skip to content

fix(mux): run the TS recovery arms clean at a 500 ms delay - #6236

Merged
kixelated merged 8 commits into
mainfrom
quest/m1/ts-reliability/release-clock-false-alarms
Oct 23, 2026
Merged

kixelated merged 8 commits into
mainfrom
quest/m1/ts-reliability/release-clock-false-alarms

Conversation

@kixelated

@kixelated kixelated commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Finishes and deletes quest/m1/ts-reliability/release-clock-false-alarms.md.

Problem

The TS export's jitter buffer raised false alarms against an exact source clock. Its consumers are export ts and anyone watching release.outOfTolerance or the output's PCR. The recovery arms from moq-dev/moq#6139 only stayed clean at a 1 s delay. At 500 ms they failed, and so did the long arm.

Root causes and fixes

  1. False drift from equally fitting envelopes (jitter::Steer::measure). After a generation restart only two steering floors remain. Each track's envelope then fits its two floors exactly. Floating-point residue in the depth comparison picked the video, whose changing send-ahead measured -294,468 ppm. Now, among tracks whose depths are within a nanosecond (the resolution of the times measured), the one with the smallest absolute slope wins. This is the maintainer's decision below.
  2. False jitter leap: a 475 ms PCR rewind with a declared discontinuity (jitter::Buffer::bound). The original quest called this the declared-PCR alarm. It reproduces in the stalled-audio arm and the long arm at 500 ms. The PCR is the export's own, so this was never the source's declared discontinuity. Cause: a skip treats a frame as a timeline leap when it is due more than a delay past everything queued. The audio is sent about 340 ms ahead of the video, so it is held that much longer. When the audio stalls, only the video's deadlines remain queued. The first resumed audio frame then lands about 19 ms past the bound, so the buffer opens a new generation. The bound now adds the track's measured lead (Buffer::lead, a median that survives generations). This fixes jumps and the restart/lagging lands checks together.
  3. Start group cut before it went out (jitter::Buffer::cut, called when the TS export reads a Cut). The late-catalog arm at 500 ms started on group 4, which was cut short. The export reads the source's Cut at 2.38 s. The jitter buffer only learned of it from the next group's first frame, at 2.46 s, but group 4's keyframe was due at 2.40 s. Now the export settles the track and reports the cut count as soon as it reads the Cut. Under --sync (moq-dev/moq#5949) a leg settles each DTS from the frames read, so instead of settling it discards what the decode clock held of the cut group, then reports the cut. Otherwise a flush at the stream's end or a restart would push that tail with the old cut count after the report (Codex review of be4c310). A buffer still acquiring keeps it until the replay, and an unstarted track drops what it holds right away. Track::cut replaces the inline trim in queue.
  4. Stall arm expectation. At 500 ms the newest whole group the video stall held is within the source's age budget (delay/2 behind the newest content) but past its deadline. It is dropped whole: 12 frames, one gap. The grader already accepts this exactly (dropped == after), so the arm now asserts (12, 1).

The recovery arms now run at DELAY = 500 ms, a_path_stall_drops_what_it_made_late included. They assert out_of_tolerance == 0 and, as before, one PCR discontinuity per source declaration and no other.

Decisions

Equal-fit tie between track envelopes (maintainer, 2026-10-22):

  • ✅ Among equally fitting envelopes, choose the smallest absolute measured slope.
  • Retain the existing estimate until the envelopes become distinguishable.

Taken by this agent, the recommended option in each case (not asked):

  • "Equally fitting" means depths within 1 ns, the timestamps' resolution, not an arbitrary epsilon.
  • Fix the leap at its source, the bound ignoring the track's lead, rather than raising the arms' delay or adding a margin.
  • Propagate a known cut to the buffer as soon as the export reads it, rather than relax the late-catalog expectation.

Trade-off of the tie decision: when a source really drifts and the tracks disagree early, the least-drift reading wins. export_timing_test::a_source_far_off_real_time_fails_within_seconds now reports -205,985 ppm (the video) instead of -220,000 (the audio). It still fails within seconds. The test now checks only the rate's order. The old exact match depended on the same rounding residue as cause 1.

Not changed (known limit)

At the old 1 s delay, a_congested_path_sheds_what_it_made_late still counts one out_of_tolerance. Its first two floors, at 2.36 s and 3.8 s, straddle the onset of 0.9 s of queueing. That reads as -623,000 ppm. This is the documented early-window limit in the jitter::Buffer docs: a move in latency past half the delay that the floors so far trace as a line reads as the rate. It does not fail the export, since nothing is charged to the startup gap. At 500 ms the same arm stays clean. Changing the counter's meaning (for example, counting only once the rate has cost slack the start did not explain) changes the public release.outOfTolerance stat. It is listed as a follow-up for the maintainer, not done here.

Impact

  • Public API: none. jitter::Buffer::cut is pub(crate).
  • Wire: none.
  • Behavior: the release clock's tie-break as above. A track sent ahead no longer opens a generation (with a PCR discontinuity) when it resumes after its own stall. A start group cut before it went out is trimmed as soon as the cut is read.

Validation

  • New unit tests, on mocked time, each failing without its fix: equal_depths_do_not_turn_a_track_lead_into_clock_drift (from the checkpoint), a_track_sent_ahead_resuming_after_a_skip_keeps_the_clock, and a_cut_reported_before_the_next_group_drops_the_start_group (acquiring and replay).
  • Recovery arms at 500 ms, including the long arm: 19 of 19 passed in each of 8 consecutive runs, then 4 more after the review fix. Without the cut report, the late-catalog arm starts on the cut group again.
  • Re-derived after merging main's arrival stamps (moq-dev/moq#6249) and --sync (moq-dev/moq#5949), at be4c310 and again at 34f57c4: all 20 arms pass at 500 ms, including the long arm and the now un-ignored a_path_stall_drops_what_it_made_late. Main fixed none of this PR's causes, so nothing was dropped:
    • Without the lead in bound, a_stalled_track_leaves_the_others_running and long_faults_hold_their_latency still fail the T-STD check on the false generation. Arrival stamps change when a frame counts as arrived, not the skip's leap test.
    • Without the cut report, a_late_catalog_starts_on_a_whole_group still starts on group 4.
    • The stall arm still drops the newest held group whole, (12, 1), under arrival stamps.
  • The same arms at the original 1 s delay pass, except the expected stall count ((0, 1) at that delay) and the congested-path known limit above (rechecked after the merge: still -622,917 ppm, one out_of_tolerance).
  • just rs check-test -p moq-mux (1645 passed after the merge, clippy, docs). just rs test over moq-mux, moq-cli, moq-hls, moq-srt, moq-rtmp, moq-rtsp, moq-archive, moq-gst and moq-ffi: 2801 passed. quest check passed.
  • just check passed its Rust lint, quest and drill stages, then stopped at nix flake check. The unchanged github-runner VM image cannot build on this host: /proc/self/uid_map: Operation not permitted, as in moq-dev/moq#6216. The diff touches no Nix.

Ownership

  • moq-dev/moq#6113 (container consumer hole timing, still open) is untouched. This PR changes neither container/consumer.rs nor failover.

Suggested follow-ups

  • Decide whether release.outOfTolerance should count a first-window measurement that has cost no slack, so the 1 s congested arm also runs clean (see the known limit above).

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 2 commits October 21, 2026 18:50
Co-Authored-By: GPT-6 <noreply@openai.com>
Checkpoint the exact-source clock regression before choosing how equally fitting track envelopes break ties.

Co-Authored-By: GPT-6 <noreply@openai.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Checkpointed the exact-source false-drift reproduction at 2b0b8ce. The regression fails deterministically on the existing estimator. Original-delay recovery coverage is intact; no production fix or delay-sensitive expected count was changed.

Awaiting the maintainer's equal-fit envelope decision, with smallest absolute slope recommended versus retaining the prior estimate until distinguishable. Even after that fix, the declared-PCR alarm remains unreproduced and the remaining 500 ms timing failures need investigation before retiring this quest. Required just ci also hit the unchanged host VM uid-map restriction. No merge/enqueue, no running background work, and no new follow-up quest was created.

(Written by GPT-6)

kixelated and others added 3 commits October 21, 2026 23:35
Among equally fitting envelopes the release clock takes the smallest
measured drift, so a track whose send-ahead wanders no longer reads as
clock drift. A track sent ahead of the others resuming after its own skip
is bounded by its lead, so it keeps the clock instead of opening a
generation that rewinds the PCR. A cut the source reports before the
track's next frame now drops the start group it left short. The recovery
arms move to 500 ms and the finished quest is deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Early on either track's envelope fits its floors, and the one measuring
less drift names the rate, so the exact figure depends on the tracks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title test(mux): reproduce false release clock drift fix(mux): run the TS recovery arms clean at a 500 ms delay Oct 22, 2026
@kixelated
kixelated marked this pull request as ready for review October 22, 2026 06:42
@coderabbitai

coderabbitai Bot commented Oct 22, 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 13 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: 36b91ad9-efe5-4a3b-9b32-265682d6b62e
📥 Commits

Reviewing files that changed from the base of the PR and between 1c72130 and 34f57c4.

📒 Files selected for processing: 6
  • quest/m1/ts-reliability/README.md
  • quest/m1/ts-reliability/release-clock-false-alarms.md
  • rs/moq-mux/src/container/ts/export.rs
  • rs/moq-mux/src/container/ts/export_timing_test.rs
  • rs/moq-mux/src/container/ts/recovery_test.rs
  • rs/moq-mux/src/jitter.rs
  • 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

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T22:08:31.158206Z 34f57c4 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-mux/src/container/ts/export.rs Outdated
Comment on lines +420 to +424
if std::mem::take(&mut self.settle) && !input.waiting_for_header {
self.release(name, input.jitter, true)?;
// A cut takes a start group it left short now, not with the next group,
// which may arrive after that start was due.
input.jitter.cut(&self.pid, self.base.2 + self.source.cuts());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Notify the jitter buffer as soon as a cut is observed

When Event::Cut is followed by jitter.full(&self.pid) on the next loop iteration, fill exits at the guard on line 379 without reaching this Poll::Pending arm, so the due frames from an unstarted, cut-short group can be released before cut is reported. An immediately finished source has the same problem because it takes the Poll::Ready(None) branch and never calls jitter.cut at all. Report the cut when the event is consumed, before any path can leave the loop, so an export cannot begin with an incomplete group.

AGENTS.md reference: rs/moq-mux/AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

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.

Agreed, fixed in 0e170f4. Event::Cut now settles the track right away, releasing what the decode clock held, and reports the cut count to the jitter buffer as it is read. A fill that stops on a full track or a stream that ends after the cut can no longer skip it. A GroupEnd still settles lazily as before. Without the report, a_late_catalog_starts_on_a_whole_group starts on the cut group 4 again; with it, the arm passes. The 500 ms arms passed 4 of 4 runs.

(Written by Claude Opus 5.5)

kixelated and others added 2 commits October 21, 2026 23:55
Reporting it only once the source next ran empty missed a fill that
stopped on a full track, and a stream that ended right after the cut.

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

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

Copy link
Copy Markdown
Collaborator Author

@codex review

(Written by Claude Opus 5.5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: be4c310d9c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-mux/src/container/ts/export.rs Outdated
Comment on lines +477 to +480
if self.sync.is_none() {
self.settle = false;
self.release(name, input.jitter, true)?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Discard sync-held frames when a cut arrives

When a --sync video track receives Event::Cut and then reaches EOF, this guard skips both flushing and discarding its DecodeClock; jitter.cut can clear only frames already queued in the jitter buffer. The EOF path subsequently flushes the clock with each frame's old cut counter, causing the incomplete group's held tail to be queued again and potentially emitted without its keyframe. Fresh evidence relative to the prior comment is the new self.sync.is_none() condition, which leaves this synchronized path untreated; discard the decode-clock frames when handling a synchronized cut.

AGENTS.md reference: rs/moq-mux/AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

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.

Agreed, fixed in 34f57c4. Under --sync, Event::Cut now discards what the decode clock held of the cut group, as on a leg that never read it, before reporting the cut. A probe confirmed the old order: after jitter.cut(1), the end-of-stream flush pushed the held tail with cut 0, resetting the buffer's count. The output was already safe, because the export drops a start that is not a keyframe. So no test was added: one asserting on output passes either way. moq-mux tests (1645) and all 20 recovery arms at 500 ms pass.

(Written by Claude Opus 5.5)

Under --sync the Cut arm reported the cut without settling, so a flush at
the stream's end or a restart still pushed the held tail with the old cut
count, resetting the jitter buffer's.

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

Copy link
Copy Markdown
Collaborator Author

@codex review

(Written by Claude Opus 5.5)

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 34f57c432a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kixelated
kixelated added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit e84ffb3 Oct 23, 2026
10 checks passed
@kixelated
kixelated deleted the quest/m1/ts-reliability/release-clock-false-alarms branch October 23, 2026 00:54
kixelated added a commit to t0ms/moq-dev that referenced this pull request Oct 23, 2026
moq-dev#6236 keyed the TS export's jitter cut by PID; this branch keys jitter
tracks by name.

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.

1 participant