Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c86405e
quest: claim m1/lite-time-provenance
kixelated Oct 21, 2026
5d7fb8a
feat(lite): carry unverified timestamps so receivers check verified t…
kixelated Oct 21, 2026
3225bfe
Merge origin/main
kixelated Oct 21, 2026
62e8288
feat(lite): drop lite-05/06 timestamps instead of flagging them
kixelated Oct 21, 2026
4cc896f
Merge remote-tracking branch 'origin/main' into quest/m1/lite-time-pr…
kixelated Oct 21, 2026
6aa6957
test: expect lite-05/06 subscribers to drop timestamps
kixelated Oct 21, 2026
5ca875f
Merge remote-tracking branch 'origin/main' into merge-6150
kixelated Oct 21, 2026
3f869eb
quest: point arrival-stamps at moq-dev/moq#6150 for the completed pro…
kixelated Oct 21, 2026
987c451
Merge remote-tracking branch 'origin/main' into merge-6150-b
kixelated Oct 21, 2026
409c75c
test: expect lite-05/06 subscribers to drop received timestamps
kixelated Oct 21, 2026
c2ed5f9
Merge remote-tracking branch 'origin/main' into merge-6150-b
kixelated Oct 21, 2026
2c5a6ed
quest: point resume-outage links at moq-dev/moq#6183
kixelated Oct 21, 2026
4758a60
Merge remote-tracking branch 'origin/main' into pr6150
kixelated Oct 21, 2026
4a58ec5
fix(lite): stamp an unchecked track with its send time on lite-05/06
kixelated Oct 21, 2026
c2c1ef4
Merge remote-tracking branch 'origin/main' into pr6150
kixelated Oct 21, 2026
65f7bc8
Merge remote-tracking branch 'origin/main' into pr6150
kixelated Oct 22, 2026
44144fa
Merge remote-tracking branch 'origin/main' into quest/m1/lite-time-pr…
kixelated Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cpp/moq/test/probe.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ int main() {
CHECK(!reading.valid());
CHECK(frame.has_value());
CHECK(frame->payload == bytes("hello"));
CHECK(frame->timestamp_us == 1000);
// The default moq-lite-06 session drops received timestamps, so the track arrives untimed.
CHECK(!frame->timestamp_us.has_value());

// A continuation receives the next frame on the executor.
{
Expand Down
8 changes: 8 additions & 0 deletions doc/concept/moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,14 @@ untimed. moq-lite 05 and 06 cannot declare an untimed track, so there its
frames go out stamped with their send time; the work-in-progress 07 carries it
untimed.

A publisher refuses a group that starts before a lower one, and so does a
moq-lite 07 receiver. Since moq-lite 05 and 06 send times look like media time,
a receiver drops every timestamp it gets over them and the track arrives
untimed.
Timestamps received over moq-transport, whose peers may stamp arrival times,
are not checked and are only passed on over moq-transport: a relay serves that
track to moq-lite untimed.

A publisher may compress a track, which pays off for repetitive data such as
JSON and not for media. Each group's frames become one DEFLATE stream
([moq-flate](/draft/moq-flate)), and a datagram
Expand Down
6 changes: 4 additions & 2 deletions doc/setup/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ These land with the next breaking release.
- **A track is timed or untimed, and nothing fills in a timestamp on
receive.** Tracks from moq-lite before 05, and moq-transport tracks without
`TIMESCALE` (every track on drafts 14 to 16), arrive untimed instead of
stamped with their arrival time. moq-lite 05 and later can't encode absence
yet and carry a send time instead; see
stamped with their arrival time. moq-lite 05 and 06 can't encode absence and
carry a send time instead, so their receivers drop every timestamp and those
tracks arrive untimed too: read media time from the payload. moq-transport
timestamps are served to moq-lite untimed; see
[untimed tracks](/concept/moq-lite#subscriptions). A new subscriber with no
start on an untimed track starts at the latest group.
- **Max age and max delay count wall-clock time too.** A superseded group is
Expand Down
8 changes: 5 additions & 3 deletions drafts/draft-lcurley-moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ Each frame of a timed Track carries a presentation timestamp expressed in the Tr
A Track without a `Timescale` is untimed: its frames and datagrams carry no timestamp.
A publisher MUST NOT start a timed Group's frame 0 earlier than that of any lower-sequence Group it publishes.
lite-05 and lite-06 cannot encode an untimed Track, so a publisher on those versions declares a `Timescale` of 1000 for it and stamps every frame and datagram with its send time in milliseconds.
A receiver cannot tell such send times from presentation timestamps, and they need not rise with sequence, so it does not check received Groups against this rule.
A receiver cannot tell such send times from presentation timestamps, and they need not rise with sequence, so it discards every timestamp received on those versions and treats the Track as untimed.
On later versions a receiver MAY check a received Group against this rule, refusing a violating Group's stream (STOP_SENDING) with TIMESTAMP_MISMATCH.
A relay that cannot vouch for a Track's timestamps, such as ones received over another protocol, MUST serve the Track untimed.

## Positions {#positions}
A Position is a (Group Sequence, Frame Index) pair identifying one frame within a Track.
Expand Down Expand Up @@ -365,7 +367,7 @@ Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (ST
| ------- | ------------- | ----------- |
| 0x38 | FRAME_TOO_LARGE | A frame declared a payload larger than the receiver accepts. |
| ------- | ------------- | ----------- |
| 0x39 | TIMESTAMP_MISMATCH | A frame's timestamp does not match its track's timescale. |
| 0x39 | TIMESTAMP_MISMATCH | A frame's timestamp does not fit its track: it is present on an untimed track, absent on a timed one, or starts a Group before a lower-sequence one. |
| ------- | ------------- | ----------- |
| 0x3A | NOT_FETCHABLE | The FETCH named a group delivered only as a datagram, which is never cached (see [Datagrams](#datagrams)). It has no moq-transport value; a bridge refuses the FETCH with DOES_NOT_EXIST. |
| ------- | ------------- | ----------- |
Expand Down Expand Up @@ -1761,7 +1763,7 @@ The `Message Length` describes the payload size on the wire.

- Replaced ANNOUNCE_OK Active Count with an ordered, empty SYNCED event (0x4), marking the initial set without retaining routes that end before delivery.

- A publisher must not start a timed Group's frame 0 earlier than a lower-sequence Group's. Receivers do not check it, since a received timestamp may be a send time.
- A publisher must not start a timed Group's frame 0 earlier than a lower-sequence Group's, and a receiver may refuse a Group that does. A receiver discards the timestamps it receives over lite-05/06, which may be send times, and a relay serves a Track whose timestamps it cannot vouch for untimed.
- Added the reliable ordered RTCDataChannel binding, with one Qmux Record per binary message and no datagrams.

- Added `Group Order` to SUBSCRIBE and SUBSCRIBE_UPDATE: descending (0x0, the previous fixed order) or ascending (0x1). A relay requests descending upstream unless every subscription it serves is ascending.
Expand Down
4 changes: 2 additions & 2 deletions js/e2ee/src/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ async function active(origin: Origin.Producer, path: Path.Valid): Promise<Broadc
}

// Whether the wire carries the route's epoch, and the track's timestamps. Neither is
// authenticated, so the frames open either way.
// authenticated, so the frames open either way. A lite-05 receiver drops timestamps.
const transports = [
["moq-lite-07", "moq-lite-07-wip", true, true],
["moq-lite-05", "moq-lite-05", false, true],
["moq-lite-05", "moq-lite-05", false, false],
["moq-transport-16", "moqt-16", false, false],
["moq-transport-17", "moqt-17", false, true],
] as const;
Expand Down
3 changes: 3 additions & 0 deletions js/net/MIGRATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ A new group or datagram raises it at frame 0 before any frame lands, and it can
### A timed track refuses a group that rewinds (#5803)

A local write whose first frame starts before a lower-sequence group's, or after a higher one's, throws `TimestampMismatch`.
A group received over moq-lite-07 is checked the same way.
A track received over moq-lite-05/06 arrives untimed, since its timestamps may be send times: read times from the payload, such as the hang container's, instead of the frame's `timestamp`.
One received over moq-transport keeps its timestamps unchecked, and a relay serves it to moq-lite untimed.
A publisher whose timeline restarts publishes a new broadcast instead.

### `Auth.Auth` is `Auth.Handle`, and `Auth.Token` is `Auth.Auth` (#5695)
Expand Down
3 changes: 2 additions & 1 deletion js/net/src/ietf/subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,6 @@ export class Subscriber {
// Keep the request pending until SUBSCRIBE_OK supplies immutable track metadata.
// Group streams already wait on the alias, so early data stays behind this response.
const producer = hooks.pendingTrackProducer(request);
hooks.receiveTrack(producer);
const subscription: Subscription = { track: producer, tail: new Tail() };

// Open the stream and wait for SUBSCRIBE_OK under a timeout. State
Expand Down Expand Up @@ -1200,6 +1199,8 @@ export class Subscriber {
timescale: ok.properties.timescale,
priority: fromWire(ok.properties.priority ?? 128),
maxAge: maxCacheDuration === undefined ? undefined : Milli(Number(maxCacheDuration)),
// A moq-transport peer may stamp arrival times, and nothing on the wire says it didn't.
unchecked: ok.properties.timescale != null,
});
if (ok.largest && ok.largest.groupId > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new RangeError("largest group exceeds safe integers");
Expand Down
16 changes: 8 additions & 8 deletions js/net/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -984,10 +984,10 @@ test("integration: a peer is served the cheaper route it was offered, not the lo
origin.close();
});

test("integration: lite draft-05 fetches a cached group", async () => {
test("integration: lite draft-07 fetches a cached group", async () => {
const enc = new TextEncoder();
const dec = new TextDecoder();
const pair = createMockTransportPair(Lite.ALPN_05);
const pair = createMockTransportPair(Lite.ALPN_07_WIP);
const origin = new OriginProducer();

const [client, server] = await Promise.all([
Expand Down Expand Up @@ -1160,10 +1160,10 @@ test("integration: lite draft-05 coalesces concurrent fetches of one group", asy
server.abort();
});

test("integration: lite draft-05 fetches an in-progress group", async () => {
test("integration: lite draft-07 fetches an in-progress group", async () => {
const enc = new TextEncoder();
const dec = new TextDecoder();
const pair = createMockTransportPair(Lite.ALPN_05);
const pair = createMockTransportPair(Lite.ALPN_07_WIP);
const origin = new OriginProducer();

const [client, server] = await Promise.all([
Expand Down Expand Up @@ -1202,8 +1202,8 @@ test("integration: lite draft-05 fetches an in-progress group", async () => {
// The publisher caches TRACK_INFO so it only asks the application once per track. The cache
// has to expire with the broadcast that answered it: a republish puts a different producer
// on the path, and its immutable properties are its own.
test("integration: lite draft-05 track info follows a republished broadcast", async () => {
const pair = createMockTransportPair(Lite.ALPN_05);
test("integration: lite draft-07 track info follows a republished broadcast", async () => {
const pair = createMockTransportPair(Lite.ALPN_07_WIP);
const origin = new OriginProducer();

const [client, server] = await Promise.all([
Expand Down Expand Up @@ -1240,9 +1240,9 @@ test("integration: lite draft-05 track info follows a republished broadcast", as

// FETCH reads the same cache to decide the timescale it serves frames in, so a stale entry
// quantizes the successor's timestamps to the predecessor's resolution.
test("integration: lite draft-05 fetch uses the republished track's timescale", async () => {
test("integration: lite draft-07 fetch uses the republished track's timescale", async () => {
const enc = new TextEncoder();
const pair = createMockTransportPair(Lite.ALPN_05);
const pair = createMockTransportPair(Lite.ALPN_07_WIP);
const origin = new OriginProducer();

const [client, server] = await Promise.all([
Expand Down
9 changes: 0 additions & 9 deletions js/net/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,12 +417,6 @@ export const hooks: {
* as the Rust `request_start`.
*/
startTrack: (producer: Producer, served: { start?: number; requested?: number }) => void;
// pending: /quest/m1/public-model/ingest.md
/**
* Mark a track as a session's copy of a remote one: its groups are never checked for a
* rewind, since a received timestamp may not be media time.
*/
receiveTrack: (producer: Producer) => void;
/** Mint a track {@link Request}; assigned by `track.ts`. */
makeRequest: (options: TrackRequestOptions) => Request;
// pending: /quest/m1/public-model/ingest.md
Expand Down Expand Up @@ -571,9 +565,6 @@ export const hooks: {
/** Name the epoch of the route an origin resolved a broadcast handle through. */
stampEpoch: (target: BroadcastConsumer, epoch: Epoch.Valid | undefined) => void;
} = {
receiveTrack: () => {
throw new Error("track.ts not loaded");
},
endTrack: () => {
throw new Error("track.ts not loaded");
},
Expand Down
18 changes: 14 additions & 4 deletions js/net/src/lite/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ function unzigzag(v: bigint): bigint {
* FRAME format), decoded into a Timestamp at that scale. Scale 0 (pre-lite-05) carries no
* timestamp, so frames arrive untimed.
*/
export function frameDecoder(scale: number): (c: Cursor) => netGroup.Frame {
export function frameDecoder(scale: number, timed = true): (c: Cursor) => netGroup.Frame {
if (scale === 0) {
return (c) => ({ payload: c.read(c.u53()) });
}
Expand All @@ -88,13 +88,23 @@ export function frameDecoder(scale: number): (c: Cursor) => netGroup.Frame {
const payload = c.read(c.u53());
// After the last read, so a decode that ran short and gets retried adds the delta once.
prevTs += delta;
// Read to stay in step, but dropped where it may be a send time.
if (!timed) return { payload };
return { payload, timestamp: new Time.Timestamp(Number(prevTs), timescale) };
};
}

/** Write a group stream's frames into `producer` until the stream ends or the producer closes. */
export async function readFrames(stream: Reader, producer: netGroup.Producer, scale: number): Promise<void> {
const decode = frameDecoder(scale);
/**
* Write a group stream's frames into `producer` until the stream ends or the producer closes.
* Each frame's timestamp is read at `scale` and kept only when `timed`.
*/
export async function readFrames(
stream: Reader,
producer: netGroup.Producer,
scale: number,
timed = true,
): Promise<void> {
const decode = frameDecoder(scale, timed);

for (;;) {
// Every frame already buffered is written without an await, so the reader wakes once
Expand Down
35 changes: 35 additions & 0 deletions js/net/src/lite/publisher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,41 @@ test("lite draft-05: the fetch response ranks the publisher's own writes", async
}
});

// An unchecked track goes out untimed, so lite-06 carries its send time, never the frame's own
// timestamp, which may be a moq-transport arrival time.
test("lite draft-06: an unchecked track's fetched frames carry their send time", async () => {
const pair = createMockTransportPair(ALPN_06);
const origin = new OriginProducer();
const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume());

const broadcast = publish(origin, Path.from("test"));
const track = broadcast.createTrack("video", { timescale: Timescale.MICRO, unchecked: true });

const group = new GroupProducer(0);
group.writeFrame(textFrame("hello", Timestamp.fromMillis(36_000_000)));
group.close();
track.writeGroup(group);

const client = await Stream.open(pair.client, { version: Version.DRAFT_06 });
const server = await Stream.accept(pair.server, Version.DRAFT_06);
if (!server) throw new Error("publisher never saw the fetch stream");

const msg = new Fetch({ broadcast: Path.from("test"), track: "video", priority: 0, group: 0 });
try {
const before = Math.floor(Timestamp.now().value);
await publisher.runFetch(msg, server);
const after = Math.ceil(Timestamp.now().value);

// The first frame's zigzag delta is against 0, in the milliseconds TRACK_INFO declares.
const sent = Number((await client.reader.u62()) >> 1n);
expect(sent).toBeGreaterThanOrEqual(before);
expect(sent).toBeLessThanOrEqual(after);
} finally {
publisher.close();
client.close();
}
});

// How long a served-subscription test waits for the next group stream before calling the
// publisher idle. Nothing waits this out: it only bounds the read when a group never comes.
const IDLE_MS = 500;
Expand Down
Loading
Loading