Skip to content

feat!: split moq-watch latency into delay + buffer, and rename --latency-max - #3396

Merged
kixelated merged 4 commits into
devfrom
claude/latency-max-naming-4c50f2
Sep 4, 2026
Merged

feat!: split moq-watch latency into delay + buffer, and rename --latency-max#3396
kixelated merged 4 commits into
devfrom
claude/latency-max-naming-4c50f2

Conversation

@kixelated

@kixelated kixelated commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • <moq-watch>'s latency-min / latency-max read as two bounds on one number, but they were anchored at different points: the floor runs from the live edge to the playhead, the ceiling from the playhead to the furthest held frame. So the ceiling silently contained the floor. #latencyCap() already named the decomposition in a comment ("lookahead + floor") without it reaching the API.
  • Replaced with two knobs anchored at the same place, pointing opposite ways. delay is how far playback trails the live edge (the jitter/startup buffer; "auto" derives it from RTT, "instant" keeps the unpaced mode). buffer is how much future-dated media is held beyond the live edge before skipping ahead, zero by default.
  • Naming follows what every other player calls these, where an unqualified "buffer" is always the forward one: hls.js liveSyncDuration / maxBufferLength, Shaka liveSyncTargetLatency / bufferingGoal, dash.js liveDelay / stableBufferTime, WebRTC jitterBufferTarget.
  • Duration attributes now require a unit ("300ms", "30s"; a bare "0" is exempt). The old parser was Number.parseFloat, so latency-max="30s" silently meant 30 milliseconds, and every other player measures these in seconds.
  • container.ConsumerProps.latency is renamed maxAge, which is what it measures: the same staleness budget as the subscription's maxAge, enforced locally as frames are read rather than on the wire. The tests already handed one number to both under two names.
  • moq CLI: --latency-max becomes --max-age on play, import, the stdout containers, and rtmp export. The Rust field behind it has been max_age since the wire renamed Max Latency to Max Age; only the flag was stale.
  • play deliberately does not get --delay. It maps to moq-watch's derived maxAge, not to delay: it feeds only the subscription and the container consumer, and never touches the presentation clock (which is derived from the speaker's own buffering, or anchored to the first frame when there is no audio). Naming it delay would promise a playout offset it does not apply.

Public API changes

Breaking, @moq/watch (0.5.2):

  • Removed: Latency, Bound, latencyBounds(), latencyFromBounds().
  • Added: Delay ("instant" | "auto" | Time.Milli).
  • Sync: input latencydelay + buffer; output bufferdelay, maxBuffermaxAge.
  • MoqWatch: properties latency / latencyMin / latencyMaxdelay / buffer; attributes latency / latency-min / latency-maxdelay / buffer. The deprecated jitter attribute still parses bare milliseconds.
  • ReanchorFloor.latency.delay, and subscribeMedia's latency prop → maxAge (both @internal).

Breaking, @moq/hang (0.4.2):

  • container.ConsumerProps.latencymaxAge.

moq CLI: --latency-max--max-age. The old spelling stays as a hidden alias, so no existing invocation breaks. A field-level alias in usage-rs is already hidden (visible_alias is the advertised form), which keeps the dead name out of --help.

moq-net's max_age and cache::Pool::expiry are untouched.

Behavior changes

  • buffer no longer contains delay, so the re-anchor cap is delay + buffer rather than max(ceiling, floor). At the default zero buffer this is identical to the old collapsed behavior; only an open range shifts, by delay.
  • What a publisher or relay sees: <moq-watch> now sends Subscriber Max Age = delay + buffer instead of max(ceiling, floor). Same field, same encoding, a slightly larger value in buffered mode. No wire format change, so no drafts/ update.
  • A duration attribute without a unit now warns and falls back to its default instead of being read as milliseconds.

Test plan

  • @moq/watch: tsc --noEmit clean, 194 tests pass (7 new in duration.test.ts, 6 rewritten in sync.test.ts covering delay + buffer and the instant override).
  • @moq/hang: tsc --noEmit clean, 113 tests pass.
  • @moq/boy and @moq/demo: tsc --noEmit clean. A full just js check (all 12 packages built) and just js test passed earlier on the branch.
  • moq-cli: cargo test -p moq-cli args:: (27 pass), including a new latency_max_still_parses_as_max_age covering the hidden alias on both import and an export sink. cargo check -p moq-cli --features play,capture and cargo clippy -p moq-cli --all-targets clean.
  • biome, cargo fmt, and remark clean on every touched file.
  • A full just check was not run locally: other worktrees were saturating the machine. The diff is JS plus moq-cli doc/attribute changes, both covered above.
  • Rebased onto fix: repair dev, which the last main merge left uncompilable #3399, which independently landed the same moq-tokio test repair and humantime removal this branch was briefly carrying; both commits dropped as redundant.

