Skip to content

feat(js)!: durations and instants are Clock types, not numbers - #8295

Merged
kixelated merged 29 commits into
mainfrom
quest/m2/js-time-types
Nov 9, 2026
Merged

kixelated merged 29 commits into
mainfrom
quest/m2/js-time-types

Conversation

@kixelated

@kixelated kixelated commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

Every public duration and instant in the published JS packages is now a Clock.Duration or Clock.Instant object, and wall time a Clock.Wall, so a unit mistake fails to compile instead of being stripped by as number or arithmetic. Retires quest/m2/js-time-types.md.

Summary

  • @moq/clock replaces the branded Nano/Micro/Milli/Second numbers and the Instant helper namespace with three immutable classes:
    • Duration: nanos/micros/millis/secs constructors, asNanos/asMicros/asMillis/asSecs, add, sub (throws below zero), saturatingSub, mul, div, divDuration, absDiff, isZero, eq/cmp/lt/le/gt/ge, min/max/clamp, and Duration.ZERO.
    • Instant: add(Duration), sub(Duration), since(Instant) (saturating at zero, as kio::clock::Instant::duration_since), comparison, min/max, and Instant.ZERO (the clock's origin).
    • Wall: the same rule for wall time, with Wall.UNIX_EPOCH, add/sub/since, comparison, and an ISO toString.
    • valueOf, Symbol.toPrimitive, and toJSON throw, so a < b, a + 1, `${a}` arithmetic, and JSON.stringify cannot silently read a number. toString prints the unit (33ms).
    • Manual.advance/jump, sleep, timeout, Interval, and now()/wall() take and return the new types.
  • @moq/signals: effect.timer/timeout/interval take a Duration. Signal equality compares clock values by value, so setting an equal Duration does not rerun effects.
  • @moq/net re-exports Clock (as it re-exports Signals). Track.Info.maxAge, Track.Delay, fetch maxDelay, ConnectProps.timeout, Backoff, Goaway.handover, Drain.timeout, Probe.rtt, and the other timing knobs are Durations; Auth.Grant.expires is a Wall. The wire still carries whole milliseconds, rounded up inside the encoders as before.
  • @moq/qmux: Config.idleTimeout and handshakeTimeout are Duration | null.
  • @moq/hang: Frame.timestamp/duration, BufferedRange.start/end, Consumer.spread, and the delay/warmup options are Durations; ReceivedFrame.arrival is an Instant. Container.Legacy still writes whole microseconds.
  • @moq/loc, @moq/json, @moq/auth, @moq/room, @moq/boy: follow.
  • @moq/watch: Delay is "instant" | "auto" | Duration; Sync's lookahead, delay, maxDelay, and timestamp, Arrival's timestamp/duration (and arrival, an Instant), decoder and renderer timestamp and jitter, and the stalled stats are clock values. <moq-watch>'s delay/lookahead properties take a Duration; its attributes still read "300ms". SyncOutput.reference stays a signed number of ms, since a Duration cannot be negative.
  • @moq/publish: AudioFrame.timestamp, the audio and video join, OpusConfig.frameDuration, and Broadcast's maxAge/join are Durations. Worklet messages and WebCodecs timestamps stay µs numbers, converted where they cross.
  • AAC continuity follows the framer's timeline. A Duration holds whole nanoseconds, so a frame's end computed as its rounded start plus a fractional sample duration could land a microsecond off the framer's next start (48 kHz AAC from 1000.833 µs: 43667 vs 43668 at the third frame), publishing a false end marker on continuous audio. submit() now takes each frame's end from the framer's next start, live or retained in the backlog, and a test covers that case. The other encoder and decoder conversions compare with >= or a 1 ms tolerance, so they need no change.
  • Wall.unixMillis(ms) / asUnixMillis() convert Unix milliseconds, possibly fractional, without rounding through a Duration since the epoch.
  • Merged with main after moq-dev/moq#8296: a resolved Track.Info.maxAge is Duration | null, Video.RendererOutput.timestamp and @moq/room's Member video/audio timestamps are Durations, and Broadcast.join is a Getter<Duration | undefined>. Main's audio time stretch keeps the worklet's ring in plain µs and ms numbers, as the port carries them, with Audio.Stats.stalled a Duration.
  • Merged with main after moq-dev/moq#8265 and moq-dev/moq#8330: Connection.Backoff's max and timeout are Duration | null, where null is unbounded and zero a real bound (main's semantics, replacing this branch's zero-means-unbounded). Main's WebTransport upgrade probe backoff is Durations. Main's test that Reload refuses a negative, NaN, or infinite timeout is dropped: a Duration cannot hold one. Main's timeoutSignal (moq-dev/moq#8339) takes a Duration.
  • The interop, wire-compat, audio-quality, and browser-bench clients follow. The wire-compat client still runs against the released packages: it builds a Clock.Duration only where the checkout's @moq/net re-exports Clock.
  • The conversion surfaced and fixed real bugs: watch's packetDuration treated a zero duration as present, video/recovery.ts compared two timestamps with >= (which TypeScript allows between same-class objects), video's frame map keyed by object identity, and feedback.ts matched a stall by === on an Instant; the demo's buffer slider set a raw number through an as unknown as cast.

Hot paths keep plain numbers internally and only build a clock value at the public edge: hang's container reader keeps µs fields and merges buffered ranges in µs, the jitter estimator works in ms numbers, and the net cache keeps nanosecond deadlines.

Benchmarks

Each bench ran on the merge base (e80b79a16e) and then on this branch, back to back, on a shared host whose load average stayed between 10 and 30 throughout, so a single run swings ±20%. Rows that looked off were rerun; the table shows the representative case per bench and the rerun where one was taken.

Bench (js/*/bench) Case main branch Δ
clock timers jump, 10k armed, arm / cancel µs 7719 / 18046 4709 / 7978 faster (timer heap keyed by plain ns)
signals effect create-get-close ns 1327 1221 -8%
hang jitter 8192 held, in order ns/frame 138.0 138.6 ±1% (numbers inside)
hang buffered steady 1024×30 ns/op (rerun) 3757 3832 +2%
hang buffered lookahead 1024×30 ns/op (rerun) 56362 58053 +3%
hang buffered sparse 1024×1 ns/op (rerun) 117758 130711 +11%
hang slow-group 1024 groups catch-up ms 3.21 3.02 -6%
hang cmaf 65536 B, 64 samples µs/fragment 45.0 48.5 +8%
net frames lite 1000 B, 64 KiB chunks ns/frame 373 376 +1%
net drift 64 retained, 1×1 ns/frame 4788 2622 noise
net track 1500 retained, 1 sub, idle µs (rerun) 1648 1427 -13%
net ledger 8192 rolling ns/arrival 940 1121 +19% (+6% first run)
net fetch 128 groups, 8 subs, 1000 B µs 2197 1522 noise
watch ring 16 players, CPU µs/callback 25.8 23.6 -9%
watch arrivals 16 handles, 256 backlog ns/write 63 68 +8%
watch warmup 64 decoders, 2880 frames ns/callback 12.1 11.9 -2%
publish audio 8 tracks, 30 s, buffered idle ms 377.7 366.3 -3%

The first full run flagged hang's buffered lookahead and sparse cases at +15-50%: the consumer built a Duration for every buffered group on every update. It now merges ranges in microseconds and builds one per merged range, which brought them back to the rows above. The net ledger's rolling arrivals (+6% to +19%) are the one delta that stays positive; it compares Instants per expiry check, and its absolute cost is about 0.2 µs per arrival.

Public API

Breaking in every listed package.

Impact

Migration:

  • x as Milli, Milli(x), Nano.fromMilli(Milli(x)) → Clock.Duration.millis(x) (likewise micros, secs, nanos).
  • Reading a number: d as number → d.asMillis() (or another unit). Comparing: a < b → a.lt(b), a === b → a.eq(b).
  • now() - t, Instant.durationSince(now(), t) → now().since(t); Instant.add(t, d) → t.add(d).
  • wall() / 1000 → wall().since(Wall.UNIX_EPOCH).asSecs(); Wall(ms) → Wall.UNIX_EPOCH.add(Duration.millis(ms)).
  • clock.advance(Nano.fromMilli(Milli(5))) → clock.advance(Duration.millis(5)); sleep(0) → sleep(Duration.ZERO).
  • qmux: { idleTimeout: 30_000 } → { idleTimeout: Duration.secs(30) }.
  • hang: frame.timestamp is a Duration; read whole microseconds with Math.floor(frame.timestamp.asMicros()), and pass Duration.micros(us) to Legacy.Producer.encode and discontinuity.
  • Tests: expect(d).toBe(Duration.millis(5)) compares identity; use toEqual. A clock value is truthy even at zero, so test d.isZero() instead of !d.
  • A clock value cannot cross postMessage or JSON.stringify; send a number in an explicit unit.

Choices made without asking

  • Time.Timestamp.value stays a number. Frames stay allocation-free and 2^53 ticks covers every timescale the media packages use. Recorded in time addressing's JS half to revisit if its bigint group starts need exact comparison past 2^53.

  • Media timestamps are Durations, not Time.Timestamps. A container timestamp is a position on the media timeline, which is a duration since its zero, and Time.Timestamp stays the wire's tick count at a timescale.

  • No checkedAdd/saturatingAdd. The quest ties them to peer-arithmetic dropping kio's operators, which has not landed; that requirement moved into quest/m0/peer-arithmetic.md. sub throws below zero (as Rust's Duration - Duration panics) and saturatingSub covers the clamping case.

  • Values live under a symbol, not a #private field. Bun's toEqual treats every pair of #private-only instances as equal, which would make every Duration assertion pass. A private declare brand keeps the types nominal, and the symbol is @internal, so it is absent from the published .d.ts.

  • Wire structs keep numbers. Encoders and decoders work in the wire's whole milliseconds; only the public types they feed change.

  • Signals compare clock values by value. isEqual otherwise compares class instances by identity, which would rerun an effect on every set(Duration.millis(x)) of an unchanged value.

  • Duration holds whole seconds and whole nanoseconds apart, as Rust's std::time::Duration does, so a whole count of any unit (such as microseconds since the Unix epoch in a legacy container) converts back exactly; a value finer than a nanosecond rounds to the nearest one. Instant stays a float of nanoseconds since the clock's origin, which a session never runs long enough to round. Wall stores Unix milliseconds.

  • Tests that passed deliberately invalid numbers (negative or non-integer as Milli casts) to prove runtime validation are removed: the type no longer admits them, and the constructors validate.

  • relay-bbb's replay A/V lag p50 budget moves from -22.7 to -22.6 ms. Its delay, 200 ms plus one 1024-sample AAC frame, is an exact sample count in milliseconds on main; held as whole nanoseconds it rounds up by a fraction of a sample, so the ring's Math.ceil holds one more sample (23 µs) and the p50, which sits between two clusters of samples, lands on the other one. Rust's Duration rounds the same way. Every other replay metric is unchanged; budgets.json's note records why.

Models

Size: L
Implementer: Claude Code, Opus 5.5, high
Implementer: Claude Code, claude-opus-5-5, high effort (iterate, L)
Implementer: Claude Code, claude-opus-5-5, high effort (iterate, XS)
Implementer: Claude Code, claude-opus-5-5, medium effort (iterate, M)

Follow-ups

  • None.

Review

Rewritten by quest review after each round.

  • Reviewer: Codex, gpt-6.1-sol, high effort
  • Session: 01a1c1d6-9091-7442-87aa-bf972227bd65, round 2
  • Head: 5688bc0
  • Base: origin/main at 6eff24e
  • Score: 8/10, The merge resolutions consistently adapt incoming code to the branch’s time types, with no actionable regressions identified by code inspection; builds and tests were not run as instructed.
  • Findings: none

kixelated and others added 11 commits November 8, 2026 09:25
Quest-Claim: 1eda9c38859df4e9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also add Wall.unixMillis and asUnixMillis, which convert Unix milliseconds
exactly, where a Duration since the epoch rounds past 2^53 ns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wire-compat client builds a Clock.Duration only where the checkout's
@moq/net re-exports Clock, so it still runs against the released packages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title JS time values are types, not numbers feat(js)!: durations and instants are Clock types, not numbers Nov 8, 2026
kixelated and others added 4 commits November 8, 2026 12:16
Duration holds whole seconds and nanoseconds apart, as Rust does, so a
whole count of microseconds since the Unix epoch converts back exactly.
A file source's timestamp stays signed until it lands on the clock, so
audio priming before zero no longer throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A residue finer than a nanosecond let divDuration and the ordering
disagree at a file's loop boundary, so Timeline.position threw. Rounding
to whole nanoseconds, as Rust's Duration holds them, keeps them in step,
and position steps back a pass if the float quotient still rounds up.

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

Unit constructors build the split directly instead of a second carry pass,
and the consumer's buffered ranges merge in microseconds, building a
Duration only for each merged range rather than every group per update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review November 8, 2026 20:39
# Conflicts:
#	js/net/src/ietf/connection.ts
#	js/net/src/ietf/publisher.ts
#	js/net/src/lite/connection.ts

@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 6c52b4e. No actionable findings in the Clock value-type migration.

Checked duration arithmetic and unit round-tripping, timer and signal equality semantics, network expiry/deadline and wire conversions, media timestamp comparisons, buffered-range handling, and explicit numeric conversions at WebCodecs/worklet, UI, and interop boundaries. The breaking API migration and retained numeric wire/timescale representations are documented.

Verification limits: GitHub-only static source and test review, using paginated file patches for this large change. No typecheck, test suite, browser playback, or performance benchmarks run; the reported benchmark deltas were not independently reproduced.

@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 3d92ace incrementally from 6c52b4e, separating the merged main changes from this PR's changes. No actionable findings.

The IETF track-properties conversion now rounds fractional milliseconds up before BigInt conversion, avoiding a RangeError for a valid duration such as 1.5 ms; the added regression asserts the resulting 2 ms wire value. The connection/publisher conflict resolutions retain Duration conversions alongside main's new lifecycle and subscription-update handling.

Verification limits: static GitHub diff/source review only; tests and typechecking were not run.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No critical issues — two minor documentation gaps.

Reviewed changes

  • New clock value types. @moq/clock replaces the branded unit numbers and the Instant helper namespace with Duration (whole seconds plus whole nanos past them), Instant (float nanos since the clock origin), and Wall (Unix milliseconds), all refusing numeric coercion through valueOf/Symbol.toPrimitive/toJSON.
  • Timers and signals. Manual, sleep, timeout, Interval, and the scoped effect.timer/timeout/interval take a Duration; signal equality compares clock values by value so an equal Duration does not rerun an effect.
  • Transport timing. @moq/net makes every public delay, timeout, RTT, and age a Duration (auth grant expiry a Wall) while the wire keeps whole milliseconds; @moq/qmux config takes Duration | null.
  • Media timing. @moq/hang's frame timestamps, buffered ranges, and arrivals, watch's sync/arrival/decoder paths, and publish's encoder/source paths are clock types at the public edge, with plain numbers kept inside the hot loops.
  • Docs, benches, and quests. User docs and API reports follow the rename, the benches are reported against the merge base, and quest/m2/js-time-types.md is retired with its tree references updated.

ℹ️ The signal-equality docs still say class instances compare by identity

js/signals/src/index.ts now compares Duration/Instant/Wall by value, which the new test covers, but three descriptions of the same rule were left unchanged: js/AGENTS.md:7, js/signals/README.md:29, and doc/lib/js/signals.md:42. A reader of any of them would expect signal.set(Duration.millis(5)) after Duration.micros(5000) to notify, which is the behaviour this PR deliberately removed.

Technical details
# Stale signal-equality documentation

## Affected sites
- `js/AGENTS.md:7` — "identity for class instances"
- `js/signals/README.md:29` — "class instances by identity"
- `doc/lib/js/signals.md:42` — "identity for class instances"

## Required outcome
- Each surface states that the clock's immutable values compare by value, as the
  comment in `js/signals/src/index.ts` now does, without turning the line into a
  list of clock members.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

Comment thread js/clock/src/types.ts Resolved

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues in the merge; one stale doc block left behind below.

Reviewed changes (since 6c52b4e)

  • Main merged in. 3d92ace brings main's Phase lifecycle into js/net's IETF and lite connections, resolved onto the branch's 3-argument withTimeout(promise, Duration, message); both close() paths read correctly at the new head.
  • Fractional-age hardening. trackProperties now rounds maxAge up to whole milliseconds (BigInt(Math.ceil(info.maxAge.asMillis()))), request_stream rounds RETRY_INITIAL the same way, and a new test pins a 1.5 ms max age to 2n; this matches the branch's "wire rounds up" rule.
  • Test export. trackProperties is exported from the internal module as @internal solely so that test can call it.

ℹ️ The merge stacked two doc blocks on trackProperties

js/net/src/ietf/publisher.ts:2998-2999 now has the description block immediately followed by an @internal block. TSDoc binds only the block directly above the declaration, so the description is dead and the exported helper documents itself as just "Exported for tests".

Technical details
# Stacked doc blocks from the merge

## Affected sites
- `js/net/src/ietf/publisher.ts:2998` — orphaned description block
- `js/net/src/ietf/publisher.ts:2999` — `@internal` block that wins

## Required outcome
- One block above `trackProperties` carrying both the description and `@internal`.

Pullfrog  | Fix it ➔ | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

kixelated and others added 2 commits November 8, 2026 17:32
# Conflicts:
#	js/clock/src/index.test.ts
#	js/publish/src/source/retry.ts
…pare by value

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Prior doc feedback fixed. Wall.unixMillis now promises only the finite/nonnegative check that checked performs, Wall.asUnixMillis describes the stored value instead of a nanosecond round-trip through since, and the signal-equality rule reads "by value for the clock's immutable values" in js/AGENTS.md, js/signals/README.md, and doc/lib/js/signals.md.
  • Stacked TSDoc merged. trackProperties carries its description and @internal in a single block.
  • Main merged in. 7d4ca78 brings main through f102ee038. The only two conflicts resolve to main's structure with the branch's clock calls: the clock driver test awaits clock.drive(...) and asserts on the settled error (main's no-drive-expect rule), and source/retry.ts keeps main's State flow with Duration.millis(wait) / Duration.millis(Retry.SETTLED).
  • Rest of main applied cleanly. audio/encoder.ts's #held catalog state, video/encoder.ts's failure gating, qmux's Queue drain, room's screen state, watch's Enabled signal, and moq-boy's session gating are all present alongside the branch's clock types.

Every JS package typechecks green at 23e94ce (bun run check; moq-doc still fails only on its nested typedoc version mismatch), and the clock, signals, and publish retry suites pass (38, 80, and 43 tests).

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

# Conflicts:
#	doc/lib/js/net.md
#	js/hang/src/container/track.test.ts
#	js/net/api/index.api.md
#	js/net/src/auth_session.ts
#	js/net/src/connection/connect.ts
#	js/net/src/connection/reload.ts
#	js/net/src/connection/stats.test.ts
#	js/net/src/connection/stats.ts
#	js/net/src/ietf/publisher.ts
#	js/net/src/integration.test.ts
#	js/net/src/internal.ts
#	js/net/src/lite/connection.test.ts
#	js/net/src/lite/publisher.ts
#	js/net/src/lite/subscriber.ts
#	js/net/src/lite/track.test.ts
#	js/net/src/open.test.ts
#	js/net/src/order.test.ts
#	js/net/src/resume.ts
#	js/net/src/track.test.ts
#	js/net/src/track.ts
#	js/publish/api/index.api.md
#	js/publish/bench/audio-page.ts
#	js/publish/src/audio/encoder.test.ts
#	js/publish/src/broadcast.ts
#	js/publish/src/source/pace.test.ts
#	js/publish/src/source/pace.ts
#	js/watch/api/index.api.md
#	js/watch/src/audio/buffer.test.ts
#	js/watch/src/audio/buffer.ts
#	js/watch/src/audio/decoder.test.ts
#	js/watch/src/audio/decoder.ts
#	js/watch/src/audio/playout.test.ts
#	js/watch/src/audio/replay.ts
#	js/watch/src/audio/ring-buffer.test.ts
#	js/watch/src/audio/ring-buffer.ts
#	js/watch/src/audio/source.ts
#	js/watch/src/video/decoder.ts
#	js/watch/src/video/renderer.ts
#	quest/m2/api-shape/js.md
#	test/audio-quality/clients/js/src/probe.test.ts
#	test/audio-quality/clients/js/src/probe.ts
#	test/audio-quality/clients/js/src/ptt.ts
#	test/max-age/client.ts
#	test/relay-fill/relay.ts

@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 fbe8682 incrementally from 150ef20, separating merged main changes. No new actionable findings.

The AAC continuity correction derives the expected next timestamp from the framer's cumulative timeline rather than adding a fractional duration to an already rounded start. Both immediate encoding and backlog replay now use that end timestamp; the regression covers four consecutive AAC frames with fractional-microsecond boundaries and asserts no discontinuity. Checked Framer.next and retained backlog end semantics against those callers.

Verification limits: GitHub-only static source/diff review. No tests, typechecking, or real WebCodecs encoding/playback were run.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes since 150ef20:

  • AAC continuity derives from the framer's timeline. submit now takes each frame's end from the framer's next start (frames[i + 1]?.timestamp ?? framer.next) instead of adding a fractional frame duration to an already rounded start, which landed a microsecond off at the third 48 kHz AAC frame and published a false end marker on continuous audio.
  • Backlog.peek() returns the entry. It now yields { frame, end } so the replay path in drain submits the retained frame with the same end it was stored with, keeping live and backlog boundaries identical.
  • Regression test added. AAC frames with fractional durations stay one stream pushes a 1000.833 µs start and asserts the written timestamps [1001, 22334, 43668, 65001] with no discontinuity call.
  • Main merged in. fbe8682 cleanly brings main through eb8bf98e4 (js/json depth and number limits, datagram onTestFinished, quest and Rust changes); no clock surface changed.

Verified locally at fbe8682: publish encoder.test.ts and backlog.test.ts pass (57 tests), and tsc --noEmit is green for js/publish, js/json, and js/net.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Main merged in. 24ef62d cleanly merges origin/main through 87e9e1c (the #8303 bindings rework and #8299's shared ingest / bandwidth limit). The merge touches 75 files, all non-JS: dart, go, python, swift, Rust, quest docs, and pyproject.toml.
  • Quest bookkeeping holds. The merge keeps the retired js-time-types line deleted from quest/m2/README.md while applying main's own gateway-ingest removal, so both sides survive.

No clock surface was affected, so the previously reviewed code and its test results still stand: git diff HEAD^1 HEAD -- js/ is empty, and every merge-touched path except quest/m2/README.md matches origin/main byte for byte — that difference is exactly this PR's intended quest deletion.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

# Conflicts:
#	js/net/api/index.api.md
#	js/net/src/lite/compression.test.ts
#	js/net/src/lite/tracks.test.ts
#	js/net/src/track.ts

@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 2e12261 incrementally from fbe8682, separating inherited main changes. No new actionable findings.

Checked compression-enum integration across track defaults/validation, group binding, lite wire conversion, API declarations and test fixtures. The merge adaptations retain Duration-based maxAge and delay values, including the fractional-maxAge regression. The previously reviewed AAC continuity fix is unchanged.

Verification limits: GitHub-only static source/diff review of merge adaptations. No tests, typechecking or browser playback executed; inherited main changes were not independently re-reviewed.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes since 24ef62d:

  • Main's compression rename applied to the branch. 2e122618d merges main through #8266, which replaces the public Track.Info.compress: boolean with Track.Info.compression: Track.Compression ("none" | "deflate"). Every site this PR touches now uses it: authoredInfo (string validation, "none" default), datagramTrackInfo, lite #toModelInfo / trackInfoMessage / fetch, resume.INFO_FIELDS's === compare, and the json, watch, publish, bench, API-report, and interop catalog/hls/tail clients. The internal lite TrackInfo.compress: boolean is correctly left alone.
  • No clock surface moved. git diff 24ef62d 2e12261 is exactly 33 files, all from #8266; the branch's Duration/Instant/Wall conversions are untouched, including the Duration.millis(...) delay in the interop tail client.

Verified at 2e12261: tsc --noEmit green for net, json, publish, hang, watch, and test/interop/clients/js-native; bun test js/net/src passes 3176 with 3 skips; biome is clean on the adapted files.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

@kixelated
kixelated added this pull request to the merge queue Nov 9, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Nov 9, 2026
@moq-bot

moq-bot Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

quest land watch: the merge queue removed this pull request at 2e12261 because it conflicts with main (merge_conflict).

The land fixer works on it in the background, and the watcher re-enqueues it at the back of the queue once it is fixed or judged flaky, or hands it back here with what it needs.

(written by quest land watch)

@moq-bot

moq-bot Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

codex, gpt-6.1-sol, medium effort land fixer note:

Resolved the four merge conflicts while preserving both the clock migration and the audio counters added on base. The counters use Duration on the main thread and plain milliseconds across the worklet boundary. Regenerated the watch API report. The initial full gate caught a type-only import that needed to become a runtime import; after correcting it, the full JavaScript type checks, builds, API validation, and unit tests passed. Every other selected gate module passed, and final Biome and Markdown checks passed. No protected paths were edited.

(written by codex, gpt-6.1-sol, medium effort)

@moq-bot

moq-bot Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

codex, gpt-6.1-sol, medium effort land fixer: GitHub refused to re-enqueue f3141cadd3ae144827c3448f978489d01bb3383c: GitHub GraphQL error: Pull request has merge conflicts and Pull request not in mergeable state. Recommendation: run /quest-iterate before picking it again.

     0  check justfile
   582  wall time
error: recipe `ci` failed on line 119 with exit code 1

exec
/bin/bash -lc "tail -28 /tmp/8295-ci-merged.log; tail -18 /tmp/8295-final-lint.log; rg -n '(fail\\)|[1-9][0-9]* fail|Exited with code [1-9])' /tmp/8295-js-test.log | head -10; git status --short; git merge-base --is-ancestor base HEAD; git merge-base --is-ancestor 2e122618dd95f305223e10a82135ff3818f2aed1 HEAD" in /work
 succeeded in 106ms:

OK
sh/go/publish-wrapper.test.sh
publish-wrapper test: no rsync on PATH, skipping
python3 sh/rs/uring.test.py
.
----------------------------------------------------------------------
Ran 1 test in 0.622s

OK
ci: check js failed
ci: seconds per module
    13  check biome
     9  check js (failed)
     4  check audio_quality
    67  check markdown
    19  check gh
   470  test gh
    43  check bench
     4  check quest
    22  check shell
     1  check large_files
     0  check text
     5  check toml
     1  check nix
     0  check justfile
   582  wall time
error: recipe `ci` failed on line 119 with exit code 1
  
  i Unsafe fix: Replace Array#findIndex() with Array#indexOf()
  
    1046 1046 │       const [output] = read(buffer, frames(100), 1);
    1047 1047 │       expect(output.length).toBe(frames(100));
    1048      │ - → → const·silence·=·output.findIndex((sample)·=>·sample·===·0);
         1048 │ + → → const·silence·=·output.indexOf(0);
    1049 1049 │       expect(silence).toBe(staged);
    1050 1050 │       expect(output.slice(silence).every((sample) => sample === 0 || sample === 0.5)).toBe(true);
  

Checked 2308 files in 2s. No fixes applied.
Found 1 info.
==> check markdown
ci: seconds per module
     7  check biome
    83  check markdown
    90  wall time

codex
Verification is complete. The initial gate failed on the import that I corrected; the full JavaScript checks and tests then passed, as did every other selected gate module. Final lint checks also pass. I’m writing the result file with the fix commit.

exec
/bin/bash -lc "python3 - <<'PY'
import json, subprocess
from pathlib import Path
head=subprocess.check_output(['git','rev-parse','pr-8295'],text=True).strip()
result=[{
 'number':8295,
 'action':'fixed',
 'head':head,
 'reason':\"Merged base, preserved audio counters through the clock migration and regenerated js/watch/api/index.api.md; ran bun biome check --write, just ci base, just js check-changed --all, just js test, MOQ_API_REPORT=update bun js/common/api.ts check js/watch, and CHECK_MODULES='biome markdown' just ci base.\",
 'failure':'merge with main: content conflicts',
 'blame':True,
 'comment':'Resolved the four merge conflicts while preserving both the clock migration and the audio counters added on base. The counters use Duration on the main thread and plain milliseconds across the worklet boundary. Regenerated the watch API report. The initial full gate caught a type-only import that needed to become a runtime import; after correcting it, the full JavaScript type checks, builds, API validation, and unit tests passed. Every other selected gate module passed, and final Biome and Markdown checks passed. No protected paths were edited.'
}]
Path('quest-land-result.json').write_text(json.dumps(result,indent=2)+'\\n')
assert len(head)==40
assert len(result[0]['failure'])<=200
assert len(result[0]['comment'])<=4000
PY
 git diff --check
 git status --short
 git rev-parse pr-8295 base batch
 git diff --name-only base HEAD | rg '("'^|/)(flake'"\\.(nix|lock)|shell.nix|default.nix|nix|\\.envrc|\\.github|\\.gitmodules)(/|"'$)'"'" in /work
 exited 1 in 324ms:
?? quest-land-result.json
f3141cadd3ae144827c3448f978489d01bb3383c
f13a39cacf5e63c5d45186ef1015e1997c2633fe
2e122618dd95f305223e10a82135ff3818f2aed1

codex
Fixed #8295 on `pr-8295` at `f3141cadd3ae144827c3448f978489d01bb3383c`.

Resolved conflicts, regenerated the API report, and verified builds, type checks, lint, and tests. No protected paths changed.

Wrote [quest-land-result.json](/work/quest-land-result.json), left uncommitted.
tokens used
124,712
Fixed #8295 on `pr-8295` at `f3141cadd3ae144827c3448f978489d01bb3383c`.

Resolved conflicts, regenerated the API report, and verified builds, type checks, lint, and tests. No protected paths changed.

Wrote [quest-land-result.json](/work/quest-land-result.json), left uncommitted.
fixer result written

(written by codex, gpt-6.1-sol, medium effort)

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes since 2e12261:

  • Main merged in. f3141ca merges main through f13a39cac, bringing #8308's player-reported trimmed/accelerated audio counters, #8298's mux/hang/json/stats surface fixes (including the Echo.Rendition.latency removal), and #8335's HLS cursor peek: 113 files against the previous head.
  • The new audio counters are clock values. Audio.Stats.trimmed/accelerated are Durations (EMPTY uses Duration.ZERO), AudioBuffer holds them in Signal<Duration> and converts the worklet's milliseconds with Duration.millis(...) at the message boundary, and the decoder accumulates each ring's totals with sub/gt(Duration.ZERO)/add exactly as main's numeric version did.
  • The port stays plain numbers. The merge keeps main's render.ts/render-worklet.ts verbatim, so the worklet payload still carries millisecond floats, AudioRingBuffer.trimmed/accelerated still return millisecond numbers, and replay.ts's Quantum keeps its documented ms fields.
  • Report and tooling follow. The watch API report gains only accelerated: Clock.Duration and trimmed: Clock.Duration, and the audio-quality probe reports stats.trimmed/accelerated.asMillis() to match schema.ts's trimmed_ms/accelerated_ms.

The accumulation is safe with Duration.sub: AudioRingBuffer.#trimmed/#accelerated only ever increase (#discarded cannot go negative, since the declared-silence subtraction is bounded by the span it runs over), and Duration.millis rounds monotonically, so total.sub(baseline) never trips the negative guard.

Verified at f3141ca: tsc --noEmit green for js/watch and the audio-quality client; bun test js/watch/src passes 580 with only the two Cannot find module '@moq/libav' environmental failures; replay.test.ts and the audio-quality cli.test.ts pass (18); biome is clean on the ported files. js/common/api.ts check js/watch could not run here (the workspace build fails at a vite plugin's require.resolve in this sandbox), so the report was checked by hand against the source instead.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

@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 f3141ca incrementally from 2e12261, separating inherited main changes. No new actionable findings in the audio-counter merge adaptations.

Checked that worklet and replay counters remain numeric milliseconds, the AudioBuffer boundary converts them to Duration, decoder accumulation uses Duration arithmetic across ring replacements, and the quality probe converts back with asMillis(). Public API declarations and test message fixtures agree with these boundaries. Previously reviewed clock and AAC continuity fixes remain intact.

Verification limits: GitHub-only static source/diff review of the integration changes. No builds, typechecking, tests or browser/audio replay measurements executed; inherited main changes were not independently re-reviewed.

Adopts main's null-for-none reload backoff (max and timeout are
Duration | null; zero is a real bound) and converts main's WebSocket
upgrade probe and new reload tests to Clock types.

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

@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 6a93db5 incrementally from f3141ca, separating changes inherited from main at 29bc0c9. No new actionable findings.

Checked the Duration | null backoff integration: null remains unbounded, zero is a real cap/window, undefined retains defaults, and late timers recheck the deadline using Instant comparisons. WebTransport probes retain main's jittered 10-to-300-second backoff and timeout/cancellation behavior with Duration arithmetic. Converted regression tests and API declarations agree with those semantics. The previously reviewed AAC continuity and audio-counter fixes are unchanged.

Verification limits: GitHub-only static source/diff review of the integration changes. No builds, typechecking, tests or browser/network runs executed; inherited main changes were not independently re-reviewed.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes since f3141ca:

  • Main merged in. 6a93db5 merges main through 29bc0c95d (now the PR's base), bringing #8330's WebSocket fallback that returns to QUIC once UDP works, #8293's scoped routes, #8300's shared transport types, #8341's dialed-host auth facts, #8307's widened max-age test teardown, and #8354/#8359's test sharding: 297 files, mostly Rust, bindings, and docs.
  • ReloadDelay.max/timeout take main's null semantics. Both are Duration | null, where null is unbounded and zero a real bound (main's #8265 rule replacing this branch's zero-means-uncapped), and pacing() reads them with explicit-undefined checks.
  • The retry window holds an Instant, not a number. #window returns Instant | null instead of main's number/Infinity, since an Instant cannot carry infinity; #retry compares with deadline.since(at), and its saturating zero covers main's negative-left case as an immediate give-up. The unbounded path is guarded explicitly rather than by comparing against Infinity.
  • Main's new timing code is converted, not rewritten. The WebTransport probe keeps PROBE_INITIAL/PROBE_MAX at 10 s/300 s with the same jitter and doubling, next() still saturates past the cap, establish/reachable arm the deadline with !limit.isZero() (identical to main's limit > 0 for a nonnegative Duration), and the ietf/lite close() paths keep main's case "leaving": with Duration.secs(1)/millis(1000).
  • Per-test conversions. Every Milli(...) and Nano.fromMilli(...) in the merged tests becomes a Duration, and duration assertions move from toBe to toEqual; pool.test.ts's linger stays a plain number because its advance(ms) helper takes milliseconds.
  • Three main tests are dropped, as the PR body states. All three are the "invalid timeout" checks in reload, connect, and pool; a Duration cannot hold a negative, NaN, or infinite value, so the branch refuses them at construction. No other main test disappeared — the test-name lists otherwise match.

Verified at 6a93db5: bunx tsc --noEmit is green for net, clock, signals, hang, watch, publish, qmux, auth, room, loc, json, and net/bench; bun test js/net/src passes 3179 with 0 failures (3185 across 128 files); the merged watch broadcast/include tests and js/room pass (65).

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

Main's timeoutSignal takes a Duration.

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

@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 5688bc0 incrementally from 6a93db5, separating changes inherited from main at 6eff24e. No new actionable findings.

Checked timeoutSignal's Duration-to-nanoseconds boundary against the host/manual backends, including already-aborted parents, synchronous zero deadlines, timer cancellation and timeout-reason precedence. Its export, API declaration and converted regression test agree. The merged codec-probe and Claims-signing changes retain the existing time conversions; prior AAC, audio-counter and retry-bound fixes are unchanged.

Verification limits: GitHub-only static source/diff review of the integration changes. No builds, typechecking, tests or browser playback executed; inherited main changes were not independently re-reviewed.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes since 6a93db5:

  • Main merged in. 5688bc0 merges origin/main through 6eff24e (12 commits): #8339's timeoutSignal, #8365's probe-accepted video codec, #8368's Key.sign(Claims), #8340's stats buckets, #8349's audio/mux performance work, plus tokio, CI, and quest changes.
  • timeoutSignal takes a Duration. The new clock export arms clock.now() + duration.asNanos(), the same expression as sleep/timeout in the branch (main used Instant.add(Instant(clock.now()), duration)), so the parent-abort, synchronous-fire, and late-abort behavior is unchanged. The regression test and clock API report are converted to match.
  • Watch's decoder keeps main's accepted codec. #8365's accepted(config) in supported.ts and #accept/#codec in decoder.ts merge byte-identical to main, leaving the branch's clock conversions in the rest of the decoder untouched.
  • Key.sign and stats buckets merge cleanly. Auth keeps wall().asUnixMillis() / 1000 and the demo stats keep wall().asUnixMillis() / Duration.millis(1000) on top of main's bucket rework, while quest/m2/README.md keeps the retired js-time-types line deleted as it applies main's own removals.

Verified at 5688bc0: bunx tsc --noEmit is green for clock, watch, auth, net, demo/web, demo/carla/web, and bench/browser; bun test passes clock (39), watch video/decoder.test.ts (100), and net group.test.ts + auth key.test.ts (115); biome is clean on the new clock and watch files.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

@kixelated
kixelated added this pull request to the merge queue Nov 9, 2026
Merged via the queue into main with commit 8ae0c8f Nov 9, 2026
7 checks passed
@kixelated
kixelated deleted the quest/m2/js-time-types branch November 9, 2026 21:06
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