Repository navigation
feat(moq-net)!: address groups by their start - #8638
Conversation
Quest-Claim: 415ca6efcd3b2333
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>
…op Timestamp::now 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>
Time starts wait for a group on a hop that resolves its own groups, and a key prefix that is all gone resolves before it. The front adopts copy-resolved time starts exactly, and Subscription::end skips empty ranges. 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>
…oups 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>
Time anchors are group IDs on every wire, so the tests of resolving a time end against the cache, refusing an unknown one, and refusing a time anchor on a wire without timestamps go. A time start past an open newest group names that group, so a narrowing there still asks from it. One-frame groups start at their frame. Send-time stamping on lite-05/06 is gone, so its tests go, and the relayed timestamps are read on groups keyed by them. 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>
A copy received over a wire without ranges (lite-03..06, moq-transport) reads a time start as the first key at or after it, as its publisher does, and a lite publisher starts its cursor at that floor. A time start before every group held misses from the time, not the oldest group held. A multi-group history read on moq-transport subscribes rather than fetching one group per key. A received copy whose clean end sits at its newest group stops waiting on the keys below it. Fuzz and bench groups start in key order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recovering stale or evicted history over sparse starts spans the keys between groups, which the publisher names with SUBSCRIBE_SKIP. The recovery refused that as a protocol violation and tore the session down; it now records the skip on the copy and answers a fill owing those keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A copy holds only part of the track, so the newest group it holds before a time need not contain it. It now starts there only once the keys between are skipped or a later group arrived, starts at a group starting at the time, or takes where its publisher reported starting the range it asked from that time (SUBSCRIBE_START and SUBSCRIBE_STARTED). Until then the reader reads the groups from the time, which are in range whatever contains it, and a publisher serving it waits to report its start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A snapshot, window, or stream group starts at its first frame's timestamp in the track's timescale. A scale-less write starts one past the newest start the track holds, standing in until every write carries a timestamp. A refresh whose newest timestamp is still the open group's own start waits for a later write, since two groups never share a start. A new group is appended before the previous one closes, so a refused start changes nothing. Groups end with close(), their end being unknown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sign and e2ee track producers take the group's start: append_group(start) and create_group(start) mirror moq-net, and both still refuse a start at or below one already signed or allocated. Group handles expose start() in place of sequence(), and close() in place of finish(), mirroring moq-net's group. The e2ee nonce and the signed coordinates bind the start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stats frames are stamped from the ambient clock's origin (wall time since the MoQ epoch), not moq-net's model clock. Each snapshot group starts at its drain's timestamp in the track's milliseconds and is appended, so the span since the last group is skipped. A track still opens at most one group per interval, and a drain whose millisecond ties the newest group waits for the next. Bucket tracks no longer count groups from 0: a run's first group is where its counters start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recorder subscribes from Anchor::Time at the next group it wants and reads group.start; the replayer creates each stored group at its key and closes it rather than finishing it, since a stored group says nothing about where the next starts. A look-up by time answers a scale-less track by key, now that moq-net hands those a bare start, instead of leaving it unanswered. The storage format still calls a group's key its sequence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Producers key each group by its first frame's timestamp in the track's timescale. A keyframe tying the open group continues it; with none open it is refused. Clock-stamped writes (catalog, SI, `timestamp(None)`) step one unit past the newest start. A discontinuity marker sits one unit below the live edge, so media resuming at the edge still follows it. A group cut at a known bound (the next keyframe, an explicit end, a media track's estimated end, or the marker after it) now ends whole with `finish(end)`. The container consumer counts that end as presented and steps past a key gap at once when the next group's first frame or marker bound continues it. Otherwise it waits the hole budget as it did for a missing sequence. A resumed source carries its reach into the new consumer. `GroupConsumer::sequence()` is now `start()`, and every `seek(start)` takes a group start that has to pass the starts the track holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The producer stamps frames from this process's monotonic clock in milliseconds and starts each group at its keyframe, a millisecond past the last start when two land in one millisecond. Group starts are times, so the drain counts holes by the group number the keyframe header already carries rather than by the start. A group that fails before its header names it is counted once the frontier passes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found. The new commits are two merges from
main, resolved against start keys, plus a quest file move and a test format fix.
Reviewed changes
I reviewed the commits between 142e7fa and bcd17e4.
- Merged
maincode now uses start keys: the new sim integration tests (quic_sim.rs,sim_client.rs) and the moq-video decoder tests now usegroup::Info { start }and readgroup.start. The JS head-fill tests now pass the requiredTimestamp.unscaled(key)towriteFrame. - Fallible timestamp conversions in the merged clocks: moq-mux's declared clock and the FLV and MKV exports now use
as_millis()?andas_nanos()?. An unscaled frame now returns an error instead of reaching an infallible conversion. - Arrival matching in the moq-video decoder:
receive()matches an arrival only when the frame has microseconds. A frame with no scale reports no arrival, and a refused access unit pops the arrival it queued. - Quest moved:
layered-runs.mdmoved fromm2/time-addressingtom3.
claude-opus-5-5 | 𝕏
|
quest land watch: the merge queue removed this pull request at bcd17e4 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) |
Carries the start-keyed model into main's chunk-streaming socket (#8741), the watch include tests, and the CARLA sim's session replies, which now key each reply group one past the last. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
quest land watch: Another session holds this pull request, so the watcher stops acting on it and leaves it to that session: the hold ends the pick, and a maintainer enqueues it again once that session is done. (written by quest land watch) |
There was a problem hiding this comment.
✅ No new issues found. The new commit merges
mainand moves the incoming socket byte-stream code onto start keys.
Reviewed changes
I reviewed the merge between bcd17e4 and 82ca7af.
- Socket pump on keys: a tcp half is one scale-less group at key 0. The pump copies each peer frame's
Timestamp::unscaled(0)into the local key-0 group, and ends the half withclose(), since the newfinishtakes an end key. A peer frame stamped with anything else failscreate_framewithTimestampMismatch, which resets only that session. - moq-tokio
TcpStream:AsyncWritestamps each write withTimestamp::unscaled(writer.start), andpoll_shutdownends the half withclose(). The tests now build groups withgroup::Info { start: 0 }. - Merged tests and demos: the socket integration tests, the
@moq/watchinclude tests and the CARLA demo's Python replies now pass the required timestamp. The CARLA replies key each group one pastlargest(). - Docs: the
moq-netpage picks updial_tcp(server, priority)andaccept(priority)frommain.
I ran cargo test -p moq-tokio --lib socket (49 passed) and cargo test -p moq-net --features async --test integration socket (31 passed).
claude-opus-5-5 | 𝕏
Main's #8712 resume tests move to start keys. Two of them caught the start-keyed model diverging from main's: - A serving copy judged whether a bounded read ran out from where its time start resolved, which can lie under its resume point, so a gap the reader gave up there kept the read open. The copy now judges from its resume point, as the logical reader judges what lies under it. - A front's reader re-anchored a relative start that reached back to the oldest group as a time start naming group 0, so it reported a floor a plain reader did not. It re-anchors as the oldest start now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main dropped TCP sockets (#8772) and peer-to-peer (#8771). The track socket's messages, main's new tests, and the interop socket clients now key each message's group, since a socket track is scale-less. A lite-07 test helper stamps its frame with its group's key, as the first frame's absolute delta must. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A segment lists its groups by start, so groups whose pts stalls in one segment grew its list without bound. Past one more than any request fetches, it only counts them: such a segment is listed as a gap or refused anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found. The new commits are three merges from
main, resolved against start keys, plus a fix that bounds how many group starts amoq-hlssegment keeps in memory.
Reviewed changes
I reviewed the commits between 82ca7af and e0761bb, including the hand-resolved hunks of each merge (git show --cc).
- Bounded segment group lists in
moq-hls:grid::Groupsnow lists at mostMAX_SEGMENT_GROUPS + 1starts and only counts the rest. A publisher whose pts stalls inside one segment can no longer grow that segment's list without bound. Such a segment still reports more groups thanmax_groups()allows, sorow()lists it as a gap andFetching::Startrefuses it, as before. - Resume reconciled with the merged mutation tests (#8712): in
resume.rsresolve, a time or relative start the copy floored staysAnchor::Time(start). One that reached back to the oldest group becomesAnchor::Oldest. A copy whose failover cap empties its range resolves nothing.is_exhaustedandexhausted_forskip groups below a copy's resume point. - Sockets without the
tcpprotocol (#8772): onlyTrackStreamremains. It subscribes fromAnchor::Time(0), which covers the whole session track under start keys. The FFI, Python, JS and interop socket tests now write only timestamped frames at key 0. - Join backlog (#8761) and group header cap (#8774):
moq-muxandmoq-audiocomparegroup.startagainst the joined largest key. The liteGroupsize-cap test now uses thekeyfield. - HLS cut tags (#8778) and transcode session budget (#8725): a cut segment's tag names its last group start (
Option<u64>) rather thanmain's exclusive group end.reaches_livematchesAnchor::Time(last) >= edge, and model ends are onlyRelativeorTime, so the check covers every end a fill run can carry.
I ran cargo test -p moq-net --lib --features async (3453 passed) and cargo test -p moq-hls --lib export:: (170 passed).
claude-opus-5-5 | 𝕏
There was a problem hiding this comment.
✅ No new issues found. The one new commit merges
main, and its hand-resolved hunks use start keys and fallible timestamp conversions correctly.
Reviewed changes
I reviewed merge a2942ba against e0761bb, including the hand-resolved hunks (git show --cc).
- Gateway egress billing (#8777):
ExportSourcekeeps this branch'sreachedresume point next to the newbill. The FLV export pushes(arrival, record, bill)with the branch's fallibleas_millis()?. The TS export usesnanos(decode)and records each frame's charge. Themoq-webrtccodec readsas_micros()throughmoq_mux::Error. The merged origin billing test opens its group withappend_group(0)and ends it withclose(). - Path router interest (#8728) and the
SendStreamack hook (#8739): both merged without conflicts. Neither reads group keys or timestamps.
I ran cargo check -p moq-net -p moq-mux -p moq-webrtc --all-targets (clean), cargo test -p moq-net --lib --features async billed (3 passed) and cargo test -p moq-webrtc --lib egress (30 passed).
claude-opus-5-5 | 𝕏
- quest/m2/cluster-routes: main declined the fresh publisher's exemption (#8735) and deleted its quest, so its link to the switch goes with it; item 28 keeps main's text with the switch linked to its PR. - `waits_out_the_doubt` writes its groups as #8638's start-addressed API does (`append_next`, `at_start`, `close`), as the other route-change tests now do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claims quest/m2/time-addressing/model.md.
Rust groups are addressed by their start. A group's key is a
u64in its track's timescale (a bare number on a scale-less track), and moq-net's API and model carry no group sequence numbers. Legacy wires (lite-01 to 06, moq-transport) carry the key verbatim as the group ID, so group 6 leaves as group 6. Every Rust crate, moq-ffi, moq-c and the interop clients move with it. JS keeps numbering groups until its own quest.A scale-less track's frames now carry a
Timestamptoo:Timestamp { value, scale: Option<Timescale> }, whereTimestamp::unscaled(key)is the group key (a datagram's sequence). Every write takes a requiredTimestamp, in Rust, JS, moq-ffi, moq-c and every binding.Decisions
Following the Plan's maintainer decisions (2026-10-25, 2026-10-26, 2026-11-03, 2026-11-07, 2026-11-13); these are the calls I made inside them.
Maintainer decisions (2026-11-13, /quest-complete)
Timestampwhose scale isNone. ✅Timestamp { value: u64, scale: Option<Timescale> },Timestamp::unscaled(key), andTimescalestaysNonZero. Every write takes a requiredTimestamp: group and trackwrite_frame(the track one now works on a scale-less track, at the key),Frame::new, datagramappend, moq-json'sTimed::at, and the JS, moq-ffi, moq-c and binding counterparts. A mismatched scale, or an unscaled key naming another group, staysTimestampMismatch. Rejected: a bareu64key with an always-scaledTimestamp, andOption<Timestamp>.Timed::at: ✅ required, with the unscaled key on a scale-less track. Rejected:Option<Timestamp>, and refusing scale-less JSON tracks.Timescale = 0as invalid. The group ID is the key, and frames carry the group key.lite::Version::wire_timescaleand the draft's lite-07 changelog say this is forced by the published wire. Rejected: main's send-time stamps (feat(lite): lite-07 carries an untimed track untimed #5461), and refusing scale-less tracks on lite-05/06.@moq/hangplayers reading new Legacy audio over lite-05/06: ✅ accept the gaps. The packages release independently, and old players report gaps until they upgrade (see Impact). Rejected: shipping@moq/hangbefore or with the Rust publishers.Taken by the agent under /quest-afk, for the maintainer's review (2026-11-13)
Decided 2026-11-14 (maintainer, /quest-complete): ✅ accepted
finish(end)ages a paused track's last group out after its span. A relay receives that group with a bare FIN (rs/moq-net/src/recv.rscloses it), so it still bounds the group by its successor, as main does on every hop. Relays serve nothing main wouldn't, and the gap closes when lite-07's GROUP_END carries the end (lite07.md). Rejected: carrying the end on the wire here, which is lite07's wire, draft and JS change; and holding own-end reach back until then, which reverts the 2026-10-26 decision.Pullfrog's review (my calls, 2026-11-14)
fmp4::Import::rejoin(): ✅ new. A reanchor seeked to the packed HLS counter,2^48 + nafter a discontinuity, so every later keyframe sat below it and one group took the rest of the epoch.rejoin()closes every track's group without an end and holds video until a sync sample, asseekdoes, and keys the next groups at their own times. Rejected: seeking to the segment's start, which HLS can't know before reading the segment; and dropping the seek, which ends the group before a skip whole at the next keyframe, hiding the lost segments, and lets video continue on delta frames.TimestampMismatch. They read an anchor's value in track units whatever its scale said, so 1 s at a millisecond scale on a 90 kHz track named tick 1000. A time anchor is now an unscaled key in the track's timescale. Rejected: converting it now, which needs the track's timescale, unknown when the bindings parse a subscription (the bindings quest's follow-up); and keeping the silent reading. Accepting a scaled anchor later breaks nothing.Anchor::Time(edge - 1), the group containing the key just below the live edge the min-delay lane found, in moq-video and moq-cli. ItsRelative(1)start failed every read over lite-01..06, whose copies refuse a relative start past the edge. lite-07 resolves the time start to the group before the edge, and older wires to the edge alone. moq-cli waits for the edge only for a warmup lane, as main waited for every lane. Rejected: having lite-01..06 copies count a relative start back through what they hold, a moq-net semantic change their doc refuses as a guess; and moving the CLI's video opening off its playback loop, which restructures playback this PR doesn't touch.@moq/net'sTime.keyOfis public: ✅@moq/jsonopened a scale-less group throughTimestamp.value, which throws on a key past 2^53.keyOfreads the exact bigint key. Rejected: a second accessor onTimestampbeside the internal one.t, so against them it names the wrong group until they key by start. Rejected: keeping main's widen-to-group-0 on wires without time anchors, which reads every group back to 0 to find one. Today's time-anchored readers are moq-transcode, which reads Rust publishers, and the bindings.Timestamp shape (my calls inside the 2026-11-13 rule)
convert,as_scale,as_secs,as_millis,as_microsandas_nanosreturnResult<_, TimeOverflow>, erring when unscaled, andDurationisTryFrom<Timestamp>. A second, always-scaled type would add a type to every binding for what oneOptionalready says.Ord.PartialOrdis temporal across scales, compares keys when both are unscaled, and returnsNonebetween scaled and unscaled. Debug prints an unscaled timestamp as#<key>.unscaledis infallible over the wholeu64, since a legacy wide key passes 2^61. Scaled values stay bounded at 2^61 - 1.track::Info::rescale(ts)checks the scale. The new publicrescale_in(ts, key)also checks the key, so a signer or encrypter refuses a frame before it spends anything.append_group(start)andgroup::Info { start }stay: a multi-frame group opens before its first frame is written.append(unscaled(seq))writes atseq, andpush/insertrefuse a timestamp that names another sequence. moq-crypto's e2ee datagrams follow suit.at.value(), which must pass every group held. A delta, op, or stream frame carries the open group's key.TimestampMismatchand never panic. hang's catalogClockrefuses an unscaled wall clock.bigint, so an unscaled JS key past 2^53 throws only whenvalueis read, never on receipt.Option, somoq_timestampgainshas_scale, following itshas_*fields.Model
u64in the track timescale, not aTimestamp, as JS's bigint starts are:group::Info { start },append_group(start),Position::group(now#[non_exhaustive]).TimestampMismatch, so two groups never share a start.Timestamp: a time on a timed track, andTimestamp::unscaled(group key)on a scale-less one.Anchoris{Oldest, Relative(n), Time(t)}with inclusive ends. A time start is the containing group: the newest key at or beforet.Time(0)is always group 0.Relative(n)counts back through the groups held, however sparse their keys.append_groupskips the starts between it and the previous append,skip_groupsskips a run, and lite-07 forwards both as SUBSCRIBE_SKIP. A reader learns them through two additions:Subscriber::next_in_rangenow passes over declared skips.Subscriber::is_skipped(range)is new.Without them, an in-order reader can't tell a skipped span from lost groups. moq-mux's consumer reported spurious gaps until it used them.
group::Producer::finish(end), or without a known end withclose()(the oldfinish()). A whole group ages from its own end, so a finished or paused track's last group expires without a successor, but not before that end.Timestamp::now()is gone. Producers stamp from their own clock.Timedmoves from moq-net to moq-json, which owns it as@moq/jsondoes. It becomes a struct with a requiredat, built withTimed::new(value, at).Wires
Sresolves to the first group keyed at or aboveS, never to an earlier group covering it (sparse 4 and 7 with a start of 5 select 7). An exclusive endEmaps toTime(E - 1), andE = 0drops the range rather than saturating to group 0.Time(S), the containing group, and encodesTime(t)as absolutet. Its encoder no longer emits wireTime.tis held.tis held, and holes or a later group prove nothing lies between them.t.Until then the span reads, and asks fills for, the groups from
ton, andSubscriber::poll_startwaits.Legacy copies resolve on their keys as their upstream does.
Timescale = 0as invalid. Its frames carry the group key, and its datagrams their sequence. A lite-02 reader of a scale-less track seesunscaled(key).Oldeststart asks with none, which they read as the latest group. That is unchanged from main.Producers and readers
is_skipped(0..group)in place ofgroup == 0to tell a track's first packet, which needs no join warmup.TestDynamicTrackRequestCanPublishAudionow reads the encoder's end at 20001us, not 20000us.Container::LegacyAudio, built from anAudioConfigwhose codec states them (Opus TOC, AAC-LC's 1024 samples, PCM length), and@moq/hang's legacy format mirrors it. A reader then tells a group that ends where the next one starts from lost audio, which sparse starts no longer say.Anchor::Relative(lookback)(moq-video, moq-cli), sincelargest - 1no longer names the previous group.Bindings (stand-in until the bindings quest)
sequenceargument is the group's start in the track timescale.append_group()without a start takes the next key past the largest.MoqAnchortime value is taken in track units. An absolute endEmaps toTime(E - 1), andE = 0drops the range.Fill::time) is dropped unanswered. The reader then falls back to the groups held, as a handler that can't resolve times does.Tests restated
raw_track_relative_update_counts_from_the_track_edgeis removed. Under containing semantics an open range far ahead holds the newest group, so it delivered with or without the update.a_cursor_records_a_publisher_without_fetchgives lite-01 to 04 a 45 s budget, since those wires lag by the hole budget (accepted 2026-11-13).Bugs this found
@moq/netreads a FINISH without one as "nothing served", settling the tail at once. The end now waits for the start, or goes out alone once none can arrive.[0, start)widened by a later reader made one JS reader in three read nothing in the finish case.resume.rs: a copy reads again whenever the reader's terms or bounds move.@moq/hangruns on one thread, so no frame lands mid-poll there.Impact
Breaking for every Rust user of moq-net, moq-mux, moq-json, moq-crypto, moq-relay and moq-cli. Wire formats are unchanged except that lite-07-wip stops sending wire
Timeanchors. Migration:group::Info { sequence }→group::Info { start }, and.sequence→.start, in the track's timescale.append_group()→append_group(start): the first frame's time,pts.convert(timescale)?.value().group.finish()→group.close().finish(end)now ends a group whole at an exclusiveend.Anchor::Absolute(g)start →Anchor::Time(g). ExclusiveAbsolute(E)end →Time(E - 1), and drop the range whenEis 0.Range::groups(..)andSubscription::with_range(..)→Subscription::with_ranges([Range { start, end }]).Anchor::Timetakes au64in the track timescale, not aTimestamp.Fill::time()returnsOption<u64>.Position { .. }literals →Position::new(group, frame)orPosition::group(g).Timestamp::now()→ stamp from your own clock.moq_net::Timed→moq_json::Timed, a struct:Timed::from(v).at(t)→Timed::new(v, t), andatis a requiredTimestamp.Timestamp::scale()returnsOption<Timescale>.convert,as_scale,as_secs,as_millis,as_microsandas_nanosreturnResult<_, TimeOverflow>.Duration::from(ts)→Duration::try_from(ts)?.Timestamphas noOrd: replace.max(),.min()and.sort()withpartial_cmporsort_by.write_frame(None, p)→write_frame(Timestamp::unscaled(group.start), p),Frame::new(None, ..)→Frame::new(Timestamp::unscaled(key), ..), and datagramappend(None, p)→append(Timestamp::unscaled(sequence), p).frame.timestamp,datagram.timestampandTimed::atare no longerOption: on a scale-less track they'reunscaled(key), andscale()isNone.write_frame, datagramappendandinserttake a requiredTimestamp, and read frames and datagrams carry one.MoqTimestamp.scaleis optional (null means unscaled, withvaluethe key).MoqFrame.timestamp,MoqDatagram.timestamp,MoqJsonValue.timestamp,MoqJsonWindowTimed.timestampand datagramappend(timestamp, ..)are required. Untimed writes passunscaled(key). In Go,TimestampToDurationreturns(time.Duration, error). In Dart,Timestampis an extension type, so annotate reads asMoqTimestamp.moq_timestampgainshas_scaleat its end, which changes the ABI of every record holding one, so recompile. A zeroedmoq_timestampis now the unscaled key 0: set{value, scale, true}, because an old positional{value, scale}silently becomes unscaled.moq_publish_track_frame,moq_publish_group_frame,moq_publish_datagrams_appendandmoq_publish_datagrams_insertrefuse a NULL timestamp withMOQ_ERROR_INVALID_POINTER. On a scale-less track, pass the unscaled key: the newmoq_publish_group_sequencereads a group's. A read'stimestampis never NULL.moq_audio_encoder_receiveandmoq_audio_encoder_packetfill a newmoq_audio_packetinstead of amoq_frame.c/moq,cpp/moq) follow moq-ffi. The Cmoq_timestamp.scaleis amoq_optional_u64, and the C++moq::Timestamp::scaleis astd::optional<uint64_t>. Frames carry their timestamp by value, andmoq_frame_default()is gone.@moq/net:Timestamp.scalemay beundefined.Timestamp.unscaled(key)is new, andas,asMillisandasMicrosthrow when unscaled.Frame.timestamp, datagram timestamps andTimed.atare required.@moq/hangand the Rust publishers release independently: until a@moq/hangplayer upgrades, it reports gaps between the sparse Legacy audio groups that lite-05/06 relays forward as drops.GroupConsumer::sequence()→start().Producer::seektakes a start, and reads'groupfields carry starts.catalog::hang::Containergains aLegacyAudiovariant, whichTryFrom<&AudioConfig>builds for Legacy audio whose codec states its packet durations. Amatchon the enum needs an arm for it./fetch?group=Nnames the group starting atN. moq-cli:fetch --grouptakes a start.@moq/net:Time.keyOf(timestamp)is public, the exact bigint key of an unscaled timestamp.fmp4::Import::rejoin()is new. moq-hls keys each group after a join, a skip or a discontinuity at its keyframe, and itsSequenceOverflowreads "too large" rather than "too large to encode".TimestampMismatchprotocol error.Anchor.TIME(Timestamp(v, scale))→Anchor.TIME(Timestamp.unscaled(v_in_track_units)), and in C sethas_scaleto false.t, so it misresolves until that publisher keys by start.n), so it can skip the groups it means, until js.md counts groups as Rust does.Validation
At
a2942bacc, which merges main at98ed0f827(#8739's send-stream hook, #8777's egress billing, #8728),just checkpasses with 12,902 Rust tests, andjust test interop --allpasses (211, 0 failed). Under the full check's load the Python socket testtest_abort_and_endpoint_close_are_errorsfailed once (Unroutable where Closed was expected). That is main's endpoint-ending race (socket-endpoint-end.md); the py lane passed on rerun, and its file passed 20 of 20 runs alone. Codex round 8 accepted the JS refutation and holds the HLS one open.At
e0761bbf5, which merges main atd1c025add(with #8772's TCP-less sockets, #8771's p2p removal, #8712's resume tests, #8725's transcode session budget, #8778's HLS cut tags and #8764's feature review), and fixes Codex's segment-membership bound:just checkpasses, with 12,887 Rust tests and the JS, Python, CARLA, bindings and API-report checks. Main's new code and tests moved to start keys: socket messages carry their group's key, a cut HLS segment is named by its last group, and a copy's resume point now bounds what it judges (see the merge commit).just test interop --all: 211 passed, 0 failed (at7b89888fd; the commit after it touches only moq-hls).just checkrun failedtest_cancelled_dial_releases_the_original_subscription(NotFound where Cancelled was expected, a race under load between the cancelled dial and the accept). The socket model is main's apart from the start anchor, and the test passed in 8 reruns of its file and in the py lane's rerun.At
82ca7af3b, which merges main atd4b46eec2(adding #8741's chunk-streaming socket, #8745's key checks, #8749 and #8768's quest moves):just checkpasses, with 14,161 Rust tests and the JS, Python, CARLA and API-report checks. The merge moved main's socket (Rust, tokio and its tests), the watch include tests and the CARLA sim's session replies to start keys. Each CARLA reply is a group keyed one past the last.just test interop --all: 214 passed, 1 failed. The failure wasCLI FETCH rust -> rust, which reported "group 1 oftailnot found" over lite-06 right after the announce. The publisher writes every group before it connects, the relay log shows nothing past the cancel, and the case passed in three--tailreruns and in every earlier full run on this branch. I'm treating it as a flake, not a regression.At
bcd17e487, which merges main at6f8b12729(adding #8698's JS head fills, #8715's declared-clock ties, #8720's picture arrivals and #8636's VA-API reorder),just checkpasses, with 13,167 Rust tests, andjust test interop --allpasses. Main's new tests and code moved to start keys and fallible time conversions in the merges.At
142e7faba, which merges main at99d789eef(with #8699's lite-07 FETCH Start Byte), fixes Pullfrog's review, and fixes the local Codex review's findings:just checkpasses, including 13,156 Rust tests and the Go, Kotlin, Dart, Python, C, C++, OBS, JS and API-report checks.just test interop --all: all checks passed. One earlier full run failedtail rust -> rustwithError: old. The track is timed and none of this round's changes touch timed freshness. The lane passed in the run before it, in three--tailreruns, and in the full rerun. It reads as a wall-clock staleness verdict under the full run's load (1 MiB groups, a 5 s budget).a_media_sequence_jump_keys_each_later_group_at_its_keyframe(moq-hls, new). It and the restated discontinuity tests now read three groups through real fMP4, and check that each group starts at its first frame. With the old seek, the groups after the reanchor started at the packed counter.a_time_resolves_by_key_not_first_frame(moq-archive, new) resolved 2.2 s to the group at 1 s, not the seeked one keyed at 2 s.time_anchors_start_at_the_containing_group(moq-ffi) andraw_track_update_refuses_a_scaled_time_anchor(moq-c) cover the scaled-anchor refusal. The Python local test now passes an unscaled anchor.a key past the safe number range opens its group(@moq/json, new) threw aRangeErrorwith the old helper.a_scale_less_track_ages_wide_keys_by_their_distance(moq-net, new) delivered a stale group from keys at 2^61.Range::is_wire(rs/moq-net/src/model/subscription.rs:248) admits a time start on those copies, where it refusedRelative(1).At
4bbf407db, which merges main at93cd63eb9(with #8694, the interoprefused sessionfix):cargo nextest run --workspace --all-features: 14,143 passed.cargo clippy --workspace --all-features --tests --benches --examples -D warningsis clean.just checkpasses, including the Go, Kotlin, Dart, Python, C, C++, OBS, JS and API-report checks. Swift compiles on Linux, but its tests need a Mac.just test interop --all: 215 passed, 0 failed.At
0bf2e87cb, after the local Codex review rounds (moq-archive, moq-json and moq-transcode only, so no wire, moq-ffi or binding change and no interop rerun):just checkpasses, including 13,065 Rust tests across the changed crates and their dependents, clippy, fmt, docs and the quest check.a_re_added_track_holding_the_last_stored_group_stores_it_once(moq-archive) stored group 1000 twice.a_fill_drops_a_gap_between_source_groups_at_once(moq-transcode) times out at nextest's 60 s with the old one-group drop. It now finishes in ~8 ms over a one-hour gap of microsecond keys.a_refresh_after_older_*_waits_for_a_later_onetests fail with the write-wake removed.a_*_fill_drops_a_gap_*,a_fill_over_an_empty_source_drops_the_gap_once_the_source_has_a_group), anda_missing_group_fetched_alone_is_answered_at_once.From the redesign itself:
test_unscaled_has_no_time(moq-net) covers the unscaledTimestamp: fallible conversions, comparison with scaled and unscaled timestamps, and the full key range.scale_less_datagrams_write_at_their_unscaled_sequence(moq-crypto) covers the sequence-naming rule, and the unchanged signing vectors cover the (0, 0) signature.raw_untimed_frames_roundtrip(moq-ffi) covers a track write at the key and a group write refused at another group's key.Before the merge with main at
f952d10d7, the earlier fixes were validated as follows:Each earlier fix's test fails without it:
a_pre07_end_declared_before_the_first_group_follows_the_starta_widened_reader_reads_a_finished_tracks_group_it_ran_out_beforelate_tail_reader::an_empty_history_read_leaves_the_group_to_readers_joining_meanwhileThe mid-poll race needs a frame to land between two reads in one poll, which a single-threaded test can't stage, so only the interop runs cover it. The
verify-resumeTLA+ model wasn't rerun: its scope leaves out range updates and local ranges, which is all the resume fix touches.Follow-ups
const moq_timestamp*. Returning the timestamp by value, as moq-ffi now does, would save an allocation per read.unscaledwith the next release.append_groupat the first frame.sequenceparameters.Fill::timelookups.is_skipped.is_skippedand the skip-awarenext_in_rangewhen@moq/netmoves to start keys.@moq/hang'scontainer/consumer.tsthen tells a Rust track's first group by its declared skips, notsequence === 0n, which a Rust track keyed at its first frame never passes. Until then, a JS viewer joining a track younger than its reach waits out the elapsed fallback.doc/concept/identity.mdstill describes group sequences. It's shared with JS, which still numbers groups, so it moves with the JS quest.Models
Size: XL
Claude Opus 5.5 (xhigh) wrote moq-net, the bindings, the interop fixes, and the merge with main, and delegated the downstream crates to parallel Claude Opus 5.5 agents.
Implementer: Claude Code, claude-opus-5-5, high effort
That iteration ported the unscaled
Timestampthrough moq-net, moq-json, moq-crypto, moq-archive, moq-ffi and the leaf crates, and delegated moq-mux, moq-video, moq-audio, JS, moq-c with C/C++, and the uniffi wrappers to parallel Claude Opus 5.5 (high) agents.Claude Code, claude-opus-5-5, iterated on 2026-11-14: merged main, fixed Pullfrog's review and the local Codex review's findings, and recorded the maintainer's acceptance.
Implementer: Claude Code, claude-opus-5-5, unknown effort (iterate, XL)
(Written by Claude Opus 5.5)
🤖 Generated with Claude Code
Review
Rewritten by
quest reviewafter each round.Head: a2942ba, named in full once no reviewer reports a finding
Base: origin/main at 98ed0f8
Risk: high,
Public group-addressing and range semantics change for publishers and cross-language subscribers, creating interoperability risks across deployed versions that a single-component revert may not resolve.Classified: medium by default
Reviewer: Codex, gpt-6.1-sol, xhigh effort
Session:
01a1de07-7fc5-7fa2-a224-3ac33ef3e5d8, round 3Score: 6/10,
The recorded plan supports the staged JavaScript migration, but the HLS completeness regression remains unresolved.Raised the risk to high:
Public group-addressing and range semantics change for publishers and cross-language subscribers, creating interoperability risks across deployed versions that a single-component revert may not resolve.Rounds: 8; findings 7 (high 2, medium 5): fixed 4, refuted 2, open 1
Findings, open until the next round settles them:
rs/moq-hls/src/export/rendition.rs:1729:Preserve dropped-group accounting in segment membershipThe last round's findings, at e0761bb:
rs/moq-hls/src/export/rendition.rs:1729:Preserve dropped-group accounting in segment membership;rendition.rs:2154 still records only delivered groups and :1729 fetches only that membership, while quest/m2/time-addressing/lite07.md:14 describes a future completeness mechanism rather than one implemented at this head.rs/moq-net/src/model/track.rs:1053:Update JavaScript relative anchors to count sparse groups;The base already contains the maintainer-planned dependency chain in quest/m2/time-addressing/js.md:27 and :36 through lite07.md:93 to the Rust model, establishing that the JavaScript migration was intentionally sequenced separately.