Cross-package sync

  • js/{watch,publish} UI/API → demo/web ✅, doc/lib/js/@moq/watch.md
  • rs/moq-clidoc/bin/cli.md ✅ (all six --latency-max mentions), plus skills/moq/SKILL.md and a stale reference in quest/m2/2829-….
  • No rs/moq-net wire or rs/hang catalog change, so no drafts/ row applies.

🤖 Generated with Claude Code

@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: 01df6e9a87

ℹ️ 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/watch/src/element.ts
Comment on lines +27 to 29
"delay",
"buffer",
"jitter",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Continue observing the renamed latency attributes

Existing pages using latency, latency-min, or latency-max now receive neither compatibility behavior nor an error because those names are no longer observed by the custom element. For example, latency="instant" silently falls back to paced delay="auto" with audio enabled, while an old buffered range silently becomes the zero-buffer default. Keep these attributes observed and translate them to delay/buffer while leaving them out of the documented surface.

AGENTS.md reference: AGENTS.md:L140-L142

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.

Partly taken, in 376b374.

latency and latency-min are observed again and translate to delay, off the documented surface, beside the existing jitter alias. You are right that the silent no-op was the worst outcome: latency="instant" fell back to paced playback with audio re-enabled, which a viewfinder or remote-control page would never connect to this rename.

latency-max is deliberately not restored, because it cannot be translated faithfully. Its ceiling included the floor, so the honest mapping is buffer = max(0, latency-max - delay), which has to track the resolved delay reactively since that moves with RTT under "auto". Reintroducing that dependency is precisely the coupling buffer was split out to remove, and it would put the confusing shape back one layer down. This targets dev, which per CONTRIBUTING.md is the branch for a semver break in a published API, and the removal is called out in the PR body.

(written by Claude Opus 5)

