Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
ff1a485
chore(quest): claim quest/m1/api-audit/lifecycle.md
kixelated Oct 25, 2026
3e88354
refactor(net)!: fold track::Producer::close into abort, rename end to…
kixelated Oct 25, 2026
b353951
feat(net)!: group closed() settles on finish; abort after finish only…
kixelated Oct 25, 2026
ba5721c
feat(net)!: track closed() returns the outcome and settles on a reach…
kixelated Oct 25, 2026
314f20a
feat(net)!: split JS Group.Producer.close into finish and abort
kixelated Oct 25, 2026
ec418e1
feat(net)!: split JS Track.Producer.close into finish, abort, and close
kixelated Oct 25, 2026
66661b7
feat(net)!: JS Lister.closed, unused rejects on close, readers drop e…
kixelated Oct 25, 2026
7349c26
feat(net)!: JS group Consumer.close releases only its handle
kixelated Oct 25, 2026
8553cad
docs(net): migration notes and JS docs for the lifecycle vocabulary
kixelated Oct 25, 2026
1d1e236
fix(net): finish the remaining callers and narrow the quest
kixelated Oct 25, 2026
0cd3e64
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Oct 25, 2026
46bd7e4
feat(net)!: consumer close releases only its handle for announcements…
kixelated Oct 25, 2026
08854f9
test(e2ee): observe a consumer's release on a session broadcast
kixelated Oct 25, 2026
cc16ff9
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Oct 25, 2026
b91d74b
fix(net): settle a track's outcome after its groups, and end a releas…
kixelated Oct 25, 2026
f897932
fix(net): count a received end's tail before settling, and list an op…
kixelated Oct 25, 2026
9420bff
feat(net)!: announcements say wanted, and the origin closes what it r…
kixelated Oct 25, 2026
f2029fb
Merge remote-tracking branch 'origin/main' into HEAD
kixelated Oct 25, 2026
1497ca5
chore(net): regenerate the API report and format
kixelated Oct 25, 2026
2c9e1a1
test(net): abort with the lifecycle verb in a resume test from main
kixelated Oct 25, 2026
8d896d6
fix(net): a released shared group consumer reads nothing more
kixelated Oct 25, 2026
29792cf
fix(net): a settled track end is final
kixelated Oct 25, 2026
c70f314
fix(net): a settled end keeps its clean outcome in JS too
kixelated Oct 25, 2026
2bd905d
Merge origin/main into quest/m1/api-audit/lifecycle
kixelated Oct 25, 2026
757a133
fix(net): keep the lifecycle docs and helpers clean in the no-async s…
kixelated Oct 25, 2026
b8bf0fb
fix(net): a request whose readers left blocks an overlapping one
kixelated Oct 25, 2026
beff2e0
fix(net): a track's closed() sees a group that finishes while it scans
kixelated Oct 25, 2026
b41e668
Merge origin/main into quest/m1/api-audit/lifecycle
kixelated Oct 26, 2026
abc3b97
test(net): the SUBSCRIPTION_ENDED tail test reads the missing end thr…
kixelated Oct 26, 2026
ac6e0c6
Merge remote-tracking branch 'origin/main' into pr-6750
kixelated Oct 26, 2026
819ba54
Merge origin/main into quest/m1/api-audit/lifecycle
kixelated Oct 26, 2026
5974aad
Merge origin/main into quest/m1/api-audit/lifecycle
kixelated Oct 26, 2026
fd1cc05
fix(net): a released shared group consumer ignores a later abort
kixelated Oct 26, 2026
b4f093a
fix(net): release a received copy without declaring an end the publis…
kixelated Oct 26, 2026
174695c
Merge remote-tracking branch 'origin/main' into quest/m1/api-audit/li…
kixelated Oct 27, 2026
fc8b671
test(net): read the group without async in abort_after_finish_keeps_t…
kixelated Oct 27, 2026
d250ed4
Merge remote-tracking branch 'origin/main' into quest/m1/api-audit/li…
kixelated Oct 27, 2026
78697ce
Merge remote-tracking branch 'origin/main' into quest/m1/api-audit/li…
kixelated Oct 27, 2026
9a856aa
fix(net): settle a track's outcome once the cache evicts its last ope…
kixelated Oct 27, 2026
6120267
Merge remote-tracking branch 'origin/main' into quest/m1/api-audit/li…
kixelated Oct 27, 2026
3f1a00c
fix(flate): finish the decoder bench's oversized group and track
kixelated Oct 27, 2026
fb45912
fix(net): latch a track's clean outcome once closed() reports it
kixelated Oct 27, 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 bench/browser/page/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ async function publish(config: Transport): Promise<void> {
});
if (connection.transport !== "webtransport") throw new Error(`publisher negotiated ${connection.transport}`);
cleanup.push(() => {
track.finish();
track.close();
producer.close();
connection.abort();
Expand All @@ -58,7 +59,7 @@ async function publish(config: Transport): Promise<void> {
const group = track.appendGroup();
for (let frame = start; frame < Math.min(start + config.group, config.frames); frame++)
group.writeFrame({ payload: fixtures[frame], timestamp: Moq.Time.Timestamp.fromMillis(frame) });
group.close();
group.finish();
}
})();
}
Expand Down
5 changes: 4 additions & 1 deletion demo/web/src/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,10 @@ meta.run((effect) => {
timescale: Net.Time.Timescale.MILLI,
maxAge: Milli(86_400_000),
});
effect.cleanup(() => track.close());
effect.cleanup(() => {
track.finish();
track.close();
});

