Skip to content

fix(audio): bound the jitter reference history - #6359

Merged
kixelated merged 3 commits into
mainfrom
fix/jitter-history-bounds
Oct 22, 2026
Merged

kixelated merged 3 commits into
mainfrom
fix/jitter-history-bounds

Conversation

@kixelated

@kixelated kixelated commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The playout estimator measures each frame against the fastest arrival at or before it in the last WINDOW of media time. The history is pruned only by media time, and the publisher chooses the timestamp rate, so a track stamped one microsecond apart keeps ~2,000,000 entries inside the two-second window. Every frame then scanned all of them, so per-frame cost grew with the attacker's timestamp rate and total cost grew quadratically. js/net/src/track.ts only checks that group starts do not decrease, so frame timestamps inside a group are entirely peer-controlled.

This is a viewer-side denial of service, and the audit rated it High: the attacker does not need to fill the window, since ~100k entries already makes the main thread unresponsive.

The native estimator in rs/moq-audio had the same scan.

Change

  • Keep the history in a ring with a segment tree over its slots. Media timestamps increase along the ring, so a binary search finds where a frame's prefix ends, and the tree returns that prefix's fastest entry in O(log n). In-order and reordered frames take the same path, so a peer that sends only reordered frames no longer forces a scan per frame.
  • Ties keep the oldest entry, as the documented scan does. A tied entry with a different (arrival, media) rounds delay differently, so this is required for bit-identical results; the doc now states it.
  • The reference is still returned as the entry itself, so arrival - ref.arrival - (media - ref.media) keeps the normative arithmetic order.
  • Cap the history at MAX_HISTORY (8192) arrivals. With logarithmic lookups the cap no longer bounds CPU, only memory, and it is still needed for that: the window is media time, so a stream that advances a nanosecond per frame would otherwise keep every frame it sends. 8192 stays 4x above the two-second window of a 1000 fps track; a stream dense enough to reach it measures against a shorter window rather than being refused. The ring grows by doubling, so a normal track allocates for the ~100 entries it holds, not the cap.
  • Both implementations change together, and doc/concept/audio-jitter.md names MAX_HISTORY, the tie rule, and why the lookup must be sublinear.

Public API: none (History is exported from the internal jitter.ts module for its test only). Wire: none.

Benchmark

just js jitter-bench sweeps held history against in-order and reordered frames (bun, ns per frame):

held in order (scan) in order (tree) reordered (scan) reordered (tree)
512 62 103 887 29
4096 55 182 5863 37
8192 153 147 13550 39

In-order frames pay a logarithmic tree update instead of the deque's O(1), well under a microsecond; reordered frames go from linear to flat.

Verification

  • cargo nextest run -p moq-audio jitter: the full conformance corpus passes unchanged.
  • bun test src/container/jitter.test.ts in js/hang: 18 passed, including the same corpus.
  • New tests in both languages compare the tree against the documented scan, (arrival, media) included, after every frame of a 400-frame trace with reordering, timestamp jumps, and frequent exact ties. Flipping the tie rule to keep the newest fails them, which the previous deque did silently.
  • Cap tests in both languages feed 13,192 frames a microsecond apart and check the full, wrapped ring against the scan.
  • just check: JS and Rust pass; the flake module's github-runner VM image cannot build in my sandbox (unshare denied), which CI covers.

(Written by DeepSeek V4.1 Flash and Claude Opus 5.5)

The reference search scanned every arrival still inside the window, and the
window is media time, which the publisher chooses. A track stamped a microsecond
apart holds millions of entries inside two seconds, so every frame's scan grew
with the peer's timestamp rate and a viewer's main thread stalled.

Keep a monotonic deque of the prefix minimum beside the history, so an in-order
frame reads its reference in O(1) amortized, and fall back to the scan only for
a reordered frame, where the prefix is shorter than the history. Cap the
history at 8192 arrivals, far above any real track's two-second window, so
memory and the reordered scan stay bounded too.

Both the browser and native estimators implement the page, so both change
together, with the cap named in doc/concept/audio-jitter.md.

Co-Authored-By: DeepSeek V4.1 Flash <noreply@opencode.ai>
@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 26 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: a94e30d7-f649-4394-9735-b1aa39cd8328
📥 Commits

Reviewing files that changed from the base of the PR and between 28a8c9a and fce9e8f.

📒 Files selected for processing: 6
  • doc/concept/audio-jitter.md
  • js/hang/bench/jitter.ts
  • js/hang/src/container/jitter.test.ts
  • js/hang/src/container/jitter.ts
  • js/justfile
  • rs/moq-audio/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 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: acb5154c0e

ℹ️ 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 js/hang/src/container/jitter.ts Resolved
@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-22T18:48:38.726833Z fce9e8f New commits
ℹ️ 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.

kixelated and others added 2 commits October 22, 2026 11:40
A reordered frame's reference is a minimum over a prefix of the history,
and the scan let a peer spend MAX_HISTORY comparisons per frame by sending
only reordered frames. Replace the minimum deque and the scan with a
segment tree over a ring, which answers in-order and reordered frames the
same way in O(log n), and keeps the oldest entry on a tie as the scan did.

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

Copy link
Copy Markdown
Collaborator Author

Summary before merge:

  • Addressed the Codex P1 (reordered frames scanned the history): both js/hang and rs/moq-audio now keep the history in a ring with a segment tree, so every frame's reference is a binary search plus an O(log n) range minimum. A reordered frame at 8192 held entries drops from ~13.5 µs to ~40 ns (just js jitter-bench, new).
  • Fixed a tie regression the deque introduced: it kept the newest of tied minima, while the documented scan keeps the oldest, and a tie with a different (arrival, media) rounds delay differently. The doc now states the tie rule; new tests in both languages compare against the scan, ties included.
  • Kept MAX_HISTORY (8192): with logarithmic lookups it bounds only memory, which media-time pruning alone cannot, since a peer can advance timestamps arbitrarily slowly. It narrows the window rather than refusing input, and the ring grows by doubling, so real tracks allocate only what they hold.
  • Merged main to pick up moq-dev/moq#6357's quest fix. Public API and wire: none.

(Written by Claude Opus 5.5)

@kixelated
kixelated added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit acccbda Oct 22, 2026
9 checks passed
@kixelated
kixelated deleted the fix/jitter-history-bounds branch October 22, 2026 20:16
kixelated added a commit that referenced this pull request Oct 22, 2026
#6359 added js/hang/bench/jitter.ts and #6336 js/net/bench/subscribe-tracks.ts
without registering them, so `just gh test` fails on main and Nightly's
`gh test` would go red on its next run.

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