Comment thread js/watch/src/duration.ts Outdated
* @internal
*/
export function formatDuration(value: Time.Milli): string {
return `${Math.round(value)}ms`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve fractional milliseconds during reflection

parseDuration accepts fractional values, but this formatter rounds them before the reflected attribute is synchronously parsed back into the signal. Consequently, delay="1.5ms" becomes 2 ms, and buffer="0.4ms" becomes zero and even disables buffered mode. Serialize the exact finite value so every duration accepted by the parser round-trips without changing behavior.

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 and fixed in 376b374formatDuration no longer rounds.

Worth noting the test file already claimed to cover this ("round-trips through parseDuration, which is what keeps attribute reflection settled") but only used integers, so it passed while the property it named was broken. Added a fractional case.

(written by Claude Opus 5)

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 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-09-04T22:25:31.425753Z c3ac52e 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 added a commit that referenced this pull request Sep 4, 2026
Four corrections, all against the tree the quest describes:

- The delay cannot live inside `send_at`. `pace` re-anchors when the result
  leads `now` by more than `lead`, which defaults to zero, so a delayed
  `send_at` overshoots on the first frame and `hurry` returns an undelayed
  `now`. The offset applies after the lead check instead.
- A wait pending on one stream has to wake when the other stream's frame moves
  the anchor, the way `Sync.#setReference` wakes existing waits. Otherwise it
  sleeps out a stale deadline or blocks the re-anchor.
- `AUDIO_BUFFER_MAX` sizes nothing; it only throttles writes against
  `sink.buffered()`. The ring is built from the private `LATENCY` and
  `CAPACITY` constants in `rs/moq-audio/src/playback/sink.rs`, so honoring
  `delay` means reaching the sink.
- Audio must not take the delay twice, once in the ring and once on the pacer.
  The ring depth is audio's offset; video is what waits.

The branch also goes back to dev: m1's plan says its quests branch from dev,
and the flag this replaces is renamed there by #3396.

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

@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: 38be5113b6

ℹ️ 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-cli/src/args.rs
// already hidden (`visible_alias` is the advertised form), so the dead name stays out of
// `--help`. An implementation comment rather than a doc one for the same reason: `///`
// here *is* the help text.
#[usage(long, alias = "latency-max")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use hidden aliases for the renamed flags

usage-rs advertises a plain alias in generated help and completions, so this keeps --latency-max on the published CLI surface even though the documentation now presents only --max-age. The same issue occurs on the container, play, and RTMP export declarations; use alias_hidden so existing scripts continue parsing without advertising the deprecated spelling. rs/CLAUDE.mdL137-L137

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.

Turning this one down: at the field level alias is already the hidden form, and alias_hidden is not accepted there at all.

From usage-derive-6.3.0/src/model.rs, in the argument attribute parser:

// clap distinguishes advertised and hidden aliases. Visible aliases
// are losslessly additional long forms in usage.
"visible_alias" | "visible_aliases" => {
    visible_long_aliases.extend(...)
}
"alias" | "aliases" => {
    hidden_longs.extend(...)
}

alias_hidden is parsed in only two places, neither of them a field: line 940 (the Cli struct) and line 5798 (a subcommand variant). Its valid-key error messages at 1172 and 5842 confirm the scope. #[usage(long, alias_hidden = "latency-max")] does not compile — that was my first attempt here and I had to back it out.

The args.rs:535 comment you are matching against is on a Subcommands enum, where alias genuinely is advertised. Fields invert it, which is why --broadcast already carries #[usage(long, alias = "name")] at args.rs:338 without name appearing in help.

(written by Claude Opus 5)

kixelated and others added 3 commits September 4, 2026 14:58
`latency-min` / `latency-max` read as two bounds on one number, which is what made
them confusing: they are anchored at different points. The floor runs from the live
edge to the playhead; the ceiling runs from the playhead to the furthest held
frame, so the ceiling silently contained the floor. `#latencyCap()` already named
that decomposition in a comment ("lookahead + floor") without it reaching the API.

Two knobs anchored at the same place, pointing opposite ways:

- `delay`: how far playback trails the live edge (the jitter/startup buffer).
  `"auto"` derives it from the RTT; `"instant"` keeps the unpaced mode.
- `buffer`: future-dated media held beyond the live edge before skipping ahead.
  Zero by default, which is the existing minimize-latency behavior.

`buffer` no longer contains `delay`, so the re-anchor cap is `delay + buffer`. At
the default zero buffer that is identical to the old collapsed behavior.

The naming matches what every other player calls these, where an unqualified
"buffer" is always the forward one: hls.js `liveSyncDuration` / `maxBufferLength`,
Shaka `liveSyncTargetLatency` / `bufferingGoal`, dash.js `liveDelay` /
`stableBufferTime`, WebRTC `jitterBufferTarget`.

Duration attributes now require a unit (`"300ms"`, `"30s"`; bare `"0"` is exempt).
The old parser was `Number.parseFloat`, so `latency-max="30s"` silently meant 30
milliseconds, and every other player measures these in seconds.

Also renames `container.ConsumerProps.latency` to `maxAge`, which is what it
measures: the same staleness budget as the subscription's `maxAge`, enforced
locally as frames are read rather than on the wire. The tests already handed the
same number to both under two different names.

Dropping the range removes the attribute-reflection guard: each knob is now 1:1
with its attribute, so there is no sugar to round-trip and no echo to suppress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Rust field behind it has been `max_age` since the wire renamed Max Latency to
Max Age; only the flag was left on the old spelling. It is a staleness budget in
every one of its four homes: `import` (how long relays keep a non-latest group
fetchable), the stdout containers, `rtmp export`, and `play`.

`play` deliberately does not become `--delay`, despite `<moq-watch>` now spelling
its playout knob that way. The value feeds only the subscription and the container
consumer; it never touches the presentation clock, which anchors to the speaker.
It maps to moq-watch's derived `maxAge`, not to its `delay`, and naming it `delay`
would promise a playout offset it does not apply.

`--latency-max` keeps parsing as a hidden alias, so no existing invocation breaks.
A field-level `alias` is already hidden in usage-rs (`visible_alias` is the
advertised form), which keeps the dead name out of `--help`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing reflection

Two findings from the Codex review.

`latency` and `latency-min` are observed again and translate to `delay`, off the
documented surface, next to the existing `jitter` alias. Dropping them outright
made an old page silently change behavior rather than break: `latency="instant"`
fell back to paced playback with audio re-enabled, which is a regression a
viewfinder or remote-control page would not connect to this rename.

`latency-max` is deliberately not restored. Its ceiling included the floor, so a
faithful translation is `buffer = max(0, latency-max - delay)`, which has to
track the resolved delay reactively (it moves with RTT under `"auto"`). That is
exactly the coupling `buffer` exists to remove, and this targets dev, which is
where a break like it belongs.

`formatDuration` no longer rounds. The reflected attribute is parsed straight
back into the signal, so rounding rewrote the caller's value: `1.5ms` became
`2ms`, and `0.4ms` became zero, which also switched buffered playback off. The
round-trip test claimed to cover this but only used integers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kixelated
kixelated force-pushed the claude/latency-max-naming-4c50f2 branch from 376b374 to 663a8be Compare September 4, 2026 21:59

@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: 663a8bec56

ℹ️ 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/watch/src/element.ts
Comment on lines +553 to +554
set delay(value: Delay) {
this.controls.delay.set(value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the legacy latency property setters functional

Existing plain-JavaScript callers that assign watch.latency = 250, watch.latency = "instant", or watch.latencyMin = 250 now create inert own-properties because the corresponding prototype setters were removed, so playback silently remains at the reflected delay value. Keep hidden compatibility setters that translate these released spellings to delay (and preserve or explicitly reject range forms) rather than allowing successful no-op assignments. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L133-L135

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 and fixed in c3ac52e. You are right that restoring the attributes but not the properties was the worst of both — the same page reaches one and not the other.

latency and latencyMin are hidden accessors onto delay now, accepting the released spellings ("real-time", "instant", bare milliseconds).

On "preserve or explicitly reject range forms": rejected, explicitly. latencyMax and the { min, max } object throw with a message naming buffer and the conversion. They cannot be preserved faithfully — the old ceiling included the floor, so an honest mapping is buffer = latencyMax - delay, which has to track the resolved delay reactively since it moves with RTT under "auto". That is the coupling buffer was split out to remove. A throw is safe here in a way it would not be in attributeChangedCallback: this is an assignment from app code, not an attribute the browser hands us, so it reaches the one caller who needs to know.

(written by Claude Opus 5)

A third Codex finding, and the property half of the attribute fix in 663a8be.
Leaving them off the prototype meant `watch.latency = 250` created an inert own
property in plain JS: the assignment appeared to work and playback silently
stayed where it was. Restoring the attributes but not the properties was the
worst of both, since the same page reached one and not the other.

`latency` and `latencyMin` are hidden accessors onto `delay`, accepting the
released spellings (`"real-time"`, `"instant"`, bare milliseconds).

`latencyMax` and the `{ min, max }` range object throw instead, naming `buffer`
and how to convert. They cannot be translated faithfully: the old ceiling
included the floor, so an honest mapping has to track the resolved delay, which
moves with RTT under `"auto"`. A throw reaches the one caller who needs to know,
which a silent no-op does not, and this is a property assignment from app code
rather than an attribute the browser hands us.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kixelated
kixelated enabled auto-merge (squash) September 4, 2026 22:22
@kixelated
kixelated merged commit 0bba8b8 into dev Sep 4, 2026
3 checks passed
@kixelated
kixelated deleted the claude/latency-max-naming-4c50f2 branch September 4, 2026 22:38
kixelated added a commit that referenced this pull request Sep 4, 2026
`moq play` has no playout offset anywhere in its path, and two clocks that
disagree. With audio the clock is re-derived per frame from `sink.buffered()`,
so the offset is whatever the speaker holds under a hardcoded 1s cap.
Video-only, `video_clock` is anchored once on the first frame and never
re-anchored, so a late first frame appears to leave playback behind live for
the session.

The plan extends `moq_mux::pace::Pacer` rather than adding a second primitive:
its `anchor` is already `js/watch/src/sync.ts`'s `reference`, it re-anchors
forward only, and `with_lead` is nearly moq-watch's `buffer`. It needs a delay
offset and the crate's usual `poll_*`/`async` pair, and a rename to
`moq_mux::Clock` now that it serves playout as well as export pacing.

Also de-stales two quests that PR #3396 moved under: 2981 said `latency_max`
where the field and flag are now `max_age`, and 2278 quoted the `Bound` /
`Latency` types it replaced with `Delay` plus a separate `buffer`, including a
naming note that break partly resolves.

Co-Authored-By: Claude Opus 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