const producer = new Json.Snapshot.Producer<unknown>({ track });
producer.update({ value: currentMeta, at: Net.Time.Timestamp.now() });
Expand Down
4 changes: 2 additions & 2 deletions doc/lib/js/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ const broadcast = publication.broadcast;
const track = broadcast.createTrack("messages", { timescale: Moq.Time.Timescale.MILLI });
const group = track.appendGroup();
group.writeFrame({ payload: new TextEncoder().encode("hello"), timestamp: Moq.Time.Timestamp.now() });
group.close();
group.finish();
publication.announce();
```

- **The origin holds the broadcasts, not the connection.** Closing a session unannounces them and leaves them created for the next one. A publication controls announcements; its `broadcast` fills tracks. The broadcast is invisible, locally and remotely, until the publication calls `announce()`.
- **Answer claimed paths with `using`.** `dynamic(prefix)` claims a prefix and yields each requested path. Use `using prepared = request.prepare(epoch)`, fill its broadcast, then `prepared.publish()` to answer with a new publisher instance and keep the `Publication` it returns. Leaving the scope without publishing, including on a throw, refuses the request as unroutable. Explicit `prepared.close()` or closing its unpublished broadcast does the same; disposal after publishing leaves the returned publication and its fill broadcast live. Reject before preparing with `request.reject(err)`. A retained, unclosed prepared answer keeps the request pending; JavaScript garbage collection provides no prompt cleanup guarantee.
- **End content by closing tracks.** A broadcast has no finished state. When the input ends, close each track cleanly; the broadcast stays announced and keeps serving them until you `close()` it. How long to keep it up for late readers is yours to decide.
- **End content by finishing tracks.** A broadcast has no finished state. When the input ends, `finish()` each track, then `close()` it; closing a track without finishing aborts it. The broadcast stays announced and keeps serving them until you `close()` it. How long to keep it up for late readers is yours to decide.
- **Requests can pin an epoch.** `consumer.request(path, { epoch })` resolves only through a route announcing that [publisher epoch](/concept/moq-lite#publisher-epochs). The resolved broadcast's `epoch` names the route it came through, pinned or not.
- **Follow restarts.** `origin.request(path, { announced: true })` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close the request when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same epoch taking over (epochs travel only on `moq-lite-07-wip`) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends the request. Add `epoch` to pin one instance; a plain `request(path)` keeps its accepted instance, can resolve blindly, and ends once that instance stops serving. To list what is live, iterate `announced(scope)`.
- **Discovery follows what you watch.** A `consume` origin asks the relay for announcements only under the prefixes its readers watch with `announced(scope)` or `request(path, { announced: true })`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. A plain `request(path)` asks nothing and subscribes blind, but once it resolves through a discovered route it watches its path until closed, so it outlives the listing that found it. A relay that refuses an announce request turns that session's announced requests blind too.
Expand Down
5 changes: 4 additions & 1 deletion doc/lib/js/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ signals.run((effect) => {
timescale: Moq.Time.Timescale.MILLI,
maxAge: Milli(86_400_000),
});
effect.cleanup(() => track.close());
effect.cleanup(() => {
track.finish();
track.close();
});

const meta = new Json.Snapshot.Producer<Meta>({ track });
meta.update({ value: current, at: Moq.Time.Timestamp.now() });
Expand Down
25 changes: 21 additions & 4 deletions js/e2ee/src/e2ee.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,15 +246,32 @@ test("a broadcast speaks semantic names and puts physical names on the wire", as
net.close();
});

// A broadcast as a session hands it out: no producer, so it ends once no handle holds it.
class SessionBroadcast extends Broadcast.Consumer {
// biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public
constructor() {
super();
}
}

test("closing a protected consumer releases its broadcast handle", () => {
const generation = credential().produce();
const net = new Broadcast.Producer();
const consumer = new Protected.Consumer(net.consume(), generation.credential.consume(generation.epoch));
const net = new SessionBroadcast();
const consumer = new Protected.Consumer(net, generation.credential.consume(generation.epoch));
expect(consumer.closed.peek()).toBeUndefined();
consumer.close();
consumer.close();
expect(consumer.closed.peek()).toBeNull();
});

test("closing a protected consumer leaves a produced broadcast to its producer", () => {
const generation = credential().produce();
const net = new Broadcast.Producer();
const consumer = new Protected.Consumer(net.consume(), generation.credential.consume(generation.epoch));
consumer.close();
expect(net.closed.peek()).toBeUndefined();
net.close();
expect(consumer.closed.peek()).toBeNull();
});

test("closing a protected producer ends its broadcast", () => {
Expand Down Expand Up @@ -388,7 +405,7 @@ test("grouped authentication failure ends the track", async () => {
const bad = new NetGroup.Producer(1);
p.net.writeGroup(bad);
bad.writeFrame({ timestamp: ms(2), payload: await sealed(p, GROUP, 1, 0, "ok", true) });
bad.close();
bad.finish();

expect(await readText(await recvGroup(p.consumer))).toBe("ok");
const forged = await recvGroup(p.consumer);
Expand Down Expand Up @@ -751,7 +768,7 @@ test("produce waits for an unaccepted track's timescale", async () => {
const video = await generation.name("video");
const closed = new NetTrack.Producer(video);
const refused = generation.produce(closed, "video");
closed.close(new Error("gone"));
closed.abort(new Error("gone"));
await expect(refused).rejects.toThrow("gone");
await generation.produce(netTrack(video), "video");
expect(await failure(generation.produce(netTrack(video), "video"))).toBe("reuse");
Expand Down
2 changes: 1 addition & 1 deletion js/e2ee/src/generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export class Producer {
if (this.#claims.has(semantic)) throw new Failure("reuse");
} catch (err) {
// Both refusals are Failures.
track.close(err as Failure);
track.abort(err as Failure);
throw err;
}
this.#claims.add(semantic);
Expand Down
9 changes: 5 additions & 4 deletions js/e2ee/src/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,14 @@ export class Producer {
}
this.#closed = true;
void this.#writer.pump.run(() => {
if (!this.#aborted) this.#inner.close();
if (!this.#aborted) this.#inner.finish();
});
}

#abort(error: Error): void {
this.#closed = true;
this.#aborted ??= error;
this.#inner.close(error);
this.#inner.abort(error);
}
}

Expand Down Expand Up @@ -171,7 +171,8 @@ export class Consumer {
return { timestamp: frame.timestamp, plaintext };
} catch (err) {
if (err instanceof Failure && (err.code === "authentication" || err.code === "identity")) {
this.#inner.close(this.#reader.terminal.fail());
this.#reader.terminal.fail();
this.#inner.close();
}
throw err;
}
Expand All @@ -181,7 +182,7 @@ export class Consumer {
#check(): void {
const failed = this.#reader.terminal.failed;
if (!failed) return;
this.#inner.close(failed);
this.#inner.close();
throw failed;
}

Expand Down
7 changes: 5 additions & 2 deletions js/e2ee/src/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,14 @@ export class Producer {
this.#writer.closed = true;
if (abort) {
this.#writer.aborted = abort;
this.#inner.close(abort);
this.#inner.abort(abort);
return;
}
void this.#writer.pump.run(() => {
if (!this.#writer.aborted) this.#inner.close();
if (!this.#writer.aborted) {
this.#inner.finish();
this.#inner.close();
}
});
}

Expand Down
3 changes: 2 additions & 1 deletion js/flate/bench/decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ for (const excess of [64 * 1024, 1024 * 1024, 8 * 1024 * 1024]) {
const track = new Track.Producer("overflow-bench").accept({});
const group = track.appendGroup();
group.writeFrame({ payload: slice });
group.close();
group.finish();
track.finish();
track.close();
const samples: number[] = [];
for (let sample = 0; sample < 4; sample++) {
Expand Down
3 changes: 2 additions & 1 deletion js/flate/src/snapshot/producer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,13 @@ export class Producer {
} finally {
// The group is already visible on the track, so leaving it open on a failed write would
// strand a subscriber that advanced into it with nothing to read and no end.
group.close();
group.finish();
}
}

/** Finish the track. */
finish(): void {
this.#track.finish();
this.#track.close();
}
}
Expand Down
7 changes: 4 additions & 3 deletions js/flate/src/snapshot/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,12 @@ test("latest lets a newer group preempt an open one", async () => {
// A complete newer value arrives while the previous group is still open.
const fresh = track.appendGroup();
fresh.writeFrame({ payload: bytes(2), timestamp: Time.Timestamp.now() });
fresh.close();
fresh.finish();

expect((await consumer.latest())?.value).toEqual(bytes(2));

stale.close();
stale.finish();
track.finish();
track.close();
});

Expand All @@ -181,7 +182,7 @@ test("an aborted track surfaces its error instead of spinning", async () => {
const consumer = new Consumer({ track: track.subscribe() });

const boom = new Error("subscription aborted");
track.close(boom);
track.abort(boom);

await expect(consumer.next()).rejects.toThrow("subscription aborted");
});
Expand Down
7 changes: 4 additions & 3 deletions js/flate/src/stream/producer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ export class Producer {
// end. The group reaches a reader already inside it, which keeps its own handle and would
// otherwise get a generic error when ours is dropped.
const abort = err instanceof Error ? err : new Error(String(err));
this.#group?.close(abort);
this.#group?.abort(abort);
this.#group = undefined;
this.#track.close(abort);
this.#track.abort(abort);
throw err;
}

Expand All @@ -100,8 +100,9 @@ export class Producer {

/** Finish the track, closing the group. */
finish(): void {
this.#group?.close();
this.#group?.finish();
this.#group = undefined;
this.#track.finish();
this.#track.close();
}
}
Expand Down
3 changes: 2 additions & 1 deletion js/flate/src/stream/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ test("a second group is a rolled log, not a continuation", async () => {
for (const pair of [payloads(2), payloads(2)]) {
const group = track.appendGroup();
for (const payload of pair) group.writeFrame({ payload, timestamp: Time.Timestamp.now() });
group.close();
group.finish();
}
track.finish();
track.close();

const consumer = new Consumer({ track: track.subscribe({ maxDelay: REPLAY_LATENCY }) });
Expand Down
14 changes: 9 additions & 5 deletions js/hang/src/container/consumer.holes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ test.each([0, 100])("a sequence hole before a paused data marker expires at %sms
const first = new Group.Producer(0);
track.writeGroup(first);
first.writeFrame({ payload: encodeFrame(new Uint8Array([1]), Micro(10_000)), timestamp: Time.Timestamp.now() });
first.close();
first.finish();
await flush();
expect(results).toHaveLength(1);
const marker = new Group.Producer(2);
track.writeGroup(marker);
marker.writeFrame({ payload: new Uint8Array(), timestamp: Time.Timestamp.now() });
marker.close();
marker.finish();
await flush();
if (budget > 0) {
expect(results).toHaveLength(1);
Expand All @@ -46,6 +46,7 @@ test.each([0, 100])("a sequence hole before a paused data marker expires at %sms
]);
} finally {
consumer.close();
track.finish();
track.close();
await reading;
installed.close();
Expand Down Expand Up @@ -75,7 +76,7 @@ test("a filled hole cancels its timer and the next hole gets a fresh budget", as
payload: timestamp === undefined ? new Uint8Array() : encodeFrame(new Uint8Array([1]), Micro(timestamp)),
timestamp: Time.Timestamp.fromMicros(timestamp ?? 20_000),
});
group.close();
group.finish();
};
try {
write(0, 0);
Expand All @@ -97,6 +98,7 @@ test("a filled hole cancels its timer and the next hole gets a fresh budget", as
]);
} finally {
consumer.close();
track.finish();
track.close();
await reading;
installed.close();
Expand Down Expand Up @@ -126,7 +128,7 @@ test("the holes a stalled source left behind share one budget", async () => {
payload: encodeFrame(new Uint8Array([1]), Micro(sequence * 10_000)),
timestamp: Time.Timestamp.fromMicros(sequence * 10_000),
});
group.close();
group.finish();
};
try {
write(0);
Expand All @@ -138,6 +140,7 @@ test("the holes a stalled source left behind share one budget", async () => {
expect(results.map((read) => read.kind)).toEqual(["frame", "gap", "frame", "gap", "frame", "gap", "frame"]);
} finally {
consumer.close();
track.finish();
track.close();
await reading;
installed.close();
Expand Down Expand Up @@ -167,7 +170,7 @@ test("a hole is late from the earliest arrival past it", async () => {
payload: encodeFrame(new Uint8Array([1]), Micro(sequence * 10_000)),
timestamp: Time.Timestamp.fromMicros(sequence * 10_000),
});
group.close();
group.finish();
};
try {
write(0);
Expand All @@ -182,6 +185,7 @@ test("a hole is late from the earliest arrival past it", async () => {
expect(results.map((read) => read.kind)).toEqual(["frame", "gap", "frame", "gap", "frame"]);
} finally {
consumer.close();
track.finish();
track.close();
await reading;
installed.close();
Expand Down
2 changes: 1 addition & 1 deletion js/hang/src/container/consumer.nonsequential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ test("Consumer waits on a non-sequential hang group because it has no per-frame
payload: encodeLegacyFrame(0 as Micro, new Uint8Array([0x01])),
timestamp: Time.Timestamp.now(),
});
a.close();
a.finish();
track.writeGroup(a);

// Drain A's frame.
Expand Down
8 changes: 5 additions & 3 deletions js/hang/src/container/consumer.outoforder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function publish(track: Track.Producer, sequence: number, millis: number) {
const group = new Group.Producer(sequence);
track.writeGroup(group);
publishFrame(group, millis, sequence);
group.close();
group.finish();
}

/** Every frame the consumer will hand over right now, as [group, payload] pairs. */
Expand Down Expand Up @@ -111,13 +111,14 @@ test("a below-cursor group still downloading is not truncated", async () => {
// rather than shift the group out, so the second frame still arrives.
const pending = nextFrame(consumer);
publishFrame(backlog, 1500, 11);
backlog.close();
backlog.finish();

const second = await pending;
expect(second?.group).toBe(1);
expect(second?.frame.payload[0]).toBe(11);

live.close();
live.finish();
track.finish();
track.close();
consumer.close();
});
Expand Down Expand Up @@ -150,6 +151,7 @@ test("delayed older groups survive a floor established by two newer groups", asy
await expect(nextFrame(consumer)).rejects.toThrow("below the previous group start");
} finally {
consumer.close();
track.finish();
track.close();
clock.mockRestore();
}
Expand Down
Loading
Loading