Repository navigation
feat(js)!: durations and instants are Clock types, not numbers - #8295
Conversation
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>
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>
# Conflicts: # js/net/src/ietf/connection.ts # js/net/src/ietf/publisher.ts # js/net/src/lite/connection.ts
kixelated
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
ℹ️ No critical issues — two minor documentation gaps.
Reviewed changes
- New clock value types.
@moq/clockreplaces the branded unit numbers and theInstanthelper namespace withDuration(whole seconds plus whole nanos past them),Instant(float nanos since the clock origin), andWall(Unix milliseconds), all refusing numeric coercion throughvalueOf/Symbol.toPrimitive/toJSON. - Timers and signals.
Manual,sleep,timeout,Interval, and the scopedeffect.timer/timeout/intervaltake aDuration; signal equality compares clock values by value so an equalDurationdoes not rerun an effect. - Transport timing.
@moq/netmakes every public delay, timeout, RTT, and age aDuration(auth grant expiry aWall) while the wire keeps whole milliseconds;@moq/qmuxconfig takesDuration | 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.mdis 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.deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
ℹ️ No new issues in the merge; one stale doc block left behind below.
Reviewed changes (since 6c52b4e)
- Main merged in.
3d92acebringsmain'sPhaselifecycle intojs/net's IETF and lite connections, resolved onto the branch's 3-argumentwithTimeout(promise, Duration, message); bothclose()paths read correctly at the new head. - Fractional-age hardening.
trackPropertiesnow roundsmaxAgeup to whole milliseconds (BigInt(Math.ceil(info.maxAge.asMillis()))),request_streamroundsRETRY_INITIALthe same way, and a new test pins a 1.5 ms max age to2n; this matches the branch's "wire rounds up" rule. - Test export.
trackPropertiesis exported from the internal module as@internalsolely 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`.deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
# 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>
# Conflicts: # js/signals/src/index.ts
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Prior doc feedback fixed.
Wall.unixMillisnow promises only the finite/nonnegative check thatcheckedperforms,Wall.asUnixMillisdescribes the stored value instead of a nanosecond round-trip throughsince, and the signal-equality rule reads "by value for the clock's immutable values" injs/AGENTS.md,js/signals/README.md, anddoc/lib/js/signals.md. - Stacked TSDoc merged.
trackPropertiescarries its description and@internalin a single block. - Main merged in.
7d4ca78brings main throughf102ee038. The only two conflicts resolve to main's structure with the branch's clock calls: the clock driver test awaitsclock.drive(...)and asserts on the settled error (main'sno-drive-expectrule), andsource/retry.tskeeps main'sStateflow withDuration.millis(wait)/Duration.millis(Retry.SETTLED). - Rest of main applied cleanly.
audio/encoder.ts's#heldcatalog state,video/encoder.ts's failure gating, qmux'sQueuedrain, room's screenstate, watch'sEnabledsignal, 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).
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
left a comment
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes since 150ef20:
- AAC continuity derives from the framer's timeline.
submitnow 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 indrainsubmits 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 streampushes a 1000.833 µs start and asserts the written timestamps[1001, 22334, 43668, 65001]with nodiscontinuitycall. - Main merged in.
fbe8682cleanly brings main througheb8bf98e4(js/json depth and number limits, datagramonTestFinished, 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.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Main merged in.
24ef62dcleanly mergesorigin/mainthrough87e9e1c(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, andpyproject.toml. - Quest bookkeeping holds. The merge keeps the retired
js-time-typesline deleted fromquest/m2/README.mdwhile applying main's owngateway-ingestremoval, 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.
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
left a comment
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes since 24ef62d:
- Main's compression rename applied to the branch.
2e122618dmerges main through #8266, which replaces the publicTrack.Info.compress: booleanwithTrack.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 liteTrackInfo.compress: booleanis correctly left alone. - No clock surface moved.
git diff 24ef62d 2e12261is exactly 33 files, all from #8266; the branch'sDuration/Instant/Wallconversions are untouched, including theDuration.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.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
|
quest land watch: the merge queue removed this pull request at 2e12261 because it conflicts with 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) |
|
codex, gpt-6.1-sol, medium effort land fixer note:
(written by codex, gpt-6.1-sol, medium effort) |
|
codex, gpt-6.1-sol, medium effort land fixer: 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) |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes since 2e12261:
- Main merged in.
f3141camerges main throughf13a39cac, bringing #8308's player-reportedtrimmed/acceleratedaudio counters, #8298's mux/hang/json/stats surface fixes (including theEcho.Rendition.latencyremoval), and #8335's HLS cursor peek: 113 files against the previous head. - The new audio counters are clock values.
Audio.Stats.trimmed/acceleratedareDurations (EMPTYusesDuration.ZERO),AudioBufferholds them inSignal<Duration>and converts the worklet's milliseconds withDuration.millis(...)at the message boundary, and the decoder accumulates each ring's totals withsub/gt(Duration.ZERO)/addexactly as main's numeric version did. - The port stays plain numbers. The merge keeps main's
render.ts/render-worklet.tsverbatim, so the worklet payload still carries millisecond floats,AudioRingBuffer.trimmed/acceleratedstill return millisecond numbers, andreplay.ts'sQuantumkeeps its documented ms fields. - Report and tooling follow. The watch API report gains only
accelerated: Clock.Durationandtrimmed: Clock.Duration, and the audio-quality probe reportsstats.trimmed/accelerated.asMillis()to matchschema.ts'strimmed_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.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
kixelated
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes since f3141ca:
- Main merged in.
6a93db5merges main through29bc0c95d(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/timeouttake main'snullsemantics. Both areDuration | null, wherenullis unbounded and zero a real bound (main's #8265 rule replacing this branch's zero-means-uncapped), andpacing()reads them with explicit-undefinedchecks.- The retry window holds an
Instant, not a number.#windowreturnsInstant | nullinstead of main'snumber/Infinity, since anInstantcannot carry infinity;#retrycompares withdeadline.since(at), and its saturating zero covers main's negative-leftcase as an immediate give-up. The unbounded path is guarded explicitly rather than by comparing againstInfinity. - Main's new timing code is converted, not rewritten. The WebTransport probe keeps
PROBE_INITIAL/PROBE_MAXat 10 s/300 s with the same jitter and doubling,next()still saturates past the cap,establish/reachablearm the deadline with!limit.isZero()(identical to main'slimit > 0for a nonnegativeDuration), and the ietf/liteclose()paths keep main'scase "leaving":withDuration.secs(1)/millis(1000). - Per-test conversions. Every
Milli(...)andNano.fromMilli(...)in the merged tests becomes aDuration, and duration assertions move fromtoBetotoEqual;pool.test.ts'slingerstays a plain number because itsadvance(ms)helper takes milliseconds. - Three main tests are dropped, as the PR body states. All three are the "invalid timeout" checks in
reload,connect, andpool; aDurationcannot 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).
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
left a comment
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes since 6a93db5:
- Main merged in.
5688bc0mergesorigin/mainthrough6eff24e(12 commits): #8339'stimeoutSignal, #8365's probe-accepted video codec, #8368'sKey.sign(Claims), #8340's stats buckets, #8349's audio/mux performance work, plus tokio, CI, and quest changes. timeoutSignaltakes aDuration. The new clock export armsclock.now() + duration.asNanos(), the same expression assleep/timeoutin the branch (main usedInstant.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)insupported.tsand#accept/#codecindecoder.tsmerge byte-identical to main, leaving the branch's clock conversions in the rest of the decoder untouched. Key.signand stats buckets merge cleanly. Auth keepswall().asUnixMillis() / 1000and the demo stats keepwall().asUnixMillis()/Duration.millis(1000)on top of main's bucket rework, whilequest/m2/README.mdkeeps the retiredjs-time-typesline 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.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

Every public duration and instant in the published JS packages is now a
Clock.DurationorClock.Instantobject, and wall time aClock.Wall, so a unit mistake fails to compile instead of being stripped byas numberor arithmetic. Retires quest/m2/js-time-types.md.Summary
@moq/clockreplaces the brandedNano/Micro/Milli/Secondnumbers and theInstanthelper namespace with three immutable classes:Duration:nanos/micros/millis/secsconstructors,asNanos/asMicros/asMillis/asSecs,add,sub(throws below zero),saturatingSub,mul,div,divDuration,absDiff,isZero,eq/cmp/lt/le/gt/ge,min/max/clamp, andDuration.ZERO.Instant:add(Duration),sub(Duration),since(Instant)(saturating at zero, askio::clock::Instant::duration_since), comparison,min/max, andInstant.ZERO(the clock's origin).Wall: the same rule for wall time, withWall.UNIX_EPOCH,add/sub/since, comparison, and an ISOtoString.valueOf,Symbol.toPrimitive, andtoJSONthrow, soa < b,a + 1,`${a}`arithmetic, andJSON.stringifycannot silently read a number.toStringprints the unit (33ms).Manual.advance/jump,sleep,timeout,Interval, andnow()/wall()take and return the new types.@moq/signals:effect.timer/timeout/intervaltake aDuration. Signal equality compares clock values by value, so setting an equalDurationdoes not rerun effects.@moq/netre-exportsClock(as it re-exportsSignals).Track.Info.maxAge,Track.Delay, fetchmaxDelay,ConnectProps.timeout,Backoff,Goaway.handover,Drain.timeout,Probe.rtt, and the other timing knobs areDurations;Auth.Grant.expiresis aWall. The wire still carries whole milliseconds, rounded up inside the encoders as before.@moq/qmux:Config.idleTimeoutandhandshakeTimeoutareDuration | null.@moq/hang:Frame.timestamp/duration,BufferedRange.start/end,Consumer.spread, and the delay/warmup options areDurations;ReceivedFrame.arrivalis anInstant.Container.Legacystill writes whole microseconds.@moq/loc,@moq/json,@moq/auth,@moq/room,@moq/boy: follow.@moq/watch:Delayis"instant" | "auto" | Duration;Sync'slookahead,delay,maxDelay, andtimestamp,Arrival'stimestamp/duration(andarrival, anInstant), decoder and renderertimestampandjitter, and thestalledstats are clock values.<moq-watch>'sdelay/lookaheadproperties take aDuration; its attributes still read"300ms".SyncOutput.referencestays a signed number of ms, since aDurationcannot be negative.@moq/publish:AudioFrame.timestamp, the audio and videojoin,OpusConfig.frameDuration, andBroadcast'smaxAge/joinareDurations. Worklet messages and WebCodecs timestamps stay µs numbers, converted where they cross.Durationholds 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 aDurationsince the epoch.Track.Info.maxAgeisDuration | null,Video.RendererOutput.timestampand@moq/room'sMembervideo/audiotimestamps areDurations, andBroadcast.joinis aGetter<Duration | undefined>. Main's audio time stretch keeps the worklet's ring in plain µs and ms numbers, as the port carries them, withAudio.Stats.stalledaDuration.Connection.Backoff'smaxandtimeoutareDuration | null, wherenullis unbounded and zero a real bound (main's semantics, replacing this branch's zero-means-unbounded). Main's WebTransport upgrade probe backoff isDurations. Main's test thatReloadrefuses a negative, NaN, or infinitetimeoutis dropped: aDurationcannot hold one. Main'stimeoutSignal(moq-dev/moq#8339) takes aDuration.Clock.Durationonly where the checkout's@moq/netre-exportsClock.packetDurationtreated a zero duration as present,video/recovery.tscompared two timestamps with>=(which TypeScript allows between same-class objects), video's frame map keyed by object identity, andfeedback.tsmatched a stall by===on anInstant; the demo's buffer slider set a raw number through anas unknown ascast.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.js/*/bench)timerseffectjitterbufferedbufferedbufferedslow-groupcmafframesdrifttrackledgerfetchringarrivalswarmupaudioThe first full run flagged hang's
bufferedlookahead and sparse cases at +15-50%: the consumer built aDurationfor 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 comparesInstants 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)(likewisemicros,secs,nanos).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).{ idleTimeout: 30_000 }→{ idleTimeout: Duration.secs(30) }.frame.timestampis aDuration; read whole microseconds withMath.floor(frame.timestamp.asMicros()), and passDuration.micros(us)toLegacy.Producer.encodeanddiscontinuity.expect(d).toBe(Duration.millis(5))compares identity; usetoEqual. A clock value is truthy even at zero, so testd.isZero()instead of!d.postMessageorJSON.stringify; send a number in an explicit unit.Choices made without asking
Time.Timestamp.valuestays anumber. 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 itsbigintgroup starts need exact comparison past 2^53.Media timestamps are
Durations, notTime.Timestamps. A container timestamp is a position on the media timeline, which is a duration since its zero, andTime.Timestampstays 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.subthrows below zero (as Rust'sDuration - Durationpanics) andsaturatingSubcovers the clamping case.Values live under a symbol, not a
#privatefield. Bun'stoEqualtreats every pair of#private-only instances as equal, which would make every Duration assertion pass. Aprivate declarebrand 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.
isEqualotherwise compares class instances by identity, which would rerun an effect on everyset(Duration.millis(x))of an unchanged value.Durationholds whole seconds and whole nanoseconds apart, as Rust'sstd::time::Durationdoes, 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.Instantstays a float of nanoseconds since the clock's origin, which a session never runs long enough to round.Wallstores Unix milliseconds.Tests that passed deliberately invalid numbers (negative or non-integer
as Millicasts) 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.ceilholds one more sample (23 µs) and the p50, which sits between two clusters of samples, lands on the other one. Rust'sDurationrounds 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
Review
Rewritten by
quest reviewafter each round.01a1c1d6-9091-7442-87aa-bf972227bd65, round 2The 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.