diff --git a/bench/browser/page/transport.ts b/bench/browser/page/transport.ts index ca6cc0c1dbc..4c56c1e28d8 100644 --- a/bench/browser/page/transport.ts +++ b/bench/browser/page/transport.ts @@ -45,6 +45,7 @@ async function publish(config: Transport): Promise { }); if (connection.transport !== "webtransport") throw new Error(`publisher negotiated ${connection.transport}`); cleanup.push(() => { + track.finish(); track.close(); producer.close(); connection.abort(); @@ -58,7 +59,7 @@ async function publish(config: Transport): Promise { 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(); } })(); } diff --git a/demo/web/src/publish.ts b/demo/web/src/publish.ts index b9f789188f6..dac12cefa54 100644 --- a/demo/web/src/publish.ts +++ b/demo/web/src/publish.ts @@ -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({ track }); producer.update({ value: currentMeta, at: Net.Time.Timestamp.now() }); diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index a798ea3d58b..19594809aab 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -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. diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index cf6f4a2c8fa..0958dfe713e 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -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({ track }); meta.update({ value: current, at: Moq.Time.Timestamp.now() }); diff --git a/js/e2ee/src/e2ee.test.ts b/js/e2ee/src/e2ee.test.ts index a14ad142370..a186320abfa 100644 --- a/js/e2ee/src/e2ee.test.ts +++ b/js/e2ee/src/e2ee.test.ts @@ -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", () => { @@ -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); @@ -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"); diff --git a/js/e2ee/src/generation.ts b/js/e2ee/src/generation.ts index 8abc89ab9bc..839c9b059e9 100644 --- a/js/e2ee/src/generation.ts +++ b/js/e2ee/src/generation.ts @@ -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); diff --git a/js/e2ee/src/group.ts b/js/e2ee/src/group.ts index a08466d085a..b723f34be0a 100644 --- a/js/e2ee/src/group.ts +++ b/js/e2ee/src/group.ts @@ -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); } } @@ -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; } @@ -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; } diff --git a/js/e2ee/src/track.ts b/js/e2ee/src/track.ts index 9d3360fdf44..0a16898b9f8 100644 --- a/js/e2ee/src/track.ts +++ b/js/e2ee/src/track.ts @@ -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(); + } }); } diff --git a/js/flate/bench/decoder.ts b/js/flate/bench/decoder.ts index 8afbeb09474..6a3d19c075f 100644 --- a/js/flate/bench/decoder.ts +++ b/js/flate/bench/decoder.ts @@ -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++) { diff --git a/js/flate/src/snapshot/producer.ts b/js/flate/src/snapshot/producer.ts index 170f9f90545..71cf8f7577d 100644 --- a/js/flate/src/snapshot/producer.ts +++ b/js/flate/src/snapshot/producer.ts @@ -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(); } } diff --git a/js/flate/src/snapshot/snapshot.test.ts b/js/flate/src/snapshot/snapshot.test.ts index 8e94be4bd12..d603f0ecacb 100644 --- a/js/flate/src/snapshot/snapshot.test.ts +++ b/js/flate/src/snapshot/snapshot.test.ts @@ -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(); }); @@ -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"); }); diff --git a/js/flate/src/stream/producer.ts b/js/flate/src/stream/producer.ts index 02098507f7f..01d46bf4b63 100644 --- a/js/flate/src/stream/producer.ts +++ b/js/flate/src/stream/producer.ts @@ -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; } @@ -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(); } } diff --git a/js/flate/src/stream/stream.test.ts b/js/flate/src/stream/stream.test.ts index e3983a7e07e..b4999ff9993 100644 --- a/js/flate/src/stream/stream.test.ts +++ b/js/flate/src/stream/stream.test.ts @@ -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 }) }); diff --git a/js/hang/src/container/consumer.holes.test.ts b/js/hang/src/container/consumer.holes.test.ts index fe77724c73d..958d2e6550e 100644 --- a/js/hang/src/container/consumer.holes.test.ts +++ b/js/hang/src/container/consumer.holes.test.ts @@ -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); @@ -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(); @@ -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); @@ -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(); @@ -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); @@ -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(); @@ -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); @@ -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(); diff --git a/js/hang/src/container/consumer.nonsequential.test.ts b/js/hang/src/container/consumer.nonsequential.test.ts index 492a8b03a80..5544943625d 100644 --- a/js/hang/src/container/consumer.nonsequential.test.ts +++ b/js/hang/src/container/consumer.nonsequential.test.ts @@ -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. diff --git a/js/hang/src/container/consumer.outoforder.test.ts b/js/hang/src/container/consumer.outoforder.test.ts index 927c61caf46..736e53fcbb6 100644 --- a/js/hang/src/container/consumer.outoforder.test.ts +++ b/js/hang/src/container/consumer.outoforder.test.ts @@ -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. */ @@ -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(); }); @@ -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(); } diff --git a/js/hang/src/container/consumer.rewind.test.ts b/js/hang/src/container/consumer.rewind.test.ts index 358c9b2d701..4b3015d8676 100644 --- a/js/hang/src/container/consumer.rewind.test.ts +++ b/js/hang/src/container/consumer.rewind.test.ts @@ -33,7 +33,7 @@ function group(track: Track.Producer, timestamps: number[], end?: number) { payload: encodeFrame(new Uint8Array(), Micro(end)), }); } - group.close(); + group.finish(); } async function frame(consumer: Consumer) { @@ -55,6 +55,7 @@ test("Consumer accepts a resuming keyframe before the estimated group end", asyn expect((await frame(consumer))?.frame?.timestamp).toBe(Micro(50_000)); } finally { consumer.close(); + track.finish(); track.close(); } }); @@ -88,6 +89,7 @@ test("Consumer accepts leading pictures below the previous group's content", asy expect((await frame(consumer))?.frame?.timestamp).toBe(Micro(150_000)); } finally { consumer.close(); + track.finish(); track.close(); } }); @@ -110,6 +112,7 @@ test("Consumer rejects a later frame below the previous group start", async () = await expect(frame(consumer)).rejects.toThrow("below"); } finally { consumer.close(); + track.finish(); track.close(); } }); diff --git a/js/hang/src/container/consumer.skips.test.ts b/js/hang/src/container/consumer.skips.test.ts index b5f6e1ef64f..02bb22e1e95 100644 --- a/js/hang/src/container/consumer.skips.test.ts +++ b/js/hang/src/container/consumer.skips.test.ts @@ -45,6 +45,7 @@ for (const count of [1, 16, 256]) { ); } finally { consumer.close(); + track.finish(); track.close(); warn.mockRestore(); } diff --git a/js/hang/src/container/consumer.startup.test.ts b/js/hang/src/container/consumer.startup.test.ts index 5bb4417e4e2..c29b9f41daf 100644 --- a/js/hang/src/container/consumer.startup.test.ts +++ b/js/hang/src/container/consumer.startup.test.ts @@ -25,6 +25,7 @@ function setup(maxDelay = 100) { })(); onTestFinished(async () => { consumer.close(); + track.finish(); track.close(); try { await reading; @@ -67,10 +68,10 @@ for (const emptySuccessor of [false, true]) { if (emptySuccessor) { const empty = new Group.Producer(17); track.writeGroup(empty); - empty.close(); + empty.finish(); await flush(); } - stale.close(new NetError.Stream(StreamCode.Old)); + stale.abort(new NetError.Stream(StreamCode.Old)); await flush(); expect(results).toEqual([]); @@ -97,7 +98,7 @@ test("Consumer preserves a declared marker before its first media across a hole" const marker = new Group.Producer(16); track.writeGroup(marker); write(marker, 0, true); - marker.close(); + marker.finish(); await flush(); expect(items(results)).toEqual(["end 16@0"]); @@ -117,7 +118,7 @@ test.each([false, true])("Consumer still waits for a gap after media (playhead r const first = new Group.Producer(16); track.writeGroup(first); write(first, 0); - first.close(); + first.finish(); await flush(); expect(items(results)).toEqual(["16@0"]); @@ -125,7 +126,7 @@ test.each([false, true])("Consumer still waits for a gap after media (playhead r const marker = new Group.Producer(17); track.writeGroup(marker); write(marker, 0, true); - marker.close(); + marker.finish(); await flush(); expect(items(results)).toEqual(["16@0", "end 17@0"]); } @@ -141,7 +142,7 @@ test.each([false, true])("Consumer still waits for a gap after media (playhead r const bridge = new Group.Producer(sequence - 1); track.writeGroup(bridge); write(bridge, 0); - bridge.close(); + bridge.finish(); await flush(); // The bridge closes the hole, so nothing reads as a gap. expect(items(results)).toEqual(["16@0", ...(reset ? ["end 17@0"] : []), `${sequence - 1}@0`, `${sequence}@50000`]); diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index 5c70cffa5b2..38e583dbdb0 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -48,8 +48,9 @@ function writeLoc(track: Track.Producer, groups: [Micro, number[]][][]) { timestamp: Time.Timestamp.fromMicros(timestamp), }); } - group.close(); + group.finish(); } + track.finish(); track.close(); } @@ -395,12 +396,14 @@ test("Consumer refuses a data marker that shares its group", async () => { const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array(), timestamp: Time.Timestamp.fromMicros(0) }); group.writeFrame({ payload: second, timestamp: Time.Timestamp.fromMicros(10_000) }); - group.close(); + group.finish(); // A finished track resolves the read instead of hanging when the reader fails to end. + track.finish(); track.close(); await expect(nextFrame(consumer)).rejects.toThrow("shares its group"); } finally { consumer.close(); + track.finish(); track.close(); } } @@ -507,7 +510,7 @@ function writeGroupWithLegacyFrames( for (const ts of timestamps) { group.writeFrame({ payload: encodeLegacy(ts), timestamp: stamp(ts) }); } - group.close(); + group.finish(); track.writeGroup(group); } @@ -517,7 +520,7 @@ function writeMarkerGroup(track: Track.Producer, sequence: number, timestamp: Mi payload: encodeLegacyFrame(timestamp, new Uint8Array()), timestamp: Time.Timestamp.fromMicros(timestamp), }); - group.close(); + group.finish(); track.writeGroup(group); } @@ -558,6 +561,7 @@ test("Consumer delivers frames from a single group", async () => { const consumer = new Consumer(replay(track), { format: new LegacyFormat("data"), maxDelay: 500 as Milli }); writeGroupWithLegacyFrames(track, 0, [0 as Micro, 33_000 as Micro]); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -572,6 +576,7 @@ test("Consumer forces keyframe true at index 0", async () => { const consumer = new Consumer(replay(track), { format: new LegacyFormat("data"), maxDelay: 500 as Milli }); writeGroupWithLegacyFrames(track, 0, [0 as Micro, 33_000 as Micro]); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -598,8 +603,9 @@ test("Consumer index spans MoQ frames for keyframe detection", async () => { const group = new Group.Producer(0); group.writeFrame({ payload: new Uint8Array([0x01]), timestamp: Time.Timestamp.now() }); // first MoQ frame → 3 samples group.writeFrame({ payload: new Uint8Array([0x02]), timestamp: Time.Timestamp.now() }); // second MoQ frame → 3 samples - group.close(); + group.finish(); track.writeGroup(group); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -631,14 +637,15 @@ for (const [where, payloads] of [ const g0 = new Group.Producer(0); for (const byte of payloads) g0.writeFrame({ payload: new Uint8Array([byte]), timestamp: Time.Timestamp.now() }); - g0.close(); + g0.finish(); track.writeGroup(g0); // A later group that decodes cleanly is never reached. const g1 = new Group.Producer(1); g1.writeFrame({ payload: new Uint8Array([0x04]), timestamp: Time.Timestamp.now() }); - g1.close(); + g1.finish(); track.writeGroup(g1); + track.finish(); track.close(); const frames: number[] = []; @@ -665,8 +672,9 @@ test("Consumer delivers everything ahead of a decode error", async () => { const malformed = new Group.Producer(3); // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: Time.Timestamp.fromMicros(200_000) }); - malformed.close(); + malformed.finish(); track.writeGroup(malformed); + track.finish(); track.close(); const items: string[] = []; @@ -692,9 +700,10 @@ test("Consumer keeps the first of several decode errors", async () => { for (const sequence of [0, 1]) { const group = new Group.Producer(sequence); group.writeFrame({ payload: new Uint8Array([sequence]), timestamp: Time.Timestamp.now() }); - group.close(); + group.finish(); track.writeGroup(group); } + track.finish(); track.close(); await settle(); @@ -722,7 +731,7 @@ test("Consumer releases its open groups when an error ends it", async () => { const malformed = new Group.Producer(0); malformed.writeFrame({ payload: new Uint8Array([0xff]), timestamp: Time.Timestamp.now() }); - malformed.close(); + malformed.finish(); track.writeGroup(malformed); const later = new Group.Producer(1); later.writeFrame({ payload: new Uint8Array([0x01]), timestamp: Time.Timestamp.now() }); @@ -763,9 +772,10 @@ test("Consumer keeps frames decoded before a group reset (truncated GoP)", async } track.writeGroup(g0); await decoded; - g0.close(new NetError.Stream(StreamCode.Cancel)); + g0.abort(new NetError.Stream(StreamCode.Cancel)); writeGroupWithLegacyFrames(track, 1, [4 as Micro]); + track.finish(); track.close(); const items: string[] = []; @@ -806,6 +816,7 @@ test("Consumer skips groups via PTS-span when over the max delay", async () => { writeGroupWithLegacyFrames(track, 0, [0 as Micro]); writeGroupWithLegacyFrames(track, 1, [100_000 as Micro]); writeGroupWithLegacyFrames(track, 2, [200_000 as Micro]); + track.finish(); track.close(); const frames = await drainFrames(consumer, 300); @@ -846,8 +857,10 @@ test("Consumer measures arrivals as they land", async () => { expect(replacement.spread.peek()).toBe(consumer.spread.peek()); replacement.close(); } finally { + track.finish(); track.close(); consumer.close(); + next.finish(); next.close(); installed.close(); } @@ -862,6 +875,7 @@ test("Consumer delivers groups in sequence order regardless of arrival order", a writeGroupWithLegacyFrames(track, 2, [60_000 as Micro]); writeGroupWithLegacyFrames(track, 0, [0 as Micro]); writeGroupWithLegacyFrames(track, 1, [30_000 as Micro]); + track.finish(); track.close(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -888,6 +902,7 @@ test("Consumer delivers a group that arrives below the cursor", async () => { // three are handed over, in sequence order, since delivery has not passed any of them yet. writeGroupWithLegacyFrames(track, 3, [0 as Micro]); writeGroupWithLegacyFrames(track, 6, [200_000 as Micro]); + track.finish(); track.close(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -905,6 +920,7 @@ test("Consumer next() yields no gap before the first frame", async () => { writeGroupWithLegacyFrames(track, 0, [0 as Micro, 33_000 as Micro]); writeGroupWithLegacyFrames(track, 1, [66_000 as Micro]); + track.finish(); track.close(); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -938,6 +954,7 @@ test("Consumer reads nothing after close, even the frame a gap was returned ahea consumer.close(); expect(await consumer.next()).toBeUndefined(); + track.finish(); track.close(); }); @@ -957,8 +974,9 @@ test("Consumer keeps a trailing duration marker internal", async () => { payload: encodeLegacyFrame(33_000 as Micro, new Uint8Array()), timestamp: Time.Timestamp.now(), }); - group.close(); + group.finish(); track.writeGroup(group); + track.finish(); track.close(); await settle(); @@ -987,8 +1005,9 @@ test("Consumer reads a leading marker as an end and keeps the first media keyfra payload: encodeLegacyFrame(20_000 as Micro, new Uint8Array([0xde, 0xad])), timestamp: Time.Timestamp.now(), }); - group.close(); + group.finish(); track.writeGroup(group); + track.finish(); track.close(); expect(await consumer.next()).toEqual({ kind: "end", group: 0, end: 20_000 as Micro }); @@ -1101,9 +1120,10 @@ test("Consumer throws the first error in delivery order", async () => { const malformed = new Group.Producer(1); // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: wallClock() }); - malformed.close(); + malformed.finish(); track.writeGroup(malformed); writeGroupWithLegacyFrames(track, 2, [0 as Micro, 100_000 as Micro], wallClock); + track.finish(); track.close(); expect((await nextFrame(consumer))?.frame?.timestamp).toBe(10_000_000 as Micro); @@ -1140,7 +1160,7 @@ test("Consumer checks a whole decoded batch before a skip drops its group", asyn const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format: batchFormat, maxDelay: 500 as Milli }); - write(0, [1_000_000]).close(); + write(0, [1_000_000]).finish(); expect((await nextFrame(consumer))?.frame?.timestamp).toBe(1_000_000 as Micro); // The newer group arrives first and stays open, so the older one is the oldest buffered group @@ -1148,10 +1168,11 @@ test("Consumer checks a whole decoded batch before a skip drops its group", asyn const newest = write(3, [3_000_000]); await later; // Its first sample is fine; the second rewinds below the delivered group's start. - write(2, [2_000_000, 0]).close(); + write(2, [2_000_000, 0]).finish(); // Read only once the skip has dropped it, before the newer group's frame is delivered. await rewound; - newest.close(); + newest.finish(); + track.finish(); track.close(); const read = async () => { @@ -1246,7 +1267,7 @@ test("Consumer reads no gap when retiring a group holding only its endpoint", as payload: encodeLegacyFrame(33_000 as Micro, new Uint8Array()), timestamp: Time.Timestamp.fromMicros(33_000), }); - group.close(); + group.finish(); track.writeGroup(group); await settle(); expect(frameOf(await consumer.next()).timestamp).toBe(0 as Micro); @@ -1272,7 +1293,7 @@ test("Consumer reads a gap when retiring a truncated group", async () => { await settle(); expect(frameOf(await consumer.next()).timestamp).toBe(0 as Micro); - group.close(new NetError.Stream(StreamCode.Cancel)); + group.abort(new NetError.Stream(StreamCode.Cancel)); writeGroupWithLegacyFrames(track, 1, [100_000 as Micro]); writeGroupWithLegacyFrames(track, 2, [200_000 as Micro]); await settle(); @@ -1303,7 +1324,7 @@ test("Consumer reads a gap when retiring an open group", async () => { expect(await consumer.next()).toEqual({ kind: "gap", timestamp: 0 as Micro, duration: 100_000 as Micro }); expect(frameOf(await consumer.next()).timestamp).toBe(100_000 as Micro); - group.close(); + group.finish(); consumer.close(); }); @@ -1325,6 +1346,7 @@ test("Consumer buffered signal updates as frames arrive", async () => { expect(ranges[0].start).toBe(0 as Milli); expect((ranges[0].end as number) >= 66).toBeTruthy(); + track.finish(); track.close(); consumer.close(); }); @@ -1341,6 +1363,7 @@ test("Consumer recovers from gap in group sequence numbers", async () => { writeGroupWithLegacyFrames(track, 3, [120_000 as Micro, 140_000 as Micro]); writeGroupWithLegacyFrames(track, 4, [160_000 as Micro, 180_000 as Micro]); writeGroupWithLegacyFrames(track, 5, [200_000 as Micro, 220_000 as Micro]); + track.finish(); track.close(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -1368,8 +1391,9 @@ test("Consumer handles empty decode result without deadlock", async () => { const group = new Group.Producer(0); group.writeFrame({ payload: new Uint8Array([0x01]), timestamp: Time.Timestamp.now() }); // empty decode group.writeFrame({ payload: new Uint8Array([0x02]), timestamp: Time.Timestamp.now() }); // valid decode - group.close(); + group.finish(); track.writeGroup(group); + track.finish(); track.close(); const frames = await drainFrames(consumer, 300); @@ -1392,8 +1416,9 @@ test("Consumer preserves empty media from formats without endpoint markers", asy const consumer = new Consumer(track.subscribe(), { format, maxDelay: 500 as Milli }); const group = new Group.Producer(0); group.writeFrame({ payload: new Uint8Array([1]), timestamp: Time.Timestamp.now() }); - group.close(); + group.finish(); track.writeGroup(group); + track.finish(); track.close(); expect(frameOf(await nextFrame(consumer)).payload).toHaveLength(0); @@ -1418,8 +1443,9 @@ test("Consumer with CmafFormat delivers correct timestamps", async () => { payload: fragment("delta"), timestamp: at(3000), }); - group.close(); + group.finish(); track.writeGroup(group); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1441,8 +1467,9 @@ test("Consumer with CmafFormat times an untimed track from tfdt", async () => { const group = new Group.Producer(0); group.writeFrame({ payload: fragment("untimedKey") }); group.writeFrame({ payload: fragment("untimedDelta") }); - group.close(); + group.finish(); track.writeGroup(group); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1487,10 +1514,11 @@ test("Consumer duration-skips a stalled group once it is covered", async () => { // Group.Producer 1: closed, starts exactly where group 0's frame ends. const g1 = new Group.Producer(1); g1.writeFrame({ payload: new Uint8Array([33]), timestamp: Time.Timestamp.now() }); - g1.close(); + g1.finish(); track.writeGroup(g0); track.writeGroup(g1); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1524,7 +1552,7 @@ test("Consumer does not duration-skip when the gap is not covered", async () => const g1 = new Group.Producer(1); g1.writeFrame({ payload: new Uint8Array([33]), timestamp: Time.Timestamp.now() }); - g1.close(); + g1.finish(); track.writeGroup(g0); track.writeGroup(g1); @@ -1532,7 +1560,8 @@ test("Consumer does not duration-skip when the gap is not covered", async () => // Let the consumer settle on group 0, then extend it before closing. await new Promise((resolve) => setTimeout(resolve, 20)); g0.writeFrame({ payload: new Uint8Array([20]), timestamp: Time.Timestamp.now() }); - g0.close(); + g0.finish(); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1559,7 +1588,7 @@ test("Consumer delivers a PTS-contiguous next group whose sequence jumped (Legac payload: encodeLegacyFrame(Math.round((0 / TIMESCALE) * 1_000_000) as Micro, new Uint8Array([0x01])), timestamp: at(0), }); - a.close(); + a.finish(); track.writeGroup(a); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); @@ -1604,7 +1633,7 @@ test("Consumer waits on a PTS gap instead of skipping to a later buffered group payload: encodeLegacyFrame(Math.round((0 / TIMESCALE) * 1_000_000) as Micro, new Uint8Array([0x01])), timestamp: at(0), }); - a.close(); + a.finish(); track.writeGroup(a); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); @@ -1668,10 +1697,10 @@ test("Consumer delivers a contiguous group after one that completed out of order const b = new Group.Producer(1_045_000); track.writeGroup(b); b.writeFrame(timedFrame(0x03, 6000, 2)); - b.close(); + b.finish(); await settle(); - a.close(); + a.finish(); expect(frameOf(await consumer.next()).payload).toEqual(new Uint8Array([0x02])); expect(frameOf(await consumer.next()).payload).toEqual(new Uint8Array([0x03])); @@ -1703,7 +1732,7 @@ test("Consumer plays the head once a waited-out gap exceeds the budget (Legacy)" track.writeGroup(a); a.writeFrame(timedFrame(0x01, 0, 0)); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); - a.close(); + a.finish(); // The reader is parked on the missing 1001, as a decoder is while it waits for the next group. const pending = consumer.next(); @@ -1735,7 +1764,7 @@ test("Consumer waits on a gap within the budget, then plays the head once it exc track.writeGroup(a); a.writeFrame(timedFrame(0x01, 0, 0)); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); - a.close(); + a.finish(); // B (seq 2000) starts at 6000 ticks (66_667µs): a gap, but only 33ms past A's end. const b = new Group.Producer(2000); @@ -1805,7 +1834,7 @@ test("Consumer empty groups mean nothing", async () => { writeGroupWithLegacyFrames(track, 0, [0 as Micro]); const empty = new Group.Producer(1); track.writeGroup(empty); - empty.close(); + empty.finish(); writeGroupWithLegacyFrames(track, 2, [1_000 as Micro]); await settle(); @@ -1873,7 +1902,7 @@ test("Consumer reads a shed marker group as a gap", async () => { // The zero budget sheds the marker unread, and what it declared is lost with it. expect(await consumer.next()).toEqual({ kind: "gap", timestamp: 0 as Micro, duration: 500 as Micro }); expect(frameOf(await consumer.next()).timestamp).toBe(500 as Micro); - group0.close(); + group0.finish(); consumer.close(); }); @@ -1889,7 +1918,7 @@ test("Consumer yields no gap across a PTS-contiguous group id jump (Legacy)", as const a = new Group.Producer(1_000_000); track.writeGroup(a); a.writeFrame(timedFrame(0x01, 0, 0)); - a.close(); + a.finish(); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); @@ -1926,11 +1955,13 @@ test("a live video frame is delivered before its duration marker arrives", async payload: encodeLegacyFrame(15_000 as Micro, new Uint8Array()), timestamp: Time.Timestamp.now(), }); - group.close(); + group.finish(); + track.finish(); track.close(); // The marker is the frame's duration, not an item. expect(await consumer.next()).toBeUndefined(); consumer.close(); + track.finish(); track.close(); }); @@ -1958,7 +1989,7 @@ for (const code of [StreamCode.Cancel, StreamCode.Internal, StreamCode.Old, Stre timestamp: Time.Timestamp.now(), }); await settle(); - group.close(new NetError.Stream(code)); + group.abort(new NetError.Stream(code)); await settle(); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([1])); const next = track.appendGroup(); @@ -1966,7 +1997,7 @@ for (const code of [StreamCode.Cancel, StreamCode.Internal, StreamCode.Old, Stre payload: encodeLegacyFrame(1 as Micro, new Uint8Array([2])), timestamp: Time.Timestamp.now(), }); - next.close(); + next.finish(); // The reset cut the group's tail. expect(await consumer.next()).toEqual({ kind: "gap", @@ -1979,6 +2010,7 @@ for (const code of [StreamCode.Cancel, StreamCode.Internal, StreamCode.Old, Stre expect(error).not.toHaveBeenCalled(); } finally { consumer.close(); + track.finish(); track.close(); error.mockRestore(); } @@ -1996,7 +2028,11 @@ for (const end of [ const consumer = new Consumer(replay(track), { format: new LegacyFormat("data") }); try { const pending = consumer.next(); - track.close(end ?? undefined); + if (end) track.abort(end); + else { + track.finish(); + track.close(); + } if (end) await expect(pending).rejects.toBe(end); else expect(await pending).toBeUndefined(); } finally { @@ -2011,16 +2047,18 @@ test("a group reset does not hide a later container decode failure", async () => try { const reset = track.appendGroup(); await settle(); - reset.close(new NetError.Stream(StreamCode.Cancel)); + reset.abort(new NetError.Stream(StreamCode.Cancel)); await settle(); const malformed = track.appendGroup(); // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: Time.Timestamp.now() }); - malformed.close(); + malformed.finish(); + track.finish(); track.close(); await expect(nextFrame(consumer)).rejects.toThrow("buffer too short"); } finally { consumer.close(); + track.finish(); track.close(); } }); @@ -2042,7 +2080,11 @@ for (const end of [ writeGroupWithLegacyFrames(track, 2, [1_000_000 as Micro]); await settle(); const pending = consumer.next(); - track.close(end ?? undefined); + if (end) track.abort(end); + else { + track.finish(); + track.close(); + } const resumed = await Promise.race([pending, settle(100).then(() => "stalled" as const)]); expect(resumed).not.toBe("stalled"); if (resumed === "stalled") return; @@ -2052,6 +2094,7 @@ for (const end of [ else expect(await consumer.next()).toBeUndefined(); } finally { consumer.close(); + track.finish(); track.close(); } }); diff --git a/js/hang/src/container/legacy.ts b/js/hang/src/container/legacy.ts index a4dc2d2326b..dd2bc91c7cf 100644 --- a/js/hang/src/container/legacy.ts +++ b/js/hang/src/container/legacy.ts @@ -146,7 +146,7 @@ export class Producer { payload: this.#format.kind === "data" ? new Uint8Array() : encodeFrame(new Uint8Array(), timestamp), timestamp: Time.Timestamp.fromMicros(timestamp), }); - group.close(); + group.finish(); this.#liveEdge = this.#liveEdge === undefined ? timestamp : (Math.max(this.#liveEdge, timestamp) as Micro); this.#marked = true; } @@ -169,7 +169,7 @@ export class Producer { : undefined; // A presentation endpoint cannot bound the decode-order tail after reordering. this.#format.finishGroup(this.#group, this.#reordered ? undefined : end); - this.#group.close(); + this.#group.finish(); this.#group = undefined; if (this.#end !== undefined) { this.#liveEdge = this.#liveEdge === undefined ? this.#end : (Math.max(this.#liveEdge, this.#end) as Micro); @@ -182,7 +182,12 @@ export class Producer { /** Close the track and current group, optionally with an error. */ close(err?: Error) { if (!err) this.#close(); - this.#group?.close(err); - this.#track.close(err); + if (err) this.#group?.abort(err); + else this.#group?.finish(); + if (err) this.#track.abort(err); + else { + this.#track.finish(); + this.#track.close(); + } } } diff --git a/js/json/src/snapshot/producer.ts b/js/json/src/snapshot/producer.ts index 30d7d1a0582..c0758ba9e99 100644 --- a/js/json/src/snapshot/producer.ts +++ b/js/json/src/snapshot/producer.ts @@ -80,7 +80,7 @@ export class Producer { const group = this.#group; this.#group = undefined; - group.close(); + group.finish(); } /** @@ -111,7 +111,7 @@ export class Producer { if (encoded.keyframe) { // The previous group is complete; no more frames will be appended to it. Drop the handle // before opening the next one, so a failure below doesn't leave a closed group behind. - this.#group?.close(); + this.#group?.finish(); this.#group = undefined; const group = this.#track.appendGroup(); @@ -122,7 +122,7 @@ export class Producer { // The group carries no frames, so close it rather than leaving it open on the track. A // rejected frame doesn't close the track, and a consumer that already advanced into this // group would otherwise block until some later update opened a newer one. - group.close(); + group.finish(); throw err; } @@ -131,7 +131,7 @@ export class Producer { this.#group = group; } else { // Deltas disabled: one frame per group, identical to a plain JSON track. - group.close(); + group.finish(); this.#group = undefined; } return; @@ -186,14 +186,19 @@ export class Producer { #close(abort?: Error): void { this.#unwatch?.(); this.#unwatch = undefined; - this.#group?.close(abort); + if (abort) this.#group?.abort(abort); + else this.#group?.finish(); this.#group = undefined; // The open group goes with the track, so the encoder must not keep emitting deltas into it. // Any further update fails on the closed track, but it has to fail as a track error rather // than as a delta with nowhere to put it. this.#encoder.reset(); - this.#track.close(abort); + if (abort) this.#track.abort(abort); + else { + this.#track.finish(); + this.#track.close(); + } } } diff --git a/js/json/src/snapshot/snapshot.test.ts b/js/json/src/snapshot/snapshot.test.ts index 4f9344a60c3..811957affee 100644 --- a/js/json/src/snapshot/snapshot.test.ts +++ b/js/json/src/snapshot/snapshot.test.ts @@ -637,7 +637,7 @@ test("snapshot consumer skips a group the publisher dropped mid-read", async () expect((await consumer.latest())?.value).toEqual({ v: 0 }); const next = consumer.latest(); - first.close(new NetError.Stream(StreamCode.Old)); + first.abort(new NetError.Stream(StreamCode.Old)); const second = track.appendGroup(); second.writeFrame({ payload: new TextEncoder().encode('{"v":1}'), timestamp: Time.Timestamp.now() }); expect((await next)?.value).toEqual({ v: 1 }); @@ -650,7 +650,7 @@ test("snapshot consumer propagates a non-gap frame failure", async () => { const group = track.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode('{"ok":true}'), timestamp: Time.Timestamp.now() }); expect((await consumer.next())?.value).toEqual({ ok: true }); - group.close(new NetError.Stream(StreamCode.Internal)); + group.abort(new NetError.Stream(StreamCode.Internal)); await expect(consumer.next()).rejects.toMatchObject({ code: StreamCode.Internal }); }); diff --git a/js/json/src/stream/codec.test.ts b/js/json/src/stream/codec.test.ts index 51af525139e..55a4f646426 100644 --- a/js/json/src/stream/codec.test.ts +++ b/js/json/src/stream/codec.test.ts @@ -158,6 +158,7 @@ test("a rejected record leaves the producer able to retry", () => { const producer = new Producer({ track, compression: "deflate" }); // Closing the track makes every write fail, standing in for any post-appendGroup rejection. + track.finish(); track.close(); expect(() => producer.append({ value: { n: 1 } })).toThrow(); diff --git a/js/json/src/stream/producer.ts b/js/json/src/stream/producer.ts index dc870763a2f..d802bc395ff 100644 --- a/js/json/src/stream/producer.ts +++ b/js/json/src/stream/producer.ts @@ -87,15 +87,16 @@ export class Producer { // Close the group with the same error first. A consumer that already pulled it holds its own // handle, which the track's close no longer reaches, so dropping ours would leave a reader // sitting in the group with a generic error instead of the failure that ended the log. - this.#group?.close(abort); + this.#group?.abort(abort); this.#group = undefined; - this.#track.close(abort); + this.#track.abort(abort); } /** Finish the track, closing the group. */ finish(): void { - this.#group?.close(); + this.#group?.finish(); this.#group = undefined; + this.#track.finish(); this.#track.close(); } } diff --git a/js/json/src/stream/stream.test.ts b/js/json/src/stream/stream.test.ts index 8e2d208e7a5..48a825e7ce4 100644 --- a/js/json/src/stream/stream.test.ts +++ b/js/json/src/stream/stream.test.ts @@ -124,7 +124,7 @@ test("a failed track preserves its error", async () => { const consumer = new Consumer({ track: track.subscribe() }); const next = consumer.next(); const failure = new Error("source failed"); - track.close(failure); + track.abort(failure); await expect(next).rejects.toBe(failure); }); diff --git a/js/json/src/window/producer.ts b/js/json/src/window/producer.ts index fdecbd3e6bd..929030c11e5 100644 --- a/js/json/src/window/producer.ts +++ b/js/json/src/window/producer.ts @@ -114,7 +114,7 @@ export class Producer { if (encoded.keyframe) { // The previous group is complete; no more frames will be appended to it. Drop the handle // before opening the next one, so a failure below doesn't leave a closed group behind. - this.#group?.close(); + this.#group?.finish(); this.#group = undefined; const group = this.#track.appendGroup(); @@ -124,7 +124,7 @@ export class Producer { } catch (err) { // The group carries no frames, so close it rather than leaving it open on the track. A // consumer that already advanced into it would otherwise block with nothing to read. - group.close(); + group.finish(); throw err; } @@ -153,7 +153,7 @@ export class Producer { const group = this.#group; this.#group = undefined; - group.close(); + group.finish(); } /** Finish the track, closing any open group. */ @@ -167,9 +167,14 @@ export class Producer { this.#unwatch?.(); this.#unwatch = undefined; - this.#group?.close(abort); + if (abort) this.#group?.abort(abort); + else this.#group?.finish(); this.#group = undefined; - this.#track.close(abort); + if (abort) this.#track.abort(abort); + else { + this.#track.finish(); + this.#track.close(); + } } } diff --git a/js/json/src/window/window.test.ts b/js/json/src/window/window.test.ts index b5a08fb9c39..82a7f8e528c 100644 --- a/js/json/src/window/window.test.ts +++ b/js/json/src/window/window.test.ts @@ -104,6 +104,7 @@ test("concurrent consumer reads are rejected", async () => { const first = consumer.next(); expect(() => consumer.next()).toThrow("multiple calls to next not supported"); + track.finish(); track.close(); expect(await first).toBeUndefined(); }); @@ -403,13 +404,14 @@ test("consumer resumes at a checkpoint after losing a group", async () => { group.writeFrame({ payload: frame.payload, timestamp: Time.Timestamp.now() }); frame.commit(); expect((await consumer.next())?.value).toEqual({ push: { index: 0, value: { n: 0 } } }); - group.close(error); + group.abort(error); frame = encoder.push({ n: 1 }); group = track.appendGroup(); group.writeFrame({ payload: frame.payload, timestamp: Time.Timestamp.now() }); frame.commit(); - group.close(); + group.finish(); + track.finish(); track.close(); expect((await consumer.next())?.value).toEqual({ push: { index: 1, value: { n: 1 } } }); diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index a1945209c07..d3ea176ca95 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,17 @@ How to update your code for each breaking change, newest first. ## Unreleased +### One lifecycle vocabulary ([#6750](https://github.com/moq-dev/moq/pull/6750)) + +`Group.Producer.close(abort?)` and `Track.Producer.close(abort?)` split by meaning: call `finish()` for a clean end and `abort(err)` for an error. On a group, `finish()` is terminal and an `abort` after it is a no-op. On a track, `finish()` only declares the end, so groups below it stay writable; then call `close()` to release the producer, which aborts with a `Cancel` stream error if no end was declared. Replace `track.close()` with `track.finish(); track.close();`. +`Track.Producer.closed` and `Track.Demand.closed` settle once the outcome is final: `null` when the declared end is reached with every group below it finished, even while the producer stays open, or the abort error. +`Group.Consumer.close()` takes no error and releases only that handle; other readers and the producer are unaffected. +`Announce.Consumer.close()` takes no error and releases only that handle. A hand-made `Announce.Producer` watches its new `wanted` getter, which turns false once the last consumer closes, instead of its `closed`. +The last `Broadcast.Consumer.close()` no longer closes a produced broadcast; only its producer's `close()` does. Close the producer when you are done with it. A broadcast created on an origin closes when a new `createBroadcast` supersedes it at its path, or when the origin or scope that created it closes, even while a consumer handle holds it. Tracks already subscribed keep serving until they end. +`Group.Demand.unused()` and `Broadcast.Demand.unused()` reject once the group or broadcast closes, instead of resolving. +The wire layer's `Lister.ended` is `closed`. +`Track.Subscriber.end()` and `Track.Ordered.end()` are gone; `await finished()` instead. + ### Publication handles own announcement control `Origin.Producer.createBroadcast(path)` and `Origin.Prepared.publish()` return an `Origin.Publication`. diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index 8450e592e75..ffd16bc741a 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -183,14 +183,14 @@ class ConnectTimeout extends Error { class Consumer { [Symbol.asyncIterator](): AsyncGenerator; - close(abort?: Error): void; + close(): void; get closed(): GetPromise; next(): Promise; get synced(): Getter; } class Consumer_2 { - close(abort?: Error): void; + close(): void; get closed(): GetPromise; get done(): boolean; get frameCount(): number; @@ -466,7 +466,6 @@ type Order = "ascending" | "descending"; class Ordered { close(abort?: Error): void; get closed(): GetPromise; - end(): Position | undefined; finished(): Promise; info(): Promise; largest(): Promise; @@ -556,12 +555,14 @@ interface Probe { class Producer { constructor(name: string); + abort(error: Error): void; accept(info?: Partial): this; appendDatagram(timestamp: Timestamp | undefined, payload: Uint8Array): number; appendGroup(): Producer_2; - close(abort?: Error): void; + close(): void; get closed(): GetPromise; demand(): Demand; + finish(): void; finishAt(end: Position): void; info(): Promise; insertDatagram(sequence: number, timestamp: Timestamp | undefined, payload: Uint8Array): void; @@ -576,9 +577,10 @@ class Producer { class Producer_2 { constructor(sequence: number); - close(abort?: Error): void; + abort(error: Error): void; get closed(): GetPromise; consume(): Consumer_2; + finish(): void; get isClosed(): boolean; readonly sequence: number; writeFrame(frame: Frame): void; @@ -798,7 +800,6 @@ function stripPrefix(prefix: Valid, path: Valid): Valid | null; class Subscriber { close(abort?: Error): void; get closed(): GetPromise; - end(): Position | undefined; finished(): Promise; info(): Promise; largest(): Promise; diff --git a/js/net/api/zod.api.md b/js/net/api/zod.api.md index 1229db6d4e6..2c10c295cf3 100644 --- a/js/net/api/zod.api.md +++ b/js/net/api/zod.api.md @@ -18,7 +18,7 @@ type Bound = { }; class Consumer { - close(abort?: Error): void; + close(): void; get closed(): GetPromise; get done(): boolean; get frameCount(): number; @@ -108,7 +108,6 @@ type Order = "ascending" | "descending"; class Ordered { close(abort?: Error): void; get closed(): GetPromise; - end(): Position | undefined; finished(): Promise; info(): Promise; largest(): Promise; @@ -135,12 +134,14 @@ interface Position { class Producer { constructor(name: string); + abort(error: Error): void; accept(info?: Partial): this; appendDatagram(timestamp: Timestamp | undefined, payload: Uint8Array): number; appendGroup(): Producer_2; - close(abort?: Error): void; + close(): void; get closed(): GetPromise; demand(): Demand; + finish(): void; finishAt(end: Position): void; info(): Promise; insertDatagram(sequence: number, timestamp: Timestamp | undefined, payload: Uint8Array): void; @@ -155,9 +156,10 @@ class Producer { class Producer_2 { constructor(sequence: number); - close(abort?: Error): void; + abort(error: Error): void; get closed(): GetPromise; consume(): Consumer; + finish(): void; get isClosed(): boolean; readonly sequence: number; writeFrame(frame: Frame): void; @@ -181,7 +183,6 @@ class Request_2 { class Subscriber { close(abort?: Error): void; get closed(): GetPromise; - end(): Position | undefined; finished(): Promise; info(): Promise; largest(): Promise; diff --git a/js/net/bench/broadcasts.ts b/js/net/bench/broadcasts.ts index 14e3a711e7e..b841afdeb93 100644 --- a/js/net/bench/broadcasts.ts +++ b/js/net/bench/broadcasts.ts @@ -101,6 +101,7 @@ for (const trackCount of [8, 32, 128]) { `${trackCount},${observerCount},${(((performance.now() - start) * 1000) / demandUpdates).toFixed(1)}`, ); for (const dispose of disposes) dispose(); + for (const track of tracks) track.finish(); for (const track of tracks) track.close(); broadcast.close(); } diff --git a/js/net/bench/compression.ts b/js/net/bench/compression.ts index 8c5cbe91ea4..bbd8ed0a1d3 100644 --- a/js/net/bench/compression.ts +++ b/js/net/bench/compression.ts @@ -22,7 +22,7 @@ for (const publishers of [1, 4, 16]) { let started = performance.now(); for (const group of groups) { for (let index = 0; index < frames; index++) group.writeFrame({ payload }); - group.close(); + group.finish(); } publish += performance.now() - started; stored = groups.reduce((bytes, group) => bytes + hooks.groupCacheBytes(group), 0); @@ -44,6 +44,7 @@ for (const publishers of [1, 4, 16]) { read += performance.now() - started; inflated = groups.reduce((bytes, group) => bytes + hooks.groupCacheBytes(group), 0) - stored; if (inflated !== publishers * frames * payload.length) throw new Error("inflated bytes scale with readers"); + for (const track of tracks) track.finish(); for (const track of tracks) track.close(); } console.log([publishers, readers, "publish", ((publish * 1000) / rounds).toFixed(1), stored].join(",")); @@ -74,6 +75,7 @@ async function datagrams(count: number, queued: number): Promise<[number, number const deliver = ((performance.now() - started) * 1000) / (count * queued); Bun.gc(true); const retained = process.memoryUsage().heapUsed - before; + for (const track of tracks) track.finish(); for (const track of tracks) track.close(); slow.length = 0; return [deliver, retained]; diff --git a/js/net/bench/drift.ts b/js/net/bench/drift.ts index a4b74268e2e..a37dfdcce29 100644 --- a/js/net/bench/drift.ts +++ b/js/net/bench/drift.ts @@ -20,7 +20,7 @@ export async function run(): Promise { for (let sequence = 0; sequence < retained; sequence++) { const group = track.appendGroup(); group.writeFrame({ payload }); - group.close(); + group.finish(); } const subscribers = Array.from({ length: readers }, () => track.subscribe({ maxDelay: Milli(60_000) }), @@ -54,6 +54,7 @@ export async function run(): Promise { `${retained},${publishers},${readers},${((elapsed * 1e6) / (iterations * publishers * readers)).toFixed(0)}`, ); } finally { + for (const track of tracks) track.finish(); for (const track of tracks) track.close(); } } diff --git a/js/net/bench/fills.ts b/js/net/bench/fills.ts index 168beca6552..60fd9654aee 100644 --- a/js/net/bench/fills.ts +++ b/js/net/bench/fills.ts @@ -25,7 +25,7 @@ for (const groups of [2, 16, 128]) { for (let i = 0; i < groups; i++) { const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array([i % 256]) }); - group.close(); + group.finish(); } const peers = await Promise.all( Array.from({ length: subscribers }, async () => { diff --git a/js/net/bench/frames.ts b/js/net/bench/frames.ts index e35f39d6e86..510b113206f 100644 --- a/js/net/bench/frames.ts +++ b/js/net/bench/frames.ts @@ -54,7 +54,7 @@ const lite: Protocol = { const producer = new Producer(0); const done = readFrames(new Reader(stream, undefined, LiteVersion.DRAFT_06), producer, { scale: 1_000_000, - }).then(() => producer.close()); + }).then(() => producer.finish()); return { group: Promise.resolve(producer.consume()), done }; }; return { open, close: () => {} }; diff --git a/js/net/bench/refresh.ts b/js/net/bench/refresh.ts index 98fd852491c..3dd5b5322e4 100644 --- a/js/net/bench/refresh.ts +++ b/js/net/bench/refresh.ts @@ -40,7 +40,7 @@ for (const retained of [1, 64, 1024]) for (let i = 0; i < tracks; i++) { const group = producers[i].appendGroup(); group.writeFrame({ payload: new Uint8Array(3) }); - group.close(); + group.finish(); const received = await baseline[i].recvGroup(); if (!received) throw new Error("baseline group missing"); await received.readFrame(); @@ -51,7 +51,7 @@ for (const retained of [1, 64, 1024]) for (let old = 1; old < retained; old++) { const group = producers[0].appendGroup(); group.writeFrame({ payload: new Uint8Array(3) }); - group.close(); + group.finish(); const received = await baseline[0].recvGroup(); if (!received) throw new Error("historical group missing"); await received.readFrame(); @@ -63,7 +63,7 @@ for (const retained of [1, 64, 1024]) const group = producers[0].appendGroup(); group.writeFrame({ payload: new Uint8Array(3) }); group.writeFrame({ payload: new Uint8Array(4) }); - group.close(); + group.finish(); const received = await baseline[0].recvGroup(); if (!received) throw new Error("measured group missing"); await received.readFrame(); diff --git a/js/net/bench/resume.ts b/js/net/bench/resume.ts index 59b93166500..8e68cd64e48 100644 --- a/js/net/bench/resume.ts +++ b/js/net/bench/resume.ts @@ -51,7 +51,7 @@ async function measure(readers: number, swaps: number | undefined, fork = false) const group = new GroupProducer(sequence); track.writeGroup(group); group.writeFrame({ payload }); - group.close(); + group.finish(); }; sources.push({ broadcast, write }); return broadcast; @@ -171,7 +171,7 @@ async function measureFlaps(readers: number, flaps: number): Promise { for (const group of held) group.close(); for (const subscriber of subscribers) subscriber.close(); front.consumer.close(); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); for (const handle of handles) handle.close(); for (const source of sources) source.close(); await flush(); @@ -215,7 +215,7 @@ async function measureReplay(readers: number, replayed: number): Promise }), }); } - producer.close(new Error("route failed")); + producer.abort(new Error("route failed")); const start = performance.now(); await Promise.all(consumers.map((consumer) => consumer.readable())); const elapsed = performance.now() - start; @@ -225,7 +225,7 @@ async function measureReplay(readers: number, replayed: number): Promise checksum += frame.payload.byteLength; consumer.close(); } - replacement.close(); + replacement.finish(); await flush(); return (elapsed * 1000) / readers; } diff --git a/js/net/bench/serve-browser-entry.ts b/js/net/bench/serve-browser-entry.ts index 64c0cfa9cd4..c5c6aec586b 100644 --- a/js/net/bench/serve-browser-entry.ts +++ b/js/net/bench/serve-browser-entry.ts @@ -99,7 +99,7 @@ async function measure(protocol: string, groups: number, viewers: number) { payload: new Uint8Array(32), timestamp: Timestamp.fromMicros(sequence * 2500 + Math.floor((frame * 2500) / 128)), }); - group.close(); + group.finish(); } let deadline: ReturnType | undefined; try { @@ -121,6 +121,7 @@ async function measure(protocol: string, groups: number, viewers: number) { clearInterval(timer); publisher.close(); for (const stream of streams) stream.close(); + track.finish(); track.close(); broadcast.close(); origin.close(); diff --git a/js/net/bench/subscribe-drop.ts b/js/net/bench/subscribe-drop.ts index 1eef8afded2..64d9a38c5f3 100644 --- a/js/net/bench/subscribe-drop.ts +++ b/js/net/bench/subscribe-drop.ts @@ -31,9 +31,10 @@ try { for (let sequence = 0; sequence < groups; sequence++) { const group = new GroupProducer(sequence); group.writeFrame(textFrame("frame")); - group.close(); + group.finish(); track.writeGroup(group); } + track.finish(); track.close(); return { origin, broadcast }; }); diff --git a/js/net/bench/subscribe-tracks.ts b/js/net/bench/subscribe-tracks.ts index e51fc49e578..cd7e735ebbb 100644 --- a/js/net/bench/subscribe-tracks.ts +++ b/js/net/bench/subscribe-tracks.ts @@ -56,7 +56,7 @@ for (const broadcasts of [1, 16, 128]) { }), ); if (sample) samples.push((performance.now() - start) * 1000); - track.close(new Error("benchmark finished")); + track.abort(new Error("benchmark finished")); } samples.sort((a, b) => a - b); console.log(`${broadcasts},${subscribers},${samples[10].toFixed(2)}`); diff --git a/js/net/bench/track.ts b/js/net/bench/track.ts index 2a2e00adbc2..63736cd8542 100644 --- a/js/net/bench/track.ts +++ b/js/net/bench/track.ts @@ -65,7 +65,7 @@ async function measure( const group = new GroupProducer(sequence); producer.writeGroup(group); group.writeFrame({ payload, timestamp: Timestamp.fromMillis(sequence++) }); - if (close) group.close(); + if (close) group.finish(); return group; }; @@ -101,7 +101,7 @@ async function measure( const elapsed = now() - start; for (const done of opened) done(); - for (const group of open) group.close(); + for (const group of open) group.finish(); await Promise.all(reads); // Completion wakes every subscriber independently of producer closure. const ending = subscribers.map((subscriber) => subscriber.recvGroup()); @@ -111,6 +111,7 @@ async function measure( if (group) throw new Error("track did not end at its declared boundary"); } const end = (now() - endStart) * 1000; + producer.finish(); producer.close(); // Ending the track releases the newest group too; idle cleanup reclaims the cache. clock += CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8 + 1; diff --git a/js/net/examples/publish.ts b/js/net/examples/publish.ts index 98217910d69..90d90d6be64 100644 --- a/js/net/examples/publish.ts +++ b/js/net/examples/publish.ts @@ -33,9 +33,10 @@ async function publishTrack(track: Moq.Track.Producer) { } // Mark the group as complete - group.close(); + group.finish(); // Mark the track as complete (optional) + track.finish(); track.close(); } diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index 63d619b840a..df01e31868e 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -66,6 +66,35 @@ test("closing resolves next with undefined", async () => { expect(await consumer.next()).toBeUndefined(); }); +test("closing a consumer releases only that handle", async () => { + const producer = new Announce.Producer(); + expect(producer.wanted.peek()).toBe(false); + const first = producer.consume(); + const second = producer.consume(); + expect(producer.wanted.peek()).toBe(true); + + // A reader parked on next() ends when its own handle closes, while another still wants it. + const early = first.next(); + first.close(); + first.close(); + expect(await early).toBeUndefined(); + expect(await first.next()).toBeUndefined(); + expect(producer.wanted.peek()).toBe(true); + expect(producer.closed.peek()).toBeUndefined(); + + // The other reader keeps the stream: an event still reaches it. + const route = Route.default; + producer.append({ prefix: p("a"), captures: undefined, kind: "start", route }); + expect((await second.next())?.prefix).toBe(p("a")); + + // The last one leaving is what the producer watches for. + const unwanted = producer.wanted.changed(); + second.close(); + expect(await unwanted).toBe(false); + expect(producer.closed.peek()).toBeUndefined(); + producer.close(); +}); + test("aborting rejects next", async () => { const producer = new Announce.Producer(); const consumer = producer.consume(); diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 35ba251bf62..4fd53dab3e7 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -68,6 +68,9 @@ class AnnounceState { // A reader holds the last event it took before syncing until it asks for the next // one, so whatever it does with that event lands before synced flips. reading = false; + // Open consumer handles; the stream is wanted while any is. + consumers = 0; + wanted = new Signal(false); } // Synced once nothing is owed and the reader has come back for more. @@ -109,6 +112,14 @@ export class Producer { return makeConsumer(this.#state); } + /** + * Whether any consumer still holds the stream: false before the first {@link consume} and + * once the last one {@link Consumer.close}s, which is how a reader cancels it. + */ + get wanted(): Getter { + return this.#state.wanted; + } + /** Writes an event to the queue. */ append(event: Event) { if (this.#state.closed.peek() !== undefined) throw new Error("announcements are closed"); @@ -162,9 +173,13 @@ let makeConsumer: (state: AnnounceState) => Consumer; */ export class Consumer { #state: AnnounceState; + // Set once this handle closes, ending its own reads and guarding the consumer count. + #released = new Signal(false); private constructor(state: AnnounceState) { this.#state = state; + state.consumers++; + state.wanted.set(true); } /** Settles once the stream closes; see {@link Producer.closed}. */ @@ -198,12 +213,13 @@ export class Consumer { } } - /** Returns the next event, or undefined once the stream closes. */ + /** Returns the next event, or undefined once the stream or this handle closes. */ async next(): Promise { const state = this.#state; state.reading = false; settle(state); for (;;) { + if (this.#released.peek()) return undefined; const announce = state.queue.peek().shift(); if (announce) { if (state.owed) state.owed -= 1; @@ -215,12 +231,18 @@ export class Consumer { if (closed instanceof Error) throw closed; if (closed !== undefined) return undefined; - await Signal.race(this.#state.queue, this.#state.closed); + await Signal.race(state.queue, state.closed, this.#released); } } - /** Closes the reader. Idempotent. */ - close(abort?: Error) { - closeState(this.#state, abort); + /** + * Release this handle, like dropping a Rust handle: other consumers keep reading, and the + * producer sees the stream no longer {@link Producer.wanted} once the last one closes. + * Idempotent. + */ + close() { + if (this.#released.peek()) return; + this.#released.set(true); + if (--this.#state.consumers === 0) this.#state.wanted.set(false); } } diff --git a/js/net/src/bandwidth.test.ts b/js/net/src/bandwidth.test.ts index a4923a6ad2d..f2dd349901f 100644 --- a/js/net/src/bandwidth.test.ts +++ b/js/net/src/bandwidth.test.ts @@ -194,6 +194,7 @@ test("a closed track is pruned", () => { const first = track(VIDEO); first.subscribe(); const firstShare = allocator.reserve(first.demand(), 4_000_000); + first.finish(); first.close(); const second = track(VIDEO); diff --git a/js/net/src/bandwidth.ts b/js/net/src/bandwidth.ts index 2d54ef7b9d1..990a47d4d95 100644 --- a/js/net/src/bandwidth.ts +++ b/js/net/src/bandwidth.ts @@ -93,7 +93,7 @@ interface Registry { function wantsOf(entries: readonly Entry[]): Want[] { const wants: Want[] = []; for (const entry of entries) { - if (entry.demand.closed.peek() !== undefined) continue; + if (hooks.demandReleased(entry.demand).peek() !== undefined) continue; if (entry.demand.wanted.peek() === undefined) continue; wants.push({ id: entry.id, priority: entry.priority, max: entry.max }); } @@ -179,7 +179,7 @@ export class Allocator implements Handle { const entries = effect.get(this.#entries); for (const entry of entries) { effect.get(entry.demand.wanted); - effect.get(entry.demand.closed); + effect.get(hooks.demandReleased(entry.demand)); } this.#publish(); }); @@ -255,7 +255,7 @@ export class Allocator implements Handle { #prune(): void { const entries = this.#entries.peek(); - const live = entries.filter((entry) => entry.demand.closed.peek() === undefined); + const live = entries.filter((entry) => hooks.demandReleased(entry.demand).peek() === undefined); if (live.length !== entries.length) this.#entries.set(live); } diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index 98a6e8e886a..ad250583bc9 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -51,6 +51,7 @@ test("consumer dedupes repeat subscriptions onto one upstream request", async () expect((await pendingRequest(consumer))?.name).toBe("audio"); // Once the shared track closes, a later subscribe re-opens it. + producer.finish(); producer.close(); consumer.track("video").subscribe(); expect((await pendingRequest(consumer))?.name).toBe("video"); @@ -87,8 +88,9 @@ test("a replacement producer starts over", async () => { const group = new GroupProducer(8); first.writeGroup(group); group.writeFrame(textFrame("a", Timestamp.fromMillis(1000))); - group.close(); + group.finish(); firstSubscriber.close(); + first.finish(); first.close(); const secondSubscriber = broadcast.track("media").subscribe(); @@ -99,6 +101,7 @@ test("a replacement producer starts over", async () => { expect(appended.sequence).toBe(0); appended.writeFrame(textFrame("b", Timestamp.fromMillis(500))); secondSubscriber.close(); + second.finish(); second.close(); broadcast.removeTrack("media"); @@ -188,6 +191,7 @@ test("publishing-side subscriptions coalesce onto one request", async () => { await broadcast.demand().unused(); expect(producer.closed.peek()).toBeUndefined(); + producer.finish(); producer.close(); broadcast.close(); }); @@ -306,6 +310,7 @@ test("a held info lookup ending after removeTrack leaves demand alone", async () await Promise.resolve(); expect(demand.used.peek()).toBe(false); + track.finish(); track.close(); broadcast.close(); }); @@ -324,6 +329,7 @@ test("removeTrack leaves a track that only a request serves", async () => { expect(producer.closed.peek()).toBeUndefined(); subscriber.close(); + producer.finish(); producer.close(); broadcast.close(); }); @@ -341,6 +347,7 @@ test("closing a broadcast rejects a dequeued request", async () => { expect(() => producer.appendGroup()).toThrow("track is closed"); await expect(subscriber.info()).rejects.toThrow("track closed before info was known"); subscriber.close(); + producer.finish(); producer.close(); }); @@ -396,6 +403,20 @@ test("a consumer clone shares the broadcast until every handle closes", () => { expect(clone.closed.peek()).toBeDefined(); }); +test("closing every consumer leaves a produced broadcast to its producer", async () => { + const producer = new BroadcastProducer(); + const track = producer.createTrack("video"); + const consumer = producer.consume(); + const subscriber = consumer.track("video").subscribe(); + consumer.close(); + subscriber.close(); + expect(producer.closed.peek()).toBeUndefined(); + + track.close(); + producer.close(); + expect(producer.closed.peek()).toBeNull(); +}); + test("double-closing a consumer handle does not prematurely close the broadcast", () => { const consumer = new TestConsumer(); const clone = consumer.clone(); @@ -436,7 +457,7 @@ test("subscribe serves a statically inserted track without a request", async () const broadcast = new BroadcastProducer(); const track1 = broadcast.createTrack("track1", { timescale: Timescale.MILLI }); - track1.appendGroup().close(); + track1.appendGroup().finish(); // The track already exists, so subscribe resolves immediately (no requested()). const sub1 = broadcast.track("track1").subscribe().ordered(); @@ -449,7 +470,7 @@ test("subscribe serves a statically inserted track without a request", async () const track2 = broadcast.createTrack("track2", { timescale: Timescale.MILLI }); const sub2 = broadcast.track("track2").subscribe().ordered(); - track2.appendGroup().close(); + track2.appendGroup().finish(); expect((await sub2.nextGroup())?.sequence).toBe(0); }); @@ -507,7 +528,7 @@ test("a read throws GroupTooLarge on an overflow, then resyncs to the next group // Group 1 is clean. const g1 = producer.appendGroup(); g1.writeFrame({ payload: new TextEncoder().encode("ok"), timestamp: Timestamp.now() }); - g1.close(); + g1.finish(); // The reader hits the abort in group 0 (error, not a silent skip), then the next // read resyncs from group 1. @@ -567,6 +588,7 @@ test("a finished track is still served from its cache", async () => { const broadcast = new BroadcastProducer(); const track = broadcast.createTrack("track1", { timescale: Timescale.MILLI }); track.writeFrame(textFrame("last")); + track.finish(); track.close(); // Like Rust, finishing keeps the track: a late subscriber reads the cache, then the clean end. @@ -583,7 +605,7 @@ test("a finished track is still served from its cache", async () => { test("an aborted track is gone, so a subscribe with no handler answers NotFound", async () => { const broadcast = new BroadcastProducer(); - broadcast.createTrack("track1").close(new Error("boom")); + broadcast.createTrack("track1").abort(new Error("boom")); // Nothing serves requests on demand, so this must fail rather than wait forever. const subscriber = broadcast.consume().track("track1").subscribe(); @@ -595,7 +617,7 @@ test("an aborted track is gone, so a subscribe with no handler answers NotFound" test("an aborted track falls through to a request when a handler is serving", async () => { const broadcast = new BroadcastProducer(); const pulled = wireOf(broadcast).requested(); - broadcast.createTrack("track1").close(new Error("boom")); + broadcast.createTrack("track1").abort(new Error("boom")); broadcast.track("track1").subscribe(); expect((await pulled)?.name).toBe("track1"); @@ -642,11 +664,11 @@ test("a fetch waits for a group still to come", async () => { const first = track.appendGroup(); first.writeFrame({ payload: new TextEncoder().encode("0"), timestamp: Timestamp.now() }); - first.close(); + first.finish(); const second = track.appendGroup(); second.writeFrame({ payload: new TextEncoder().encode("1"), timestamp: Timestamp.now() }); - second.close(); + second.finish(); const group = await pending; expect(group.sequence).toBe(1); @@ -665,7 +687,7 @@ test("fetch maxDelay tightens publisher retention without changing omission", as payload: new TextEncoder().encode(String(millis)), timestamp: Timestamp.fromMillis(millis), }); - group.close(); + group.finish(); } const omitted = await wireOf(broadcast).fetchGroup("video", 0); @@ -715,6 +737,7 @@ test("broadcast demand watches static and pending tracks rather than broadcast h await demand.unused(); expect(demand.used.peek()).toBe(false); request.reject(); + video.finish(); video.close(); broadcast.close(); expect(await demand.closed).toBeNull(); @@ -730,8 +753,10 @@ test("broadcast closure clears active demand and subscriptions", async () => { expect(demand.used.peek()).toBe(true); broadcast.close(); expect(demand.used.peek()).toBe(false); - await demand.unused(); + // Like Rust, a closed broadcast rejects rather than reporting it unused. + await expect(demand.unused()).rejects.toThrow("broadcast is closed"); subscriber.close(); + track.finish(); track.close(); }); @@ -772,6 +797,7 @@ test("removeTrack stops counting the removed track's demand at once", async () = expect(demand.used.peek()).toBe(false); subscriber.close(); + video.finish(); video.close(); broadcast.close(); }); diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index a629250770f..453c665076d 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -62,9 +62,11 @@ class BroadcastState { // publisher that has started pulling them. The counterpart of a live Rust `broadcast::Dynamic`; // without one, a track nobody publishes is `NotFound` rather than a request nobody will answer. served = false; - // Live consumer handles sharing this state (see {@link Consumer.clone}). The broadcast - // closes once the last one closes, so a shared consumer can be handed to several callers. + // Live consumer handles sharing this state (see {@link Consumer.clone}). consumers = 0; + // A wire layer's broadcast, which has no producer: it ends once no consumer holds it, as a + // Rust session retires a source nothing holds. A produced broadcast ends only on its close. + unheld = false; used = new Signal(false); active = 0; // The demand watcher of each open track, disposed when it closes or leaves the broadcast. @@ -79,12 +81,12 @@ function watchDemand(state: BroadcastState, producer: track.Producer, lookups?: if (state.demands.has(producer)) return; const demand = producer.demand(); // A closed track is never demand, and its close already fired, so nothing would dispose a watcher. - if (demand.closed.peek() !== undefined) return; + if (hooks.demandReleased(demand).peek() !== undefined) return; let active = false; const update = () => { const used = state.closed.peek() === undefined && - demand.closed.peek() === undefined && + hooks.demandReleased(demand).peek() === undefined && ((lookups?.pending ?? 0) > 0 || demand.wanted.peek() !== undefined); if (active === used) return; state.active += used ? 1 : -1; @@ -93,8 +95,8 @@ function watchDemand(state: BroadcastState, producer: track.Producer, lookups?: }; const disposeUsed = demand.wanted.subscribe(update); if (lookups) lookups.changed = update; - const disposeClosed = demand.closed.subscribe(() => { - if (demand.closed.peek() === undefined) return; + const disposeClosed = hooks.demandReleased(demand).subscribe(() => { + if (hooks.demandReleased(demand).peek() === undefined) return; cleanup(); }); const cleanup = () => { @@ -159,13 +161,13 @@ function closeState(state: BroadcastState) { function lookup(state: BroadcastState, name: string): track.Producer | undefined { const inserted = state.tracks.get(name); if (inserted) { - if (!(inserted.closed.peek() instanceof Error)) return inserted; + if (!(hooks.trackReleased(inserted).peek() instanceof Error)) return inserted; state.tracks.delete(name); } const requested = state.requests.get(name); if (requested) { - if (requested.closed.peek() === undefined) return requested; + if (hooks.trackReleased(requested).peek() === undefined) return requested; state.requests.delete(name); } @@ -191,7 +193,7 @@ function logical(state: BroadcastState, name: string): { producer: track.Produce if (!state.served) { // Answer through the track rather than throwing, so a peer's subscribe is refused with // the code instead of an unhandled error. - producer.close(new NotFound(`track ${name}`)); + producer.abort(new NotFound(`track ${name}`)); return { producer, requested: false }; } @@ -199,7 +201,7 @@ function logical(state: BroadcastState, name: string): { producer: track.Produce state.lookups.set(producer, lookups); watchDemand(state, producer, lookups); state.requests.set(name, producer); - void producer.closed.then(() => { + void hooks.trackReleased(producer).then(() => { if (state.requests.get(name) === producer) state.requests.delete(name); }); @@ -229,7 +231,8 @@ async function resolveTrackInfo(state: BroadcastState, name: string, hold?: Abor let answered = false; let ended = false; const release = () => { - if (lookups.opened && lookups.pending === 0 && producer.demand().wanted.peek() === undefined) producer.close(); + if (lookups.opened && lookups.pending === 0 && producer.demand().wanted.peek() === undefined) + hooks.releaseTrack(producer); }; info.then( () => { @@ -313,7 +316,7 @@ function adopt(state: BroadcastState, producer: track.Producer, info: track.Info state.listing.listOwn(producer.name, producer, info); // A finished track keeps serving its cache, so only an abort evicts it. - void producer.closed.then((closed) => { + void hooks.trackReleased(producer).then((closed) => { if (!(closed instanceof Error)) return; if (state.tracks.get(producer.name) === producer) state.tracks.delete(producer.name); state.listing.unlistOwn(producer.name, producer); @@ -341,9 +344,11 @@ export class Demand { return this.#state.used; } - /** Wait until every track becomes unused or the broadcast closes. */ + /** Wait until every track becomes unused. Throws once the broadcast closes. */ async unused(): Promise { - while (this.#state.used.peek() && this.#state.closed.peek() === undefined) { + for (;;) { + if (this.#state.closed.peek() !== undefined) throw new Error("broadcast is closed"); + if (!this.#state.used.peek()) return; await Signal.race(this.#state.used, this.#state.closed); } } @@ -443,7 +448,7 @@ export class Producer { // One logical track per name: an open track or a live request already serves it. // A request a handler already pulled stays duplicate, as in Rust. const live = [this.#state.tracks.get(name), this.#state.requests.get(name)]; - if (live.some((existing) => existing && existing.closed.peek() === undefined)) { + if (live.some((existing) => existing && hooks.trackReleased(existing).peek() === undefined)) { throw new Error(`duplicate track: ${name}`); } @@ -518,6 +523,7 @@ export class Consumer { // A standalone consumer is a wire layer's, which serves every track on demand. this.#state = new BroadcastState(); this.#state.served = true; + this.#state.unheld = true; } this.#state.consumers++; registerWire(this, { @@ -572,8 +578,9 @@ export class Consumer { * Settles with `null` once the broadcast closes; a broadcast end carries no cause. * Peek it synchronously (`undefined` while open), observe it reactively, or `await` it. * - * Shared by every {@link clone}: it settles once the last handle closes. The subscribing - * wire layer peeks it to evict a closed entry from its per-path consume cache. + * Shared by every {@link clone}. A produced broadcast settles once its producer closes; a + * broadcast consumed from a session, once the session ends it or the last handle closes. + * The subscribing wire layer peeks it to evict a closed entry from its per-path consume cache. */ get closed(): GetPromise { return this.#state.closed; @@ -583,7 +590,7 @@ export class Consumer { * Return another handle to the same broadcast, reference-counted with this one. * * Both handles read the same tracks, carry the same {@link path}, and share one {@link closed} - * state; the broadcast closes only once *every* handle has {@link close}d. Used by the connection's per-path + * state. Used by the connection's per-path * consume cache to share one subscription across callers. Subclasses that resolve info over * the wire override this to preserve their type (see the wire layer's consumed broadcast). */ @@ -630,13 +637,13 @@ export class Consumer { } /** - * Release this handle. The broadcast is closed once this was the last live handle; - * while other {@link clone}s remain open it stays live. + * Release this handle, like dropping a Rust handle. A produced broadcast stays open until + * its producer closes; one consumed from a session ends once no {@link clone} holds it. */ close(): void { if (this.#closed) return; this.#closed = true; - if (--this.#state.consumers > 0) return; + if (--this.#state.consumers > 0 || !this.#state.unheld) return; closeState(this.#state); } } diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index 9fd8a540f2a..7bec151809d 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -7,6 +7,7 @@ import * as Epoch from "../epoch.ts"; import { Route } from "../hop.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { registerWire, wireOf } from "../wire.ts"; import type { Established } from "./established.ts"; import { forwardAnnounced } from "./forward.ts"; @@ -66,7 +67,10 @@ class FakeSession { const stream = new Announce.Producer(); this.requests.push({ scope: scope.text, hidden: options?.hidden, stream }); if (this.syncedAtOnce) stream.synced(); - return stream.consume(); + const consumer = stream.consume(); + // The last reader leaving cancels the request, as a session's does. + closeUnwanted(stream); + return consumer; } /** The scopes of the announce requests still open. */ diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 1b4a626edaa..2c28614658b 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -12,6 +12,7 @@ import type * as Auth from "../auth.ts"; import type { Handle } from "../bandwidth.ts"; import * as Origin from "../origin.ts"; import * as Path from "../path.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { Follow } from "../util/observed.ts"; import { after } from "../util/timer.ts"; import { type AcceptProps as AcceptPropsType, accept } from "./accept.ts"; @@ -362,7 +363,8 @@ export class Connection { pumpAnnounced(effect, origin.announced(scope, options), producer); }); - void consumer.closed.then(stop); + closeUnwanted(producer); + void producer.closed.then(stop); return consumer; } diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 247def4228f..d5b6a6c086e 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -762,7 +762,8 @@ for (const [name, teardown] of Object.entries(teardowns)) { fin.hold?.enable(); const last = producer.appendGroup(); last.writeFrame(textFrame("last")); - last.close(); + last.finish(); + producer.finish(); producer.close(); teardown.end(handle, win); await clock.advance(Nano.fromMilli(teardown.linger)); @@ -856,6 +857,7 @@ for (const [name, drainEnd] of Object.entries(drainEnds)) { remote.close(); reload.close(); for (const dial of dials) (await dial.server).abort(); + producer.finish(); producer.close(); broadcast.close(); origin.close(); @@ -922,6 +924,7 @@ test("a retired GOAWAY predecessor still closes by its handover cap", async () = remote.close(); reload.close(); for (const dial of dials) (await dial.server).abort(); + producer.finish(); producer.close(); broadcast.close(); origin.close(); @@ -982,7 +985,8 @@ test("closing the loop keeps the tokens until the session drains", async () => { fin.hold?.enable(); const last = producer.appendGroup(); last.writeFrame(textFrame("last")); - last.close(); + last.finish(); + producer.finish(); producer.close(); reload.close(); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index ab40e3c824c..cbd713f336c 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -7,6 +7,7 @@ import { Allocator } from "../bandwidth.ts"; import { error, reason, SessionCode, SessionError } from "../error.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { Follow } from "../util/observed.ts"; import { wireOf } from "../wire.ts"; import { @@ -854,7 +855,8 @@ export class Reload { }); this.#signals.cleanup(() => pump.close()); - void consumer.closed.then(() => pump.close()); + closeUnwanted(producer); + void producer.closed.then(() => pump.close()); return consumer; } diff --git a/js/net/src/consume.test.ts b/js/net/src/consume.test.ts index a3efb9b3503..ecea5231327 100644 --- a/js/net/src/consume.test.ts +++ b/js/net/src/consume.test.ts @@ -31,7 +31,7 @@ test("cache identity follows the serving announcement, preserving held tracks", const shared = cache.get(job, cache.instance(job)); expect(shared?.closed).toBe(fresh.closed); // Cache replacement never transfers or closes the track an old handle is reading. - track.appendGroup().close(); + track.appendGroup().finish(); expect((await ordered.nextGroup())?.sequence).toBe(0); cache.withdraw(job, specific); expect(cache.get(job, cache.instance(job))).toBeUndefined(); diff --git a/js/net/src/flate/group.test.ts b/js/net/src/flate/group.test.ts index 59dd9d40cb0..517e47c5cca 100644 --- a/js/net/src/flate/group.test.ts +++ b/js/net/src/flate/group.test.ts @@ -16,7 +16,7 @@ test("synchronous writers store slices; plain readers share one charged inflated const raw = group.mirror(); group.writeFrame({ payload }); group.writeFrame({ payload }); - group.close(); + group.finish(); const stored = hooks.groupCacheBytes(group); expect(stored).toBeLessThan(payload.length); const inflating = spyOn(Decoder.prototype, "frame"); @@ -35,6 +35,7 @@ test("synchronous writers store slices; plain readers share one charged inflated expect(inflating).toHaveBeenCalledTimes(2); } finally { inflating.mockRestore(); + track.finish(); track.close(); } }); @@ -44,7 +45,7 @@ test("received slices are forwarded byte-for-byte and exact declarations fail al hooks.bindGroupCompression(group, true); const slice = new Encoder().frame(payload); hooks.writeGroupFrame(group, { payload: slice }, payload.length + 1); - group.close(); + group.finish(); const raw = await hooks.readGroupFrame(group.mirror(), true); expect(raw?.frame.payload).toBe(slice); const bad = group.mirror(); @@ -65,7 +66,7 @@ test("declared plain bytes consume the group budget before any inflation", () => expect(hooks.groupCacheBytes(group)).toBe(0); } finally { inflating.mockRestore(); - group.close(); + group.finish(); } }); @@ -77,9 +78,10 @@ test("empty frames preserve the window and prewritten groups bind without duplic const track = new Track("compressed").accept({ compress: true }); track.writeGroup(group); group.writeFrame({ payload }); - group.close(); + group.finish(); for (const expected of [payload, new Uint8Array(), payload]) expect((await reader.readFrame())?.payload).toEqual(expected); + track.finish(); track.close(); }); @@ -89,9 +91,10 @@ test("a cut compressed group preserves its committed frames before returning its const reader = group.mirror(); group.writeFrame({ payload }); const cut = new Error("cut"); - group.close(cut); + group.abort(cut); expect((await reader.readFrame())?.payload).toEqual(payload); await expect(reader.readFrame()).rejects.toThrow(cut); + track.finish(); track.close(); }); @@ -103,8 +106,9 @@ test("a shared plain read cannot change a later synchronous write's admission", expect((await reader.readFrame())?.payload.length).toBe(MAX_GROUP_CACHE_BYTES); // The plain-byte budget is full, but another empty frame still fits, regardless of readers. expect(() => group.writeFrame({ payload: new Uint8Array() })).not.toThrow(); - group.close(); + group.finish(); expect((await reader.readFrame())?.payload.length).toBe(0); + track.finish(); track.close(); }); @@ -122,6 +126,7 @@ test("datagrams buffered before accept share one stored form, each reader its ow expect(stored(first)?.inflated).toBe(payload.length); expect(first.payload).toEqual(payload); expect(second.payload).toEqual(payload); + track.finish(); track.close(); }); @@ -134,6 +139,7 @@ test("a malformed compressed datagram is refused before reaching readers", () => /declared size/, ); expect(hooks.tryRecvDatagram(reader).kind).toBe("idle"); + track.finish(); track.close(); }); @@ -148,7 +154,8 @@ test("refresh measures stored slices without inflating a compressed group", () = expect(inflating).toHaveBeenCalledTimes(0); } finally { inflating.mockRestore(); - group.close(); + group.finish(); + track.finish(); track.close(); } }); diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index fcfa1c5b3cd..f67d874767a 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -29,11 +29,78 @@ test("used reflects mirror demand and unused resolves when the last reader leave expect(producer.demand().used.peek()).toBe(false); }); +test("unused rejects once the group closes, even while read", async () => { + const producer = new Producer(0); + const reader = producer.mirror(); + const pending = producer.demand().unused(); + const abort = new Error("boom"); + producer.abort(abort); + await expect(pending).rejects.toBe(abort); + + const finished = new Producer(1); + finished.finish(); + await expect(finished.demand().unused()).rejects.toThrow("group is closed"); + reader.close(); +}); + function pair(sequence: number) { const producer = new Producer(sequence); return { producer, consumer: producer.consume() }; } +test("closing a consumer releases only that handle", async () => { + const { producer, consumer } = pair(0); + const other = producer.consume(); + producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); + + consumer.close(); + expect(await consumer.closed).toBeNull(); + expect(producer.closed.peek()).toBeUndefined(); + + // The group stays writable, and another reader sees every frame and the end. + producer.writeFrame({ payload: new Uint8Array([2]), timestamp: Timestamp.now() }); + producer.finish(); + expect((await other.readFrame())?.payload[0]).toBe(1); + expect((await other.readFrame())?.payload[0]).toBe(2); + expect(await other.readFrame()).toBeUndefined(); +}); + +test("closing a consumer ends its own parked reads", async () => { + const { producer, consumer } = pair(0); + const other = producer.consume(); + const reading = consumer.readFrame(); + const waiting = consumer.readable(); + consumer.close(); + expect(await reading).toBeUndefined(); + await waiting; + expect(consumer.isClosed).toBe(true); + expect(consumer.done).toBe(true); + + // The other reader is still parked on the open group, and reads the next frame. + const next = other.readFrame(); + producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); + expect((await next)?.payload[0]).toBe(1); + expect(other.isClosed).toBe(false); + + // A later write never reaches the released handle, so it takes nothing from the other reader. + producer.writeFrame({ payload: new Uint8Array([2]), timestamp: Timestamp.now() }); + expect(consumer.done).toBe(true); + expect(consumer.tryReadFrame()).toBeUndefined(); + expect(await consumer.readFrame()).toBeUndefined(); + expect((await other.readFrame())?.payload[0]).toBe(2); + producer.finish(); +}); + +test("a released consumer ignores a later abort", async () => { + const { producer, consumer } = pair(0); + const reading = consumer.readFrame(); + consumer.close(); + producer.abort(new Error("upstream")); + expect(await reading).toBeUndefined(); + expect(await consumer.readFrame()).toBeUndefined(); + expect(await consumer.closed).toBeNull(); +}); + test("a group caps its frame count and aborts on the next write", async () => { const { producer, consumer } = pair(0); @@ -91,7 +158,7 @@ test("a closed group rejects a further write without discarding committed frames for (let i = 0; i < MAX_GROUP_FRAMES; i++) { producer.writeFrame({ payload: new Uint8Array([i & 0xff]), timestamp: Timestamp.now() }); } - producer.close(); + producer.finish(); expect(() => producer.writeFrame({ payload: new Uint8Array([0]), timestamp: Timestamp.now() })).toThrow( "group is closed", @@ -100,6 +167,17 @@ test("a closed group rejects a further write without discarding committed frames expect((await consumer.readFrame())?.payload[0]).toBe(0); }); +test("an abort after finish keeps the clean outcome", async () => { + const { producer, consumer } = pair(0); + producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); + producer.finish(); + producer.abort(new Error("boom")); + + expect(producer.closed.peek()).toBeNull(); + expect((await consumer.readFrame())?.payload[0]).toBe(1); + expect(await consumer.readFrame()).toBeUndefined(); +}); + test("a read that starts later skips frames below it instead of returning them", async () => { const { producer, consumer } = pair(0); @@ -118,7 +196,7 @@ test("a group with no eviction reads every frame without error", async () => { const { producer, consumer } = pair(0); producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); producer.writeFrame({ payload: new Uint8Array([2]), timestamp: Timestamp.now() }); - producer.close(); + producer.finish(); expect((await consumer.readFrame())?.payload[0]).toBe(1); expect((await consumer.readFrame())?.payload[0]).toBe(2); @@ -163,7 +241,7 @@ test("done distinguishes a finished group from one that is merely empty", () => producer.writeFrame(textFrame("a")); // Buffered but closed: still not done until the frame is drained. - producer.close(); + producer.finish(); expect(consumer.done).toBe(false); consumer.tryReadFrame(); @@ -176,7 +254,7 @@ test("a previously obtained closed handle peeks source closure synchronously", ( const { producer, consumer } = pair(0); const closed = consumer.closed; - producer.close(); + producer.finish(); expect(closed.peek()).toBeNull(); }); @@ -201,7 +279,7 @@ test("readable resolves once a frame is buffered", async () => { test("readable resolves once the group closes, even with nothing buffered", async () => { const { producer, consumer } = pair(0); const readable = consumer.readable(); - producer.close(); + producer.finish(); await readable; // resolves on close so callers don't wait forever expect(consumer.tryReadFrame()).toBeUndefined(); }); @@ -246,8 +324,8 @@ test("readable waits past replayed frames after adopting an open replacement", a await readable; expect(consumer.tryReadFrameSequence()).toMatchObject({ sequence: 1, payload: textFrame("one").payload }); consumer.close(); - producer.close(); - replacement.close(); + producer.finish(); + replacement.finish(); }); test("done ignores replayed frames an adopted copy buffers", async () => { @@ -279,17 +357,17 @@ test("done ignores replayed frames an adopted copy buffers", async () => { await new Promise((resolve) => setImmediate(resolve)); // The adopted copy ends having repeated only the frame already read. replacement.writeFrame(textFrame("zero")); - replacement.close(); + replacement.finish(); expect(consumer.done).toBe(true); await readable; expect(consumer.tryReadFrame()).toBeUndefined(); - producer.close(); + producer.finish(); }); test("buffered frames are still readable after the group closes", async () => { const { producer, consumer } = pair(0); producer.writeFrame(textFrame("a")); - producer.close(); + producer.finish(); // Closing doesn't discard buffered frames; the blocking reader drains them before ending. expect(await consumer.readString()).toBe("a"); @@ -306,7 +384,7 @@ test("a frame larger than the cache is rejected rather than silently dropped", ( // Nothing was appended, so the group is still empty rather than holding a phantom frame. const consumer = producer.consume(); - producer.close(); + producer.finish(); expect(consumer.tryReadFrame()).toBeUndefined(); }); @@ -332,7 +410,7 @@ test("a group that ends on its own abandons the replacement it asked for", async await new Promise((resolve) => setTimeout(resolve, 0)); expect(signal?.aborted).toBe(false); - producer.close(); + producer.finish(); expect(await read).toBeUndefined(); expect(signal?.aborted).toBe(true); }); @@ -381,7 +459,7 @@ test.each([false, true])("a group ending beside its replacement discards it (con consumer.close(); expect(released).toBe(1); } else { - producer.close(); + producer.finish(); } expect(await read).toBeUndefined(); expect(adopted).toBe(false); @@ -409,13 +487,13 @@ test("a group recovered twice reads on and settles", async () => { await consumer.readFrame(); // Two failures in a row: each moves the group to the next copy. - producer.close(new Error("route failed")); + producer.abort(new Error("route failed")); replacements[0].writeFrame({ payload: new Uint8Array([0]) }); replacements[0].writeFrame({ payload: new Uint8Array([1]) }); expect((await consumer.readFrame())?.payload[0]).toBe(1); - replacements[0].close(new Error("route failed")); + replacements[0].abort(new Error("route failed")); for (const payload of [0, 1, 2]) replacements[1].writeFrame({ payload: new Uint8Array([payload]) }); - replacements[1].close(); + replacements[1].finish(); expect((await consumer.readFrame())?.payload[0]).toBe(2); expect(await consumer.readFrame()).toBeUndefined(); expect(await consumer.closed).toBeNull(); @@ -438,7 +516,7 @@ test("a stalled group whose copy then fails asks again with the failure", async route.set(1); const read = consumer.readFrame(); await new Promise((resolve) => setTimeout(resolve, 0)); - producer.close(new Error("route failed")); + producer.abort(new Error("route failed")); await expect(read).rejects.toBe(verdict); }); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 08b525a37c8..4e04be14278 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -142,9 +142,12 @@ export class Demand { return this.#used; } - /** Wait until all mirror readers leave or the group closes. */ + /** Wait until all mirror readers leave. Throws once the group closes: its abort error, or a clean-close error. */ async unused(): Promise { - while (this.#used.peek() && this.#state.closed.peek() === undefined) { + for (;;) { + const closed = this.#state.closed.peek(); + if (closed !== undefined) throw closed ?? new Error("group is closed"); + if (!this.#used.peek()) return; await Signal.race(this.#used, this.#state.closed); } } @@ -271,7 +274,7 @@ export class Producer { /** A read handle for this group. */ consume(): Consumer { - return makeConsumer(this.#state); + return makeConsumer(this.#state, false); } /** @@ -298,7 +301,7 @@ export class Producer { const closed = this.#state.closed.peek(); if (closed !== undefined) { dst.closed.set(closed); - return makeConsumer(dst); + return makeConsumer(dst, true); } this.#mirrors ??= new Set(); @@ -314,7 +317,7 @@ export class Producer { dispose(); }); - return makeConsumer(dst); + return makeConsumer(dst, true); } /** @@ -393,7 +396,7 @@ export class Producer { mirror.frames.set([]); mirror.cacheBytes = 0; } - this.close(error); + this.abort(error); throw error; } @@ -402,22 +405,34 @@ export class Producer { return this.#state.closed.peek() !== undefined; } - /** Closes the group, optionally with an error to abort readers. */ - close(abort?: Error) { + /** Marks the group complete: no more frames will be written. Idempotent. */ + finish() { + this.#end(null); + } + + /** + * Ends the group with `error`, which readers that have not drained it see. A no-op once the + * group finished or aborted: the outcome is already settled. + */ + abort(error: Error) { + this.#end(error); + } + + #end(outcome: Error | null) { if (this.#state.closed.peek() !== undefined) return; this.#state.flate?.finish(); - this.#state.closed.set(abort ?? null); + this.#state.closed.set(outcome); if (this.#mirrors) { for (const mirror of this.#mirrors) { - if (mirror.closed.peek() === undefined) mirror.closed.set(abort ?? null); + if (mirror.closed.peek() === undefined) mirror.closed.set(outcome); } this.#mirrors.clear(); } } } -let makeConsumer: (state: GroupState) => Consumer; +let makeConsumer: (state: GroupState, owned: boolean) => Consumer; // A replacement copy asked for on behalf of one consumer, settled or still out. interface Attempt { @@ -619,9 +634,13 @@ export class Consumer { #expired?: Expired; // Closed by its reader, which ends it whatever is left unread. #released = false; + // Whether this consumer alone reads `#state` (a mirror), so releasing it closes that state. + // A `consume()` handle shares the producer's state and only lets go of its own view. + #owned: boolean; - private constructor(state: GroupState) { + private constructor(state: GroupState, owned: boolean) { this.#state = state; + this.#owned = owned; this.sequence = state.sequence; } @@ -669,7 +688,8 @@ export class Consumer { } static { - makeConsumer = (state) => new Consumer(state); + makeConsumer = (state, owned) => new Consumer(state, owned); + hooks.releaseGroupReader = (group, abort) => group.#release(abort); hooks.groupTimestamp = (group) => (group.#state.unverified ? undefined : group.#state.timestamp); hooks.groupLatest = (group) => (group.#state.unverified ? undefined : group.#state.latest); hooks.unverifyGroup = (group) => { @@ -744,7 +764,8 @@ export class Consumer { // The next change a parked read waits for. #changed(): GetPromise { - const changed: Getter[] = [this.#state.frames, this.#state.closed]; + // The verdict too: releasing a shared handle settles only it, and must end a parked read. + const changed: Getter[] = [this.#state.frames, this.#state.closed, this.#verdict]; if (this.#expiry) changed.push(...this.#expiry.changed); if (this.#recover) changed.push(this.#recover.changed); return Signal.race(...changed); @@ -826,6 +847,7 @@ export class Consumer { // The copy left behind stops teeing into the state this consumer read. if (this.#state.closed.peek() === undefined) this.#state.closed.set(null); this.#state = next; + this.#owned = replacement.group.#owned; this.#expiry = replacement.expiry; this.#skip = index; this.#closed?.refresh(); @@ -835,6 +857,8 @@ export class Consumer { // The unread frames. Every look at the buffer goes through here, so readiness, reads and // `done` agree on what is left. #bufferedFrames(): Frame[] { + // A released shared handle reads nothing more: the queue it shares belongs to the other readers. + if (this.#released && !this.#owned) return []; const frames = this.#state.frames.peek(); // A resumed copy repeats the frames an earlier one already delivered. while (this.#state.start < this.#skip && frames.length > 0) { @@ -865,14 +889,16 @@ export class Consumer { return { sequence, frame, inflated: slice?.inflated }; } - /** True once no further frames can be read: the group has closed and every buffered frame is read. */ + /** True once no further frames can be read: this handle or the group has closed, and every buffered frame is read. */ get done(): boolean { - return this.#terminal !== undefined || (this.#bufferedFrames().length === 0 && this.#settled()); + return ( + this.#terminal !== undefined || (this.#bufferedFrames().length === 0 && (this.#released || this.#settled())) + ); } - /** True once the group has been closed, regardless of whether buffered frames remain unread. Synchronous complement to the {@link closed} promise. */ + /** True once this handle or the group has been closed, regardless of whether buffered frames remain unread. Synchronous complement to the {@link closed} promise. */ get isClosed(): boolean { - return this.#terminal !== undefined || this.#settled(); + return this.#terminal !== undefined || this.#released || this.#settled(); } // Whether the copy being read ended, for good: a failure recovery may continue is not an end. @@ -926,7 +952,7 @@ export class Consumer { this.#closed?.refresh(); return; } - if (this.#expire()) return; + if (this.#released || this.#expire()) return; if (this.#recover?.wants(undefined)) { await this.#resume(undefined); continue; @@ -966,6 +992,8 @@ export class Consumer { return read; } + // A released shared handle ends here, whatever the group does next for its other readers. + if (this.#released && !this.#owned) return; const closed = this.#state.closed.peek(); if (closed instanceof Error) { if (await this.#resume(closed)) continue; @@ -1009,8 +1037,19 @@ export class Consumer { return frame ? frame.payload[0] === 1 : undefined; } - /** Closes the group, optionally with an error to abort readers. Idempotent. */ - close(abort?: Error) { + /** + * Release this handle, like dropping a Rust consumer. Never changes the group for other + * readers. A track's read handle drains what it buffered and then ends; one from + * {@link Producer.consume} ends at once, since its queue is shared with the producer's other + * readers. Idempotent. + */ + close(): void { + this.#release(undefined); + } + + // Release this reader, failing it with `abort` if given. A mirror closes its own state, + // which drops its demand; a shared handle ends only its own view. + #release(abort: Error | undefined): void { // A copy failure recovery was continuing past ends with the abort instead. if (abort && this.#recover && this.#state.closed.peek() instanceof Error) { this.#terminal ??= abort; @@ -1021,7 +1060,12 @@ export class Consumer { this.#recover = undefined; // Closing is terminal for this reader, even with frames still unread. this.#released = true; - if (this.#state.closed.peek() === undefined) this.#state.closed.set(abort ?? null); + if (this.#owned) { + if (this.#state.closed.peek() === undefined) this.#state.closed.set(abort ?? null); + } else if (this.#state.closed.peek() === undefined) { + if (abort) this.#terminal ??= abort; + if (this.#verdict.peek() === undefined) this.#verdict.set(this.#terminal ?? null); + } this.#closed?.refresh(); } } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index f5405c2aa1e..89201ddcc4a 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -8,7 +8,7 @@ import { error, ProtocolViolation, SessionCode, SessionError, StreamCode, unauth import { Producer as GroupProducer, MAX_GROUP_FRAMES } from "../group.ts"; import { type Hop, HopSchema } from "../hop.ts"; import { hooks } from "../internal.ts"; -import { createMockTransportPair } from "../mock.ts"; +import { createMockTransportPair, SessionBroadcast } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { Reader, Stream, Writer } from "../stream.ts"; @@ -434,7 +434,7 @@ test("a cancelled TRACK_STATUS releases its demand on every draft", async () => await resolving; expect(early.written).toEqual([]); // Withdrawn, so a late answer is closed rather than served. - const answer = new BroadcastProducer().consume(); + const answer = new SessionBroadcast(); hooks.acceptRequest(pending, answer); expect(answer.closed.peek()).toBeNull(); @@ -484,7 +484,7 @@ test("a SUBSCRIBE withdrawn during setup releases its demand on every draft", as await resolving; expect(early.written).toEqual([]); // Withdrawn, so a late answer is closed rather than served. - const answer = new BroadcastProducer().consume(); + const answer = new SessionBroadcast(); if (pending) hooks.acceptRequest(pending, answer); expect(answer.closed.peek()).toBeNull(); @@ -538,6 +538,7 @@ test("a SUBSCRIBE withdrawn while resuming releases its demand", async () => { const track = await first; while (track.subscription.peek() !== undefined) await track.subscription.changed(); // Gone, so the resume asks for the track again, and nothing answers. + track.finish(); track.close(); const second = wireOf(fx.broadcast).requested(); const parameters = new Parameters(); @@ -648,6 +649,7 @@ test("RENDEZVOUS_TIMEOUT has a zero cap with TIMEOUT for a requested wait", asyn expect(await wire.u53()).toBe(RequestError.id); expect((await RequestError.decode(wire, version)).errorCode).toBe(millis && !live ? 0x02 : 0x10); } finally { + track?.finish(); track?.close(); broadcast?.close(); origin.close(); @@ -683,6 +685,7 @@ test("reserved namespace requests get DOES_NOT_EXIST while other dot namespaces } } finally { request.cancel(); + track?.finish(); track?.close(); broadcast?.close(); claim?.close(); @@ -790,13 +793,13 @@ test("a blocked group header is reset when the group expires", async () => { const old = new GroupProducer(0); old.writeFrame({ payload: new TextEncoder().encode("old"), timestamp: Timestamp.fromMillis(0) }); - old.close(); + old.finish(); track.writeGroup(old); await headerStarted; const edge = new GroupProducer(1); edge.writeFrame({ payload: new TextEncoder().encode("edge"), timestamp: Timestamp.fromMillis(10_000) }); - edge.close(); + edge.finish(); track.writeGroup(edge); // A group beyond the edge, so group 0's reach (10s, where group 1 begins) is @@ -804,7 +807,7 @@ test("a blocked group header is reset when the group expires", async () => { // the successor alone never convicts it: nothing yet proves group 0 ends sooner. const later = new GroupProducer(2); later.writeFrame({ payload: new TextEncoder().encode("later"), timestamp: Timestamp.fromMillis(20_000) }); - later.close(); + later.finish(); track.writeGroup(later); const resetBeforeRelease = await Promise.race([ @@ -876,7 +879,7 @@ test("a group stream whose FIN never completes is reset when the group expires", const write = (sequence: number) => { const group = new GroupProducer(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); track.writeGroup(group); }; @@ -889,6 +892,7 @@ test("a group stream whose FIN never completes is reset when the group expires", // Its successor presents no further, so only the wall clock since it arrived convicts // group 0, after the track has ended. write(1); + track.finish(); track.close(); await clock.advance(Nano.fromMilli(Milli(budget - 1))); expect(reset).toBe(false); @@ -960,7 +964,7 @@ test("a group stream whose FIN never completes is reset after the cache evicts i const write = (sequence: number) => { const group = new GroupProducer(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); track.writeGroup(group); }; @@ -971,6 +975,7 @@ test("a group stream whose FIN never completes is reset after the cache evicts i const later = Milli(10_000); await clock.advance(Nano.fromMilli(later)); write(2); + track.finish(); track.close(); await clock.advance(Nano.fromMilli(Milli(budget - later - 1))); @@ -1045,7 +1050,7 @@ test("a group that goes stale while its stream opens writes nothing", async () = const write = (sequence: number, ms: number) => { const group = new GroupProducer(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(ms) }); - group.close(); + group.finish(); track.writeGroup(group); }; write(0, 0); @@ -2639,7 +2644,11 @@ test("subscription completion sends PUBLISH_DONE on every supported draft", asyn expect(await client.reader.u53()).toBe(SubscribeOk.id); await SubscribeOk.decode(client.reader, version); - track.close(abort); + if (abort) track.abort(abort); + else { + track.finish(); + track.close(); + } expect(await client.reader.u53()).toBe(PublishDone.id); const done = await PublishDone.decode(client.reader, version); @@ -2848,7 +2857,7 @@ function writeGroup(track: TrackProducer, frames: number): void { for (let i = 0; i < frames; i++) { group.writeFrame({ payload: new TextEncoder().encode(`${group.sequence}.${i}`), timestamp: Timestamp.now() }); } - group.close(); + group.finish(); } /** Send `msg` on a fresh subscribe stream and read the publisher's SUBSCRIBE_OK. */ @@ -3139,7 +3148,7 @@ test.each(["nextObject", "unfiltered"] as const)( // Everything past the snapshot belongs to the subscription, not the fill. group.writeFrame({ payload: new TextEncoder().encode("1.2"), timestamp: Timestamp.now() }); - group.close(); + group.finish(); const live = await nextUni(fx.uni); if (!live) throw new Error("the subscription never served the live tail"); @@ -3180,7 +3189,7 @@ test("draft-22: a fill never rewinds an absolute live join", async () => { ]); const group = track.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode("1.0"), timestamp: Timestamp.now() }); - group.close(); + group.finish(); const live = await nextUni(fx.uni); if (!live) throw new Error("missing live group after the fill"); expect(await readGroup(live)).toEqual({ @@ -3278,7 +3287,7 @@ test.each(["ascending", "descending"] as const)("draft-22: a sparse fill serves track.writeGroup(group); for (let i = 0; i < 2; i++) group.writeFrame({ payload: new TextEncoder().encode(`${sequence}.${i}`), timestamp: Timestamp.now() }); - group.close(); + group.finish(); } const { client } = await runSubscribe( @@ -3349,7 +3358,7 @@ async function subscribeOkLargest(version: IetfVersion): Promise Frame.decode(c, header.flags, undefined)); @@ -3664,6 +3675,7 @@ test("draft-22: a clean close past a bounded filter's end still sends PUBLISH_DO // Beyond the end, so it is never served, and it must not hold the subscription open. writeGroup(track, 1); // group 1 + track.finish(); track.close(); expect(await client.reader.u53()).toBe(PublishDone.id); @@ -3741,7 +3753,7 @@ test("draft-22: the subscriber leaving ends a fill still reading its group", asy const served = await reading; expect(served.reset?.message).toContain("unsubscribed"); } finally { - open.close(); + open.finish(); fx.close(); client.close(); } @@ -3796,7 +3808,7 @@ test("draft-22: a fill works on a dynamically requested track", async () => { reset: undefined, }); } finally { - group.close(); + group.finish(); fx.close(); client.close(); } @@ -3878,6 +3890,7 @@ test("draft-22: PUBLISH_DONE waits for a queued group and counts every stream", try { writeGroup(track, 1); + track.finish(); track.close(); // Nothing ends the subscription while the group waits for its slot. @@ -4047,6 +4060,7 @@ test("requester FIN keeps a draft-22 subscription serving; STOP_SENDING cancels await serving; } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4076,6 +4090,7 @@ for (const version of [Version.DRAFT_17, Version.DRAFT_18] as const) { expect(track.subscription.peek()).toBeUndefined(); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4109,6 +4124,7 @@ test("REQUEST_UPDATE applies priority and preserves it when omitted", async () = expect(track.subscription.peek()?.priority).toBe(117); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4134,6 +4150,7 @@ test("REQUEST_UPDATE accepts delivery timeouts without changing the subscription expect(track.subscription.peek()).toEqual(before); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4168,6 +4185,7 @@ test("TRACK_PROPERTY_FILTER on a subscription update closes with PROTOCOL_VIOLAT expect(Number((failure as ProtocolViolation).code)).toBe(0x03); } finally { client.close(); + track.finish(); track.close(); fx.close(); await serving; @@ -4197,6 +4215,7 @@ test("unsupported REQUEST_UPDATE is refused and ends with UPDATE_FAILED", async expect(done.statusCode).toBe(0x08); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4211,7 +4230,7 @@ test("a SUBSCRIBE pauses without demand and resumes as a new subscriber", async const write = (text: string) => { const group = track.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode(text), timestamp: Timestamp.now() }); - group.close(); + group.finish(); }; const { client } = await runSubscribe( fx, @@ -4260,6 +4279,7 @@ test("a SUBSCRIBE pauses without demand and resumes as a new subscriber", async } } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4287,6 +4307,7 @@ test("a paused SUBSCRIBE ends once its grant is revoked", async () => { expect((await PublishDone.decode(client.reader, V20)).statusCode).toBe(PublishDoneStatus.UNAUTHORIZED); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4334,6 +4355,7 @@ test("a pause resets a group stream parked on flow control", async () => { expect(reset).toBe(true); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4498,6 +4520,7 @@ test("draft-22: a range update replaces the live subscription bounds", async () expect(await client.reader.u53()).toBe(RequestOk.id); await RequestOk.decode(client.reader, fx.version); for (let i = 0; i < 5; i++) writeGroup(track, 2); + track.finish(); track.close(); const served: ServedGroup[] = []; for (let i = 0; i < 2; i++) { @@ -4520,6 +4543,7 @@ test("draft-22: a range update replaces the live subscription bounds", async () expect(await readEndOfTrack(end)).toBe(5); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4559,6 +4583,7 @@ test.each([Version.DRAFT_15, Version.DRAFT_16, Version.DRAFT_17, Version.DRAFT_1 } } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4608,6 +4633,7 @@ test("draft-22: an update fills its snapshot under its own request ID", async () ); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4639,7 +4665,8 @@ test("draft-22: an empty newer group is not an object in Largest or a relative f expect(served.objects).toEqual([0, 1].map((id) => ({ group: 0, id, payload: `0.${id}` }))); } finally { client.close(); - empty.close(); + empty.finish(); + track.finish(); track.close(); fx.close(); } @@ -4675,9 +4702,10 @@ test("draft-22: reported Largest survives absent headers, older cache entries, a groupId: 4n, objectId: 3n, }); - empty.close(); + empty.finish(); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4704,13 +4732,14 @@ test("draft-22: Next Object behind an upstream group report still delivers its f expect(ok.largest).toBeUndefined(); expect(group.sequence).toBe(0); group.writeFrame({ payload: new TextEncoder().encode("first"), timestamp: Timestamp.now() }); - group.close(); + group.finish(); const stream = await nextUni(fx.uni); if (!stream) throw new Error("missing first received object"); expect((await readGroup(stream)).objects).toEqual([{ id: 0, payload: "first" }]); } finally { client.close(); - group.close(); + group.finish(); + track.finish(); track.close(); fx.close(); } @@ -4734,13 +4763,14 @@ test("draft-22: Next Object on an empty group still delivers its first object", try { expect(ok.largest).toBeUndefined(); group.writeFrame({ payload: new TextEncoder().encode("first"), timestamp: Timestamp.now() }); - group.close(); + group.finish(); const stream = await nextUni(fx.uni); if (!stream) throw new Error("missing first published object"); expect((await readGroup(stream)).objects).toEqual([{ id: 0, payload: "first" }]); } finally { client.close(); - group.close(); + group.finish(); + track.finish(); track.close(); fx.close(); } @@ -4782,6 +4812,7 @@ test("draft-22: cancellation releases a fill parked on flow control", async () = expect(reset).toBe(true); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4821,8 +4852,9 @@ test("draft-22: narrowing gives up an excluded group's open stream", async () => if (!next) throw new Error("missing group after narrowed range"); expect((await readGroup(next)).sequence).toBe(1); } finally { - open.close(); + open.finish(); client.close(); + track.finish(); track.close(); fx.close(); } @@ -4859,7 +4891,7 @@ test("draft-22: widening with a fill keeps the open live group", async () => { { group: 1, id: 0, payload: "1.0" }, ]); open.writeFrame({ payload: new TextEncoder().encode("1.1"), timestamp: Timestamp.now() }); - open.close(); + open.finish(); // The live stream still owns the rest of its group, so nothing else repeats it. expect((await reading).objects).toEqual([ { id: 0, payload: "1.0" }, @@ -4867,8 +4899,9 @@ test("draft-22: widening with a fill keeps the open live group", async () => { ]); expect(await nextUni(fx.uni)).toBeUndefined(); } finally { - open.close(); + open.finish(); client.close(); + track.finish(); track.close(); fx.close(); } @@ -4906,7 +4939,7 @@ test("draft-22: widening with a fill resumes after the open live group's end", a await RequestOk.decode(client.reader, fx.version); for (const payload of ["1.1", "1.2"]) open.writeFrame({ payload: new TextEncoder().encode(payload), timestamp: Timestamp.now() }); - open.close(); + open.finish(); // The live stream keeps the objects up to its old end, so the tail starts after them. expect((await reading).objects).toEqual([ { id: 0, payload: "1.0" }, @@ -4915,8 +4948,9 @@ test("draft-22: widening with a fill resumes after the open live group's end", a expect((await readServed(fx.uni, 2)).sort()).toEqual(["0.0", "1.0", "1.2"]); expect(await nextUni(fx.uni)).toBeUndefined(); } finally { - open.close(); + open.finish(); client.close(); + track.finish(); track.close(); fx.close(); } @@ -4953,7 +4987,7 @@ test("draft-22: a fill-only update short of the edge keeps the live join's floor if (!fill) throw new Error("missing update fill"); expect((await readFill(fill, "ascending")).objects).toEqual([{ group: 0, id: 0, payload: "0.0" }]); open.writeFrame({ payload: new TextEncoder().encode("1.1"), timestamp: Timestamp.now() }); - open.close(); + open.finish(); expect((await reading).objects).toEqual([ { id: 0, payload: "1.0" }, { id: 1, payload: "1.1" }, @@ -4961,8 +4995,9 @@ test("draft-22: a fill-only update short of the edge keeps the live join's floor // Only the fill carries group 0; the live cursor never owned it. expect(await nextUni(fx.uni)).toBeUndefined(); } finally { - open.close(); + open.finish(); client.close(); + track.finish(); track.close(); fx.close(); } @@ -4995,7 +5030,7 @@ test("draft-22: a fill-only update keeps the subscription's floor", async () => if (!fill) throw new Error("missing update fill"); expect((await readFill(fill, "ascending")).objects).toEqual([{ group: 1, id: 0, payload: "1.0" }]); open.writeFrame({ payload: new TextEncoder().encode("1.1"), timestamp: Timestamp.now() }); - open.close(); + open.finish(); expect((await reading).objects).toEqual([ { id: 0, payload: "1.0" }, { id: 1, payload: "1.1" }, @@ -5003,8 +5038,9 @@ test("draft-22: a fill-only update keeps the subscription's floor", async () => // The live join's floor stays at group 1, so group 0 is never offered. expect(await nextUni(fx.uni)).toBeUndefined(); } finally { - open.close(); + open.finish(); client.close(); + track.finish(); track.close(); fx.close(); } @@ -5045,11 +5081,12 @@ test.each([ } expect((await reading)?.message).toContain("range update"); open.writeFrame({ payload: new TextEncoder().encode("1.1"), timestamp: Timestamp.now() }); - open.close(); + open.finish(); expect((await readServed(fx.uni, 2)).sort()).toEqual(["0.0", "1.0", "1.1"]); } finally { - open.close(); + open.finish(); client.close(); + track.finish(); track.close(); fx.close(); } @@ -5101,6 +5138,7 @@ test("subscriber max age and REQUEST_UPDATE preserve omitted preferences", async expect(track.subscription.peek()?.maxDelay).toBe(Milli(900)); } finally { client.close(); + track.finish(); track.close(); fx.close(); } diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 0963ebc160f..4d1c05ffd92 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1101,7 +1101,7 @@ export class Publisher { // Draft 14 on has no end location in PUBLISH_DONE: an END_OF_TRACK object is what // tells the subscriber where the track ended. - const final = track.end(); + const final = hooks.subscriberEnd(track); if (ended && !publishError && final !== undefined && !streams.terminalSent) { await this.#runEndOfTrack({ requestId: msg.requestId, @@ -1193,7 +1193,7 @@ export class Publisher { if (excludes(bounds.peek(), group.sequence, slice)) { if (!finished) live.retired = true; rangeError = new Error("group excluded by range update"); - group.close(rangeError); + hooks.releaseGroupReader(group, rangeError); excluded.resolve(); } }; @@ -1223,7 +1223,7 @@ export class Publisher { opened.resolve(); } if (!stream) { - group.close(failure ?? new Error("no stream slot")); + hooks.releaseGroupReader(group, failure ?? new Error("no stream slot")); return; } streams.opened += 1; @@ -1309,7 +1309,7 @@ export class Publisher { // The track ends right after this group's last object, so the marker rides this // stream rather than a separate one. - const end = track.end(); + const end = hooks.subscriberEnd(track); const marked = end?.group === group.sequence && end.frame === next; if (marked) { await new Frame({ endOfTrack: true }).encode( @@ -1347,7 +1347,7 @@ export class Publisher { } } catch (err: unknown) { // A failed open has no stream to reset, but still ends only this group. - group.close(error(err)); + hooks.releaseGroupReader(group, error(err)); } finally { changed(); group.close(); diff --git a/js/net/src/ietf/subscribe_tracks.test.ts b/js/net/src/ietf/subscribe_tracks.test.ts index 41a9b5b46bd..1a910440c96 100644 --- a/js/net/src/ietf/subscribe_tracks.test.ts +++ b/js/net/src/ietf/subscribe_tracks.test.ts @@ -151,7 +151,7 @@ for (const version of drafts) { const track = broadcast.createTrack("video"); const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array([1]) }); - group.close(); + group.finish(); const listing = await fx.list(); await ok(listing, version); const { stream, publish } = await fx.offered(); @@ -199,6 +199,7 @@ for (const version of drafts) { const listing = await fx.list(); await ok(listing, version); const { stream } = await fx.offered(); + if (finished) track.finish(); if (finished) track.close(); broadcast.removeTrack("video"); expect(await stream.reader.u53()).toBe(PublishDone.id); @@ -221,6 +222,7 @@ for (const version of drafts) { const listing = await fx.list(); await ok(listing, version); const { stream } = await fx.offered(false); + track.finish(); track.close(); broadcast.removeTrack("video"); await clock.advance(Nano.zero); @@ -257,6 +259,7 @@ for (const version of drafts) { const listing = await fx.list(); await ok(listing, version); const { stream } = await fx.offered(false); + track.finish(); track.close(); broadcast.removeTrack("video"); await clock.advance(Nano.zero); @@ -292,6 +295,7 @@ for (const version of drafts) { published.set(publish.trackName, stream); } await clock.advance(Nano.zero); + video.finish(); video.close(); if (remove) broadcast.removeTrack("video"); broadcast.close(); @@ -513,7 +517,7 @@ for (const version of [Version.DRAFT_22]) { for (let i = 0; i < 2; i++) { const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array([i]) }); - group.close(); + group.finish(); } await ok(await fx.list(), version); const { stream } = await fx.offered(); @@ -532,7 +536,7 @@ for (const version of [Version.DRAFT_22]) { await update(stream, version, 4n, true); const fresh = track.appendGroup(); fresh.writeFrame({ payload: new Uint8Array([2]) }); - fresh.close(); + fresh.finish(); const uni = fx.pair.client.incomingUnidirectionalStreams.getReader(); const next = await uni.read(); if (!next.value) throw new Error("missing filtered group"); @@ -546,7 +550,7 @@ for (const version of [Version.DRAFT_22]) { while (track.subscription.peek() !== undefined) await track.subscription.changed(); const paused = track.appendGroup(); paused.writeFrame({ payload: new Uint8Array([3]) }); - paused.close(); + paused.finish(); await update(stream, version, 8n, true); const resumed = (await uni.read()).value; if (!resumed) throw new Error("missing group selected while paused"); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 424ba09004c..c7084e45e9b 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -37,6 +37,7 @@ import { Tail } from "../tail.ts"; import type { Timescale, Timestamp } from "../time.ts"; import type * as track from "../track.ts"; import { untilAborted } from "../util/abort.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { Deadline, requestOptions } from "../util/deadline.ts"; import { abandoned as demandAbandoned, isAbandoned, unwanted } from "../util/demand.ts"; import { LIMITS, type Limits, SlotMap, Slots } from "../util/limits.ts"; @@ -355,10 +356,11 @@ export class Subscriber { // The overlapping requests on their way out, such as the narrower ones an exclusive // discovery just closed for this one. Those still open overlap it only where the - // caller holds both on purpose. + // caller holds both on purpose. One whose last reader just closed is leaving even + // before its producer closes. const blockers: Promise[] = []; for (const [other, { prefix: asked, gone }] of this.#announcedConsumers) { - if (gone === undefined || other.closed.peek() === undefined) continue; + if (gone === undefined || (other.wanted.peek() && other.closed.peek() === undefined)) continue; if (Path.hasPrefix(asked, prefix) || Path.hasPrefix(prefix, asked)) blockers.push(gone); } @@ -373,13 +375,16 @@ export class Subscriber { }); } this.#announcedConsumers.set(announced, filter); + const consumer = announced.consume(); + // The last reader leaving ends the stream, which cancels the request. + closeUnwanted(announced); filter.gone = this.#runAnnounced(announced, prefix, filter.hidden && this.#hidden, blockers).finally(() => { this.#announcedConsumers.delete(announced); announced.close(); }); - return announced.consume(); + return consumer; } /** @@ -793,8 +798,8 @@ export class Subscriber { try { for (;;) { while (!lister.used.peek()) { - if (lister.ended.peek() !== undefined) return; - await race([Signal.race(lister.used, lister.ended)]); + if (lister.closed.peek() !== undefined) return; + await race([Signal.race(lister.used, lister.closed)]); } lister.abort(new Error("track listing requires moq-lite-07 or newer")); } @@ -894,7 +899,7 @@ export class Subscriber { maxAge = result.maxAge; break; } - if (demand.closed.peek() === undefined && demand.wanted.peek() !== undefined) continue; + if (hooks.demandReleased(demand).peek() === undefined && demand.wanted.peek() !== undefined) continue; throw abandoned; } console.debug(`subscribe ok: id=${requestId} broadcast=${broadcast} track=${request.name}`); @@ -979,7 +984,7 @@ export class Subscriber { const unanswered = new Signal(0); const done = race([ this.#runPublishDone(stream, subscription, broadcast, unanswered).then(() => publisherEnded), - producer.closed.then(() => localEnded), + hooks.trackReleased(producer).then(() => localEnded), revoked, ]); const updates = this.#runUpdates(stream, requestId, producer, priority, maxAge, unanswered, done); @@ -991,7 +996,11 @@ export class Subscriber { const demand = producer.demand(); for (;;) { const reason = await race([done, unwanted(demand).then(() => idle)]); - if (reason === idle && demand.closed.peek() === undefined && demand.wanted.peek() !== undefined) + if ( + reason === idle && + hooks.demandReleased(demand).peek() === undefined && + demand.wanted.peek() !== undefined + ) continue; terminal = reason; break; @@ -1003,9 +1012,10 @@ export class Subscriber { // straight out of its loop for the same reason. if (terminal === revokedEnded) { console.info(`subscription no longer authorized: broadcast=${broadcast} track=${request.name}`); - producer.close(refused); + producer.abort(refused); } else { - producer.close(); + // Only a received end declares one; a copy nobody wants anymore is released, not finished. + hooks.releaseTrack(producer); } // Closing settled `done`, so an update still being written finishes and nothing starts @@ -1023,7 +1033,7 @@ export class Subscriber { stream.abort(err); const e = await sessionCause(this.#quic, err); if (this.#localClose) hooks.releaseTrack(producer); - else producer.close(e); + else producer.abort(e); console.warn( `subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`, ); @@ -1081,7 +1091,7 @@ export class Subscriber { const { tail, track } = subscription; const complete = () => count > 0n && BigInt(tail.streams) >= count; - await tail.settle(complete, track.closed); + await tail.settle(complete, hooks.trackReleased(track)); // SUBSCRIPTION_ENDED ends just this subscription, so the copy fails rather than declare an // end the track may run past: a later reader subscribes afresh, and its groups continue. if (done.statusCode !== PublishDoneStatus.TRACK_ENDED) { @@ -1156,7 +1166,7 @@ export class Subscriber { } catch (err) { // Nothing reached the wire, but the track can no longer follow its subscribers. console.warn(`subscribe update failed: id=${requestId} error=${reason(error(err))}`); - producer.close(error(err)); + producer.abort(error(err)); // Drafts 14 and 15 require the next request ID, so one spent and never sent would // stall every later request: as in Rust, that ends the session. if (version === Version.DRAFT_14 || version === Version.DRAFT_15) { @@ -1628,7 +1638,7 @@ export class Subscriber { // cache. const frame = stream.tryDecode(decode) ?? - (await race([stream.decodeMaybe(decode), producer?.closed ?? track.closed])); + (await race([stream.decodeMaybe(decode), producer?.closed ?? hooks.trackReleased(track)])); if (!producer && (frame === null || frame instanceof Error)) { stream.stop(frame ?? new StreamError(StreamCode.Cancel)); return; @@ -1653,7 +1663,7 @@ export class Subscriber { // the track ends with it, and at object 0 it ends before it. if (producer && frame.idDelta !== 0) throw new ObjectIdGap(frame.idDelta); const end = { group: group.groupId, frame: nextObject + frame.idDelta }; - producer?.close(); + producer?.finish(); try { hooks.endTrack(track, end); } catch (err: unknown) { @@ -1676,7 +1686,7 @@ export class Subscriber { } // A group with no objects still exists. - open().close(); + open().finish(); } catch (err: unknown) { if (err instanceof ProtocolViolation) this.#fail(err.code, err.message); const e = await sessionCause(this.#quic, err); @@ -1703,20 +1713,20 @@ export class Subscriber { if (e instanceof ObjectIdGap) console.warn(`unsupported object gap: ${reason(e)}`); if (e instanceof EndOfTrackProperties) { // The group before the marker is complete; only the track's end can't be carried. - producer?.close(); - track.close(e); + producer?.finish(); + track.abort(e); } else if ( e instanceof ProtocolViolation || (e instanceof StreamError && e.code === StreamCode.MalformedTrack) ) { // The publisher broke the track's end or its content, which no later group can repair. - producer?.close(e); - track.close(e); + producer?.abort(e); + track.abort(e); } else { // A stream that fails before its first object still names a group, which the // reader sees fail rather than silently go missing. try { - open().close(e); + open().abort(e); } catch { // The track has already closed or ended below this group. } diff --git a/js/net/src/ietf/tail.test.ts b/js/net/src/ietf/tail.test.ts index f0132141fbf..27a33406830 100644 --- a/js/net/src/ietf/tail.test.ts +++ b/js/net/src/ietf/tail.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; import { ProtocolViolation, StreamCode, StreamError } from "../error.ts"; import type { Consumer as GroupConsumer } from "../group.ts"; +import { hooks } from "../internal.ts"; import { GRACE } from "../ledger.ts"; import { createMockTransportPair } from "../mock.ts"; import * as Path from "../path.ts"; @@ -175,7 +176,8 @@ test("a SUBSCRIPTION_ENDED PUBLISH_DONE drains, then aborts the track", async () expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); expect(await reader.closed).toBeInstanceOf(Error); - expect(reader.end()).toBeUndefined(); + // No end was declared: the track fails rather than finish. + await expect(reader.finished()).rejects.toBeInstanceOf(Error); }); test("an UNAUTHORIZED PUBLISH_DONE reads as the revocation a moq-lite peer sends", async () => { @@ -198,7 +200,7 @@ test("END_OF_TRACK after a group's last object ends the track after that group", await done(TRACK_ENDED, 1n); expect(await reader.recvGroup()).toBeUndefined(); - expect(reader.end()).toEqual({ group: 4, frame: 1 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 4, frame: 1 }); }); test("END_OF_TRACK at object 0 ends the track before its group, which never exists", async () => { @@ -214,7 +216,7 @@ test("END_OF_TRACK at object 0 ends the track before its group, which never exis await done(TRACK_ENDED, 2n); expect((await reader.recvGroup())?.sequence).toBe(0); expect(await reader.recvGroup()).toBeUndefined(); - expect(reader.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 2, frame: 0 }); }); test("a standalone END_OF_TRACK at object 5 preserves its terminal group", async () => { @@ -223,7 +225,7 @@ test("a standalone END_OF_TRACK at object 5 preserves its terminal group", async end.write(new Uint8Array([5, 0, 0x4])); end.finish(); await end.handled; - expect(reader.end()).toEqual({ group: 5, frame: 5 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 5, frame: 5 }); await done(TRACK_ENDED, 1n); expect(await reader.recvGroup()).toBeUndefined(); }); @@ -243,7 +245,7 @@ test("a compact stream starting at object 5 is subgroup 5, refused without a gro huge.write(new Uint8Array([...new Array(9).fill(0xff), 1, 42])); huge.finish(); await huge.handled; - expect(reader.end()).toBeUndefined(); + expect(hooks.subscriberEnd(reader)).toBeUndefined(); const group = groupStream(subscriber, 6, { ...FLAGS, hasSubgroupObject: true }); group.write(object("6.0")); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 64c985cbdfa..b8bfb03686d 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -351,7 +351,7 @@ test("integration: lite applies initial and updated group bounds", async () => { const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().close(); + for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().finish(); const remote = wireOf(client).consume(Path.from("test")); const subscriber = remote @@ -402,7 +402,7 @@ test("integration: lite refuses an empty requested range on open and on update", const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().close(); + for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().finish(); const remote = wireOf(client).consume(Path.from("test")); const video = remote.track("video"); @@ -869,7 +869,7 @@ test("integration: a group reset carries the peer's code to the subscriber", asy expect(await consumer.readString()).toBe("frame"); // Reset the group stream the way a peer that dropped the group does. - group.close(new Reset(2)); + group.abort(new Reset(2)); // The subscriber reads the code back off a typed error rather than whichever shape the // transport produced, so nothing has to feature-detect a browser global. @@ -910,7 +910,7 @@ test("integration: a locally raised group error reaches the peer as its own code // The publisher's own cache dropped the rest of the group. Nothing hand-builds a transport // error here, which is the point: the condition is raised the way the library raises it. - group.close(new TooFarBehind()); + group.abort(new TooFarBehind()); const err = await consumer.readFrame().then( () => undefined, @@ -1005,11 +1005,11 @@ test("integration: lite draft-07 fetches a cached group", async () => { const group0 = producer.appendGroup(); group0.writeFrame({ payload: enc.encode("alpha"), timestamp: Timestamp.fromMillis(10) }); group0.writeFrame({ payload: enc.encode("beta"), timestamp: Timestamp.fromMillis(15) }); - group0.close(); + group0.finish(); const group1 = producer.appendGroup(); group1.writeFrame({ payload: enc.encode("newer"), timestamp: Timestamp.fromMillis(20) }); - group1.close(); + group1.finish(); // Fetch group 0 without holding a live subscription; the timestamps round-trip. const remote = wireOf(client).consume(Path.from("test")); @@ -1044,13 +1044,15 @@ test.each(["gap", "end"])("integration: lite fetch rejects coalesced misses at % const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = new GroupProducer(1); producer.writeGroup(group); - group.close(); + group.finish(); } else { serving = (async () => { for (;;) { const request = await wireOf(broadcast).requested(); if (!request) return; - request.accept({ timescale: Timescale.MILLI }).close(); + const accepted = request.accept({ timescale: Timescale.MILLI }); + accepted.finish(); + accepted.close(); } })(); } @@ -1104,14 +1106,14 @@ test.each(["frame", "FIN"])("integration: lite fetch waits for the publisher's f await sleep(0); expect(settled).toBe(0); if (answer === "frame") group.writeFrame(textFrame("accepted")); - else group.close(); + else group.finish(); const consumers = await Promise.all([a, b]); for (const consumer of consumers) { expect(await consumer.readString()).toBe(answer === "frame" ? "accepted" : undefined); consumer.close(); } } finally { - group.close(); + group.finish(); broadcast.close(); remote.close(); client.abort(); @@ -1137,7 +1139,7 @@ test("integration: lite draft-05 coalesces concurrent fetches of one group", asy const group0 = producer.appendGroup(); group0.writeFrame({ payload: enc.encode("alpha"), timestamp: Timestamp.fromMillis(10) }); group0.writeFrame({ payload: enc.encode("beta"), timestamp: Timestamp.fromMillis(15) }); - group0.close(); + group0.finish(); const remote = wireOf(client).consume(Path.from("test")); const trackConsumer = remote.track("video"); @@ -1194,7 +1196,7 @@ test("integration: lite draft-07 fetches an in-progress group", async () => { expect(dec.decode(second?.payload)).toBe("beta"); expect(second?.timestamp?.asMillis()).toBe(15); - group0.close(); + group0.finish(); expect(await fetched.readFrame()).toBeUndefined(); broadcast.close(); @@ -1259,7 +1261,7 @@ test("integration: lite draft-07 fetch uses the republished track's timescale", const firstTrack = first.createTrack("video", { timescale: Timescale.MILLI }); const firstGroup = firstTrack.appendGroup(); firstGroup.writeFrame({ payload: enc.encode("alpha"), timestamp: Timestamp.fromMillis(10) }); - firstGroup.close(); + firstGroup.finish(); // Prime the publisher's TRACK_INFO cache against the predecessor. const remote = wireOf(client).consume(path); @@ -1269,7 +1271,7 @@ test("integration: lite draft-07 fetch uses the republished track's timescale", const secondTrack = second.createTrack("video", { timescale: Timescale.MICRO }); const secondGroup = secondTrack.appendGroup(); secondGroup.writeFrame({ payload: enc.encode("beta"), timestamp: Timestamp.fromMicros(1234) }); - secondGroup.close(); + secondGroup.finish(); // A millisecond timescale would round this to 1000us. const fetched = await remote.track("video").fetchGroup(0); @@ -1527,7 +1529,7 @@ test("integration: lite fetch teardown when the reader abandons an open group", fetched.close(); await waitUntil(() => !group.demand().used.peek()); - group.close(); + group.finish(); broadcast.close(); remote.close(); client.abort(); @@ -1640,7 +1642,7 @@ test("integration: lite coalesced fetch stays until every reader abandons the op f2.close(); await waitUntil(() => !group.demand().used.peek()); - group.close(); + group.finish(); broadcast.close(); remote.close(); client.abort(); @@ -1682,7 +1684,7 @@ test("integration: lite fetch re-armed by a late reader keeps every frame", asyn expect(`${delay}: ${more}`).toBe(`${delay}: more`); f2.close(); - group.close(); + group.finish(); } broadcast.close(); @@ -1706,7 +1708,7 @@ test("integration: lite fetch delivers every frame of a finite multi-frame group const group = video.appendGroup(); const count = 50; for (let i = 0; i < count; i++) group.writeFrame(textFrame(`f${i}`)); - group.close(); // finite + group.finish(); // finite const remote = wireOf(client).consume(Path.from("test")); const fetched = await remote.track("video").fetchGroup(group.sequence); @@ -2811,7 +2813,11 @@ async function runSessionDeath( client.abort(); server.abort(); origin.close(); - return [closed, await withTimeout(groupEnd, Nano.fromMilli(Milli(2000)), "the group never ended"), track.end()]; + return [ + closed, + await withTimeout(groupEnd, Nano.fromMilli(Milli(2000)), "the group never ended"), + hooks.subscriberEnd(track), + ]; } for (const [name, protocol, version] of [ @@ -2865,7 +2871,7 @@ test("integration: lite draft-05 ends a track with the publisher's reset", async expect(await group?.readString()).toBe("head"); const reset = StreamCode(70); - for (const producer of served) producer.close(new StreamError(reset)); + for (const producer of served) producer.abort(new StreamError(reset)); const closed = await withTimeout( Promise.resolve(track.closed), Nano.fromMilli(Milli(2000)), diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 43f20d20382..df098329efb 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -366,9 +366,19 @@ export const hooks: { /** * End a wire subscription's copy cleanly without declaring an end, for a session closed * locally: a dead route, not finished content, so a resumed reader's replacement route - * continues past it. Mirrors the Rust `close` on a `received()` copy; assigned by `track.ts`. + * continues past it. Rust aborts its copy with `Cancel` instead, which its resume also reads + * as a dead route. Assigned by `track.ts`. */ releaseTrack: (producer: Producer) => void; + /** + * Settles once the producer is released (closed or aborted), unlike its public `closed`, + * which also settles on a reached end while the producer keeps serving. Assigned by `track.ts`. + */ + trackReleased: (producer: Producer) => GetPromise; + /** {@link trackReleased} for a demand handle. Assigned by `track.ts`. */ + demandReleased: (demand: Demand) => GetPromise; + /** Whether the producer declared the track's end, released or not. Assigned by `track.ts`. */ + trackFinished: (producer: Producer) => boolean; /** * Record that the publisher gave up the groups `[start, end)` with `error`: none will * arrive, so a reader waiting on one moves past it, a group still arriving in them ends with @@ -380,6 +390,11 @@ export const hooks: { producerLargest: (producer: Producer) => Getter; /** Re-evaluate a live-edge join at this subscriber's copy. */ + /** + * The track's exclusive end as this subscriber's copy knows it now, for a publisher + * forwarding it. Readers await `finished()` instead. Assigned by `track.ts`. + */ + subscriberEnd: (subscriber: Subscriber) => Position | undefined; joinSubscriber: (subscriber: Subscriber) => void; /** Re-evaluate a live-edge join at a producer. */ joinProducer: (producer: Producer) => void; @@ -518,6 +533,11 @@ export const hooks: { /** Keep a mirror of a group accepted unverified below its track's eviction watermark out of media-time expiry. */ unverifyGroup: (group: GroupConsumer) => void; /** Return a group's first timestamp, retained even after its first frame is read. */ + /** + * Release a group reader, failing it with `abort` if given: a mirror closes its own copy, + * a shared handle ends only itself. Assigned by `group.ts`. + */ + releaseGroupReader: (group: GroupConsumer, abort?: Error) => void; groupTimestamp: (group: GroupConsumer) => Timestamp | undefined; groupLatest: (group: GroupConsumer) => Timestamp | undefined; /** Return when a group joined its track at this hop, on the installed clock. */ @@ -562,6 +582,15 @@ export const hooks: { releaseTrack: () => { throw new Error("track.ts not loaded"); }, + trackReleased: () => { + throw new Error("track.ts not loaded"); + }, + demandReleased: () => { + throw new Error("track.ts not loaded"); + }, + trackFinished: () => { + throw new Error("track.ts not loaded"); + }, dropTrackGroups: () => { throw new Error("track.ts not loaded"); }, @@ -571,6 +600,9 @@ export const hooks: { joinProducer: () => { throw new Error("track.ts not loaded"); }, + subscriberEnd: () => { + throw new Error("track.ts not loaded"); + }, joinSubscriber: () => { throw new Error("track.ts not loaded"); }, @@ -709,6 +741,9 @@ export const hooks: { arriveGroup: () => { throw new Error("group.ts not loaded"); }, + releaseGroupReader: () => { + throw new Error("group.ts not loaded"); + }, groupTimestamp: () => { throw new Error("group.ts not loaded"); }, diff --git a/js/net/src/listing.test.ts b/js/net/src/listing.test.ts index 7f47d064fe5..a74c0f92f77 100644 --- a/js/net/src/listing.test.ts +++ b/js/net/src/listing.test.ts @@ -76,13 +76,15 @@ test("a track leaving the table is removed", async () => { const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "+b", "synced"]); broadcast.removeTrack("a"); - b.close(new Error("aborted")); + b.abort(new Error("aborted")); expect(await tracks.drain()).toEqual(["-a", "-b"]); }); test("a finished track is listed while the table holds it", async () => { const broadcast = new BroadcastProducer(); - broadcast.createTrack("a").close(); + const a = broadcast.createTrack("a"); + a.finish(); + a.close(); const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "synced"]); // It finished before it was removed. @@ -92,6 +94,17 @@ test("a finished track is listed while the table holds it", async () => { expect(await tracks.drain()).toEqual(["end"]); }); +test("a track that finished but stays open ends the listing as finished", async () => { + const broadcast = new BroadcastProducer(); + const a = broadcast.createTrack("a"); + broadcast.createTrack("b"); + a.finish(); + const tracks = list(broadcast); + expect(await tracks.drain()).toEqual(["+a", "+b", "synced"]); + broadcast.close(); + expect(await tracks.drain()).toEqual(["~a", "end"]); +}); + test("a lister finishes or removes a track", async () => { const broadcast = new BroadcastProducer(); const lister = wireOf(broadcast).lister(); @@ -108,7 +121,9 @@ test("a lister finishes or removes a track", async () => { test("a recreated track leaves as it ended before it is added again", async () => { const broadcast = new BroadcastProducer(); - broadcast.createTrack("a").close(); + const a = broadcast.createTrack("a"); + a.finish(); + a.close(); const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "synced"]); // The finished track still holds the name; its replacement takes it over. @@ -205,10 +220,12 @@ test("a clean end follows the removals before it", async () => { const live = broadcast.createTrack("live"); const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "+b", "+live", "synced"]); + a.finish(); a.close(); broadcast.removeTrack("a"); broadcast.removeTrack("b"); broadcast.createTrack("unseen"); + live.finish(); live.close(); broadcast.close(); expect(await tracks.drain()).toEqual(["~a", "-b", "~live", "end"]); @@ -221,6 +238,7 @@ test("a clean end follows the removals before it", async () => { const own = both.createTrack("a"); const served = list(both); expect(await served.drain()).toEqual(["+a", "synced"]); + own.finish(); own.close(); both.close(); expect(await served.drain()).toEqual(["~a", "end"]); @@ -230,6 +248,7 @@ test("a clean end follows the removals before it", async () => { const gone = closing.createTrack("gone"); const reader = list(closing); expect(await reader.drain()).toEqual(["+gone", "synced"]); + gone.finish(); gone.close(); closing.removeTrack("gone"); closing.close(); @@ -396,5 +415,5 @@ test("a lister sees the broadcast end", async () => { const broadcast = new BroadcastProducer(); const lister = wireOf(broadcast).lister(); broadcast.close(); - expect(lister.ended.peek()).toBeNull(); + expect(lister.closed.peek()).toBeNull(); }); diff --git a/js/net/src/listing.ts b/js/net/src/listing.ts index 736f742cdb0..05f612d0533 100644 --- a/js/net/src/listing.ts +++ b/js/net/src/listing.ts @@ -14,6 +14,7 @@ */ import { type GetPromise, type Getter, Signal } from "@moq/signals"; import { StreamCode, StreamError } from "./error.ts"; +import { hooks } from "./internal.ts"; import type * as track from "./track.ts"; /** @@ -34,9 +35,13 @@ export type TracksEvent = | { kind: "removed"; name: string } | { kind: "finished"; name: string }; -/** Whether `own` closed cleanly, so its track leaves the listing as finished. */ +/** + * Whether `own` leaves the listing as finished: it closed cleanly, or it declared its end and is + * still open, as the Rust listing counts a producer that finished but still holds its track. + */ function finished(own: track.Producer): boolean { - return own.closed.peek() === null; + const released = hooks.trackReleased(own).peek(); + return released === null || (released === undefined && hooks.trackFinished(own)); } /** One listed track and who lists it. */ @@ -429,8 +434,8 @@ export class Lister { return this.#listing.used; } - /** Settles once the broadcast ended. */ - get ended(): GetPromise { + /** Settles once the broadcast ended, as every listing of it does. */ + get closed(): GetPromise { return this.#listing.closed; } diff --git a/js/net/src/lite/compression.test.ts b/js/net/src/lite/compression.test.ts index a023997c355..8acd2b32145 100644 --- a/js/net/src/lite/compression.test.ts +++ b/js/net/src/lite/compression.test.ts @@ -46,7 +46,7 @@ for (const downstream of [ALPN_06, ALPN_07_WIP]) { const group = track.appendGroup(); const payloads = [new Uint8Array(4096).fill(7), new Uint8Array(), new Uint8Array(4096).fill(7)]; for (const payload of payloads) group.writeFrame({ payload }); - group.close(); + group.finish(); const received = await reader.recvGroup(); if (!received) throw new Error("missing group"); expect(received.sequence).toBe(0); diff --git a/js/net/src/lite/connection.test.ts b/js/net/src/lite/connection.test.ts index 1012a28a98f..863fdd23c8b 100644 --- a/js/net/src/lite/connection.test.ts +++ b/js/net/src/lite/connection.test.ts @@ -245,7 +245,8 @@ for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { fin.enable(); const last = producer.appendGroup(); last.writeFrame(textFrame("last")); - last.close(); + last.finish(); + producer.finish(); producer.close(); const closing = server.close(); @@ -287,7 +288,7 @@ async function servedRawSubscription() { const group = producer.appendGroup(); group.writeFrame(textFrame("last")); - group.close(); + group.finish(); const subscriber = await Stream.open(pair.client, { version }); await subscriber.writer.u53(StreamId.Subscribe); @@ -302,6 +303,7 @@ async function servedRawSubscription() { // Finish the track only once it is served: a track closed before anyone subscribed is gone. expect("start" in (await decodeSubscribeResponse(subscriber.reader, version))).toBe(true); + producer.finish(); producer.close(); // SUBSCRIBE_END, then the publisher's FIN. await subscriber.reader.readAll(); @@ -389,7 +391,7 @@ test("close waits for a served FETCH to be acknowledged", async () => { const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = producer.appendGroup(); group.writeFrame(textFrame("last")); - group.close(); + group.finish(); const server = new Connection({ url: new URL("https://relay.example/"), quic: pair.server, @@ -442,7 +444,7 @@ test("close waits for a request served while withdrawals are in flight", async ( const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = producer.appendGroup(); group.writeFrame(textFrame("last")); - group.close(); + group.finish(); const url = new URL("https://localhost/test"); const [client, server] = await Promise.all([ connect({ url, transport: pair.client, consume: destination }), @@ -471,6 +473,7 @@ test("close waits for a request served while withdrawals are in flight", async ( startGroup: 0, }).encode(subscriber.writer, version); expect("start" in (await decodeSubscribeResponse(subscriber.reader, version))).toBe(true); + producer.finish(); producer.close(); await subscriber.reader.readAll(); diff --git a/js/net/src/lite/group.test.ts b/js/net/src/lite/group.test.ts index b0f36716873..cf5019c0cd6 100644 --- a/js/net/src/lite/group.test.ts +++ b/js/net/src/lite/group.test.ts @@ -58,7 +58,7 @@ test("frames split at every byte keep their payloads and timestamps", async () = const chunks = Array.from(bytes, (byte) => new Uint8Array([byte])); await readFrames(streamOf(chunks), producer, { scale: SCALE }); - producer.close(); + producer.finish(); let ts = 0; for (const { delta, payload } of frames) { @@ -74,7 +74,7 @@ test("frames without a timescale carry no timestamp prefix", async () => { const producer = new Producer(0); const consumer = producer.consume(); await readFrames(streamOf([encode([{ payload: [7] }, { payload: [8, 9] }])]), producer, { scale: 0 }); - producer.close(); + producer.finish(); expect((await consumer.readFrame())?.payload).toEqual(new Uint8Array([7])); expect((await consumer.readFrame())?.payload).toEqual(new Uint8Array([8, 9])); @@ -94,6 +94,6 @@ test("stops once the group closes", async () => { // Never ends: only the close can stop the read. const stream = new Reader(new ReadableStream(), undefined, VERSION); const done = readFrames(stream, producer, { scale: SCALE }); - producer.close(); + producer.finish(); await done; }); diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 8f6d25862ab..04426fc450a 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -212,7 +212,7 @@ async function subscribeEnd(sequences: number[], version: Version = Version.DRAF for (const sequence of sequences) { const group = new GroupProducer(sequence); group.writeFrame(textFrame("hello", Timestamp.fromMillis(sequence))); - group.close(); + group.finish(); track.writeGroup(group); // Let the publisher drain this group before the next, so arrival order is the @@ -220,6 +220,7 @@ async function subscribeEnd(sequences: number[], version: Version = Version.DRAF // arrives late opens no stream at all, so there is nothing firmer to wait on. await new Promise((resolve) => setTimeout(resolve, 5)); } + track.finish(); track.close(); try { @@ -305,7 +306,7 @@ async function groupSendOrders(options: { }); } finally { opened.releaseLock(); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); publisher.close(); client.close(); } @@ -397,7 +398,7 @@ test("lite draft-05: a subscribe update re-ranks a group already on the wire", a expect(stream.sendOrder).toBe(sendOrder({ priority: 9 })); } finally { - group.close(); + group.finish(); publisher.close(); client.close(); } @@ -456,7 +457,7 @@ test("lite draft-05: a subscribe update during the stream open still ranks the g expect(pair.server.sendStreams.uni[0]?.sendOrder).toBe(sendOrder({ priority: 9 })); } finally { - group.close(); + group.finish(); publisher.close(); client.close(); } @@ -511,7 +512,7 @@ test("lite draft-05: many concurrent groups share one subscription listener", as expect(pair.server.sendStreams.uni.map((stream) => stream.sendOrder)).toEqual(expected); } finally { - for (const group of groups) group.close(); + for (const group of groups) group.finish(); publisher.close(); client.close(); } @@ -529,7 +530,7 @@ test("lite draft-05: the fetch response ranks the publisher's own writes", async const group = new GroupProducer(7); group.writeFrame(textFrame("hello")); - group.close(); + group.finish(); track.writeGroup(group); const client = await Stream.open(pair.client, { version: Version.DRAFT_05 }); @@ -565,7 +566,7 @@ test("lite draft-06: an unchecked track's fetched frames carry their send time", const group = new GroupProducer(0); group.writeFrame(textFrame("hello", Timestamp.fromMillis(36_000_000))); - group.close(); + group.finish(); track.writeGroup(group); const client = await Stream.open(pair.client, { version: Version.DRAFT_06 }); @@ -718,7 +719,7 @@ async function servedSubscription( serve(sequence: number) { const group = new GroupProducer(sequence); for (const frame of frames) group.writeFrame(textFrame(frame, Timestamp.fromMillis(sequence))); - group.close(); + group.finish(); track.writeGroup(group); }, // Makes the next group stream withhold its FIN from the client, the way a stream the @@ -833,13 +834,14 @@ test("lite draft-06: a cut group is dropped early, once", async () => { const cut = new GroupProducer(1); sub.track.writeGroup(cut); - cut.close(new Error("cut")); + cut.abort(new Error("cut")); const resp = await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06); if (!("drop" in resp)) throw new Error("expected SUBSCRIBE_DROP"); expect([resp.drop.start, resp.drop.end]).toEqual([1, 1]); sub.serve(2); expect(await sub.servedSequence()).toBe(2); + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([]); } finally { @@ -859,7 +861,7 @@ for (const version of [Version.DRAFT_06, Version.DRAFT_07]) { const cut = new GroupProducer(1); sub.track.writeGroup(cut); - cut.close(new StreamError(StreamCode(70), { message: "cut" })); + cut.abort(new StreamError(StreamCode(70), { message: "cut" })); const resp = await decodeSubscribeResponse(sub.client.reader, version); if (!("drop" in resp)) throw new Error("expected SUBSCRIBE_DROP"); expect([resp.drop.start, resp.drop.end, resp.drop.error]).toEqual([ @@ -899,6 +901,7 @@ for (const version of [Version.DRAFT_06, Version.DRAFT_07]) { expect(await sub.servedSequence()).toBe(5); await replayUpdate({ priority: 0, startGroup: 2 }).encode(sub.client.writer, version); await flush(); + sub.track.finish(); sub.track.close(); const codes: number[] = []; for (;;) { @@ -969,6 +972,7 @@ test("lite draft-06: the end sweep does not drop a sent datagram", async () => { expect(await sub.servedSequence()).toBe(0); sub.track.insertDatagram(1, Timestamp.fromMillis(1), new Uint8Array([1])); await flush(); + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([]); } finally { @@ -987,7 +991,7 @@ test("lite draft-06: a cut dropped then re-created is not served", async () => { const cut = new GroupProducer(1); sub.track.writeGroup(cut); - cut.close(new Error("cut")); + cut.abort(new Error("cut")); const resp = await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06); if (!("drop" in resp)) throw new Error("expected SUBSCRIBE_DROP"); expect([resp.drop.start, resp.drop.end]).toEqual([1, 1]); @@ -995,6 +999,7 @@ test("lite draft-06: a cut dropped then re-created is not served", async () => { sub.serve(1); sub.serve(2); expect(await sub.servedSequence()).toBe(2); + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([]); } finally { @@ -1052,6 +1057,7 @@ test("lite draft-06: an update lowering the start is swept at the end", async () expect(await sub.servedSequence()).toBe(5); await replayUpdate({ priority: 0, startGroup: 2 }).encode(sub.client.writer, Version.DRAFT_06); await flush(); + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([[2, 4]]); } finally { @@ -1074,6 +1080,7 @@ test("lite draft-06: a raised floor is not dropped early", async () => { sub.serve(sequence); expect(await sub.servedSequence()).toBe(sequence); } + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([]); } finally { @@ -1178,6 +1185,7 @@ for (const version of [Version.DRAFT_06, Version.DRAFT_07]) { hooks.startTrack(sub.track, { requested: 2 }); sub.serve(6); await flush(); + sub.track.finish(); sub.track.close(); const response = await decodeSubscribeResponse(sub.client.reader, version); if (!("start" in response)) throw new Error("expected SUBSCRIBE_START"); @@ -1200,7 +1208,8 @@ for (const skipped of ["failed", "stale"] as const) { const group = new GroupProducer(5); group.writeFrame(textFrame("old", Timestamp.fromMillis(5))); sub.track.writeGroup(group); - group.close(skipped === "failed" ? new Error("cut") : undefined); + if (skipped === "failed") group.abort(new Error("cut")); + else group.finish(); if (skipped === "failed") hooks.dropTrackGroups(sub.track, 7, 8, new Error("cut")); sub.serve(6); const response = await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06); @@ -1660,7 +1669,7 @@ test("lite draft-07: subscribe end precedes groups below a declared finish", asy try { const first = new GroupProducer(0); first.writeFrame(textFrame("hello")); - first.close(); + first.finish(); track.writeGroup(first); track.finishAt({ group: 2, frame: 0 }); @@ -1672,8 +1681,9 @@ test("lite draft-07: subscribe end precedes groups below a declared finish", asy const second = new GroupProducer(1); second.writeFrame(textFrame("hello")); - second.close(); + second.finish(); track.writeGroup(second); + track.finish(); track.close(); const resp = await pending; @@ -1714,8 +1724,9 @@ async function heldOpenEnd() { const group = new GroupProducer(0); group.writeFrame(textFrame("hello")); - group.close(); + group.finish(); track.writeGroup(group); + track.finish(); track.close(); const start = await decodeSubscribeResponse(client.reader, Version.DRAFT_07); @@ -1796,8 +1807,9 @@ test("lite draft-07: a group whose close fails is dropped", async () => { try { const group = new GroupProducer(0); group.writeFrame(textFrame("hello")); - group.close(); + group.finish(); track.writeGroup(group); + track.finish(); track.close(); const start = await decodeSubscribeResponse(client.reader, Version.DRAFT_07); @@ -1846,7 +1858,7 @@ test("blocked DROP responses stop opening later groups", async () => { const append = (sequence: number) => { const group = new GroupProducer(sequence); group.writeFrame(textFrame("frame")); - group.close(); + group.finish(); track.writeGroup(group); }; for (let sequence = 0; sequence < 8; sequence++) append(sequence); @@ -1863,6 +1875,7 @@ test("blocked DROP responses stop opening later groups", async () => { await new Promise((resolve) => setImmediate(resolve)); expect(opens).toHaveBeenCalledTimes(8); release(); + track.finish(); track.close(); let dropped = 0; for (;;) { @@ -1922,13 +1935,14 @@ async function serve( for (const [sequence, frames] of Object.entries(groups)) { const group = new GroupProducer(Number(sequence)); for (const frame of frames) group.writeFrame(textFrame(frame)); - group.close(); + group.finish(); track.writeGroup(group); // Let the publisher drain each group before the next, so the streams arrive in // the order written rather than whatever the cache hands over in one batch. await new Promise((resolve) => setTimeout(resolve, 5)); } + track.finish(); track.close(); const reader = pair.client.incomingUnidirectionalStreams.getReader(); @@ -2102,7 +2116,7 @@ async function saturatedGroup() { // A finished group still has frames to send, so its own close must not drop it. const group = new GroupProducer(0); group.writeFrame(textFrame("hello")); - group.close(); + group.finish(); track.writeGroup(group); // The group is queued inside the open from here on. @@ -2183,13 +2197,13 @@ test("lite draft-05: a blocked group header is reset when the group expires", as const old = new GroupProducer(0); old.writeFrame({ payload: new TextEncoder().encode("old"), timestamp: Timestamp.fromMillis(0) }); - old.close(); + old.finish(); track.writeGroup(old); await headerStarted; const edge = new GroupProducer(1); edge.writeFrame({ payload: new TextEncoder().encode("edge"), timestamp: Timestamp.fromMillis(1000) }); - edge.close(); + edge.finish(); track.writeGroup(edge); const resetBeforeRelease = await Promise.race([ @@ -2227,6 +2241,7 @@ test("lite draft-05: a group waiting for a stream slot is dropped when the subsc test("lite draft-05: a group waiting for a stream slot survives the track finishing", async () => { const { client, track, freeSlot, outcome, close } = await saturatedGroup(); + track.finish(); track.close(); // SUBSCRIBE_END goes out while the group is still waiting for its slot. @@ -2326,7 +2341,7 @@ test("lite draft-05: a group parked at its tail resets as too old when it expire for (const ms of [1000, 2000]) { const group = new GroupProducer(ms / 1000); group.writeFrame(frame(ms)); - group.close(); + group.finish(); track.writeGroup(group); } @@ -2337,7 +2352,7 @@ test("lite draft-05: a group parked at its tail resets as too old when it expire expect(end).toMatchObject({ code: StreamCode.Old }); } finally { opened.releaseLock(); - open.close(); + open.finish(); publisher.close(); client.close(); broadcast.close(); @@ -2397,7 +2412,7 @@ test("lite draft-05: a group that goes stale while its stream opens writes nothi const write = (sequence: number, ms: number) => { const group = new GroupProducer(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(ms) }); - group.close(); + group.finish(); track.writeGroup(group); }; write(0, 0); @@ -2699,7 +2714,7 @@ test("lite draft-06: a grant that shrinks mid-fetch resets the fetch", async () expect(unauthorizedResets(resets)).toContain("unauthorized: live"); } finally { resets.mockRestore(); - group.close(); + group.finish(); publisher.close(); client.close(); broadcast.close(); @@ -2739,7 +2754,7 @@ test("lite draft-07: a fetch whose budget gives up partway resets as too old", a for (const ms of [1000, 2000]) { const group = new GroupProducer(ms / 1000); group.writeFrame(frame(ms)); - group.close(); + group.finish(); track.writeGroup(group); } await serving; @@ -2747,7 +2762,7 @@ test("lite draft-07: a fetch whose budget gives up partway resets as too old", a expect(resets.mock.calls.map(([reason]) => toStreamCode(reason as Error))).toContain(StreamCode.Old); } finally { resets.mockRestore(); - open.close(); + open.finish(); publisher.close(); client.close(); broadcast.close(); @@ -2764,7 +2779,7 @@ test.each([0, 1])("lite draft-07 reports the cached largest position when starti const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = new GroupProducer(0); group.writeFrame(textFrame("cached")); - group.close(); + group.finish(); track.writeGroup(group); const client = await Stream.open(pair.client, { version }); const server = await Stream.accept(pair.server, version); @@ -2809,7 +2824,7 @@ test("lite draft-07 reports the source's largest position past what a relay hold hooks.reportedLargest(track, { group: 4, frame: 0 }); const group = new GroupProducer(0); group.writeFrame(textFrame("oldest")); - group.close(); + group.finish(); track.writeGroup(group); const client = await Stream.open(pair.client, { version }); const server = await Stream.accept(pair.server, version); @@ -2904,6 +2919,7 @@ for (const version of [Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07]) { client.close(); server.close(); publisher.close(); + track.finish(); track.close(); broadcast.close(); origin.close(); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 6fdce03851b..f7f03b36f04 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1001,7 +1001,7 @@ export class Publisher { let fetched: group.Consumer | undefined; const watch = this.#gate.watch(msg.broadcast, () => { console.debug(`fetch revoked: broadcast=${msg.broadcast} track=${msg.track} group=${msg.group}`); - fetched?.close(revoked); + if (fetched) hooks.releaseGroupReader(fetched, revoked); stream.abort(revoked); }); try { @@ -1075,7 +1075,7 @@ export class Publisher { serving(group); // Revoked while the group resolved: the watch already reset the stream. if (watch.revoked()) { - group.close(unauthorized(msg.broadcast)); + hooks.releaseGroupReader(group, unauthorized(msg.broadcast)); return; } await this.#runFetchGroup(group, stream.writer, { @@ -1093,7 +1093,7 @@ export class Publisher { console.warn( `fetch error: broadcast=${msg.broadcast} track=${msg.track} group=${msg.group} error=${reason(e)}`, ); - group?.close(e); + if (group) hooks.releaseGroupReader(group, e); stream.abort(e); } } finally { @@ -1177,7 +1177,7 @@ export class Publisher { // Lite cannot name a frame, so a mid-group end rounds up past its group, whose own // stream FIN says where it stops. const boundary = () => { - const end = track.end(); + const end = hooks.subscriberEnd(track); return end ? finalSequence(end) : (hooks.latestSequence(track) ?? -1) + 1; }; @@ -1323,7 +1323,7 @@ export class Publisher { } } // A declared end goes out while lower groups are still being produced. - if (!endSent && track.end() !== undefined) { + if (!endSent && hooks.subscriberEnd(track) !== undefined) { if (!(await sendEnd())) return false; continue; } @@ -1736,13 +1736,13 @@ export class Publisher { } if (!stream) { const failed = failure ?? new Error("no stream slot"); - group.close(failed); + hooks.releaseGroupReader(group, failed); return failed; } return stream; } catch (err: unknown) { const failed = error(err); - group.close(failed); + hooks.releaseGroupReader(group, failed); return failed; } } @@ -1821,7 +1821,7 @@ export class Publisher { } catch (err: unknown) { const e = error(err); stream.reset(e); - group.close(e); + hooks.releaseGroupReader(group, e); return e; } finally { priority.remove(stream); diff --git a/js/net/src/lite/refresh.test.ts b/js/net/src/lite/refresh.test.ts index cdde14e64d8..4a1cc2e0524 100644 --- a/js/net/src/lite/refresh.test.ts +++ b/js/net/src/lite/refresh.test.ts @@ -74,7 +74,7 @@ test.each([ALPN_06, ALPN_07_WIP])("refresh joins reach a browser publisher on %s for (const join of joins) join.close(); received.close(); baseline.close(); - group.close(); + group.finish(); } finally { spy.mockRestore(); for (const origin of [b, source]) origin.close(); diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index 21928323ace..3dc71585c9e 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -2128,7 +2128,8 @@ test("a publisher FIN without SUBSCRIBE_END still violates an open subscription" }); // A lite-07 publisher FINs a bounded subscription without SUBSCRIBE_END once it covered the range, -// which ends the subscription cleanly rather than violating the protocol. +// which ends the subscription cleanly rather than violating the protocol. Covering a range is +// not ending the track, so no end is declared. test("a lite-07 publisher FIN ends a covered bounded subscription", async () => { const version = Version.DRAFT_07; const { quic, streams, opened, closes } = fakeSession(); @@ -2154,6 +2155,7 @@ test("a lite-07 publisher FIN ends a covered bounded subscription", async () => for (const chunk of chunks) stream.inbound.enqueue(chunk); stream.inbound.close(); expect(await track.closed).toBeNull(); + await expect(track.finished()).rejects.toThrow("track closed before its end was known"); await drainReactions(); expect(closes).toEqual([]); } finally { diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index c45ed3a4762..aaf2753c419 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -32,6 +32,7 @@ import { Tail } from "../tail.ts"; import * as Time from "../time.ts"; import type * as track from "../track.ts"; import { untilAborted } from "../util/abort.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { Deadline, REQUEST_TIMEOUT_MS, requestOptions } from "../util/deadline.ts"; import { abandoned, isAbandoned, unwanted } from "../util/demand.ts"; import { LIMITS, type Limits, SlotMap, Slots } from "../util/limits.ts"; @@ -271,8 +272,11 @@ export class Subscriber { announced(scope: Path.Pattern = Path.Pattern.all(), options?: announce.Options): announce.Consumer { const announced = new announce.Producer(); // The wire speaks announce interest by prefix, and echoes suffixes beneath it. + const consumer = announced.consume(); + // The last reader leaving ends the stream, which cancels the request. + closeUnwanted(announced); void this.#runAnnounced(announced, scopeHead(scope), scope, options?.hidden ?? false); - return announced.consume(); + return consumer; } async #runAnnounced( @@ -682,9 +686,9 @@ export class Subscriber { async #runListing(broadcast: Path.Valid, consumed: Consumed, lister: Lister) { try { for (;;) { - if (lister.ended.peek() !== undefined || this.#closed.signal.aborted) return; + if (lister.closed.peek() !== undefined || this.#closed.signal.aborted) return; if (!lister.used.peek()) { - await untilAborted(race([Signal.race(lister.used, lister.ended)]), this.#closed.signal).catch( + await untilAborted(race([Signal.race(lister.used, lister.closed)]), this.#closed.signal).catch( () => {}, ); continue; @@ -720,8 +724,8 @@ export class Subscriber { ?.ready(broadcast, "subscribe", { signal: setup, until: async () => { - while (lister.used.peek() && lister.ended.peek() === undefined) { - await Signal.race(lister.used, lister.ended); + while (lister.used.peek() && lister.closed.peek() === undefined) { + await Signal.race(lister.used, lister.closed); } }, }) @@ -857,7 +861,7 @@ export class Subscriber { state.revoked = refused; console.debug(`subscribe revoked: id=${id} broadcast=${broadcast} track=${request.name}`); state.cancel.abort(refused); - state.producer?.close(refused); + state.producer?.abort(refused); state.stream?.abort(refused); }); try { @@ -906,7 +910,7 @@ export class Subscriber { // its TRACK stream so a peer that never answers can't hold one per attempt. const retirer = (signal?: AbortSignal) => () => { state.cancel.abort(signal?.reason); - state.producer?.close(error(signal?.reason)); + state.producer?.abort(error(signal?.reason)); state.stream?.abort(error(signal?.reason)); }; const retire = retirer(valid); @@ -983,7 +987,7 @@ export class Subscriber { // once; race them into one stable promise so the demand loop doesn't re-subscribe each pass. // Updates stop quietly at the FIN or a failed write, either of which can land before the // responses ahead of it are decoded, so only a refused update is terminal on its own. - const terminal: PromiseLike[] = [closed, producer.closed]; + const terminal: PromiseLike[] = [closed, hooks.trackReleased(producer)]; if (subscriptionUpdates !== undefined) terminal.push(subscriptionUpdates.then(() => closed)); const done = race(terminal); @@ -994,14 +998,21 @@ export class Subscriber { const demand = producer.demand(); for (;;) { const reason = await race([done, unwanted(demand).then(() => idle)]); - if (reason === idle && demand.closed.peek() === undefined && demand.wanted.peek() !== undefined) + if ( + reason === idle && + hooks.demandReleased(demand).peek() === undefined && + demand.wanted.peek() !== undefined + ) continue; break; } // A publisher that covered the range before reading an update widening it sent - // fewer groups than the track has, so it fails rather than ending clean. - producer.close(short); + // fewer groups than the track has, so it fails rather than ending clean. Otherwise + // only a received SUBSCRIBE_END declares the track's end: a range covered or a + // subscription nobody wants anymore is a released copy, not finished content. + if (short) producer.abort(short); + else hooks.releaseTrack(producer); // A settled lite07 tail acknowledges completion with FIN, without cancelling // the publisher's already-finished receive half. if (tailSettled && waitsForSubscriberFin(this.version)) stream.writer.close(); @@ -1009,7 +1020,7 @@ export class Subscriber { console.debug(`subscribe close: id=${id} broadcast=${broadcast} track=${request.name}`); } catch (err) { const e = await sessionCause(this.#quic, err); - producer.close(e); + producer.abort(e); console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); stream.abort(e); } finally { @@ -1283,7 +1294,7 @@ export class Subscriber { // Arm after admission, before checking: another unanswered token may still // widen the union while we wait, which is not a revocation of this fetch. const disposeGrant = this.#grant?.subscribe(() => { - if (this.#denied(broadcast)) group.close(refused); + if (this.#denied(broadcast)) group.abort(refused); }); void group.closed.then(() => disposeGrant?.()); if (this.#denied(broadcast)) throw refused; @@ -1310,7 +1321,7 @@ export class Subscriber { hooks.bindGroupCompression(group, accepted.info.compress); void this.#runFetchResponse(accepted.stream, group, accepted.info); } catch (err: unknown) { - group.close(error(err)); + group.abort(error(err)); throw err; } } @@ -1380,7 +1391,10 @@ export class Subscriber { let unused = group .demand() .unused() - .then((): typeof idle => idle); + .then( + (): typeof idle => idle, + (): typeof idle => idle, + ); // A decode consumes its frame whenever it lands, so one outstanding across a re-arm is // kept and awaited again rather than abandoned with its frame. let pending: Promise | undefined; @@ -1396,7 +1410,10 @@ export class Subscriber { unused = group .demand() .unused() - .then((): typeof idle => idle); + .then( + (): typeof idle => idle, + (): typeof idle => idle, + ); continue; } // Abandoned mid-group: the truncated group must never end clean. @@ -1411,11 +1428,11 @@ export class Subscriber { hooks.writeGroupFrame(group, frame, frame.inflated); } - group.close(); + group.finish(); stream.close(); } catch (err: unknown) { const e = error(err); - group.close(e); + group.abort(e); stream.abort(e); } } @@ -1436,7 +1453,7 @@ export class Subscriber { if (!resp) { // A bounded range may have widened since, in an update the publisher never read. const covered = hasRanges(this.version) && entry.bounded; - if (entry.end === undefined && !covered && entry.track.closed.peek() === undefined) + if (entry.end === undefined && !covered && hooks.trackReleased(entry.track).peek() === undefined) throw new ProtocolViolation("subscribe stream ended without SUBSCRIBE_END"); return; } @@ -1452,7 +1469,7 @@ export class Subscriber { if (entry.end !== undefined) throw new ProtocolViolation("duplicate SUBSCRIBE_END"); entry.end = resp.end.group; // A local close can win the race with the response; there is nothing left to end. - if (entry.track.closed.peek() !== undefined) continue; + if (hooks.trackReleased(entry.track).peek() !== undefined) continue; try { // Lower groups may still be on the wire behind a higher one, so readers hold // at a hole below the end until the tail settles. @@ -1507,8 +1524,9 @@ export class Subscriber { return tail.covers(start, end); }; - await tail.settle(complete, track.closed); - if (entry.end !== undefined || !hasRanges(this.version) || track.closed.peek() !== undefined) return true; + const released = hooks.trackReleased(track); + await tail.settle(complete, released); + if (entry.end !== undefined || !hasRanges(this.version) || released.peek() !== undefined) return true; const demand = groupBounds(track.subscription.peek()?.groups); if (demand.start === entry.asked.start && demand.end === entry.asked.end) return true; // Without SUBSCRIBE_START nothing says which ranges the publisher read, so only the @@ -1535,7 +1553,7 @@ export class Subscriber { stream: Stream, ): Promise { const track = entry.track; - const stopped: Promise = race([track.closed, stream.reader.closed]).then(() => null); + const stopped: Promise = race([hooks.trackReleased(track), stream.reader.closed]).then(() => null); let lastSent: track.Subscription = { priority: msg.priority, order: msg.order, @@ -1662,7 +1680,7 @@ export class Subscriber { const violation = new ProtocolViolation( `group ${group.sequence} is at or past the declared end ${entry.end}`, ); - track.close(violation); + track.abort(violation); throw violation; } track.writeGroup(producer); @@ -1671,29 +1689,29 @@ export class Subscriber { // TRACK_INFO (or implicit defaults) resolves it on the subscribe stream. let scale = timescale.peek(); while (scale === undefined) { - if (track.closed.peek() !== undefined) { + if (hooks.trackReleased(track).peek() !== undefined) { // Subscription ended before the scale resolved; nothing to decode. - producer.close(); + producer.finish(); stream.stop(StreamCode.Cancel); return; } - await Signal.race(timescale, track.closed); + await Signal.race(timescale, hooks.trackReleased(track)); scale = timescale.peek(); } await readFrames(stream, producer, { scale, timed: hasMediaTime(this.version), compress: entry.compress }); - producer.close(); + producer.finish(); stream.stop(StreamCode.Cancel); } catch (err: unknown) { // A violation ends the session, which the connection does once it escapes; anything // else ends only this group. if (err instanceof ProtocolViolation) { - producer.close(err); + producer.abort(err); throw err; } const e = await sessionCause(this.#quic, err); - producer.close(e); + producer.abort(e); stream.stop(e); } finally { read(); @@ -1856,7 +1874,7 @@ export class Subscriber { this.#closed.abort(cut); for (const { track } of this.#subscribes.values()) { - if (err) track.close(err); + if (err) track.abort(err); else hooks.releaseTrack(track); } @@ -1864,7 +1882,7 @@ export class Subscriber { // This also releases callers still awaiting acceptance. for (const { group } of this.#fetches.values()) { - group.close(cut); + group.abort(cut); } } } @@ -1890,7 +1908,10 @@ async function untilAbandoned(group: netGroup.Producer, step: Promise): Pr group .demand() .unused() - .then((): typeof idle => idle), + .then( + (): typeof idle => idle, + (): typeof idle => idle, + ), ]), ); if (value !== idle) return value as T; @@ -1898,7 +1919,7 @@ async function untilAbandoned(group: netGroup.Producer, step: Promise): Pr // Close here rather than where the error lands, so no fetch coalesces onto the group // in between only to fail with it. const err = new StreamError(StreamCode.Cancel, { message: "cancel" }); - group.close(err); + group.abort(err); throw err; } } diff --git a/js/net/src/lite/tail.test.ts b/js/net/src/lite/tail.test.ts index 911a934a684..1fdcbea68e4 100644 --- a/js/net/src/lite/tail.test.ts +++ b/js/net/src/lite/tail.test.ts @@ -139,7 +139,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver expect(await readAll(await reader.recvGroup())).toEqual(["1.0"]); expect(await reader.recvGroup()).toBeUndefined(); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 2, frame: 0 }); }); test("a group read across the subscribe stream's FIN is delivered whole", async () => { @@ -221,7 +221,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver await settle(); expect(ended).toBe(false); expect(reader.closed.peek()).toBeUndefined(); - expect(reader.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 2, frame: 0 }); // The grace gives up on group 0, and the settled subscription ends the reader. now = GRACE; @@ -316,7 +316,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 0, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 0, frame: 0 }); }); // lite-05 specified an inclusive end, so there the group costs only its own stream. @@ -443,7 +443,7 @@ test("lite-07 settles a dropped range without grace", async () => { expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 3, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 3, frame: 0 }); }); for (const started of [false, true]) { @@ -481,7 +481,7 @@ test("lite-07 FIN waits for the skipped and reset final range to settle", async finished = true; }); // Wait for SUBSCRIBE_END to be decoded without advancing any timers. - while (reader.end()?.group !== 3) await Promise.resolve(); + while (hooks.subscriberEnd(reader)?.group !== 3) await Promise.resolve(); expect(finished).toBe(false); last.reset(); await last.handled; diff --git a/js/net/src/lite/widened_max_age.test.ts b/js/net/src/lite/widened_max_age.test.ts index 67a394013a2..259511181ee 100644 --- a/js/net/src/lite/widened_max_age.test.ts +++ b/js/net/src/lite/widened_max_age.test.ts @@ -36,7 +36,7 @@ test("a widened maxDelay re-offers what the publisher skipped below the start", for (let sequence = 0; sequence < 10; sequence++) { const group = producer.appendGroup(); group.writeFrame({ payload: new Uint8Array([sequence]), timestamp: Timestamp.fromMillis(sequence * 1000) }); - group.close(); + group.finish(); } const [client, server] = await Promise.all([ @@ -51,6 +51,7 @@ test("a widened maxDelay re-offers what the publisher skipped below the start", reader.update({ maxDelay: Milli(6500) }); expect(await received(reader, 4)).toEqual([2, 3, 4, 5]); // Ending the track flushes anything else the publisher would send: nothing, not even a repeat. + producer.finish(); producer.close(); expect(await reader.recvGroup()).toBeUndefined(); diff --git a/js/net/src/mock.ts b/js/net/src/mock.ts index 826842e81cf..ba6e74ef78b 100644 --- a/js/net/src/mock.ts +++ b/js/net/src/mock.ts @@ -4,6 +4,7 @@ * Creates paired client/server transports connected via TransformStreams. */ +import { Consumer as BroadcastConsumer } from "./broadcast.ts"; import type { TransportStats } from "./connection/stats.ts"; import type { Frame } from "./group.ts"; import type { SendStream } from "./stream.ts"; @@ -14,6 +15,17 @@ export function textFrame(text: string, timestamp = Timestamp.now()): Frame { return { payload: new TextEncoder().encode(text), timestamp }; } +/** + * A broadcast as a session hands it out: no producer, so it ends once no handle holds it, + * which is how a test sees the origin let go of an answer. + */ +export class SessionBroadcast extends BroadcastConsumer { + // biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public + constructor() { + super(); + } +} + // High watermark to prevent writes from blocking on backpressure. // Real WebTransport has kernel buffers; we simulate this with a large queue. const WRITABLE_STRATEGY: QueuingStrategy = { highWaterMark: 256 }; diff --git a/js/net/src/order.test.ts b/js/net/src/order.test.ts index 0cb8b6433b3..9b14735aee0 100644 --- a/js/net/src/order.test.ts +++ b/js/net/src/order.test.ts @@ -106,7 +106,7 @@ async function backlog(protocol: string, order: Order, update?: Order) { const result = { upstream: video.subscription.peek()?.order, orders: sendOrders(transport) }; - for (const group of groups) group.close(); + for (const group of groups) group.finish(); subscriber.close(); remote.close(); broadcast.close(); @@ -155,7 +155,7 @@ test.each(["ascending", "descending"] as const)("ietf serves GROUP_ORDER %s", as } expect(sendOrders(transport)).toEqual(expected(order, 3)); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); subscriber.close(); remote.close(); broadcast.close(); @@ -220,12 +220,12 @@ test("ietf keeps a closing group stream ranked until it settles", async () => { } // The newest group, ranked first, finishes while its FIN is held. - groups[2]?.close(); + groups[2]?.finish(); await fins.closing; expect(sendOrders(transport)).toEqual(expected("descending", 3)); fins.release(); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); subscriber.close(); remote.close(); broadcast.close(); @@ -268,7 +268,7 @@ test("a shared track aggregates descending and serves each subscriber in its own expect(sendOrders(ascending.transport)).toEqual(expected("ascending", 3)); expect(sendOrders(descending.transport)).toEqual(expected("descending", 3)); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); ascendingTrack.close(); descendingTrack.close(); ascendingRemote.close(); diff --git a/js/net/src/origin-demand.test.ts b/js/net/src/origin-demand.test.ts index 89d2f5e207c..0b5fb78344d 100644 --- a/js/net/src/origin-demand.test.ts +++ b/js/net/src/origin-demand.test.ts @@ -12,7 +12,7 @@ import { Publisher as LitePublisher } from "./lite/publisher.ts"; import { Subscribe as LiteSubscribe } from "./lite/subscribe.ts"; import { Track } from "./lite/track.ts"; import { Version as LiteVersion } from "./lite/version.ts"; -import { createMockTransportPair } from "./mock.ts"; +import { createMockTransportPair, SessionBroadcast } from "./mock.ts"; import { Producer } from "./origin.ts"; import * as Path from "./path.ts"; import { readsFrom } from "./resume.test-util"; @@ -170,9 +170,8 @@ test.each([true, false])( local.close(); } await settle(); - const late = new Broadcast(); - const front = late.consume(); - hooks.acceptRequest(old, front); + const late = new SessionBroadcast(); + hooks.acceptRequest(old, late); expect(late.closed.peek()).toBeNull(); const next = origin.request(path); const fresh = (await requests.next()).value; diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 57261d5b64e..e49cb50cad2 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -7,6 +7,7 @@ import { StreamCode, StreamError } from "./error.ts"; import { Producer as GroupProducer } from "./group.ts"; import { HopSchema, Route } from "./hop.ts"; import { hooks, spreadHash } from "./internal.ts"; +import { SessionBroadcast, textFrame } from "./mock.ts"; import type { Consumer, Table } from "./origin.ts"; import { Producer } from "./origin.ts"; import * as Path from "./path.ts"; @@ -242,34 +243,38 @@ test("a republish closes the superseded broadcast", async () => { origin.close(); }); -test("a consumer clone keeps a superseded broadcast alive", async () => { +test("a superseded broadcast closes, and its subscribers keep reading", async () => { const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("room"); const publication = origin.createBroadcast(path); const first = publication.broadcast; + const track = first.createTrack("video", { timescale: Timescale.MILLI }); publication.announce(); const mine = await routed(consumer, path); - expect(mine).toBeDefined(); + const subscriber = mine?.track("video").subscribe(); + if (!subscriber) throw new Error("expected a subscriber"); const replacement = origin.createBroadcast(path); replacement.announce(); - await settle(); - - // The application's clone holds the old broadcast open even though it is unpublished. - expect(first.closed.peek()).toBeUndefined(); - // Holding the fill alive never lets its stale publication displace or retract the replacement. + // The origin closes the old producer itself, whoever still holds a handle on it. + expect(first.closed.peek()).toBeNull(); + // Its stale publication never displaces or retracts the replacement. expect(() => publication.announce({ epoch: EPOCH })).toThrow("broadcast is closed"); publication.unannounce(); const current = await routed(consumer, path); expect(readsFrom(current, replacement.broadcast)).toBe(true); current?.close(); - mine?.close(); - await settle(); - expect(first.closed.peek()).not.toBeUndefined(); + // A track already subscribed is not the broadcast: it keeps serving until it ends. + track.writeFrame(textFrame("late")); + expect((await subscriber.recvGroup())?.sequence).toBe(0); + subscriber.close(); + mine?.close(); + track.finish(); + track.close(); origin.close(); }); @@ -478,10 +483,10 @@ test("a request resolves once a front answers, and survives its withdrawal", asy expect(request.active.peek()).toBeUndefined(); // A session answers (simulated): the slot's front resolves the request. - const upstream = new BroadcastProducer(); + const upstream = new SessionBroadcast(); const slot = wireOf(origin).requests.peek()?.get(path); expect(slot).toBeDefined(); - expect(wireOf(origin).answer(path, upstream.consume())).toBeDefined(); + expect(wireOf(origin).answer(path, upstream)).toBeDefined(); expect(request.active.peek()).toBeDefined(); // A second request for the same path shares the answer, each through a handle of its @@ -1978,8 +1983,8 @@ test("a broadcast a derived handle released cannot announce again", async () => scoped.close(); - // The application's clone keeps the broadcast open, but the released handle no longer routes it. - expect(broadcast.closed.peek()).toBeUndefined(); + // The released handle closes what it created, whoever still holds a handle on it. + expect(broadcast.closed.peek()).toBeNull(); expect(() => publication.announce()).toThrow("broadcast is closed"); expect((await announcedView(origin)).peek().has(path)).toBe(false); @@ -2426,7 +2431,7 @@ test.each([ expect(resolved?.epoch).toBe(from); const sticky = resolved?.track("video").subscribe(); - oldTrack.appendGroup().close(); + oldTrack.appendGroup().finish(); expect((await sticky?.recvGroup())?.sequence).toBe(0); // A re-price at the same epoch keeps every handle, and the handler is not asked again. @@ -2448,7 +2453,7 @@ test.each([ expect(pinned.active.peek()?.epoch).toBe(from); // The subscription already open stays on the old answer. - oldTrack.appendGroup().close(); + oldTrack.appendGroup().finish(); expect((await sticky?.recvGroup())?.sequence).toBe(1); // A re-request, local or from a session, asks the handler again and never reads the @@ -2469,7 +2474,7 @@ test.each([ // Existing request handles can still subscribe, but never join the new answer. const retained = held.active.peek()?.track("video").subscribe(); expect((await retained?.recvGroup())?.sequence).toBe(1); - oldTrack.appendGroup().close(); + oldTrack.appendGroup().finish(); expect((await retained?.recvGroup())?.sequence).toBe(2); expect(held.active.peek()).toBe(resolved); @@ -2525,7 +2530,7 @@ test("an unread resolved request retains its broadcast until its last handle clo const dynamic = origin.dynamicRoute(Path.from("live"), { epoch: EPOCH }); const requests = dynamic.requested(); const held = origin.consume().request(path); - const old = new BroadcastProducer(); + const old = new SessionBroadcast(); hooks.acceptRequest((await requests.next()).value, old); // Change epochs before the active getter or its subscription can clone the answer. dynamic.updateRoute({ epoch: NEWER }); @@ -2536,7 +2541,6 @@ test("an unread resolved request retains its broadcast until its last handle clo expect(held.active.peek()).toBeUndefined(); await requests.return?.(); dynamic.close(); - old.close(); origin.close(); }); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index d3264191f67..ae96f898108 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -29,6 +29,7 @@ import { } from "./internal.ts"; import * as Path from "./path.ts"; import { Front } from "./resume.ts"; +import { closeUnwanted } from "./util/announce.ts"; import { type Advertised, type Advertisements, @@ -481,6 +482,19 @@ function publishPath(path: Path.Valid): void { } } +// A broadcast created through the origin: its producer, and the handle the origin routes. +interface Created { + producer: broadcast.Producer; + front: broadcast.Consumer; +} + +// End a created broadcast the origin no longer routes. Consumer handles never end a produced +// broadcast, so the origin closes its producer; tracks already subscribed keep serving. +function retire({ producer, front }: Created): void { + front.close(); + producer.close(); +} + /** Reactive backing state shared by origin producers and consumers. */ class OriginState { // Both tables decouple the application producing into the origin from the @@ -494,7 +508,7 @@ class OriginState { // newest first. Local and routes stay separate so a session can never announce a // received entry back to a peer, which is what makes an origin shared by both // directions echo-free. - created: Map | undefined = new Map(); + created: Map | undefined = new Map(); local = new VersionedSignal | undefined>(new Map()); advertisedLocal = new VersionedSignal | undefined>(new Map()); routes = new VersionedSignal | undefined>(new Map()); @@ -1189,8 +1203,8 @@ export class Producer implements Table { * withdraws it from everyone again. * * Close the producer to drop it. Creating a path again supersedes the previous - * broadcast: the origin drops its handle on the old one, which closes it unless the - * application still holds a consumer clone. Announce with a {@link Route.epoch} + * broadcast: the origin retracts and closes the old one, and tracks already subscribed + * keep serving until they end. Announce with a {@link Route.epoch} * (`Epoch.mint()` per run) so a restart replaces the old broadcast rather than resuming * into it; at the same epoch, a local broadcast competes on cost and wins ties. */ @@ -1215,20 +1229,21 @@ export class Producer implements Table { }; hooks.attachAnnouncer(producer, announcer); + const entry = { producer, front }; const previous = created.get(path); - created.set(path, front); + created.set(path, entry); if (previous) { - this.#retractExact(path, previous); - previous.close(); + this.#retractExact(path, previous.front); + retire(previous); } // Drop it when the broadcast closes, unless a recreate already replaced it: a stale // broadcast closing must not unpublish the live one. Clearing `created` also stops a - // later announce, since a consumer clone can keep the broadcast open past this close. + // later announce. const release = this.#hold(() => { this.#retractExact(path, front); - if (this.#state.created?.get(path) === front) this.#state.created.delete(path); - front.close(); + if (this.#state.created?.get(path) === entry) this.#state.created.delete(path); + retire(entry); }); void front.closed.then(release); @@ -1237,7 +1252,7 @@ export class Producer implements Table { #advertiseExact(path: Path.Valid, front: broadcast.Consumer, route: Route): void { if (!this.#state.local.peek()) throw new Error("origin is closed"); - if (this.#state.created?.get(path) !== front) throw new Error("broadcast is closed"); + if (this.#state.created?.get(path)?.front !== front) throw new Error("broadcast is closed"); // Both maps move together, so every reader sees the broadcast and its route at once. this.#state.local.mutate((broadcasts) => { broadcasts?.set(path, front); @@ -1489,9 +1504,7 @@ export class Producer implements Table { if (this.#state.closed.peek() !== undefined) return; this.#state.closed.set(abort ?? null); for (const watch of this.#state.watchers.values()) watch.version.update((n) => n + 1); - for (const front of this.#state.created?.values() ?? []) { - front.close(); - } + for (const created of this.#state.created?.values() ?? []) retire(created); this.#state.created = undefined; this.#state.local.update(() => undefined); this.#state.advertisedLocal.update(() => undefined); @@ -1957,7 +1970,10 @@ export class Consumer { const patterns = this.#scope.patterns(scope); void producer.closed.then(this.#state.watch([...patterns].map(scopeHead))); void this.#runAnnounced(producer, patterns, options?.hidden ?? false); - return producer.consume(); + const consumer = producer.consume(); + // The last reader leaving ends the stream and its watch. + closeUnwanted(producer); + return consumer; } /** The routes visible to one announcement watcher. */ diff --git a/js/net/src/parked-expiry.test.ts b/js/net/src/parked-expiry.test.ts index 8ad77a26948..427c55d7741 100644 --- a/js/net/src/parked-expiry.test.ts +++ b/js/net/src/parked-expiry.test.ts @@ -38,7 +38,7 @@ for (const protocol of protocols) { const write = (sequence: number) => { const group = new Group(sequence); group.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); track.writeGroup(group); }; @@ -164,7 +164,7 @@ for (const protocol of protocols) { for (let frame = 0; frame < frames; frame++) { group.writeFrame({ payload: new Uint8Array([frame]), timestamp: Timestamp.fromMillis(0) }); } - group.close(); + group.finish(); track.writeGroup(group); }; diff --git a/js/net/src/resume.test-util.ts b/js/net/src/resume.test-util.ts index 01bca3d581a..41d2911028e 100644 --- a/js/net/src/resume.test-util.ts +++ b/js/net/src/resume.test-util.ts @@ -9,12 +9,13 @@ export function readsFrom(active: BroadcastConsumer | undefined, source: Broadca const track = source.createTrack(name); const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array([1]) }); - group.close(); + group.finish(); const sub = active.track(name).subscribe(); try { return sub.tryRecvGroup() !== undefined; } finally { sub.close(); + track.finish(); track.close(); source.removeTrack(name); } diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 2f346bd457f..cb91ff7b195 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -55,7 +55,7 @@ function copy(source: BroadcastProducer) { producer.writeGroup(group); for (let frame = 0; frame < frames; frame++) group.writeFrame({ payload: enc.encode(`${sequence}.${frame}`) }); - if (close) group.close(); + if (close) group.finish(); return group; }, }; @@ -137,7 +137,7 @@ test("returning to a route reuses its subscription and held group", async () => held.close(); } finally { mirrored.mockRestore(); - open.close(); + open.finish(); sub.close(); front.consumer.close(); for (const handle of handles) handle.close(); @@ -176,7 +176,7 @@ test("a returning route resumes where a fresh copy would", async () => { held.close(); } finally { mirrored.mockRestore(); - open.close(); + open.finish(); sub.close(); front.consumer.close(); for (const handle of handles) handle.close(); @@ -228,7 +228,7 @@ test("a fork continues its open group across a route failure after its parent cl const next = group.readFrame(); tb.write(0, 3); front.serve(b.consume()); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await next)?.payload)).toBe("0.1"); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.2"); expect(await group.readFrame()).toBeUndefined(); @@ -358,14 +358,14 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on if (lagging && group.sequence === 1 && index === 2) await release.promise; copy.writeFrame(frame); } - copy.close(); + copy.finish(); } finally { group.close(); } } } catch (err) { if (stopped) return; - output.close(err instanceof Error ? err : new Error(String(err))); + output.abort(err instanceof Error ? err : new Error(String(err))); broadcast.close(); return err; } @@ -377,7 +377,11 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on stopped = true; input.close(); release.resolve(); - output.close(err); + if (err) output.abort(err); + else { + output.finish(); + output.close(); + } broadcast.close(); }, }; @@ -398,7 +402,7 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on sub = active.track("seq").subscribe({ maxDelay: Milli(60_000) }); const first = source.appendGroup(); for (let frame = 0; frame < 4; frame++) first.writeFrame({ payload: enc.encode(`0.${frame}`) }); - first.close(); + first.finish(); const seen: string[] = []; const initial = await sub.recvGroup(); if (!initial) throw new Error("expected group 0"); @@ -414,10 +418,10 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on expect(interrupted.sequence).toBe(1); for (let frame = 0; frame < 2; frame++) seen.push(dec.decode((await interrupted.readFrame())?.payload)); for (let frame = 2; frame < 4; frame++) open.writeFrame({ payload: enc.encode(`1.${frame}`) }); - open.close(); + open.finish(); const last = source.appendGroup(); for (let frame = 0; frame < 4; frame++) last.writeFrame({ payload: enc.encode(`2.${frame}`) }); - last.close(); + last.finish(); // The standby holds the complete original while the incumbent cannot pass 1.1. for (let sequence = 0; sequence < 3; sequence++) { @@ -529,7 +533,7 @@ test("a group whose route fails continues from the replacement", async () => { tb.write(0, 2); await swap(); // The old route's session goes, failing what it was still delivering. - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); disposeA(); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.1"); expect(await group.readFrame()).toBeUndefined(); @@ -551,7 +555,7 @@ test("a group no route continues is given up once a newer one supersedes it", as // The new route never had group 0, and its group 1 is past a zero budget's reach for it. tb.write(1); await swap(); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); await expect(group.readFrame()).rejects.toBeInstanceOf(StreamError); expect(drain(sub)).toEqual([1]); @@ -569,7 +573,7 @@ test("a replacement claiming different track properties is refused once the old expect(sub.closed.peek()).toBeUndefined(); // The old copy failing with its route is what ends the track. - old.close(new StreamError(StreamCode.SessionClosed)); + old.abort(new StreamError(StreamCode.SessionClosed)); await settle(); expect(() => sub.tryRecvGroup()).toThrow("changed its properties"); @@ -588,7 +592,7 @@ test("waiting for the end of a track whose replacement was refused ends with the finished.catch(() => {}); // The old copy failing with its route leaves only the refusal. - old.close(new StreamError(StreamCode.SessionClosed)); + old.abort(new StreamError(StreamCode.SessionClosed)); await expect(finished).rejects.toThrow("changed its properties"); sub.close(); @@ -618,10 +622,10 @@ test("a fetched group continued from an empty, already-closed copy releases that const read = group.readFrame(); const whole = new GroupProducer(5); b.createTrack("fetch").writeGroup(whole); - whole.close(); + whole.finish(); front.serve(b.consume()); await settle(); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); expect(await read).toBeUndefined(); group.close(); await settle(); @@ -661,7 +665,7 @@ test("a fetched group failing as it is handed out releases its route once", asyn failing.writeFrame({ payload: enc.encode("5.0") }); const fetching = front.consumer.track("fetch").fetchGroup(5); for (let turn = 0; turn < turns; turn++) await Promise.resolve(); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); await settle(); // The next route holds the fetched group whole; the old one still feeds the reader a @@ -671,7 +675,7 @@ test("a fetched group failing as it is handed out releases its route once", asyn fb.writeGroup(whole); whole.writeFrame({ payload: enc.encode("5.0") }); whole.writeFrame({ payload: enc.encode("5.1") }); - whole.close(); + whole.finish(); b.createTrack("seq"); front.serve(b.consume()); const group = await fetching; @@ -796,7 +800,7 @@ test("a route that goes for good ends the track as its last copy does", async () ta.write(0); expect(drain(sub)).toEqual([0]); - ta.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); disposeA(); await settle(); expect(request.active.peek()).toBeUndefined(); @@ -880,7 +884,7 @@ test("a fetched group continues from the next route once its own fails", async ( tb.write(5, 2); front.serve(b.consume()); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("5.1"); expect(await group.readFrame()).toBeUndefined(); @@ -902,7 +906,7 @@ test("a replacement spliced before the first copy answers is still checked again await settle(); expect((await sub.info()).priority).toBe(1); - old.close(new StreamError(StreamCode.SessionClosed)); + old.abort(new StreamError(StreamCode.SessionClosed)); await settle(); expect(() => sub.tryRecvGroup()).toThrow("changed its properties"); @@ -979,7 +983,7 @@ test("a recovery fetch a later route supersedes is abandoned", async () => { // The next route does not have the group yet: recovery fetches it there and waits. front.serve(b.consume()); await settle(); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); const read = group.readFrame(); await settle(); const fetching = tb.producer.demand().wanted.peek() !== undefined; @@ -1014,7 +1018,7 @@ for (const fails of [true, false]) { const group = sub.tryRecvGroup(); if (!group) throw new Error("expected group 0"); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.0"); - if (fails) stalled.close(new StreamError(StreamCode.SessionClosed)); + if (fails) stalled.abort(new StreamError(StreamCode.SessionClosed)); front.serve(b.consume()); const read = group.readFrame(); @@ -1152,8 +1156,8 @@ test("a drop a replaced route held back is no verdict on the route serving next" expect(recvDisposition(sub)).toBe("idle"); front.serve(c.consume()); await settle(); - ta.producer.close(new StreamError(StreamCode.SessionClosed)); - tb.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); + tb.producer.abort(new StreamError(StreamCode.SessionClosed)); await settle(); expect(recvDisposition(sub)).toBe("idle"); tc.write(1); @@ -1264,7 +1268,7 @@ test("a failed group on a route whose replacement was refused ends with the refu front.serve(b.consume()); await settle(); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); await expect(group.readFrame()).rejects.toThrow("changed its properties"); sub.close(); @@ -1310,7 +1314,7 @@ test("a failed group whose replacement cannot fetch ends with the failure, or co front.serve(Unfetchable.of(b.consume())); await settle(); const failure = new StreamError(StreamCode.Cancel); - stalled.close(failure); + stalled.abort(failure); if (held) { expect(dec.decode((await group.readFrame())?.payload)).toBe("0.1"); expect(await group.readFrame()).toBeUndefined(); @@ -1333,6 +1337,7 @@ test("draining groups to their end leaves buffered datagrams readable", async () const sub = front.consumer.track("seq").subscribe(); ta.write(0); ta.producer.appendDatagram(undefined, enc.encode("d")); + ta.producer.finish(); ta.producer.close(); expect(drain(sub)).toEqual([0]); expect(await sub.recvGroup()).toBeUndefined(); @@ -1375,7 +1380,7 @@ test("a fetch never reads a replacement refused for its track properties", async const group = new GroupProducer(0); tb.writeGroup(group); group.writeFrame({ payload: enc.encode("b") }); - group.close(); + group.finish(); front.serve(a.consume()); await front.consumer.track("seq").info(); front.serve(b.consume()); @@ -1398,7 +1403,7 @@ test("the first fetch of a track says what the track is for later routes", async const group = new GroupProducer(0); producer.writeGroup(group); group.writeFrame({ payload: enc.encode(`${priority}`) }); - group.close(); + group.finish(); } front.serve(a.consume()); (await front.consumer.track("seq").fetchGroup(0)).close(); @@ -1572,7 +1577,7 @@ test.each([ // Both routes hold group 1: the old one whole, the serving one failing after a frame. ta.write(1, 2); const failing = tb.write(1, 1, false); - const fail = () => failing.close(new StreamError(StreamCode.SessionClosed)); + const fail = () => failing.abort(new StreamError(StreamCode.SessionClosed)); if (early) fail(); const group = await next(); if (group?.sequence !== 1) throw new Error("expected group 1"); @@ -1619,10 +1624,10 @@ test.each([true, false])( expect(dec.decode((await group.readFrame())?.payload)).toBe("1.0"); // The old route goes away, and the reader reads on and sees its copy end. - ta.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); const later = next(); await settle(); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.1"); expect(await group.readFrame()).toBeUndefined(); @@ -1649,7 +1654,7 @@ test("a fetched group that fails on the route still serving waits for the next r expect(dec.decode((await group.readFrame())?.payload)).toBe("5.0"); // Its route stays current, so its only copy of group 5 is the failed one. - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); const read = group.readFrame(); // Timers still run: recovery does not spin on the failed copy. await settle(); @@ -1675,7 +1680,7 @@ test("a group handed out already failed with its route still continues on the ne const sub = front.consumer.track("seq").subscribe(); // The old route fails group 0 before the reader takes it. const failed = ta.write(0, 1, false); - failed.close(new StreamError(StreamCode.SessionClosed)); + failed.abort(new StreamError(StreamCode.SessionClosed)); const group = sub.tryRecvGroup(); if (!group) throw new Error("expected group 0"); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.0"); @@ -1797,7 +1802,7 @@ test("a fetch waits for the copy being read to say what the track is", async () const group = new GroupProducer(0); tb.writeGroup(group); group.writeFrame({ payload: enc.encode("b") }); - group.close(); + group.finish(); front.serve(a.consume()); const sub = front.consumer.track("seq").subscribe(); front.serve(b.consume()); @@ -1903,7 +1908,7 @@ test("the largest follows the serving copy across a swap without moving back, an expect(hooks.peekLargest(sub).largest).toEqual({ group: 3, frame: 0 }); // The route fails: its copy's stream ends, the logical track's does not. - ta.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); let settled = false; const next = sub.largest().finally(() => { settled = true; @@ -1922,6 +1927,7 @@ test("the largest follows the serving copy across a swap without moving back, an // No route replaces this one: the stream ends with its copy. front.conclude(); const last = sub.largest(); + tb.producer.finish(); tb.producer.close(); expect(await last).toBeUndefined(); @@ -2092,7 +2098,7 @@ test.each([true, false])( // The old route finishes group 1 only now, and the new route's copy of it fails. ta.write(1, 2); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.1"); expect(await group.readFrame()).toBeUndefined(); @@ -2124,7 +2130,7 @@ test.each([true, false])("a replaced copy backs a group before its first arrival front.serve(b.consume()); await settle(); // Its route fails once replaced: closing cleanly would declare the track ends at group 1. - ta.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); front.serve(c.consume()); await settle(); @@ -2137,7 +2143,7 @@ test.each([true, false])("a replaced copy backs a group before its first arrival // b's first group lands only now, and c's copy of it fails. tb.write(1, 2); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.1"); expect(await group.readFrame()).toBeUndefined(); @@ -2167,6 +2173,7 @@ test("a clean end waits for a group the replaced copy still carries", async () = // The new route has only the group already handed out, and ends there. tb.write(2); + tb.producer.finish(); tb.producer.close(); front.serve(b.consume()); await settle(); @@ -2289,6 +2296,7 @@ test("the serving copy closing cleanly settles closed and leaves its datagrams r const sub = front.consumer.track("seq").subscribe(); ta.write(0); ta.producer.appendDatagram(undefined, enc.encode("d")); + ta.producer.finish(); ta.producer.close(); await settle(); expect(sub.closed.peek()).toBeNull(); @@ -2317,14 +2325,14 @@ test("an end a replaced copy declared holds across a swap", async () => { // b serves before its subscription learns the end. front.serve(b.consume()); await settle(); - expect(sub.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(sub)).toEqual({ group: 2, frame: 0 }); expect(await sub.finished()).toEqual({ group: 2, frame: 0 }); // A copy declaring another end claims different content. tb.producer.finishAt({ group: 3, frame: 0 }); await settle(); expect(sub.closed.peek()).toBeInstanceOf(Error); - expect(sub.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(sub)).toEqual({ group: 2, frame: 0 }); // Refused, the reader holds no copy. expect(ta.producer.demand().wanted.peek()).toBeUndefined(); expect(tb.producer.demand().wanted.peek()).toBeUndefined(); @@ -2349,7 +2357,7 @@ test("an end a replaced copy declared holds once the copy is let go in the same // A datagram read lets go of the replaced copy before its end is reported. ta.producer.finishAt({ group: 2, frame: 0 }); expect(hooks.tryRecvDatagram(sub).kind).toBe("idle"); - expect(sub.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(sub)).toEqual({ group: 2, frame: 0 }); tb.producer.finishAt({ group: 3, frame: 0 }); await settle(); @@ -2381,7 +2389,7 @@ test("a replaced copy whose session closed locally binds no end", async () => { tb.write(1); await settle(); expect(drain(sub)).toEqual([1]); - expect(sub.end()).toBeUndefined(); + expect(hooks.subscriberEnd(sub)).toBeUndefined(); expect(sub.closed.peek()).toBeUndefined(); sub.close(); @@ -2443,7 +2451,7 @@ test("closing with an abort settles a recovery in flight with it", async () => { // The next route does not have the group yet: recovery fetches it there and waits. front.serve(b.consume()); await settle(); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); const read = group.readFrame(); await settle(); const abort = new Error("aborted"); @@ -2475,6 +2483,7 @@ test.each(["lands", "expired"])( // The new route has only the group already handed out, and ends there. Nothing reads. tb.write(2); + tb.producer.finish(); tb.producer.close(); front.serve(b.consume()); await settle(); @@ -2608,13 +2617,14 @@ test("a clean end keeps the serving copy while a group read from a replaced one // The new route has all of group 1 and ends there. tb.write(1, 2); + tb.producer.finish(); tb.producer.close(); await settle(); expect(sub.closed.peek()).toBeNull(); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.0"); // The old route fails: the group continues from the copy that ended. - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.1"); expect(await group.readFrame()).toBeUndefined(); group.close(); @@ -2642,6 +2652,7 @@ test("a retained copy declaring another end after a clean end fails the reads", // The new route ends at 3, while the old one still owes group 1. tb.write(2); tb.producer.finishAt({ group: 3, frame: 0 }); + tb.producer.finish(); tb.producer.close(); front.serve(b.consume()); await settle(); @@ -2652,7 +2663,7 @@ test("a retained copy declaring another end after a clean end fails the reads", // The copy still holding group 1 claims another end: its content is refused. ta.producer.finishAt({ group: 5, frame: 0 }); await settle(); - expect(sub.end()).toEqual({ group: 3, frame: 0 }); + expect(hooks.subscriberEnd(sub)).toEqual({ group: 3, frame: 0 }); await expect(sub.recvGroup()).rejects.toThrow("changed its properties"); } finally { sub.close(); @@ -2681,12 +2692,13 @@ test("a clean end keeps the serving copy while a group handed out before the swa front.serve(b.consume()); await settle(); tb.write(0, 2); + tb.producer.finish(); tb.producer.close(); await settle(); expect(sub.closed.peek()).toBeNull(); // The old route fails: the group continues from the copy that ended. - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.1"); expect(await group.readFrame()).toBeUndefined(); group.close(); @@ -2904,7 +2916,7 @@ for (const fetched of [false, true]) { if (!hostile) return undefined; const copy = new GroupProducer(sequence); copy.writeFrame({ payload: enc.encode(`${sequence}.0`) }); - setTimeout(() => copy.close(malformed), 0); + setTimeout(() => copy.abort(malformed), 0); return copy.consume(); }); const sub = front.consumer.track("seq").subscribe(); @@ -2915,7 +2927,7 @@ for (const fetched of [false, true]) { asked.length = 0; hostile = true; - stalled.close(malformed); + stalled.abort(malformed); await expect(group.readFrame()).rejects.toBe(malformed); await settle(); expect(asked).toEqual([]); @@ -2943,10 +2955,10 @@ test("a verdict every route repeats moves to the newer route once, then ends the front.serve(routeB.route); await settle(); - stalled.close(malformed); + stalled.abort(malformed); const read = group.readFrame(); expect(await isPending(read)).toBe(true); - repeat.close(malformed); + repeat.abort(malformed); await expect(read).rejects.toBe(malformed); await expect(group.readFrame()).rejects.toBe(malformed); expect([routeA.asked, routeB.asked]).toEqual([[], []]); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index a19dbef1e20..9bb4abf29a7 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -263,7 +263,7 @@ function subscribe(wire: BroadcastWire, name: string, subscription: track.Subscr return wire.subscribe(name, subscription); } catch (err) { const failed = new track.Producer(name); - failed.close(err instanceof Error ? err : new Error(String(err))); + failed.abort(err instanceof Error ? err : new Error(String(err))); return failed.subscribe(subscription); } } @@ -1019,7 +1019,7 @@ class Reader implements ResumeSource { // refuses an end below what it received. #learnEnd(): track.Position | undefined { for (const copy of this.#copies) { - const end = copy.sub.end(); + const end = hooks.subscriberEnd(copy.sub); if (end) this.#declare(end); } const declared = this.#declared; @@ -1177,7 +1177,7 @@ class Reader implements ResumeSource { for (const dispose of this.#dispose) dispose(); this.#dispose = []; // A recovery in flight settles with the group rather than adopting a replacement. - for (const group of this.#open) group.close(abort); + for (const group of this.#open) hooks.releaseGroupReader(group, abort); this.#open.clear(); for (const copy of this.#copies) this.#drop(copy, abort); this.#copies = []; diff --git a/js/net/src/retention.test.ts b/js/net/src/retention.test.ts index 24f73d49bc4..029176110d3 100644 --- a/js/net/src/retention.test.ts +++ b/js/net/src/retention.test.ts @@ -141,7 +141,7 @@ for (const [name, alpn] of [ for (let i = 0; i < count; i++) { const group = producer.appendGroup(); group.writeFrame(frame(offset + i)); - group.close(); + group.finish(); const consumer = await track.nextGroup(); if (!consumer) throw new Error("no group"); await readFrames(consumer, 1); diff --git a/js/net/src/tail.test.ts b/js/net/src/tail.test.ts index dfa55351175..1e3d8d3ae48 100644 --- a/js/net/src/tail.test.ts +++ b/js/net/src/tail.test.ts @@ -70,10 +70,11 @@ test.each([Lite.ALPN_05, Lite.ALPN_06])( expect(await reader.finished()).toEqual({ group: 3, frame: 0 }); video.writeFrame(textFrame("2")); + video.finish(); video.close(); expect(await readAll(reader)).toEqual(["2"]); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 3, frame: 0 }); + expect(await reader.finished()).toEqual({ group: 3, frame: 0 }); } finally { close(); } @@ -95,11 +96,12 @@ test.each([Ietf.ALPN.DRAFT_16, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])( video.writeFrame(textFrame("1")); video.writeFrame(textFrame("2")); const ended = performance.now(); + video.finish(); video.close(); expect(await readAll(reader)).toEqual(["1", "2"]); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 4, frame: 0 }); + expect(await reader.finished()).toEqual({ group: 4, frame: 0 }); // Every counted stream arrived, so nothing waited out the grace. expect(performance.now() - ended).toBeLessThan(TAIL_GRACE_MS); } finally { diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index ef17798ca3e..a00b70088e4 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -105,7 +105,7 @@ function refreshGroup(producer: TrackProducer, delta: number): void { const group = producer.appendGroup(); group.writeFrame({ payload: enc.encode("key") }); if (delta > 0) group.writeFrame({ payload: new Uint8Array(delta) }); - group.close(); + group.finish(); } test("the first subscriber of a track with no group wants group 0", () => { @@ -228,6 +228,7 @@ test("the group range bounds datagrams", async () => { for (const sequence of [4, 5, 7, 6]) { producer.insertDatagram(sequence, Timestamp.fromMillis(0), enc.encode("x")); } + producer.finish(); producer.close(); expect((await track.recvDatagram())?.sequence).toBe(5); @@ -242,6 +243,7 @@ for (const timed of [true, false]) { producer.appendDatagram(timestamp, enc.encode("before")); const subscriber = producer.subscribe({ maxDelay: Milli(50) }); const sequence = producer.appendDatagram(timestamp, enc.encode("after")); + producer.finish(); producer.close(); const datagram = await subscriber.recvDatagram(); expect(datagram?.sequence).toBe(sequence); @@ -259,6 +261,7 @@ test("datagram buffers drop oldest at capacity without delaying active subscribe producer.appendDatagram(Timestamp.fromMillis(0), enc.encode("x")); expect((await fast.recvDatagram())?.sequence).toBe(sequence); } + producer.finish(); producer.close(); for (let sequence = count - 64; sequence < count; sequence++) { expect((await slow.recvDatagram())?.sequence).toBe(sequence); @@ -371,6 +374,7 @@ test("insertDatagram stale does not rewind after append", async () => { test("insertDatagram after close is refused", () => { const producer = new TrackProducer("test"); const ts = Timestamp.fromMillis(0); + producer.finish(); producer.close(); expect(() => producer.insertDatagram(0, ts, enc.encode("x"))).toThrow("track is closed"); expect(() => producer.appendDatagram(ts, enc.encode("x"))).toThrow("track is closed"); @@ -378,7 +382,7 @@ test("insertDatagram after close is refused", () => { test("insertDatagram after abort is refused", () => { const producer = new TrackProducer("test"); - producer.close(new Error("cancel")); + producer.abort(new Error("cancel")); expect(() => producer.insertDatagram(0, Timestamp.fromMillis(0), enc.encode("x"))).toThrow("track is closed"); }); @@ -432,6 +436,7 @@ test("subscriber maxAge is refused before defaults or updates take effect", () = // Publisher retention keeps its distinct maxAge field. producer.accept({ maxAge: Milli(500) }); track.close(); + producer.finish(); producer.close(); }); @@ -440,6 +445,7 @@ test("an unknown subscriber order is refused", () => { const producer = new TrackProducer("test"); expect(() => producer.subscribe({ order: "newest" } as unknown as Subscription)).toThrow(TypeError); expect(producer.subscription.peek()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -588,7 +594,7 @@ test("a long group is not stale while its tail reaches the edge", async () => { for (const ms of [0, 500, 1000, 1500, 2000]) { long.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); } - long.close(); + long.finish(); producer.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(2000) }); expect((await sub.recvGroup())?.sequence).toBe(0); @@ -603,7 +609,7 @@ test("a long group is stale once its successor falls behind", async () => { for (const ms of [0, 500, 1000]) { long.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); } - long.close(); + long.finish(); // Group 0 reaches at most 3s (where group 1 starts), a second behind the 4s edge and // so past the budget. A frame's duration is not on the wire, so group 0's own last // timestamp proves nothing about where it ends; only group 1's start bounds it. @@ -679,7 +685,7 @@ test("a late-arriving successor bounds a group's reach", async () => { ]) { const group = new GroupProducer(sequence); group.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); - group.close(); + group.finish(); producer.writeGroup(group); } @@ -718,17 +724,17 @@ for (const take of ["nextGroup", "tryNextGroup"] as const) { const zero = new GroupProducer(0); zero.writeFrame(textFrame("0.0")); zero.writeFrame(textFrame("0.1")); - zero.close(); + zero.finish(); producer.writeGroup(zero); const one = new GroupProducer(1); one.writeFrame(textFrame("1.0")); - one.close(); + one.finish(); producer.writeGroup(one); const two = new GroupProducer(2); two.writeFrame(textFrame("2.0")); - two.close(); + two.finish(); producer.writeGroup(two); // The frame path enters group 0; a direct take then claims group 1. @@ -750,18 +756,18 @@ test("ordered frame reads skip a late lower-sequence group", async () => { const five = new GroupProducer(5); five.writeFrame(textFrame("five", Timestamp.fromMillis(5))); - five.close(); + five.finish(); producer.writeGroup(five); expect(await track.readString()).toBe("five"); const three = new GroupProducer(3); three.writeFrame(textFrame("three", Timestamp.fromMillis(3))); - three.close(); + three.finish(); producer.writeGroup(three); const six = new GroupProducer(6); six.writeFrame(textFrame("six", Timestamp.fromMillis(6))); - six.close(); + six.finish(); producer.writeGroup(six); expect(await track.readString()).toBe("six"); @@ -805,7 +811,7 @@ test("a late lower group within the budget is delivered", async () => { const group = new GroupProducer(sequence); producer.writeGroup(group); group.writeFrame({ payload: enc.encode(`${timestamp}`), timestamp: Timestamp.fromMillis(timestamp) }); - group.close(); + group.finish(); } const track = producer.subscribe({ maxDelay: Milli(5000) }); @@ -819,9 +825,10 @@ test("a late lower group within the budget is delivered", async () => { const late = new GroupProducer(4); producer.writeGroup(late); late.writeFrame({ payload: enc.encode("late"), timestamp: Timestamp.fromMillis(500) }); - late.close(); + late.finish(); expect((await track.recvGroup())?.sequence).toBe(4); + producer.finish(); producer.close(); expect(await track.recvGroup()).toBeUndefined(); }); @@ -895,6 +902,7 @@ test("a stamped successor expires a group stalled before its first frame", async clock.set(11_000); producer.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(1000) }); + producer.finish(); producer.close(); expect((await track.recvGroup())?.sequence).toBe(1); @@ -943,7 +951,7 @@ test("real time reads a live stream without truncating it", async () => { const next = producer.appendGroup(); next.writeFrame({ payload: enc.encode("key"), timestamp: Timestamp.fromMillis(n * 2000) }); await settle(); - open.close(); + open.finish(); next.writeFrame({ payload: enc.encode("tail"), timestamp: Timestamp.fromMillis(n * 2000 + 1900) }); expect(await end).toMatchObject({ code: StreamCode.Old }); @@ -1080,7 +1088,7 @@ test("a fully read group still reports its expiry", async () => { const track = producer.subscribe({ maxDelay: Milli(1000) }); const source = producer.appendGroup(); source.writeFrame({ payload: enc.encode("a"), timestamp: Timestamp.fromMillis(0) }); - source.close(); + source.finish(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); @@ -1101,6 +1109,7 @@ test("a fully read group still reports its expiry", async () => { expect(await raced).toMatchObject({ code: StreamCode.Old }); expect(group.expired.peek()).toMatchObject({ code: StreamCode.Old }); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1137,6 +1146,7 @@ test("a stalled track keeps only its newest group", () => { clock.set(12_000); expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([2]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1162,6 +1172,7 @@ test("frozen timestamps still age out by wall clock", () => { clock.set(12_500); expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([2]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1177,7 +1188,7 @@ test("a group shared by two tracks ages each from its own arrival", () => { first.writeFrame({ payload: enc.encode("0") }); const shared = new GroupProducer(1); shared.writeFrame({ payload: enc.encode("1") }); - shared.close(); + shared.finish(); first.writeGroup(shared); clock.set(11_000); second.writeGroup(shared); @@ -1185,7 +1196,9 @@ test("a group shared by two tracks ages each from its own arrival", () => { // Group 0's successor joined the first track a full budget ago. expect(drain(first.subscribe({ maxDelay: Milli(1000) }))).toEqual([1]); } finally { + first.finish(); first.close(); + second.finish(); second.close(); clock.installed.close(); } @@ -1204,6 +1217,7 @@ test("a startup burst ages out by media time", () => { // behind, past 1.5s. Their successors arrived just now. expect(drain(producer.subscribe({ maxDelay: Milli(1500) }))).toEqual([2, 3, 4]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1240,6 +1254,7 @@ test("a parked read wakes at its wall deadline", async () => { await clock.advance(11_000); expect(await read).toMatchObject({ code: StreamCode.Old }); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1257,7 +1272,7 @@ test("an evicted successor with unread frames still starts the wall clock", asyn const write = () => { const group = producer.appendGroup(); group.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); }; write(); const group = track.tryRecvGroup(); @@ -1281,6 +1296,7 @@ test("an evicted successor with unread frames still starts the wall clock", asyn expect(stale).toBe(true); expect(await guarded).toMatchObject({ code: StreamCode.Old }); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1297,7 +1313,7 @@ test("a failed successor hands the wall clock on after the cache evicts it", asy const track = producer.subscribe({ maxDelay: Milli(budget) }); const held = producer.appendGroup(); held.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(0) }); - held.close(); + held.finish(); const group = track.tryRecvGroup(); if (!group) throw new Error("missing group"); expect(await group.readString()).toBe("x"); @@ -1313,10 +1329,10 @@ test("a failed successor hands the wall clock on after the cache evicts it", asy // Past the cache window, so the next write evicts the failed successor at once. const replacement = 10_000 + CACHE_WINDOW_MS + 10_000; await clock.advance(replacement); - failed.close(new Error("upstream failed")); + failed.abort(new Error("upstream failed")); const live = producer.appendGroup(); live.writeFrame({ payload: enc.encode("y"), timestamp: Timestamp.fromMillis(0) }); - live.close(); + live.finish(); await clock.advance(10_000 + budget + 1); expect(stale).toBe(false); @@ -1326,6 +1342,7 @@ test("a failed successor hands the wall clock on after the cache evicts it", asy expect(stale).toBe(true); expect(await guarded).toMatchObject({ code: StreamCode.Old }); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1343,7 +1360,7 @@ test("a run of empty groups ages out by wall clock", () => { producer.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(0) }); for (let sequence = 1; sequence <= RUN; sequence++) { const group = producer.appendGroup(); - if (sequence % 2 === 0) group.close(); + if (sequence % 2 === 0) group.finish(); } producer.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(10) }); expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toHaveLength(RUN + 2); @@ -1352,6 +1369,7 @@ test("a run of empty groups ages out by wall clock", () => { expect(drain(track)).toEqual([RUN + 1]); expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([RUN + 1]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1365,7 +1383,7 @@ test("a live track retains its newest closed group through idle expiry", async ( for (const sequence of [2, 1]) { const group = new GroupProducer(sequence); group.writeFrame({ payload: enc.encode("snapshot"), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); producer.writeGroup(group); } await clock.advance(10_000 + CACHE_WINDOW_MS + 200); @@ -1374,6 +1392,7 @@ test("a live track retains its newest closed group through idle expiry", async ( expect(replay.tryRecvGroup()).toBeUndefined(); replay.close(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1391,7 +1410,7 @@ test("retention eviction surfaces as a gap for a handed-out group", async () => const source = producer.appendGroup(); source.writeFrame(textFrame("first")); source.writeFrame(textFrame("tail")); - source.close(); + source.finish(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); @@ -1414,12 +1433,13 @@ test("idle expiry reclaims an ended track's held mirror", async () => { const source = producer.appendGroup(); source.writeFrame(textFrame("first")); source.writeFrame(textFrame("tail")); - source.close(); + source.finish(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); expect(new TextDecoder().decode((await group.readFrame())?.payload)).toBe("first"); + producer.finish(); producer.close(); clock.set(10_000 + CACHE_WINDOW_MS + 200); producer.subscribe({ maxDelay: Milli(100) }); @@ -1502,6 +1522,7 @@ test("an abandoned open group ages out with no further write", async () => { await clock.advance(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); expect(await read).toBeInstanceOf(TooFarBehind); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1522,6 +1543,7 @@ test("publisher maxAge does not change the idle cache deadline", async () => { await clock.advance(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); expect(idle.closed.peek()).toBeInstanceOf(TooFarBehind); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1535,13 +1557,14 @@ test("retention pruning preserves clean EOF for a drained mirror", async () => { const track = producer.subscribe({ maxDelay: Milli(100) }); const source = producer.appendGroup(); source.writeFrame(textFrame("only")); - source.close(); + source.finish(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); expect(await group.readString()).toBe("only"); expect(await group.readFrame()).toBeUndefined(); + producer.finish(); producer.close(); clock.set(10_000 + CACHE_WINDOW_MS + 200); producer.subscribe({ maxDelay: Milli(100) }); @@ -1592,14 +1615,15 @@ test("latency budget retains a consumed live-edge anchor", async () => { const edge = new GroupProducer(2); edge.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(2000) }); - edge.close(); + edge.finish(); producer.writeGroup(edge); expect((await track.recvGroup())?.sequence).toBe(2); const late = new GroupProducer(1); late.writeFrame({ payload: enc.encode("late"), timestamp: Timestamp.fromMillis(0) }); - late.close(); + late.finish(); producer.writeGroup(late); + producer.finish(); producer.close(); expect(track.recvGroup()).resolves.toBeUndefined(); @@ -1611,12 +1635,12 @@ test("an aborted live-edge anchor does not make older content stale", async () = const edge = new GroupProducer(2); edge.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(2000) }); - edge.close(new Error("aborted")); + edge.abort(new Error("aborted")); producer.writeGroup(edge); const older = new GroupProducer(1); older.writeFrame({ payload: enc.encode("older"), timestamp: Timestamp.fromMillis(0) }); - older.close(); + older.finish(); producer.writeGroup(older); expect((await track.recvGroup())?.sequence).toBe(1); @@ -1647,11 +1671,13 @@ test.each(["arrival", "ordered"] as const)("subscription bounds stay reader-loca // Arrival readers can now offer the unread group below the old floor; ordered ones cannot rewind. if (!ordered) expect((await capped)?.sequence).toBe(0); expect((await (ordered ? capped : read()))?.sequence).toBe(4); + producer.finish(); producer.close(); expect(await read()).toBeUndefined(); } finally { subscriber.close(); everything.close(); + producer.finish(); producer.close(); } }); @@ -1671,6 +1697,7 @@ test("subscription updates narrow the cursor before its first read", async () => } finally { track.close(); everything.close(); + producer.finish(); producer.close(); } }); @@ -1692,6 +1719,7 @@ test("a subscription cap chooses its own live edge", async () => { } finally { track.close(); everything.close(); + producer.finish(); producer.close(); } }); @@ -1715,6 +1743,7 @@ test("subscription updates bound each reader's datagrams", async () => { } finally { track.close(); everything.close(); + producer.finish(); producer.close(); } }); @@ -1762,6 +1791,7 @@ test("withRange caps frame-level reads like the group cursor", async () => { producer.writeFrame({ payload: enc.encode("zero"), timestamp: Timestamp.fromMillis(0) }); producer.writeFrame({ payload: enc.encode("one"), timestamp: Timestamp.fromMillis(1) }); + producer.finish(); producer.close(); expect((await track.readFrame())?.group).toBe(0); @@ -1810,6 +1840,7 @@ test("recvGroup serves a late arrival after a newer group", async () => { // Staleness is the latency window's job, not arrival order's: the track // still finishes normally afterward. + producer.finish(); producer.close(); expect(await track.recvGroup()).toBeUndefined(); }); @@ -1830,6 +1861,7 @@ test("withRange parks recvGroup beyond the cap and a raised cap re-offers", asyn expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); // A clean close keeps the parked group claimable: the cap may still rise. + producer.finish(); producer.close(); expect(await Promise.race([pending, new Promise((resolve) => setTimeout(() => resolve("parked"), 10))])).toBe( "parked", @@ -1890,7 +1922,7 @@ test("writeGroup rejects a duplicate live sequence", async () => { const first = new GroupProducer(7); first.writeFrame(textFrame("first")); - first.close(); + first.finish(); producer.writeGroup(first); // A peer re-sending a live sequence is rejected, not fanned out again. @@ -1900,11 +1932,11 @@ test("writeGroup rejects a duplicate live sequence", async () => { // An aborted incarnation is replaceable: the retry serves the sequence fresh. const aborted = new GroupProducer(8); producer.writeGroup(aborted); - aborted.close(new Error("upstream reset")); + aborted.abort(new Error("upstream reset")); const retry = new GroupProducer(8); retry.writeFrame(textFrame("retry")); - retry.close(); + retry.finish(); producer.writeGroup(retry); const got = await track.recvGroup(); @@ -1925,6 +1957,7 @@ test("closing the subscriber releases a recvGroup parked after a clean close", a const pending = track.recvGroup(); expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); + producer.finish(); producer.close(); track.close(); expect(await pending).toBeUndefined(); @@ -2004,6 +2037,7 @@ test("recvDatagram does not commit the group cursor", async () => { test("nextGroup returns undefined when track closes", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe().ordered(); + producer.finish(); producer.close(); expect(await track.nextGroup()).toBeUndefined(); }); @@ -2018,7 +2052,7 @@ test("a closed track still delivers a group parked above the cap once the cap is for (let sequence = 0; sequence < 3; sequence++) { const group = new GroupProducer(sequence); group.writeFrame(textFrame(`frame-${sequence}`)); - group.close(); + group.finish(); producer.writeGroup(group); } @@ -2028,6 +2062,7 @@ test("a closed track still delivers a group parked above the cap once the cap is // Group 2 parks above the cap; a clean close must not resolve it as finished. const parked = track.nextGroup(); + producer.finish(); producer.close(); const timeout = new Promise((resolve) => setTimeout(() => resolve("pending"), 10)); expect(await Promise.race([parked, timeout])).toBe("pending"); @@ -2048,25 +2083,27 @@ test("a closed track still delivers a group parked above the cap once the cap is test("end reports the head after the highest produced sequence, datagrams included", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe(); - expect(track.end()).toBeUndefined(); + expect(hooks.subscriberEnd(track)).toBeUndefined(); producer.writeGroup(new GroupProducer(0)); producer.appendDatagram(Timestamp.fromMillis(1), enc.encode("x")); + producer.finish(); producer.close(); - expect(track.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(track)).toEqual({ group: 2, frame: 0 }); }); // 0 is the only encoding for "nothing produced"; an abort declares no boundary at all. test("end is group 0 for an empty track and undefined after an abort", async () => { const empty = new TrackProducer("test"); const emptyTrack = empty.subscribe(); + empty.finish(); empty.close(); - expect(emptyTrack.end()).toEqual({ group: 0, frame: 0 }); + expect(hooks.subscriberEnd(emptyTrack)).toEqual({ group: 0, frame: 0 }); const aborted = new TrackProducer("test"); const abortedTrack = aborted.subscribe(); - aborted.close(new Error("boom")); - expect(abortedTrack.end()).toBeUndefined(); + aborted.abort(new Error("boom")); + expect(hooks.subscriberEnd(abortedTrack)).toBeUndefined(); }); test("readFrame does not livelock when a sole group finishes before the next arrives", async () => { @@ -2077,13 +2114,14 @@ test("readFrame does not livelock when a sole group finishes before the next arr // readable() resolves immediately, so the reader must not busy-wait on it (which would starve the // macrotask queue and never observe the next group). const g0 = producer.appendGroup(); - g0.close(); + g0.finish(); // The next group arrives via a macrotask; if the reader livelocks on microtasks it never runs. setTimeout(() => { const g1 = producer.appendGroup(); g1.writeFrame(textFrame("hello")); - g1.close(); + g1.finish(); + producer.finish(); producer.close(); }, 10); @@ -2108,7 +2146,7 @@ test("largest names the newest frame written", async () => { // The producer's getter holds no cursor, so every caller reads the same edge. expect(producer.largest()).toEqual({ group: 0, frame: 1 }); expect(producer.largest()).toEqual({ group: 0, frame: 1 }); - first.close(); + first.finish(); // A new group raises the largest at its head before any frame, so its first frame names // nothing newer. @@ -2125,11 +2163,12 @@ test("largest names the newest frame written", async () => { expect(advanced).toBe(false); second.writeFrame({ payload: enc.encode("d"), timestamp: Timestamp.now() }); expect(await next).toEqual({ group: 1, frame: 1 }); - second.close(); + second.finish(); // Reading a group does not move the edge: it is what the track produced, not what is left. await subscriber.recvGroup(); const ended = subscriber.largest(); + producer.finish(); producer.close(); expect(await ended).toBeUndefined(); }); @@ -2149,8 +2188,9 @@ test("largest ends at the declared end while the producer stays open", async () const ended = subscriber.largest(); expect(await Promise.race([ended.then(() => "ready"), Promise.resolve("pending")])).toBe("pending"); - group.close(); + group.finish(); expect(await ended).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -2163,10 +2203,10 @@ test("a late group below the largest changes nothing", async () => { const framed = new GroupProducer(2); producer.writeGroup(framed); framed.writeFrame({ payload: enc.encode("a"), timestamp: Timestamp.now() }); - framed.close(); + framed.finish(); const empty = new GroupProducer(5); producer.writeGroup(empty); - empty.close(); + empty.finish(); producer.finishAt({ group: 6, frame: 0 }); expect(await subscriber.largest()).toEqual({ group: 5, frame: 0 }); @@ -2178,6 +2218,7 @@ test("a late group below the largest changes nothing", async () => { late.writeFrame({ payload: enc.encode("b"), timestamp: Timestamp.now() }); expect(await subscriber.largest()).toBeUndefined(); expect(await producer.subscribe().largest()).toEqual({ group: 5, frame: 0 }); + producer.finish(); producer.close(); }); @@ -2191,7 +2232,7 @@ test("a recreated largest group is reported before the end", async () => { const first = new GroupProducer(0); producer.writeGroup(first); first.writeFrame({ payload: enc.encode("a"), timestamp }); - first.close(new Error("cut")); + first.abort(new Error("cut")); producer.finishAt({ group: 1, frame: 0 }); expect(await subscriber.largest()).toEqual({ group: 0, frame: 0 }); @@ -2207,17 +2248,19 @@ test("a recreated largest group is reported before the end", async () => { producer.writeGroup(again); for (const payload of ["a", "b"]) again.writeFrame({ payload: enc.encode(payload), timestamp }); expect(await next).toEqual({ group: 0, frame: 1 }); - again.close(); + again.finish(); expect(await subscriber.largest()).toBeUndefined(); + producer.finish(); producer.close(); }); test("a datagram raises the largest", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); const subscriber = producer.subscribe(); - producer.appendGroup().close(); + producer.appendGroup().finish(); expect(producer.appendDatagram(Timestamp.now(), enc.encode("d"))).toBe(1); expect(await subscriber.largest()).toEqual({ group: 1, frame: 0 }); + producer.finish(); producer.close(); }); @@ -2261,6 +2304,7 @@ test("largest reports only within the bounds", async () => { expect(await isPending(next)).toBe(true); framedGroup(producer, 8, 0); expect(await next).toEqual({ group: 8, frame: 0 }); + producer.finish(); producer.close(); }); @@ -2291,6 +2335,7 @@ test("largest follows the bounds as they move", async () => { expect(await next).toEqual({ group: 6, frame: 0 }); reader.withRange({ start: { included: 3 } }); expect(await isPending(reader.largest())).toBe(true); + producer.finish(); producer.close(); }); @@ -2298,7 +2343,8 @@ test("largest follows the bounds as they move", async () => { // it back. test("largest stays ended when the bounds widen", async () => { const producer = new TrackProducer("test").accept({}); - framedGroup(producer, 5, 1).close(); + framedGroup(producer, 5, 1).finish(); + producer.finish(); producer.close(); const reader = producer.subscribe().withRange({ end: { excluded: 3 } }); @@ -2313,7 +2359,7 @@ test("a live-edge reader starts on an empty largest group", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); const first = producer.appendGroup(); first.writeFrame({ payload: enc.encode("a"), timestamp: Timestamp.now() }); - first.close(); + first.finish(); const empty = producer.appendGroup(); const reader = producer.subscribe({ maxDelay: Milli(30_000) }); @@ -2321,7 +2367,7 @@ test("a live-edge reader starts on an empty largest group", async () => { expect(edge).toEqual({ group: 1, frame: 0 }); reader.withRange({ start: { included: edge?.group ?? 0 } }); - empty.close(); + empty.finish(); const next = producer.appendGroup(); next.writeFrame({ payload: enc.encode("b"), timestamp: Timestamp.now() }); @@ -2331,6 +2377,7 @@ test("a live-edge reader starts on an empty largest group", async () => { const after = await reader.recvGroup(); expect(after?.sequence).toBe(2); expect(await after?.readFrame()).toBeDefined(); + producer.finish(); producer.close(); }); @@ -2347,6 +2394,7 @@ test("largest survives a mirror replay of an aborted group", async () => { const late = producer.subscribe(); expect(await late.largest()).toEqual({ group: 0, frame: MAX_GROUP_FRAMES - 1 }); + producer.finish(); producer.close(); }); @@ -2391,7 +2439,7 @@ test("a lowered range re-offers a cached cut with its error", async () => { const cut = new GroupProducer(0); producer.writeGroup(cut); const error = new StreamError(StreamCode(70), { message: "cut" }); - cut.close(error); + cut.abort(error); producer.writeGroup(new GroupProducer(1)); expect(track.tryRecvGroup()?.sequence).toBe(1); @@ -2400,6 +2448,7 @@ test("a lowered range re-offers a cached cut with its error", async () => { expect(group?.sequence).toBe(0); await expect(group?.readString()).rejects.toBe(error); track.close(); + producer.finish(); producer.close(); }); @@ -2408,6 +2457,7 @@ test("a lowered range still re-offers after the producer closes", () => { const track = producer.subscribe({ maxDelay: Milli(5000), groups: { start: { included: 1 } } }); for (const sequence of [0, 1]) producer.writeGroup(new GroupProducer(sequence)); expect(track.tryRecvGroup()?.sequence).toBe(1); + producer.finish(); producer.close(); track.withRange({}); @@ -2426,6 +2476,7 @@ for (const abort of [undefined, new Error("gone")]) { track.withRange({}); expect(hooks.tryRecvGroup(track, false)).toEqual(abort ? { kind: "error", error: abort } : { kind: "done" }); + producer.finish(); producer.close(); }); } @@ -2453,6 +2504,7 @@ test("finishAt refuses an end at or below a produced sequence", () => { expect(() => producer.writeGroup(new GroupProducer(6))).toThrow(); expect(() => producer.appendGroup()).toThrow(); expect(() => producer.insertDatagram(6, Timestamp.now(), new Uint8Array(1))).toThrow(); + producer.finish(); producer.close(); }); @@ -2464,26 +2516,27 @@ test("finishAt declares an end ahead of the live edge without ending the track", expect(first?.sequence).toBe(5); producer.finishAt({ group: 8, frame: 0 }); - expect(track.end()).toEqual({ group: 8, frame: 0 }); + expect(hooks.subscriberEnd(track)).toEqual({ group: 8, frame: 0 }); expect(await track.finished()).toEqual({ group: 8, frame: 0 }); // Not terminal: groups below the end still arrive, including a straggler. const late = new GroupProducer(7); - late.close(); + late.finish(); producer.writeGroup(late); const straggler = new GroupProducer(6); - straggler.close(); + straggler.finish(); producer.writeGroup(straggler); expect((await track.recvGroup())?.sequence).toBe(6); expect((await track.recvGroup())?.sequence).toBe(7); // A clean close keeps the declared end rather than the live edge. + producer.finish(); producer.close(); expect(await track.recvGroup()).toBeUndefined(); - expect(track.end()).toEqual({ group: 8, frame: 0 }); + expect(hooks.subscriberEnd(track)).toEqual({ group: 8, frame: 0 }); // A late subscriber sees the same end. - expect(producer.subscribe().end()).toEqual({ group: 8, frame: 0 }); + expect(hooks.subscriberEnd(producer.subscribe())).toEqual({ group: 8, frame: 0 }); }); for (const ordered of [false, true]) { @@ -2516,10 +2569,11 @@ for (const ordered of [false, true]) { expect(track.closed.peek()).toBeUndefined(); const aborted = new Error("cut off"); - producer.close(aborted); + producer.abort(aborted); await expect(receive()).rejects.toThrow("cut off"); await expect(received?.readString() ?? Promise.resolve()).rejects.toThrow("cut off"); } finally { + producer.finish(); producer.close(); track.close(); } @@ -2536,7 +2590,7 @@ for (const channel of ["group", "datagram"]) { const datagramRead = track.recvDatagram(); if (channel === "group") { const group = new GroupProducer(2); - group.close(); + group.finish(); producer.writeGroup(group); } else { producer.insertDatagram(2, Timestamp.fromMillis(0), enc.encode("tail")); @@ -2547,6 +2601,7 @@ for (const channel of ["group", "datagram"]) { expect(await track.recvDatagram()).toBeUndefined(); expect(track.closed.peek()).toBeUndefined(); } finally { + producer.finish(); producer.close(); track.close(); } @@ -2556,7 +2611,7 @@ for (const channel of ["group", "datagram"]) { test("finished rejects when the track aborts or closes without an end", async () => { const aborted = new TrackProducer("test"); const pending = aborted.subscribe().finished(); - aborted.close(new Error("boom")); + aborted.abort(new Error("boom")); await expect(pending).rejects.toThrow("boom"); const producer = new TrackProducer("test"); @@ -2567,7 +2622,8 @@ test("finished rejects when the track aborts or closes without an end", async () // A clean close is an end, so it resolves. const clean = new TrackProducer("test"); const reader = clean.subscribe(); - clean.appendGroup().close(); + clean.appendGroup().finish(); + clean.finish(); clean.close(); expect(await reader.finished()).toEqual({ group: 1, frame: 0 }); }); @@ -2580,7 +2636,7 @@ test("an omitted publisher limit still ages idle groups out", async () => { try { const source = producer.appendGroup(); source.writeFrame(textFrame("old")); - source.close(); + source.finish(); clock.set(10_000 + CACHE_WINDOW_MS - 1_000); producer.appendGroup(); const within = producer.subscribe({ maxDelay: Milli(100_000) }); @@ -2594,6 +2650,7 @@ test("an omitted publisher limit still ages idle groups out", async () => { expect(after.tryRecvGroup()).toBeUndefined(); after.close(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2605,16 +2662,17 @@ test("a live track does not pin an aborted newest group", () => { try { const good = producer.appendGroup(); good.writeFrame(textFrame("snapshot")); - good.close(); + good.finish(); const reset = producer.appendGroup(); reset.writeFrame(textFrame("partial")); - reset.close(new Error("reset")); + reset.abort(new Error("reset")); clock.set(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); const late = producer.subscribe({ maxDelay: Milli(100_000) }); expect(late.tryRecvGroup()).toBeUndefined(); late.close(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2627,10 +2685,11 @@ test("closing a track keeps an idle newest group for subscribers to drain", asyn const subscriber = producer.subscribe({ maxDelay: Milli(100_000) }); const snapshot = producer.appendGroup(); snapshot.writeFrame(textFrame("snapshot")); - snapshot.close(); + snapshot.finish(); // Idle well past the window, but protected while the track is live. clock.set(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); + producer.finish(); producer.close(); expect(await (await subscriber.recvGroup())?.readString()).toBe("snapshot"); expect(await subscriber.recvGroup()).toBeUndefined(); @@ -2651,7 +2710,8 @@ test("a late subscriber to a closed unlimited track is released once the cache a const producer = new TrackProducer("unlimited").accept({ timescale: Timescale.MILLI }); const source = producer.appendGroup(); source.writeFrame(textFrame("last")); - source.close(); + source.finish(); + producer.finish(); producer.close(); // Counts the map entries subscribing leaves behind, rather than the heap: any entry @@ -2690,11 +2750,11 @@ test("an abort keeps finished groups for a slow reader, then reports it", async for (let i = 0; i < 2; i++) { const group = producer.appendGroup(); group.writeFrame(textFrame(`g${i}`)); - group.close(); + group.finish(); } producer.appendGroup().writeFrame(textFrame("open")); const boom = new Error("boom"); - producer.close(boom); + producer.abort(boom); for (const next of [() => arrival.recvGroup(), () => ordered.nextGroup()]) { for (let i = 0; i < 2; i++) { @@ -2714,10 +2774,10 @@ test("an abort after the declared end settles ends clean", async () => { for (let i = 0; i < 2; i++) { const group = producer.appendGroup(); group.writeFrame(textFrame(`g${i}`)); - group.close(); + group.finish(); } producer.finishAt({ group: 2, frame: 0 }); - producer.close(new Error("boom")); + producer.abort(new Error("boom")); for (const next of [() => arrival.recvGroup(), () => ordered.nextGroup()]) { expect((await next())?.sequence).toBe(0); @@ -2730,8 +2790,90 @@ test("an abort after the declared end reached by a datagram ends clean", () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); producer.appendDatagram(Timestamp.fromMillis(0), enc.encode("d")); producer.finishAt({ group: 1, frame: 0 }); - producer.close(new Error("boom")); + producer.abort(new Error("boom")); + expect(producer.closed.peek()).toBeNull(); +}); + +test("a settled end survives a late group and an abort", async () => { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + const subscriber = producer.subscribe(); + const last = new GroupProducer(5); + producer.writeGroup(last); + last.finish(); + producer.finishAt({ group: 6, frame: 0 }); + await settle(); + expect(producer.closed.peek()).toBeNull(); + + producer.writeGroup(new GroupProducer(4)); + producer.abort(new Error("boom")); + expect(producer.closed.peek()).toBeNull(); + expect((await subscriber.recvGroup())?.sequence).toBe(5); + // The abort cut nothing the end promised, so the subscriber ends cleanly. + expect(await subscriber.recvGroup()).toBeUndefined(); +}); + +// As in Rust, an evicted group no longer holds the end open. +test("closed settles once the cache evicts the last open group below the end", async () => { + const clock = manualTime(10_000); + try { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + const open = producer.appendGroup(); + open.writeFrame(textFrame("open")); + const last = producer.appendGroup(); + last.writeFrame(textFrame("last")); + last.finish(); + producer.finish(); + await settle(); + expect(producer.closed.peek()).toBeUndefined(); + + await clock.advance(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); + await settle(); + expect(open.closed.peek()).toBeInstanceOf(TooFarBehind); + expect(producer.closed.peek()).toBeNull(); + } finally { + clock.installed.close(); + } +}); + +test("finish is not terminal: closed settles once the groups below the end finish", async () => { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + const subscriber = producer.subscribe(); + const group = producer.appendGroup(); + group.writeFrame(textFrame("late")); + producer.finish(); + expect(producer.closed.peek()).toBeUndefined(); + + // The group below the end is still writable after finish. + group.writeFrame(textFrame("later")); + group.finish(); + await settle(); expect(producer.closed.peek()).toBeNull(); + // Settled, not released: the producer keeps serving its cache. + expect(hooks.trackReleased(producer).peek()).toBeUndefined(); + + const read = await subscriber.recvGroup(); + expect(read?.sequence).toBe(0); + expect(await subscriber.recvGroup()).toBeUndefined(); + producer.close(); + expect(hooks.trackReleased(producer).peek()).toBeNull(); +}); + +test("closed reports an abort before the end settles", () => { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + producer.appendGroup().writeFrame(textFrame("open")); + producer.finish(); + const abort = new Error("boom"); + producer.abort(abort); + expect(producer.closed.peek()).toBe(abort); +}); + +test("close without finish aborts with a cancel", () => { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + producer.writeFrame(textFrame("only")); + producer.close(); + const closed = producer.closed.peek(); + expect(closed).toBeInstanceOf(StreamError); + expect((closed as StreamError).code).toBe(StreamCode.Cancel); }); test("an ended track's buffered groups age out for a stale subscriber", async () => { @@ -2742,14 +2884,19 @@ test("an ended track's buffered groups age out for a stale subscriber", async () const stale = producer.subscribe({ maxDelay: Milli(30) }); const group = producer.appendGroup(); group.writeFrame(textFrame("x")); - group.close(); - producer.close(abort); + group.finish(); + if (abort) producer.abort(abort); + else { + producer.finish(); + producer.close(); + } // Nothing writes after close; the scheduled cleanup can lag by one scan slice. await clock.advance(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); if (abort) expect(() => stale.tryRecvGroup()).toThrow(abort); else expect(stale.tryRecvGroup()).toBeUndefined(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2763,7 +2910,7 @@ test("an abort leaves a group already taken readable, then reports the abort", a producer.appendGroup().writeFrame(textFrame("held")); const held = [await arrival.recvGroup(), await ordered.nextGroup()]; const boom = new Error("boom"); - producer.close(boom); + producer.abort(boom); for (const group of held) { expect(group?.sequence).toBe(0); @@ -2812,7 +2959,7 @@ test("a frame whose timedness disagrees with its track is refused", async () => TimestampMismatch, ); other.writeFrame({ payload: enc.encode("y") }); - other.close(); + other.finish(); // The untimed frame reaches the subscriber as it was written: no time filled in. const frame = await (await subscriber.recvGroup())?.readFrame(); @@ -2862,6 +3009,7 @@ test("unknown track metadata keeps presentation-time expiry until accepted", () producer.accept({ timescale: Timescale.MILLI }); expect(drain(producer.subscribe({ maxDelay: Milli(500) }))).toEqual([2, 3]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2891,6 +3039,7 @@ test("an untimed subscriber starts at the oldest fresh group", () => { producer.writeFrame({ payload: enc.encode("5") }); expect(drain(live)).toEqual([5]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2955,11 +3104,12 @@ test("explicit and producer-cut drops survive outside the group cache", async () const group = new GroupProducer(4); producer.writeGroup(group); const cut = new Error("cut"); - group.close(cut); + group.abort(cut); await settle(); expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 4, end: 5, error: cut }); reader.close(); + producer.finish(); producer.close(); }); @@ -2976,12 +3126,13 @@ test("a group ended by a cancel or cache policy is a hole, not a drop", async () for (const [sequence, hole] of holes.entries()) { const group = new GroupProducer(sequence); producer.writeGroup(group); - group.close(hole); + group.abort(hole); } await settle(); for (const sequence of holes.keys()) expect(hooks.givenUp(reader, sequence).error).toBeUndefined(); reader.close(); + producer.finish(); producer.close(); }); @@ -2994,7 +3145,7 @@ test("an upstream drop ends a group still arriving with its error", async () => const finished = new GroupProducer(4); producer.writeGroup(finished); finished.writeFrame({ payload: enc.encode("4") }); - finished.close(); + finished.finish(); const group = await reader.recvGroup(); expect(group?.sequence).toBe(3); @@ -3008,6 +3159,7 @@ test("an upstream drop ends a group still arriving with its error", async () => expect(await whole?.readString()).toBe("4"); reader.close(); + producer.finish(); producer.close(); }); @@ -3021,6 +3173,7 @@ test("a drop below the window is reported once the window lowers to it", () => { expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 2, end: 3, error: DROPPED }); reader.close(); + producer.finish(); producer.close(); }); @@ -3029,7 +3182,7 @@ test("a re-created cut group is served, not dropped", async () => { const reader = producer.subscribe({ maxDelay: Milli(60_000) }); const cut = new GroupProducer(4); producer.writeGroup(cut); - cut.close(new Error("cut")); + cut.abort(new Error("cut")); await settle(); const retry = new GroupProducer(4); @@ -3039,6 +3192,7 @@ test("a re-created cut group is served, not dropped", async () => { expect(recv.kind === "group" ? recv.group.sequence : recv).toBe(4); reader.close(); + producer.finish(); producer.close(); }); @@ -3061,6 +3215,7 @@ test("a wide drop is held and reported as one run", () => { expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "idle" }); reader.close(); + producer.finish(); producer.close(); }); @@ -3081,6 +3236,7 @@ test("adjacent drops with the same code merge into one run", () => { expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "idle" }); reader.close(); + producer.finish(); producer.close(); }); @@ -3092,16 +3248,18 @@ test("a mid-group end survives eviction and a filtered late reader", async () => const group = new GroupProducer(5); producer.writeGroup(group); for (let i = 0; i < 5; i++) group.writeFrame({ payload: new Uint8Array([i]) }); - group.close(); + group.finish(); + producer.finish(); producer.close(); await clock.advance(10_000 + CACHE_WINDOW_MS + 1); expect(await producer.subscribe().largest()).toEqual({ group: 5, frame: 4 }); // The cap excludes the only group, so nothing is within it. const reader = producer.subscribe().withRange({ end: { excluded: 5 } }); expect(await reader.largest()).toBeUndefined(); - expect(reader.end()).toEqual({ group: 5, frame: 5 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 5, frame: 5 }); reader.close(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -3113,7 +3271,7 @@ test("a wire end repeats or refines but never moves", () => { hooks.endTrack(producer, { group: 6, frame: 0 }); hooks.endTrack(producer, { group: 5, frame: 5 }); hooks.endTrack(producer, { group: 5, frame: 5 }); - expect(producer.subscribe().end()).toEqual({ group: 5, frame: 5 }); + expect(hooks.subscriberEnd(producer.subscribe())).toEqual({ group: 5, frame: 5 }); for (const moved of [ { group: 5, frame: 4 }, { group: 6, frame: 0 }, @@ -3121,6 +3279,7 @@ test("a wire end repeats or refines but never moves", () => { ]) { expect(() => hooks.endTrack(producer, moved)).toThrow(ProtocolViolation); } + producer.finish(); producer.close(); }); @@ -3132,8 +3291,9 @@ test("a mid-group end refuses to cut existing frames", () => { producer.writeGroup(group); for (let i = 0; i < 6; i++) group.writeFrame({ payload: new Uint8Array([i]) }); expect(() => producer.finishAt({ group: 5, frame: 5 })).toThrow(ProtocolViolation); - expect(producer.subscribe().end()).toBeUndefined(); + expect(hooks.subscriberEnd(producer.subscribe())).toBeUndefined(); producer.finishAt({ group: 5, frame: 6 }); + producer.finish(); producer.close(); }); @@ -3153,8 +3313,9 @@ test.each([true, false])("a mid-group end caps later writes (declared first: %s) // A group handed over already past the end is refused before it is cached. const overfull = new GroupProducer(5); for (let i = 0; i < 3; i++) overfull.writeFrame({ payload: new Uint8Array([i]) }); - group.close(new Error("replaced")); + group.abort(new Error("replaced")); expect(() => producer.writeGroup(overfull)).toThrow(ProtocolViolation); + producer.finish(); producer.close(); }); @@ -3166,10 +3327,10 @@ function stampGroup(producer: TrackProducer, sequence: number, ms: number): Grou try { group.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); } catch (err) { - group.close(err as Error); + group.abort(err as Error); throw err; } - group.close(); + group.finish(); return group; } @@ -3188,7 +3349,7 @@ test("a re-created group is handed out once", () => { producer.writeGroup(aborted); expect(drainGroups(subscriber)).toEqual([0, 1]); - aborted.close(new Error("aborted")); + aborted.abort(new Error("aborted")); stampGroup(producer, 1, 1); stampGroup(producer, 2, 2); expect(drainGroups(subscriber)).toEqual([2]); @@ -3261,6 +3422,7 @@ test("IETF provenance survives normalization on timed and untimed tracks", () => stampGroup(producer, 1, 500); producer.writeFrame({ payload: enc.encode("arrival"), timestamp: Timestamp.fromMillis(250) }); } finally { + producer.finish(); producer.close(); } }); @@ -3292,12 +3454,12 @@ test("a replaced aborted group still bounds later ones", () => { const first = new GroupProducer(0); producer.writeGroup(first); first.writeFrame({ payload: enc.encode("1000"), timestamp: Timestamp.fromMillis(1000) }); - first.close(new Error("aborted")); + first.abort(new Error("aborted")); const replacement = new GroupProducer(0); replacement.writeFrame({ payload: enc.encode("500"), timestamp: Timestamp.fromMillis(500) }); producer.writeGroup(replacement); - replacement.close(); + replacement.finish(); expect(() => stampGroup(producer, 1, 600)).toThrow(TimestampMismatch); stampGroup(producer, 1, 1000); }); @@ -3352,6 +3514,7 @@ test("an evicted group still bounds, and a late group below it is unverified", a const track = producer.subscribe({ maxDelay: Milli(60_000) }); expect(() => stampGroup(producer, 1, 4000)).toThrow(TimestampMismatch); stampGroup(producer, 1, 500); + producer.finish(); producer.close(); const timestamps = new Map(); @@ -3372,7 +3535,7 @@ test("an untimed track takes groups in any order", () => { const group = new GroupProducer(sequence); producer.writeGroup(group); group.writeFrame({ payload: enc.encode(`${sequence}`) }); - group.close(); + group.finish(); } }); @@ -3385,7 +3548,7 @@ function pendingTail(end: number, maxDelay = Milli(60_000)) { hooks.endTrack(producer, { group: end, frame: 0 }); const write = (sequence: number) => { const group = new GroupProducer(sequence); - group.close(); + group.finish(); producer.writeGroup(group); }; return { producer, reader, write }; @@ -3402,6 +3565,26 @@ async function settled(read: Promise): Promise { return done; } +// The end arrives after a higher group did, with lower ones still owed: it must not settle. +test("a received end behind a hole settles only once the hole fills", () => { + const producer = new TrackProducer("test"); + const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + hooks.startTrack(producer, { start: 0 }); + const late = new GroupProducer(2); + late.finish(); + producer.writeGroup(late); + hooks.endTrack(producer, { group: 3, frame: 0 }); + expect(producer.closed.peek()).toBeUndefined(); + + for (const sequence of [0, 1]) { + const group = new GroupProducer(sequence); + group.finish(); + producer.writeGroup(group); + } + expect(producer.closed.peek()).toBeNull(); + reader.close(); +}); + test("a pending tail holds readers at a hole until it fills", async () => { const { producer, reader, write } = pendingTail(3); write(2); @@ -3418,6 +3601,7 @@ test("a pending tail holds readers at a hole until it fills", async () => { expect((await next)?.sequence).toBe(1); expect(await reader.recvGroup()).toBeUndefined(); expect(reader.closed.peek()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -3428,6 +3612,7 @@ test("a pending tail ends readers at the boundary once the track closes", async const next = reader.recvGroup(); expect(await settled(next)).toBe(false); + producer.finish(); producer.close(); expect(await next).toBeUndefined(); expect(await reader.closed).toBeNull(); @@ -3438,10 +3623,11 @@ test("without a declared start, the first arrival stands in for it", async () => const reader = producer.subscribe({ maxDelay: Milli(60_000) }); hooks.endTrack(producer, { group: 3, frame: 0 }); const group = new GroupProducer(2); - group.close(); + group.finish(); producer.writeGroup(group); expect((await reader.recvGroup())?.sequence).toBe(2); expect(await reader.recvGroup()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -3467,6 +3653,7 @@ test("a source start follows the current requested floor after an update", async expect(hooks.sourceStart(reader)).toBe(9); } finally { reader.close(); + producer.finish(); producer.close(); } }); @@ -3479,7 +3666,7 @@ test("a pending tail owes from SUBSCRIBE_START while the demand keeps its floor" hooks.startTrack(producer, { start: 3, requested: 1 }); hooks.endTrack(producer, { group: 4, frame: 0 }); const group = new GroupProducer(3); - group.close(); + group.finish(); producer.writeGroup(group); expect((await reader.recvGroup())?.sequence).toBe(3); expect(await reader.recvGroup()).toBeUndefined(); @@ -3488,6 +3675,7 @@ test("a pending tail owes from SUBSCRIBE_START while the demand keeps its floor" expect((await lower.recvGroup())?.sequence).toBe(3); const next = lower.recvGroup(); expect(await settled(next)).toBe(false); + producer.finish(); producer.close(); expect(await next).toBeUndefined(); }); @@ -3498,7 +3686,7 @@ test("an end after the highest group holds the reader at the hole", async () => const reader = producer.subscribe({ maxDelay: Milli(60_000) }); hooks.startTrack(producer, { start: 0 }); const high = new GroupProducer(2); - high.close(); + high.finish(); producer.writeGroup(high); expect((await reader.recvGroup())?.sequence).toBe(2); const next = reader.recvGroup(); @@ -3507,12 +3695,13 @@ test("an end after the highest group holds the reader at the hole", async () => expect(await settled(next)).toBe(false); for (const sequence of [0, 1]) { const low = new GroupProducer(sequence); - low.close(); + low.finish(); producer.writeGroup(low); } expect((await next)?.sequence).toBe(0); expect((await reader.recvGroup())?.sequence).toBe(1); expect(await reader.recvGroup()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -3525,7 +3714,7 @@ test("an abort errors a pending tail short of a group", async () => { const next = reader.recvGroup(); expect(await settled(next)).toBe(false); - producer.close(new Error("dropped")); + producer.abort(new Error("dropped")); await expect(next).rejects.toThrow("dropped"); }); @@ -3533,7 +3722,7 @@ test("an abort after a full tail ends cleanly", async () => { const { producer, reader, write } = pendingTail(2); write(1); write(0); - producer.close(new Error("dropped")); + producer.abort(new Error("dropped")); expect((await reader.recvGroup())?.sequence).toBe(0); expect((await reader.recvGroup())?.sequence).toBe(1); expect(await reader.recvGroup()).toBeUndefined(); @@ -3559,7 +3748,7 @@ for (const [waited, clean] of [ expect(await settled(next)).toBe(false); clock.set(waited); - producer.close(new Error("dropped")); + producer.abort(new Error("dropped")); if (clean) expect(await next).toBeUndefined(); else await expect(next).rejects.toThrow("dropped"); } finally { @@ -3583,6 +3772,7 @@ test("an evicted group still fills a pending tail", async () => { expect(producer.subscribe({ maxDelay: Milli(60_000) }).tryRecvGroup()?.sequence).toBe(2); expect((await reader.recvGroup())?.sequence).toBe(2); expect(await reader.recvGroup()).toBeUndefined(); + producer.finish(); producer.close(); } finally { clock.installed.close(); @@ -3595,6 +3785,7 @@ test("a datagram fills its sequence in a pending tail", async () => { write(1); expect((await reader.recvGroup())?.sequence).toBe(1); expect(await reader.recvGroup()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -3609,10 +3800,11 @@ for (const abort of [false, true]) { expect(await settled(next)).toBe(false); hooks.dropTrackGroups(producer, 0, 2, DROPPED); - if (abort) producer.close(new Error("dropped")); + if (abort) producer.abort(new Error("dropped")); expect(await next).toBeUndefined(); if (abort) expect(await reader.closed).toBeNull(); else expect(reader.closed.peek()).toBeUndefined(); + producer.finish(); producer.close(); }); } diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 2adbebf3511..530af000e97 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -379,7 +379,7 @@ export class Request { return producer.accept(info); } catch (err) { // Refused, so its subscribers see the error instead of waiting on an open request. - producer.close(err instanceof Error ? err : new Error(String(err))); + producer.abort(err instanceof Error ? err : new Error(String(err))); throw err; } }, @@ -389,7 +389,11 @@ export class Request { /** Reject the request, closing the track optionally with an error. */ reject(err?: Error): void { this.#pending.delete(this); - this.#producer.close(err); + if (err) this.#producer.abort(err); + else { + this.#producer.finish(); + this.#producer.close(); + } } } @@ -523,6 +527,9 @@ class TrackState { // end does not end readers yet. Mirrors the Rust `awaits_tail`. owed = new Signal(false); closed = new Once(); + // The producer's outcome, settled by its close or by a reached and settled end, whichever + // is first. Only the producer's own state uses it. + outcome = new Once(); update: Signal; /** Resolved once the producer commits the immutable properties. */ info = new Signal(undefined); @@ -653,6 +660,7 @@ export class Demand { static { makeDemand = (name, wanted, state) => new Demand(name, wanted, state); hooks.demandPriority = (demand) => demand.#state.info.peek()?.priority ?? DEFAULT_PRIORITY; + hooks.demandReleased = (demand) => demand.#state.closed; } /** @@ -667,9 +675,12 @@ export class Demand { return this.#wanted; } - /** Settles once the track closes: `null` on a clean close, or the abort {@link Error}. */ + /** + * Settles once the track's outcome is final: `null` once its declared end is reached and + * settled, or on a clean release, else the abort {@link Error}. Never because readers left. + */ get closed(): GetPromise { - return this.#state.closed; + return this.#state.outcome; } } @@ -768,9 +779,12 @@ export class Producer { hooks.producerLargest = (producer) => producer.#state.largest; hooks.joinProducer = (producer) => producer.#join(); hooks.endTrack = (producer, end) => producer.#endPending(end); - hooks.releaseTrack = (producer) => producer.#close(undefined, false); + hooks.releaseTrack = (producer) => producer.#close(undefined); + hooks.trackReleased = (producer) => producer.#state.closed; + hooks.trackFinished = (producer) => producer.#state.end.peek() !== undefined; hooks.dropTrackGroups = (producer, start, end, error) => producer.#drop(start, end, error, true); hooks.startTrack = (producer, served) => { + producer.#settle(); producer.#served = served; producer.#owe(); for (const state of [producer.#state, ...producer.#sinks]) state.startChanged.update((rev) => rev + 1); @@ -844,7 +858,7 @@ export class Producer { if (open) { for (const [sequence, entry] of this.#cached) { if (sequence >= start && sequence < end && entry.group.closed.peek() === undefined) { - entry.group.close(error); + entry.group.abort(error); } } } @@ -881,11 +895,13 @@ export class Producer { } /** - * Settles once the track closes: `null` on a clean close, or the abort {@link Error}. + * Settles once the track's outcome is final: `null` once its declared end is reached and + * settled (every group below it finished) or on a clean release, else the abort + * {@link Error}. The producer stays open after a settled end, serving its cache. * Peek it synchronously (`undefined` while open), observe it reactively, or `await` it. */ get closed(): GetPromise { - return this.#state.closed; + return this.#state.outcome; } /** @@ -914,7 +930,7 @@ export class Producer { } } } catch (err) { - this.close(err instanceof Error ? err : new Error(String(err))); + this.abort(err instanceof Error ? err : new Error(String(err))); throw err; } this.#state.info.set(resolved); @@ -1022,11 +1038,11 @@ export class Producer { for (const entry of this.#cache) { const mirror = entry.mirrors.get(sink); if (mirror) { - mirror.close(abort); + hooks.releaseGroupReader(mirror, abort); entry.mirrors.delete(sink); } } - for (const group of sink.groups.peek()) group.close(abort); + for (const group of sink.groups.peek()) hooks.releaseGroupReader(group, abort); dispose(); }); } @@ -1047,6 +1063,7 @@ export class Producer { // the aggregate. The wire layer observes this signal and emits SUBSCRIBE_UPDATE. #updateSubscription(): void { const combined = combineSubscriptions(this.#sinks); + this.#settle(); const retained = this.#state.info.peek()?.maxAge; if (combined && retained !== undefined) combined.maxDelay = Milli(Math.min(combined.maxDelay ?? Milli.zero, retained)); @@ -1082,7 +1099,7 @@ export class Producer { if (!entry.group.isClosed) { const evicted = new TooFarBehind(); EVICTED.add(evicted); - entry.group.close(evicted); + entry.group.abort(evicted); } const mirrors = [...entry.mirrors.values()]; for (const mirror of mirrors) hooks.evictGroup(mirror); @@ -1243,7 +1260,10 @@ export class Producer { this.#evict(entry); } } + const evicted = retained.length < this.#cache.length; this.#cache = retained; + // An evicted group below the end no longer holds it open, as in Rust. + if (evicted) this.#settle(); // Replace the wakeup with one for the next entry due to age out. Writes settle // retention inline, so this only has to cover the case no write follows. Cheap to @@ -1278,6 +1298,7 @@ export class Producer { // frame 0 passes its checked `start` and whether it is `late`; any other checks frame 0 // against this track when it lands. #publish(group: GroupProducer, start?: Timestamp, late = false): void { + this.#settle(); const entry: CachedGroup = { group, mirrors: new Map(), arrived: now() }; this.#undrop(group.sequence); group.closed.then((closed) => { @@ -1432,6 +1453,7 @@ export class Producer { // Late subscribers do NOT replay old datagrams (best-effort, unlike the group cache). A // compressed track's rings share only `slice`: each holds its own lazy view of it. #publishDatagram(datagram: Datagram, slice?: Stored): void { + this.#settle(); const { sequence, timestamp } = datagram; this.#advanceObject({ group: sequence, frame: 0 }); this.#advance(sequence, false); @@ -1520,6 +1542,8 @@ export class Producer { // The wire's end: repeating it is a no-op, and a mid-group end may refine the group // boundary it falls just before. Mirrors the Rust `finish_at` on a `received()` copy. #endPending(end: Position): void { + // Before the end lands, so settling it already counts what the tail still owes. + this.#tailPending = true; const declared = this.#state.end.peek(); if (declared === undefined) { this.finishAt(end); @@ -1529,7 +1553,6 @@ export class Producer { } this.#declareEnd(end); } - this.#tailPending = true; this.#owe(); } @@ -1553,6 +1576,14 @@ export class Producer { const end = this.#state.end.peek(); const owed = end !== undefined && this.#owes(finalSequence(end)); for (const state of [this.#state, ...this.#sinks]) state.owed.set(owed); + this.#settle(); + } + + // Settle the outcome cleanly once the declared end is reached and settled. Also run before a + // write that could reopen it (a late group below the end, a fresh grace for the tail): that + // write is still served, but the clean outcome stays final. Mirrors the Rust `latch_settled`. + #settle(): void { + if (this.#state.outcome.peek() === undefined && this.#settled()) this.#state.outcome.set(null); } // Whether the pending tail misses a sequence from where the feed starts up to `final`: one @@ -1610,6 +1641,7 @@ export class Producer { // the end raised it already, so neither a late group below it nor a recreated copy of it // moves it after this. #endLargest(): void { + this.#settle(); if (this.#state.largestEnded.peek() || this.#largestOpen) return; const end = this.#state.end.peek(); if (end === undefined || this.#received < finalSequence(end)) return; @@ -1617,40 +1649,64 @@ export class Producer { } /** - * Close the track and every subscriber, mirroring the abort to their groups. Idempotent. + * Declare the track's end after the highest sequence written. A no-op once an end is declared or the track closed, + * so a cleanup path can finish whatever the track's state. * - * A clean close keeps the end {@link finishAt} declared, or declares one past the highest - * sequence produced; an abort ends without one. Subscribers still draining get the - * finished groups first, then the end or the abort. An abort after the declared end - * settled (reached, with every group below it finished) is a clean close. The groups - * left behind still age out on the idle cache window, so a stale subscriber can't pin - * them. + * Not terminal: groups below the end may still be written. Readers end once the live edge + * reaches it. */ - close(abort?: Error) { - this.#close(abort, true); + finish(): void { + if (this.#state.end.peek() !== undefined || this.#state.closed.peek() !== undefined) return; + this.finishAt({ group: this.#received, frame: 0 }); + } + + /** + * Abort the track and every subscriber, mirroring the error to their open groups. Terminal + * and idempotent. + * + * Subscribers still draining get the finished groups first, then the error. An abort after + * the declared end settled (reached, with every group below it finished) only releases the + * track: readers end cleanly. The groups left behind still age out on the idle cache + * window, so a stale subscriber can't pin them. + */ + abort(error: Error): void { + this.#close(error); } - // `declare` is false for a received copy whose session closed locally: a dead route, not - // finished content, so a clean close there leaves the end undeclared. - #close(abort: Error | undefined, declare: boolean) { + /** + * Release this producer, like dropping the last Rust producer. Idempotent. + * + * With an end declared ({@link finish} or {@link finishAt}), the track ends there and its + * subscribers drain to it. Without one, the track aborts: nothing finished it. + */ + close(): void { + if (this.#state.end.peek() !== undefined) this.#close(undefined); + else this.#close(new StreamError(StreamCode.Cancel, { message: `track ${this.name} closed without finish` })); + } + + // `abort` is undefined for a clean close, which keeps whatever end was declared. A received + // copy whose session closed locally closes cleanly without one: a dead route, not finished + // content. + #close(abort: Error | undefined) { if (this.#state.closed.peek() !== undefined) return; // A gap past the grace was lost before the abort rather than cut off by it. if (abort && this.#tailPending) this.#arrivals.expire(now(), this.#grace()); - if (abort && this.#settled()) abort = undefined; - if (declare && abort === undefined && this.#state.end.peek() === undefined) { - this.#declareEnd({ group: this.#received, frame: 0 }); - } + if (abort && (this.#state.outcome.peek() === null || this.#settled())) abort = undefined; // Nobody will finish these, so a subscriber that has not taken one yet never sees it. // Not evicted: a reader already holding one keeps its frames and sees the abort. const open = abort ? this.#cache.filter((entry) => entry.group.closed.peek() === undefined) : []; closeTrackState(this.#state, abort); - for (const { group } of this.#cache) group.close(abort); + if (this.#state.outcome.peek() === undefined) this.#state.outcome.set(abort ?? null); + for (const { group } of this.#cache) { + if (abort) group.abort(abort); + else group.finish(); + } for (const entry of open) { this.#unlink(entry); this.#uncache(entry); } for (const sink of this.#sinks) { - for (const group of sink.groups.peek()) group.close(abort); + for (const group of sink.groups.peek()) hooks.releaseGroupReader(group, abort); closeTrackState(sink, abort); } this.#sinks.clear(); @@ -1677,10 +1733,10 @@ export class Producer { group.writeFrame(frame); } catch (err) { // Abort the group so no subscriber waits on a group that will never get its frame. - group.close(err instanceof Error ? err : new Error(String(err))); + group.abort(err instanceof Error ? err : new Error(String(err))); throw err; } - group.close(); + group.finish(); } } @@ -2062,6 +2118,8 @@ export class Subscriber { static { makeSubscriber = (name, state) => new Subscriber(name, state); + hooks.subscriberEnd = (subscriber) => + subscriber.#resume ? subscriber.#resume.end() : subscriber.#state.end.peek(); hooks.joinSubscriber = (subscriber) => { if (subscriber.#resume) subscriber.#resume.join(); else if (subscriber.#state.producer) hooks.joinProducer(subscriber.#state.producer); @@ -2246,17 +2304,6 @@ export class Subscriber { return this.#state.update; } - /** - * The track's exclusive end: the one {@link Producer.finishAt} declared, which can be - * ahead of the live edge, or the head of the sequence after the highest produced once the - * producer closes cleanly (`{ group: 0, frame: 0 }` for a track that produced none). - * Groups and datagrams share the sequence namespace, so it can sit past the last group. - * Undefined until declared, and after an abort that declared none. Mirrors the Rust `end`. - */ - end(): Position | undefined { - return this.#resume ? this.#resume.end() : this.#state.end.peek(); - } - /** * Resolve with the track's exclusive end once it is known, mirroring the Rust `finished`. * @@ -2395,10 +2442,10 @@ export class Subscriber { closeTrackState(this.#state, abort); this.#resume?.close(abort); this.#state.groups.mutate((groups) => { - for (const group of groups) group.close(abort); + for (const group of groups) hooks.releaseGroupReader(group, abort); groups.length = 0; }); - this.#frameGroup?.close(abort); + if (this.#frameGroup) hooks.releaseGroupReader(this.#frameGroup, abort); this.#frameGroup = undefined; this.#state.timeline.length = 0; this.#wall.timer?.abort(); @@ -2920,11 +2967,6 @@ export class Ordered { return this.#subscriber.largest(); } - /** The track's exclusive end; see {@link Subscriber.end}. */ - end(): Position | undefined { - return this.#subscriber.end(); - } - /** Resolve with the track's exclusive end once known; see {@link Subscriber.finished}. */ finished(): Promise { return this.#subscriber.finished(); diff --git a/js/net/src/untimed.test.ts b/js/net/src/untimed.test.ts index a4a38b3b766..1a18e72115e 100644 --- a/js/net/src/untimed.test.ts +++ b/js/net/src/untimed.test.ts @@ -57,7 +57,7 @@ async function roundTrip(wire: Wire, timescale: Timescale | undefined, timestamp for (const [i, timestamp] of timestamps.entries()) { group.writeFrame({ payload: new Uint8Array([i]), timestamp }); } - group.close(); + group.finish(); } })(); @@ -87,13 +87,14 @@ async function republish(input: BroadcastConsumer): Promise { for (let group = await groups.nextGroup(); group; group = await groups.nextGroup()) { const out = video.appendGroup(); for (let frame = await group.readFrame(); frame; frame = await group.readFrame()) out.writeFrame(frame); - out.close(); + out.finish(); } // Closing the broadcast does not end a track inserted into it. + video.finish(); video.close(); } catch (err) { // An upstream abort goes downstream as an abort, never as a clean end. - video.close(err instanceof Error ? err : new Error(String(err))); + video.abort(err instanceof Error ? err : new Error(String(err))); } finally { copy.close(); } @@ -124,7 +125,7 @@ test("a forwarded untimed track stays untimed", async () => { if (!request) break; const group = request.accept({ timescale: Timescale.MICRO }).appendGroup(); group.writeFrame({ payload: new Uint8Array([7]), timestamp: Timestamp.fromMicros(1_234) }); - group.close(); + group.finish(); } })(); @@ -200,7 +201,7 @@ for (const wire of WIRES.filter((wire) => wire.name === "lite-06" || wire.name = // Datagrams aren't cached, so a group read back first proves the subscription is live. const group = producer.appendGroup(); group.writeFrame({ payload: new Uint8Array([0]) }); - group.close(); + group.finish(); expect(await subscriber.recvGroup()).toBeDefined(); producer.appendDatagram(undefined, new Uint8Array([7])); diff --git a/js/net/src/util/announce.ts b/js/net/src/util/announce.ts new file mode 100644 index 00000000000..71f3760a1b8 --- /dev/null +++ b/js/net/src/util/announce.ts @@ -0,0 +1,12 @@ +import { Signal } from "@moq/signals"; +import type { Producer } from "../announced.ts"; + +/** Close an announcement stream once no consumer wants it: how a reader cancels the request. */ +export function closeUnwanted(producer: Producer): void { + void (async () => { + while (producer.wanted.peek() && producer.closed.peek() === undefined) { + await Signal.race(producer.wanted, producer.closed); + } + producer.close(); + })(); +} diff --git a/js/net/src/util/demand.ts b/js/net/src/util/demand.ts index 7f0789573a8..4f9a2384604 100644 --- a/js/net/src/util/demand.ts +++ b/js/net/src/util/demand.ts @@ -1,10 +1,11 @@ import { Signal } from "@moq/signals"; +import { hooks } from "../internal.ts"; import type { Demand } from "../track.ts"; /** Resolve once no subscriber wants the track, or it closed. */ export async function unwanted(demand: Demand): Promise { - while (demand.wanted.peek() !== undefined && demand.closed.peek() === undefined) { - await Signal.race(demand.wanted, demand.closed); + while (demand.wanted.peek() !== undefined && hooks.demandReleased(demand).peek() === undefined) { + await Signal.race(demand.wanted, hooks.demandReleased(demand)); } } @@ -13,8 +14,8 @@ export async function unwanted(demand: Demand): Promise { * attaches no subscriber, so demand that never arrived is not abandonment. */ export async function abandoned(demand: Demand): Promise { - while (demand.wanted.peek() === undefined && demand.closed.peek() === undefined) { - await Signal.race(demand.wanted, demand.closed); + while (demand.wanted.peek() === undefined && hooks.demandReleased(demand).peek() === undefined) { + await Signal.race(demand.wanted, hooks.demandReleased(demand)); } for (;;) { await unwanted(demand); @@ -24,5 +25,5 @@ export async function abandoned(demand: Demand): Promise { /** Whether no subscriber wants the track now, or it closed. */ export function isAbandoned(demand: Demand): boolean { - return demand.closed.peek() !== undefined || demand.wanted.peek() === undefined; + return hooks.demandReleased(demand).peek() !== undefined || demand.wanted.peek() === undefined; } diff --git a/js/net/src/zod.test.ts b/js/net/src/zod.test.ts index ddc8cee2798..95a3cf48b51 100644 --- a/js/net/src/zod.test.ts +++ b/js/net/src/zod.test.ts @@ -9,7 +9,7 @@ test("read validates each frame and ends only at the end of the group", async () const consumer = producer.consume(); // Falsy values are frames like any other, not the end of the stream. for (const json of ["0", "false", "null", '"ok"']) producer.writeFrame(textFrame(json)); - producer.close(); + producer.finish(); const schema = z.union([z.number(), z.boolean(), z.null(), z.string()]); expect(await Zod.read(consumer, schema)).toBe(0); @@ -23,7 +23,7 @@ test("read rejects a frame the schema refuses", async () => { const producer = new Producer(0); const consumer = producer.consume(); producer.writeFrame(textFrame('{"count":"three"}')); - producer.close(); + producer.finish(); await expect(Zod.read(consumer, z.object({ count: z.number() }))).rejects.toThrow(); }); diff --git a/js/publish/src/audio/capture.test.ts b/js/publish/src/audio/capture.test.ts index f7ce74a3b3c..e2fa6760c7c 100644 --- a/js/publish/src/audio/capture.test.ts +++ b/js/publish/src/audio/capture.test.ts @@ -611,6 +611,7 @@ test("a failed encoder stays failed until the capture source changes", async () } finally { encoder.close(); capture.close(); + track.finish(); track.close(); await settle(); error.mockRestore(); diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index dd6cd153f61..3830e540388 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -283,7 +283,10 @@ async function setup(order = new Order(), codec?: Codec, groupDuration?: Milli) const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const feed = new Feed(); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index cd7f3408980..f309f7019e6 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -424,7 +424,7 @@ export class Encoder { // A dead encoder can't serve anyone, so the current subscriber and every later one get // the real error rather than a track that stays silent. - if (fatal) track?.close(fatal); + if (fatal) track?.abort(fatal); effect.set(this.#out.active, enabled && !!format && !!track && !fatal, false); }); @@ -746,7 +746,7 @@ export class Encoder { const cause = error instanceof Error ? error : new Error(String(error)); this.#failure.peek().set(cause); this.#out.error.set(cause); - this.#live?.track.close(cause); + this.#live?.track.abort(cause); } /** Stop and release everything this holds. */ diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index 4a0661965a5..680f6a74830 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -167,6 +167,7 @@ test("subscriber demand hands the producer to the rendition and clears it when t // Closing the producer (encoder error / teardown) clears the signal, with no lingering per-subscription // effect watching it. + track?.finish(); track?.close(); await settle(); expect(rendition.track.peek()).toBeUndefined(); @@ -281,7 +282,7 @@ test("a rendition keeps its track across rendition changes", async () => { await settle(); const track = video.track.peek(); if (!track) throw new Error("expected the demanded track"); - track.appendGroup().close(); + track.appendGroup().finish(); // Registering another rendition leaves this one's track alone. const audio = broadcast.audio("audio"); @@ -340,8 +341,8 @@ test("replacing a closed rendition track keeps the broadcast and publishes a fre if (!track) throw new Error("expected the demanded track"); // The encoder wrote, then failed. - track.appendGroup().close(); - track.close(new Error("encoder failed")); + track.appendGroup().finish(); + track.abort(new Error("encoder failed")); video.close(); await settle(); @@ -437,7 +438,7 @@ test("registering a sibling after an encoder failure keeps the network broadcast await settle(); const track = video.track.peek(); if (!track) throw new Error("expected demanded video track"); - track.close(new Error("encoder failed")); + track.abort(new Error("encoder failed")); await settle(); broadcast.audio("audio/new").config.set(audioConfig); await settle(); @@ -477,7 +478,7 @@ test("a served decoder identity change moves the rendition to a fresh track name await settle(); const track = video.track.peek(); if (!track) throw new Error("expected the demanded track"); - track.appendGroup().close(); + track.appendGroup().finish(); // Bitrate, frame rate, and coded size move either way on the served track. for (const update of [ @@ -604,7 +605,7 @@ test("a replacement track name is never reused, even one never served", async () const fail = async (name: string) => { const sub = net.track(name).subscribe(); await settle(); - video.track.peek()?.close(new Error("encoder failed")); + video.track.peek()?.abort(new Error("encoder failed")); video.close(); await settle(); sub.close(); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 62c20977638..901fbaaeecd 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -367,6 +367,7 @@ export class Broadcast { const kinds = new Map(); this.#kinds = kinds; effect.cleanup(() => { + for (const { track } of tracks.values()) track.finish(); for (const { track } of tracks.values()) track.close(); if (this.#kinds === kinds) this.#kinds = new Map(); }); @@ -398,7 +399,10 @@ export class Broadcast { maxAge: Milli(Number.MAX_SAFE_INTEGER), priority: Catalog.PRIORITY.catalog, }); - effect.cleanup(() => track.close()); + effect.cleanup(() => { + track.finish(); + track.close(); + }); this.catalog.serve(track, effect, { compression }); } } @@ -418,11 +422,12 @@ export class Broadcast { let entry = tracks.get(rendition.name); const cause = entry && errors.get(entry.owner)?.peek(); - if (entry && cause) entry.track.close(cause); + if (entry && cause) entry.track.abort(cause); const name = names[rendition.name] ?? rendition.name; if (entry && entry.track.name !== name) { // The catalog moved this rendition to a new name for new media. End the old track // cleanly on its last identity, so its subscribers follow the catalog. + entry.track.finish(); entry.track.close(); entry = undefined; } else if (entry && entry.owner !== rendition && entry.track.closed.peek() !== undefined) { @@ -449,7 +454,7 @@ export class Broadcast { if (error) { inner.run((failure) => { const cause = failure.get(error); - if (cause) served.close(cause); + if (cause) served.abort(cause); }); } inner.run((demand) => { diff --git a/js/publish/src/video/capture.test.ts b/js/publish/src/video/capture.test.ts index 0e46e581472..a2dd356f559 100644 --- a/js/publish/src/video/capture.test.ts +++ b/js/publish/src/video/capture.test.ts @@ -458,6 +458,7 @@ test("an encoder resuming on a still source encodes a keyframe of the current pi } finally { encoder.close(); capture.close(); + track.finish(); track.close(); clock.mockRestore(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); @@ -556,6 +557,7 @@ test("an encoder torn down while a frame is in flight closes that frame", async } finally { encoder.close(); capture.close(); + track.finish(); track.close(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); else Reflect.deleteProperty(globalThis, "VideoEncoder"); diff --git a/js/publish/src/video/encoder-stall.test.ts b/js/publish/src/video/encoder-stall.test.ts index b0902272fbd..8441cfb25f5 100644 --- a/js/publish/src/video/encoder-stall.test.ts +++ b/js/publish/src/video/encoder-stall.test.ts @@ -109,7 +109,14 @@ test("a slower-than-realtime encoder drops input instead of ratcheting catalog l const order = new Order(); // An on-time audio rendition shares the broadcast, writing each capture as it happens. const audio = new Estimator(); - const rendition = { config: new Signal(undefined), track: new Signal(track), close: () => track.close() }; + const rendition = { + config: new Signal(undefined), + track: new Signal(track), + close: () => { + track.finish(); + track.close(); + }, + }; const encoder = new Encoder("video", { broadcast: { video: () => rendition, order } as never, capture: { @@ -164,6 +171,7 @@ test("a slower-than-realtime encoder drops input instead of ratcheting catalog l } finally { encoder.close(); fanout.close(); + track.finish(); track.close(); await clock.advance(Nano.zero); installed.close(); diff --git a/js/publish/src/video/encoder.test.ts b/js/publish/src/video/encoder.test.ts index 60dcebf392c..67dbf8a023d 100644 --- a/js/publish/src/video/encoder.test.ts +++ b/js/publish/src/video/encoder.test.ts @@ -116,7 +116,10 @@ test("encoding tracks encoder config in its child effect", async () => { const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const broadcast = { video: () => rendition, order: new Order() }; const capture = { @@ -154,7 +157,10 @@ test("a demand gap marks a discontinuity and leaves the broadcast-owned track op const rendition = { config: new Signal(undefined), track: live, - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal(undefined) }, @@ -181,11 +187,13 @@ test("a demand gap marks a discontinuity and leaves the broadcast-owned track op expect(track.closed.peek()).toBeUndefined(); discontinuity.mockClear(); + track.finish(); track.close(); encoder.close(); expect(discontinuity).not.toHaveBeenCalled(); } finally { encoder.close(); + track.finish(); track.close(); discontinuity.mockRestore(); } @@ -203,7 +211,10 @@ test("a bandwidth estimate updates the bitrate without blanking the config or re const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { @@ -285,7 +296,10 @@ test("every published config fits a probe for its own codec", async () => { const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { @@ -615,7 +629,10 @@ test("disabling encodes one black keyframe and keeps the rendition with enabled: const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const source = new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }); const capture = { @@ -671,6 +688,7 @@ test("disabling encodes one black keyframe and keeps the rendition with enabled: } finally { encoder.close(); fanout.close(); + track.finish(); track.close(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); else Reflect.deleteProperty(globalThis, "VideoEncoder"); @@ -778,7 +796,10 @@ test("the probe encodes with the frame rate and bitrate ceiling", async () => { const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }) }, @@ -951,7 +972,10 @@ async function recordKeyframes( const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) } as never) }, @@ -975,6 +999,7 @@ async function recordKeyframes( } finally { encoder.close(); fanout.close(); + track.finish(); track.close(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); else Reflect.deleteProperty(globalThis, "VideoEncoder"); @@ -1222,7 +1247,10 @@ test("a shrinking capture resizes in band below the advertised ceiling", async ( const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) } as never) }, @@ -1273,6 +1301,7 @@ test("a shrinking capture resizes in band below the advertised ceiling", async ( } finally { encoder.close(); fanout.close(); + track.finish(); track.close(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); else Reflect.deleteProperty(globalThis, "VideoEncoder"); @@ -1290,7 +1319,10 @@ test("the catalog bitrate and frame rate only rise", async () => { const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }) }, diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index 9b4dfcf64bc..1b5f2faebfd 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -572,7 +572,7 @@ export class Encoder { const cause = error instanceof Error ? error : new Error(String(error)); this.#failure.peek().set(cause); this.#out.error.set(cause); - track.close(cause); + track.abort(cause); } // Publishes the catalog for the probe: its codec string, the coded-size ceiling, and the bitrate and diff --git a/js/room/src/chat.test.ts b/js/room/src/chat.test.ts index 4b342004491..ceeece0e18a 100644 --- a/js/room/src/chat.test.ts +++ b/js/room/src/chat.test.ts @@ -45,7 +45,7 @@ test("chat rejects non-string window records and propagates track failures", asy timestamp: Time.Timestamp.now(), }); await expect(subscriber.recv()).rejects.toThrow("chat record must be a string"); - track.close(new Error("chat aborted")); + track.abort(new Error("chat aborted")); await expect(subscriber.recv()).rejects.toThrow("chat aborted"); } finally { subscriber.close(); diff --git a/js/room/src/metadata.ts b/js/room/src/metadata.ts index 63a27ef5c15..defca98bee8 100644 --- a/js/room/src/metadata.ts +++ b/js/room/src/metadata.ts @@ -161,7 +161,10 @@ function serveSnapshot( maxAge: Milli(86_400_000), priority: PRIORITY, }); - effect.cleanup(() => track.close()); + effect.cleanup(() => { + track.finish(); + track.close(); + }); const producer = new Json.Snapshot.Producer({ track }); effect.cleanup(() => producer.finish()); diff --git a/js/watch/src/audio/decoder.test.ts b/js/watch/src/audio/decoder.test.ts index 32460f62ad0..ab2df5e2c88 100644 --- a/js/watch/src/audio/decoder.test.ts +++ b/js/watch/src/audio/decoder.test.ts @@ -940,6 +940,7 @@ async function livePlayback(burst: number) { }, // Rename the rendition, publishing the rest of the timeline on the new track. rename(name: string, patch: Record = {}) { + track.finish(); track.close(); track = producer.createTrack(name, { timescale: Moq.Time.Timescale.MILLI }); sequence = 0; @@ -965,7 +966,7 @@ async function livePlayback(burst: number) { for (const timestamp of held) { const group = new Moq.Group.Producer(sequence++); group.writeFrame(legacyFrame(Micro.fromMilli(Milli(timestamp)))); - group.close(); + group.finish(); track.writeGroup(group); } held = []; diff --git a/js/watch/src/audio/playout.test.ts b/js/watch/src/audio/playout.test.ts index b97c17ef421..5ee4387f408 100644 --- a/js/watch/src/audio/playout.test.ts +++ b/js/watch/src/audio/playout.test.ts @@ -100,7 +100,7 @@ async function measure(t: Arrival[], budget: (target: Milli) => Milli): Promise< const media = Micro.fromMilli(t[next].media as Milli); const group = new Group.Producer(next); group.writeFrame({ payload: encode(media), timestamp: Time.Timestamp.fromMicros(media) }); - group.close(); + group.finish(); track.writeGroup(group); } @@ -112,6 +112,7 @@ async function measure(t: Arrival[], budget: (target: Milli) => Milli): Promise< subscriber.update({ maxDelay: budget(current) }); } } finally { + track.finish(); track.close(); consumer.close(); await reader; diff --git a/js/watch/src/audio/replay.ts b/js/watch/src/audio/replay.ts index 82a9b91d633..d164695f3a1 100644 --- a/js/watch/src/audio/replay.ts +++ b/js/watch/src/audio/replay.ts @@ -246,7 +246,7 @@ export async function* replay(trace: Arrival[], options: Options): AsyncGenerato timestamp: Time.Timestamp.fromMicros(timestamp), }); if (ends.get(arrival.group) === next) { - group.close(); + group.finish(); groups.delete(arrival.group); } next++; @@ -287,6 +287,7 @@ export async function* replay(trace: Arrival[], options: Options): AsyncGenerato delivery.resolve(); abort.abort(); consumer.close(); + track.finish(); track.close(); await decode; signals.close(); diff --git a/js/watch/src/media.test.ts b/js/watch/src/media.test.ts index 9dd5b22c67b..7a5605f325c 100644 --- a/js/watch/src/media.test.ts +++ b/js/watch/src/media.test.ts @@ -53,7 +53,7 @@ for (const end of [ const consumer = new Container.Consumer(track.subscribe(), { format: new Container.Legacy.Format("data") }); try { const pending = nextMedia(consumer); - track.close(end); + track.abort(end); if (end instanceof Moq.Error.Stream) expect(await pending).toBeUndefined(); else await expect(pending).rejects.toBe(end); } finally { diff --git a/js/watch/src/video/decoder.test.ts b/js/watch/src/video/decoder.test.ts index e69bbdc6963..0618cff1547 100644 --- a/js/watch/src/video/decoder.test.ts +++ b/js/watch/src/video/decoder.test.ts @@ -902,7 +902,7 @@ function liveVideo(clock: ReturnType, track: Moq.Track.Producer for (const media of held) { const group = new Moq.Group.Producer(sequence++); group.writeFrame(legacyFrame(Micro.fromMilli(Milli(media)))); - group.close(); + group.finish(); track.writeGroup(group); } held = []; @@ -962,6 +962,7 @@ it("a renamed video track keeps the delay, so audio neither skips nor goes silen const before = { inserted, played, buffered: ring.length }; silent = 0; + (playback.track as Moq.Track.Producer).finish(); (playback.track as Moq.Track.Producer).close(); playback.switchRendition("video/1"); video.replace(playback.producer.createTrack("video/1", { timescale: Moq.Time.Timescale.MILLI })); @@ -1031,7 +1032,7 @@ it("buffered video holds its poster until its own span reaches the floor", async await clock.advance(40); expect(playback.decoder.out.frame.peek()?.timestamp).toBe(40_000); } finally { - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); @@ -1055,7 +1056,7 @@ it("bounds startup decoding while another track is missing and releases it on cl expect(playback.pictures.every((frame) => frame.closed)).toBe(true); } finally { remove(); - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); @@ -1081,7 +1082,7 @@ it("resumes decoding the moment shared startup releases", async () => { expect(playback.submitted).toHaveLength(8); } finally { remove(); - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); @@ -1130,7 +1131,7 @@ it("continues filling when the shared floor rises while video waits on audio", a expect(playback.decoder.out.frame.peek()?.timestamp).toBeGreaterThanOrEqual(200_000); } finally { remove(); - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); @@ -1148,7 +1149,7 @@ it("keeps feeding a decoder that has not output its first picture yet", async () await clock.advance(160); expect(playback.decoder.out.frame.peek()?.timestamp).toBe(160_000); } finally { - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index 1828b9f529f..756f4710d42 100644 --- a/quest/m1/api-audit/README.md +++ b/quest/m1/api-audit/README.md @@ -77,7 +77,7 @@ Rejected in planning: - [Two broadcast resolutions](/quest/m1/api-audit/resolve.md) - Rust, JS, and the bindings resolve a broadcast with `request(path, epoch)` or `follow(path, epoch)`, and every racy two-step caller is gone - [Binding publications](/quest/m1/api-audit/binding-publication.md) - moq-ffi, the C ABI, and every wrapper hand out a publication handle that announces, replacing the private adapters - [Encoded consumers read gaps and ends](/quest/m1/api-audit/encoded-read-bindings.md) - moq-ffi, moq-c, and every wrapper read encoded media with the tagged-record shape the decoded consumers use -- [One lifecycle vocabulary](/quest/m1/api-audit/lifecycle.md) - finish, abort, closed, and close mean one thing on every handle, and Rust and JS observers fire alike +- [One lifecycle vocabulary in @moq/e2ee](/quest/m1/api-audit/lifecycle.md) - @moq/e2ee ends tracks and groups with finish and abort, as @moq/net does - [moq-net names say one thing](/quest/m1/api-audit/net-names.md) - demand says `wanted`, colliding stats and transport names are renamed, and Rust and JS info lookups mirror - [Request handlers are named requests](/quest/m1/api-audit/requests.md) - `dynamic()` and `Dynamic` become `requests()` and `Requests` at every layer - [Demand is wanted, everywhere](/quest/m1/api-audit/wanted.md) - every `used`/`unused` pair becomes `wanted()` and `wanted_changed()`, and unused ones go diff --git a/quest/m1/api-audit/lifecycle.md b/quest/m1/api-audit/lifecycle.md index cd8d5c08c17..4aa41d96977 100644 --- a/quest/m1/api-audit/lifecycle.md +++ b/quest/m1/api-audit/lifecycle.md @@ -1,58 +1,21 @@ -# [L] One lifecycle vocabulary +# [S] One lifecycle vocabulary in @moq/e2ee ## Goal -Every model handle in moq-net and `@moq/net` (broadcast, track, group, their -demand, `Finish`, listings) uses each lifecycle word with one meaning, and -Rust and JS agree on when each observer fires. +`@moq/e2ee` uses the lifecycle words the way `@moq/net` does, so a publisher +wrapping its tracks and groups in encryption ends them with the same calls. ## Plan -Decided 2026-10-23 (API audit follow-up, maintainer), from a matrix of every -handle's writer verbs and observers: +Decided 2026-10-23 (API audit follow-up, maintainer): `finish()` declares a +clean end, `abort(err)` is terminal, `closed()` resolves on the final outcome, +and JS `close()` releases only that handle, like a Rust drop. +[moq-dev/moq#6750](https://github.com/moq-dev/moq/pull/6750) landed this in +moq-net and `@moq/net`, including consumer `close()` releasing only its handle +for groups, announcements, and broadcasts. -- **`finish()` / `finish_at(end)`** declare a clean end. Not terminal: lower - writes stay possible. JS gains `Track.Producer.finish()`. -- **`abort(err)`** ends with an error and is terminal. An abort after the - outcome settled (a finished group, a track whose end was reached) only - releases cache: readers keep the clean outcome. Tracks behave this way - today; groups change to match and record no `cut` unless the abort is - eviction policy. -- **`closed()`** on producers, `Finish`, and demand handles resolves once the - outcome is final: a settled finish, an abort, or the last writer dropping. - Rust returns `Result<(), Error>` (`Ok` for clean), JS `Error | null`. It - never fires because readers left; `unused` reports that. Today Rust - `closed()` ignores finish at every layer and reads `Dropped` after a clean - close. -- **`close()`** is JS-only and releases that handle, like a Rust drop. It - never changes state other handles share. Today a JS group, announce, or - broadcast consumer's `close()` ends it for every reader, and the last - broadcast consumer's close ends the producer; check what the JS wire layer - relies on before changing it. `broadcast::Producer::close` keeps its name: - a broadcast has no abort, and it means "drop every clone now". +What remains: `@moq/e2ee` still wraps the old verbs +(`Group.Producer.close(abort?)`, `Track.Producer.close(abort?)`). Split them +into `finish()` and `abort(err)` as in `@moq/net`. -Changes: - -- Rust `track::Producer::close()`, crate-private since - [moq-dev/moq#6747](https://github.com/moq-dev/moq/pull/6747), - folds into `abort` with the clean-close error - ([moq-dev/moq#6482](https://github.com/moq-dev/moq/pull/6482) makes it - `Stream(Session(Cancel))`). Readers that drained a closed copy to a clean - end now get that error: update `resume.rs`'s clean-end mapping and the - sessions that call it; failover is unchanged since `route_failed` already - accepts it. -- Rust `group::Producer::closed`/`poll_closed` fire on finish; sessions that - need abort-only get a private poll. -- JS `Group.Producer.close(abort?)` and `Track.Producer.close(abort?)` split - into `finish()` and `abort(err)`. -- JS `Finish.closed` also fires when a newer epoch supersedes it, as Rust does. -- JS `Lister.ended` becomes `closed`. JS `Demand.unused` rejects on close as - Rust's does. -- Rust `track::Producer::end()` becomes `finished()` (the declared end, a - snapshot). JS `Subscriber.end()` and `Ordered.end()` go: readers await - `finished()`. - -Behavior changes get tests on mock time in both languages. - -Public API: moq-net and `@moq/net` breaking (observer semantics, JS producer -verbs, renames). Wire: none. +Public API: `@moq/e2ee` breaking. Wire: none. diff --git a/rs/moq-audio/src/encode/capture.rs b/rs/moq-audio/src/encode/capture.rs index 25420cb5ca4..841d530f75c 100644 --- a/rs/moq-audio/src/encode/capture.rs +++ b/rs/moq-audio/src/encode/capture.rs @@ -512,8 +512,8 @@ impl Driver { tokio::select! { biased; changed = desired_changed(&self.desired, revision) => changed.is_some(), - err = track.closed() => { - log_track_ended(err); + closed = track.closed() => { + log_track_ended(closed); false } } @@ -770,7 +770,7 @@ async fn until_wanted(demand: &mut moq_net::track::Demand, wanted: bool) -> bool Ok(next) if next.is_some() == wanted => return true, Ok(_) => {} Err(err) => { - log_track_ended(err); + log_track_ended(Err(err)); return false; } } @@ -1152,9 +1152,14 @@ impl Supervisor { } } -/// An application dropping or closing its producer ends publishing normally; -/// a specific abort (such as a transport reset) stays visible in diagnostics. -fn log_track_ended(err: moq_net::Error) { +/// A finished track, or an application dropping or closing its producer, ends +/// publishing normally; a specific abort (such as a transport reset) stays +/// visible in diagnostics. +fn log_track_ended(outcome: Result<(), moq_net::Error>) { + let Err(err) = outcome else { + tracing::debug!("audio track finished; stopping capture"); + return; + }; if matches!(err, moq_net::Error::Dropped | moq_net::Error::Closed) { tracing::debug!("audio track no longer announced; stopping capture"); } else { diff --git a/rs/moq-audio/src/encode/producer.rs b/rs/moq-audio/src/encode/producer.rs index 97e0f6d1a83..6b067bc9a74 100644 --- a/rs/moq-audio/src/encode/producer.rs +++ b/rs/moq-audio/src/encode/producer.rs @@ -634,7 +634,7 @@ mod tests { let closed = tokio::time::timeout(Duration::from_secs(1), demand.closed()) .await .expect("producer closed"); - assert!(matches!(closed, moq_net::Error::Dropped)); + assert!(closed.is_ok(), "{closed:?}"); } /// A finished publication may live as long as its broadcast; the codec and input diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index a5ed46a82d6..9fa64eebb3d 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -201,7 +201,7 @@ impl Abort for Kept> { impl Abort for Kept { fn abort(self, err: moq_net::Error) { // A track with a declared end ends there, as when its last producer drops. - if self.inner.end().is_none() { + if self.inner.finished().is_none() { let _ = self.inner.abort(err); } } @@ -1101,7 +1101,7 @@ impl Publish { pub fn track_finish(&mut self, track: Id) -> Result<(), Error> { let id = track; let track = self.tracks.remove(id).ok_or(Error::TrackNotFound)?; - if track.end().is_none() { + if track.finished().is_none() { track.finish()?; } track.keep(Child::Track(id)); diff --git a/rs/moq-e2ee/src/tests.rs b/rs/moq-e2ee/src/tests.rs index 33fcd9b14f9..ee925dd21e1 100644 --- a/rs/moq-e2ee/src/tests.rs +++ b/rs/moq-e2ee/src/tests.rs @@ -521,7 +521,7 @@ fn refused_track_is_aborted() { ); assert!(matches!( net.poll_closed(&kio::Waiter::noop()), - Poll::Ready(moq_net::Error::Cancel) + Poll::Ready(Err(moq_net::Error::Cancel)) )); } diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 778e35acac9..7acd7809c97 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -675,7 +675,7 @@ impl Child for MoqTrackProducer { let _guard = crate::ffi::enter(); // A finished track ends at its declared end, as when its handle drops. let track = self.inner.lock().unwrap().take(); - if let Some(track) = track.filter(|track| track.end().is_none()) { + if let Some(track) = track.filter(|track| track.finished().is_none()) { let _ = track.abort(err); } } @@ -686,7 +686,7 @@ impl Drop for MoqTrackProducer { let _guard = crate::ffi::enter(); // A finished track lives on in its broadcast, and may still take groups below its end. let track = self.inner.get_mut().unwrap().take(); - if let Some(track) = track.filter(|track| track.end().is_none()) { + if let Some(track) = track.filter(|track| track.finished().is_none()) { let _ = track.abort(moq_net::Error::Unroutable); } } @@ -843,7 +843,7 @@ impl MoqTrackProducer { let _guard = crate::ffi::enter(); let mut guard = self.inner.lock().unwrap(); let track = guard.as_mut().ok_or(MoqError::Closed)?; - if track.end().is_none() { + if track.finished().is_none() { track.finish()?; } if let Some(children) = self.keep.lock().unwrap().take() { diff --git a/rs/moq-flate/src/stream/producer.rs b/rs/moq-flate/src/stream/producer.rs index 0c1376e31b7..cf6203693ec 100644 --- a/rs/moq-flate/src/stream/producer.rs +++ b/rs/moq-flate/src/stream/producer.rs @@ -107,8 +107,8 @@ impl Inner { // A closed track refuses every append, so say so before the budget: `GroupTooLarge` promises // the log is still writable. - if let Poll::Ready(err) = self.track.poll_closed(&kio::Waiter::noop()) { - return Err(err.into()); + if let Poll::Ready(outcome) = self.track.poll_closed(&kio::Waiter::noop()) { + return Err(outcome.err().unwrap_or(moq_net::Error::Closed).into()); } // A timestamp that doesn't match the track is refused before anything changes: found by diff --git a/rs/moq-json/src/snapshot/producer.rs b/rs/moq-json/src/snapshot/producer.rs index 408b91a933e..872ba84f782 100644 --- a/rs/moq-json/src/snapshot/producer.rs +++ b/rs/moq-json/src/snapshot/producer.rs @@ -321,7 +321,7 @@ impl Inner { let next = track.largest().map_or(0, |largest| largest.group.saturating_add(1)); if track.is_closed() || track - .end() + .finished() .is_some_and(|end| moq_net::track::Position::group(next) >= end) { return Err(moq_net::Error::Closed.into()); diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 6b9f68055e4..6b40978fa36 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### One lifecycle vocabulary ([#6750](https://github.com/moq-dev/moq/pull/6750)) + +`closed()` on `group::Producer`, `group::Demand`, `track::Producer`, and `track::Demand` now waits for the outcome and returns `Result<()>`: `Ok(())` once the end settles (a finished group; a track whose declared end was reached with every group below it finished), or the abort error. Match on the result where you awaited `()` or treated `Error::Dropped` as a clean end; `poll_closed` returns `Poll>`. +`group::Producer::abort` after `finish` only releases the cache: readers keep the clean end. +A session closing locally aborts its copies with `Error::Stream(StreamError::Session(SessionError::Cancel))`, so their readers end with that error instead of a clean end. +`track::Producer::end()` is `finished()`. + ### An idle reservation reads the share it would get `bandwidth::Reservation::peek` and its `consumer()` report the slice the track would get back while nothing subscribes to it, instead of `None`; it still claims nothing from the other tracks. diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index c52c55bfd27..0c91a0c130d 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -241,11 +241,11 @@ impl State { request.reject(Error::Cancel); } if let Some(producer) = track.producer { - let _ = producer.close(); + let _ = producer.abort(Error::Stream(crate::StreamError::Session(SessionError::Cancel))); } } for (_, producer) in self.lingering.drain() { - let _ = producer.close(); + let _ = producer.abort(Error::Stream(crate::StreamError::Session(SessionError::Cancel))); } } } @@ -4131,7 +4131,7 @@ impl Subscriber { } let resume = loop { let step = kio::wait(|waiter| { - if idle.track.poll_closed(waiter).is_ready() { + if idle.track.poll_aborted(waiter).is_ready() { return Poll::Ready(Step::Closed); } if gate.poll_denied(waiter).is_ready() { @@ -4364,7 +4364,7 @@ impl Subscriber { let mut opening = this.session.clone(); let mut stream = kio::wait(|waiter| { validity.check(waiter)?; - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } Stream::poll_open(&mut opening, this.version, &mut waiter.context()) @@ -4374,7 +4374,7 @@ impl Subscriber { let mut timeout = crate::time::Deadline::after(&this.runtime, Control::REQUEST_ID_TIMEOUT); let fetch_id = kio::wait(|waiter| { validity.check(waiter)?; - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } this.control.poll_credit(waiter, &mut timeout) @@ -4801,7 +4801,7 @@ impl Subscriber { &mut reading ))); kio::wait(|waiter| { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } waiter.poll_future(open.as_mut()) @@ -4856,10 +4856,10 @@ impl Subscriber { ingest.expected_first = Some(start); let res = { kio::wait(|waiter| { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } - if let Poll::Ready(err) = observing.poll_closed(waiter) { + if let Poll::Ready(err) = observing.poll_aborted(waiter) { return Poll::Ready(Err(err)); } ingest.poll(stream, &mut producer, waiter) @@ -5217,7 +5217,7 @@ impl Subscriber { ) -> Result, Error> { reading.park(); let settled = kio::wait(|waiter| { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } @@ -5393,7 +5393,7 @@ impl Subscriber { multiple ))); kio::wait(|waiter| { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } waiter.poll_future(serve.as_mut()) @@ -5740,7 +5740,7 @@ impl Subscriber { let mut opening = self.session.clone(); let mut stream = match kio::wait(|waiter| { validity.check(waiter)?; - if let Poll::Ready(err) = fill.poll_closed(waiter) { + if let Poll::Ready(err) = fill.poll_aborted(waiter) { return Poll::Ready(Err(err)); } if fill.poll_unwanted_groups(run.clone(), waiter).is_ready() { @@ -5757,7 +5757,7 @@ impl Subscriber { let mut timeout = crate::time::Deadline::after(&self.runtime, Control::REQUEST_ID_TIMEOUT); let fetch_id = match kio::wait(|waiter| { validity.check(waiter)?; - if let Poll::Ready(err) = fill.poll_closed(waiter) { + if let Poll::Ready(err) = fill.poll_aborted(waiter) { return Poll::Ready(Err(err)); } if fill.poll_unwanted_groups(run.clone(), waiter).is_ready() { @@ -5834,7 +5834,7 @@ impl Subscriber { if let Err(err) = validity.check(waiter) { return Poll::Ready(SetupResponse::Cancelled(err)); } - if let Poll::Ready(err) = fill.poll_closed(waiter) { + if let Poll::Ready(err) = fill.poll_aborted(waiter) { return Poll::Ready(SetupResponse::Cancelled(err)); } if let Poll::Ready(res) = response { @@ -5957,7 +5957,7 @@ impl Subscriber { Poll::Pending => {} } } - if let Poll::Ready(err) = fill.poll_closed(waiter) { + if let Poll::Ready(err) = fill.poll_aborted(waiter) { // Completed history stays cached. Only this FETCH's unfinished group is // ours to abort; closing a track never cascades into cached siblings. let state = slot.read(); @@ -6103,7 +6103,7 @@ impl Subscriber { let mut aborted = Error::Cancel; slot.poll(waiter, |state| match &**state { GroupFetch::Cancelled => Poll::Ready(()), - GroupFetch::Receiving(Some(group)) => group.poll_closed(waiter).map(|err| aborted = err), + GroupFetch::Receiving(Some(group)) => group.poll_aborted(waiter).map(|err| aborted = err), _ => Poll::Pending, }) .map(|_| Err(aborted)) @@ -14292,7 +14292,7 @@ mod stitch_tests { assert!( matches!( futures::FutureExt::now_or_never(h.track.closed()), - Some(Error::Unsupported) + Some(Err(Error::Unsupported)) ), "{objects:?}: the subscription must end with the refusal" ); @@ -15270,7 +15270,10 @@ mod stitch_tests { let mut stream = h.stream().await; let res = h.subscriber.clone().recv_fill(&mut stream).await; assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); - assert!(matches!(h.track.closed().now_or_never(), Some(Error::MalformedTrack))); + assert!(matches!( + h.track.closed().now_or_never(), + Some(Err(Error::MalformedTrack)) + )); } /// A subgroup object without a Timestamp on a timed track makes the whole track @@ -15299,7 +15302,10 @@ mod stitch_tests { let mut stream = h.stream().await; let res = h.subscriber.clone().recv_group(&mut stream).await; assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); - assert!(matches!(h.track.closed().now_or_never(), Some(Error::MalformedTrack))); + assert!(matches!( + h.track.closed().now_or_never(), + Some(Err(Error::MalformedTrack)) + )); } /// END_OF_TRACK after a group's last object ends the track right after that group. @@ -15314,7 +15320,7 @@ mod stitch_tests { h.subscriber.clone().recv_group(&mut stream).await.unwrap(); assert_eq!( - h.track.end(), + h.track.finished(), Some(track::Position { group: SEQUENCE, frame: 1 @@ -15336,7 +15342,7 @@ mod stitch_tests { let h = Harness::new(Fill::Done, vec![marker]); let mut stream = h.stream().await; h.subscriber.clone().recv_group(&mut stream).await.unwrap(); - assert_eq!(h.track.end(), Some(track::Position { group: 5, frame: 5 })); + assert_eq!(h.track.finished(), Some(track::Position { group: 5, frame: 5 })); assert_eq!( h.track.latest_sequence(), None, @@ -15358,7 +15364,7 @@ mod stitch_tests { assert!(matches!(res, Err(Error::ProtocolViolation)), "{res:?}"); assert!(matches!( h.track.closed().now_or_never(), - Some(Error::ProtocolViolation) + Some(Err(Error::ProtocolViolation)) )); } @@ -15373,7 +15379,7 @@ mod stitch_tests { let mut stream = h.stream().await; h.subscriber.clone().recv_group(&mut stream).await.unwrap(); - assert_eq!(h.track.end(), Some(track::Position::group(SEQUENCE))); + assert_eq!(h.track.finished(), Some(track::Position::group(SEQUENCE))); assert_eq!(h.track.latest_sequence(), None, "no group was created"); } @@ -16041,7 +16047,7 @@ mod stitch_tests { assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); assert!(matches!( futures::FutureExt::now_or_never(track.closed()), - Some(Error::MalformedTrack) + Some(Err(Error::MalformedTrack)) )); } @@ -17420,7 +17426,10 @@ mod joining_fetch_tests { }; if !complete && let Some(observed) = observed { assert!( - matches!(observed.poll_closed(&kio::Waiter::noop()), Poll::Ready(Error::Cancel)), + matches!( + observed.poll_closed(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Cancel)) + ), "partial group must abort" ); } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 3ccbdf5c7ae..f2b72aada1b 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -565,7 +565,9 @@ impl SubscriptionCleanup { for (_, entry) in self.0.lock().drain() { let _ = match entry.tail.read().is_ended() { true => entry.producer.finish(), - false => entry.producer.close(), + false => entry + .producer + .abort(Error::Stream(crate::StreamError::Session(crate::SessionError::Cancel))), }; } } @@ -1059,10 +1061,10 @@ impl GroupRecv { // The track or group dying cancels the stream; the peer's own close // arrives through the ingest's reads. let res = 'serve: { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { break 'serve Err(err); } - if let Poll::Ready(err) = group.handle().poll_closed(waiter) { + if let Poll::Ready(err) = group.handle().poll_aborted(waiter) { break 'serve Err(err); } match ingest.poll(&mut self.reader, group, waiter) { @@ -3023,7 +3025,10 @@ mod tests { } _ => { assert!(matches!(res, Err(Error::ProtocolViolation)), "{version:?}: {res:?}"); - assert!(matches!(track.closed().now_or_never(), Some(Error::ProtocolViolation))); + assert!(matches!( + track.closed().now_or_never(), + Some(Err(Error::ProtocolViolation)) + )); } } } diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index d4a703a2126..419a7f5d103 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -754,6 +754,16 @@ impl GroupState { } } + /// The group's outcome once final: `Ok` once finished, which a later abort only frees + /// the cache of, or the abort error. + fn poll_outcome(&self) -> Poll> { + match (self.fin, &self.abort) { + (Some(_), _) => Poll::Ready(Ok(())), + (None, Some(err)) => Poll::Ready(Err(err.clone())), + (None, None) => Poll::Pending, + } + } + /// Resolve whether a reader at `index` can still make progress, answering the same /// question as a read without consuming anything. fn poll_end(&self, index: usize) -> Poll> { @@ -1658,6 +1668,9 @@ impl Producer { /// Consumes the writer. Drops the cached frames so a stale [`Consumer`] can't pin /// their buffers in memory forever; consumers that haven't drained yet surface the /// abort error instead of the leftover cache. + /// + /// After [`finish`](Self::finish) the outcome is already settled, so this only releases + /// the cache: the group stays finished, and a reader that drained it ends cleanly. pub fn abort(self, err: Error) -> Result<()> { self.handle.close_aborted(err) } @@ -1669,9 +1682,10 @@ impl Producer { Error::GroupTooLarge } - /// Block until the group is aborted, including by eviction. Finishing does not close it. + /// Block until the group's outcome is final: `Ok` once finished, or the abort error, + /// including eviction. #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { + pub async fn closed(&self) -> Result<()> { kio::wait(|waiter| self.handle.poll_closed(waiter)).await } /// The non-writing observer used by the cache and built-in sessions. @@ -1705,9 +1719,9 @@ impl Producer { self.handle.consume() } - /// Poll until the group is aborted, including by eviction; ready with the cause. - /// Finishing does not close it. - pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { + /// Poll until the group's outcome is final: `Ok` once finished, or the abort error, + /// including eviction. + pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { self.handle.poll_closed(waiter) } @@ -1855,13 +1869,17 @@ impl Handle { fn commit_abort(&self, mut guard: kio::Mut<'_, GroupState>, err: Error) { let policy = matches!(err, Error::Old | Error::Timeout | Error::Evicted); + // A finished group's outcome is settled: an abort only releases its cache, so it + // neither failed nor was cut. + let settled = guard.fin.is_some(); // Cache policy frees the frames, as does an oversized group whichever write found // it; a copy that failed or was given up lends them on. let salvage = !policy && !matches!(err, Error::GroupTooLarge); // A reset carrying a policy code is the upstream's cache policy, a cancel is a reader // no longer wanting the group, and a fill refused as unfetchable abandons backfill: // deliberate holes, never a cut readers see. The publisher giving the group up is. - let cut = !policy + let cut = !settled + && !policy && !matches!( err, Error::Cancel @@ -1877,7 +1895,7 @@ impl Handle { if cut { let _ = self.alive.cut.set(err.clone()); } - if !policy { + if !settled && !policy { self.alive.failed.store(true, Ordering::Release); } guard.abort = Some(err); @@ -2140,10 +2158,16 @@ impl Handle { } } - /// Poll until the group is aborted, including by eviction; ready with the cause. - /// Finishing does not close it. + /// Poll until the group's outcome is final: `Ok` once finished, or the abort error. // Sessions observe a group while its sole writer belongs to a frame. - pub(crate) fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { + pub(crate) fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { + settle(self.state.poll_ref(waiter, |state| state.poll_outcome())) + } + + /// Poll until the group is aborted, including by eviction and after it finished; ready + /// with the cause. Finishing alone does not end it. + // Sessions and track scans watch for an abort that frees a group, finished or not. + pub(crate) fn poll_aborted(&self, waiter: &kio::Waiter) -> Poll { self.state.poll_closed(waiter).map(|()| self.abort_reason()) } @@ -2168,10 +2192,22 @@ impl Handle { } } +/// The outcome of a group's state polled from a handle: the channel closing short of one +/// (every writer gone without a finish or abort) reads as [`Error::Dropped`]. +fn settle(poll: Poll, kio::Ref<'_, GroupState>>>) -> Poll> { + Poll::Ready(match ready!(poll) { + Ok(outcome) => outcome, + Err(closed) => match closed.poll_outcome() { + Poll::Ready(outcome) => outcome, + Poll::Pending => Err(Error::Dropped), + }, + }) +} + /// A cloneable, watch-only handle to a group's readers. /// -/// A finished or dropped group ends waits with [`Error::Dropped`]; an aborted one keeps -/// its abort reason. +/// Once every handle to a finished group is gone, demand waits end with +/// [`Error::Dropped`]; an aborted group's keep its abort reason. #[derive(Clone)] pub struct Demand { sequence: u64, @@ -2211,11 +2247,15 @@ impl Demand { self.state.poll_unused(waiter).map_err(|_| self.abort_reason()) } - /// Wait until the group closes, returning its cause. + /// Wait until the group's outcome is final: `Ok` once finished, or the abort error. #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { - self.state.closed().await; - self.abort_reason() + pub async fn closed(&self) -> Result<()> { + kio::wait(|waiter| self.poll_closed(waiter)).await + } + + /// Poll until the group's outcome is final: `Ok` once finished, or the abort error. + pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { + settle(self.state.consume_weak().poll(waiter, |state| state.poll_outcome())) } fn abort_reason(&self) -> Error { @@ -4735,10 +4775,53 @@ mod test { )); assert!(matches!( handle.poll_closed(&kio::Waiter::noop()), - Poll::Ready(Error::FrameTooLarge) + Poll::Ready(Err(Error::FrameTooLarge)) )); } + /// `closed` settles on a finish, and an abort after it only frees the cache: the + /// outcome stays clean, a drained reader ends cleanly, and nothing records a cut. + #[test] + fn abort_after_finish_keeps_the_clean_outcome() { + let mut producer = Info { sequence: 0 }.produce(); + let handle = producer.handle(); + let demand = producer.demand(); + let mut consumer = producer.consume(); + let waiter = kio::Waiter::noop(); + assert!(producer.poll_closed(&waiter).is_pending()); + assert!(demand.poll_closed(&waiter).is_pending()); + + producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"a")).unwrap(); + producer.finish().unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!( + handle.poll_aborted(&waiter).is_pending(), + "a finish alone frees nothing" + ); + assert!(matches!(consumer.poll_read_frame(&waiter), Poll::Ready(Ok(Some(_))))); + + producer.abort(Error::Cancel).unwrap(); + assert!(matches!(handle.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(handle.poll_aborted(&waiter), Poll::Ready(Error::Cancel))); + assert!(handle.cut_error().is_none()); + assert!(!handle.fate().failed()); + assert!(matches!(consumer.poll_read_frame(&waiter), Poll::Ready(Ok(None)))); + } + + /// An abort before the finish is the outcome, on every observer. + #[test] + fn abort_before_finish_is_the_outcome() { + let producer = Info { sequence: 0 }.produce(); + let handle = producer.handle(); + let demand = producer.demand(); + producer.abort(Error::Timeout).unwrap(); + let waiter = kio::Waiter::noop(); + assert!(matches!(handle.poll_closed(&waiter), Poll::Ready(Err(Error::Timeout)))); + assert!(matches!(demand.poll_closed(&waiter), Poll::Ready(Err(Error::Timeout)))); + } + /// Completing a frame charges whatever it wrote since the last notify. #[test] fn frame_commit_charges_the_tail() { diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 6b7f5c892cc..af40c466e76 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -18,7 +18,7 @@ use super::{ front::{Action, Candidate, Event, Front, Refusal}, }; use crate::{ - AsPath, Epoch, Error, InvalidPattern, Path, PathOwned, Pattern, Patterns, + AsPath, Epoch, Error, InvalidPattern, Path, PathOwned, Pattern, Patterns, SessionError, StreamError, coding::{BoundsExceeded, Decode, DecodeError, Decoder, Encode, EncodeError, Encoder, Form, varint}, path::Segment, time::{Deadlines, Instant}, @@ -3954,7 +3954,7 @@ async fn serve_front(task: FrontTask) -> Vec { let Some(io) = tracks.get_mut(&name) else { continue }; io.copy = None; io.held = None; - io.closed = matches!(result, Err(Error::Closed)); + io.closed = matches!(result, Err(Error::Stream(StreamError::Session(SessionError::Cancel)))); Event::TrackEnded { track: name, source, diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index f727a0508d8..94628830631 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -45,7 +45,7 @@ use std::task::{Poll, ready}; use std::time::Duration; use crate::time::Instant; -use crate::{Error, Result, StreamError, group, stats, track}; +use crate::{Error, Result, SessionError, StreamError, group, stats, track}; use super::delivered::Delivered; use super::subscription::{Anchor, Position, Resolved, Subscription, Unheld, Window}; @@ -1431,7 +1431,9 @@ impl Reader { // ending retains its session error. Some(None) => match serving.map(&done) { None => return Some(Poll::Ready(Err(Error::Unroutable))), - Some(Some(Ok(()) | Err(Error::Closed))) => return Some(Poll::Ready(Ok(None))), + Some(Some(Ok(()) | Err(Error::Stream(StreamError::Session(SessionError::Cancel))))) => { + return Some(Poll::Ready(Ok(None))); + } Some(Some(Err(err))) => return Some(Poll::Ready(Err(err))), Some(None) => {} }, diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index ce39ba46611..2dd189c59d7 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -14,7 +14,7 @@ //! The track is closed with [Error] when all writers or readers are dropped. use crate::sync::Mutex; -use crate::{Error, Result, SessionError, StreamError, Timescale, Timestamp, coding}; +use crate::{Error, Result, StreamError, Timescale, Timestamp, coding}; use crate::{broadcast, cache, group, stats}; use super::Datagram; @@ -399,8 +399,7 @@ pub(crate) struct TrackState { // cache, so an evicted last group does not lose where the track ended. end: Option, - // Nothing more can arrive: the last producer dropped after a declared boundary, - // or the receiving session closed locally without declaring an end. + // Nothing more can arrive: the last producer dropped after a declared boundary. sealed: bool, // Set once a received copy's end is declared, ahead of group streams below it that QUIC @@ -465,9 +464,11 @@ pub(crate) struct TrackState { // The error that caused the track to be aborted, if any. abort: Option, - // Whether the declared end still stands after an abort: every group below it was - // produced and finished before the abort landed. See [`Self::is_complete`]. - settled: bool, + // Whether the declared end settled before a write that could reopen it or an abort, or + // `closed()` reported it: every group below it was produced and finished then, so the + // clean end stands for good. Atomic so reporting it latches through a read guard. See + // [`Self::latch_settled`]. + settled: AtomicBool, // Active subscriptions, in their own [`kio::Lock`] so a read-only `Consumer` // registers under that lock instead of writing back into the track state. @@ -1038,6 +1039,7 @@ impl TrackState { /// Billed before the debt is settled, so its bytes accrue debt, but pushed after, so the /// newcomer is never a victim of its own write. fn push_datagram(&mut self, datagram: super::datagram::Stored) { + self.latch_settled(); let datagram = super::datagram::Buffered::new(datagram, &self.cache); self.charge_debt(); if self.datagrams.len() == MAX_DATAGRAMS { @@ -1079,7 +1081,7 @@ impl TrackState { // Once nothing more is in range: an abort before the end settled cut the track // off. One after it is a clean end (see `is_complete`), as is reaching the end. let end = || match &self.abort { - Some(err) if !self.settled => Err(err.clone()), + Some(err) if !self.settled.load(Ordering::Relaxed) => Err(err.clone()), _ => Ok(None), }; @@ -1305,7 +1307,7 @@ impl TrackState { ) -> Option<&'a Slot> { loop { match select(self) { - Some(slot) if slot.group.poll_closed(waiter).is_ready() && slot.group.is_aborted() => continue, + Some(slot) if slot.group.poll_aborted(waiter).is_ready() => continue, slot => return slot, } } @@ -1463,8 +1465,7 @@ impl TrackState { // edge again, so each pass resolves a lower one. if let Some(live) = edge.presentation && let Some(slot) = self.lookup.get(&live.sequence) - && slot.group.poll_closed(waiter).is_ready() - && slot.group.is_aborted() + && slot.group.poll_aborted(waiter).is_ready() { continue; } @@ -1870,6 +1871,7 @@ impl TrackState { /// groups reach subscribers, fetched backfill is served by sequence only. A group /// counts as content only once [`Self::reveal`] reports its first frame or finish. fn insert_group(&mut self, group: &group::Handle, visible: bool) { + self.latch_settled(); let sequence = group.sequence; self.next_stamp = self.next_stamp.wrapping_add(1); let stamp = self.next_stamp; @@ -2202,6 +2204,7 @@ impl TrackState { /// or above it, and a pending tail above the last arrival, is waited for a fresh grace: /// the feed may only now be sending it. fn set_start(&mut self, start_sequence: Option, pending: bool) { + self.latch_settled(); self.start_sequence = start_sequence; self.start_pending = pending; if let Some(start) = start_sequence { @@ -2268,13 +2271,13 @@ impl TrackState { /// An abort before the end settled wins over it: a group below the boundary was /// still open, so the track was cut off rather than ended. fn is_complete(&self) -> bool { - // `sealed` also ends a locally closed receive track without a declared end. - // An abort still wins unless that end had already settled: a group below it was still open. + // `sealed` is a declared end the last producer dropped short of. An abort still wins + // unless that end had already settled: a group below it was still open. let reached = self.sealed || self .final_sequence() .is_some_and(|fin| self.reaches(fin) && !self.awaits_tail(fin)); - reached && (self.abort.is_none() || self.settled) + reached && (self.abort.is_none() || self.settled.load(Ordering::Relaxed)) } /// Whether the live edge reached `fin`, or a received copy's tail decides it instead: @@ -2361,18 +2364,22 @@ impl TrackState { /// Whether the declared end is reached, the pending tail owes nothing below it, and /// every cached group below it finished, so nothing the end promised is still in flight. - /// - /// Decided as an abort lands, which ends any wait for the tail: a group the tail still - /// owed was cut off by the abort, so readers get the error rather than a clean end. fn is_settled(&self) -> bool { let Some(fin) = self.final_sequence() else { return false; }; let reached = self.sealed || self.reaches(fin); - reached - && (self.abort.is_none() || self.settled) - && !self.owes(fin) - && self.lookup.range(..fin).all(|(_, slot)| slot.group.is_finished()) + reached && !self.owes(fin) && self.lookup.range(..fin).all(|(_, slot)| slot.group.is_finished()) + } + + /// Record that the end settled before a write that could reopen it: an abort, a late + /// group below the end, or a fresh grace for the tail. The clean outcome `closed()` saw + /// then stands, and a later abort ends readers cleanly. A late group or a wider + /// subscription is still served; it just cannot turn the outcome into an error. + fn latch_settled(&mut self) { + if self.abort.is_none() && self.is_settled() { + self.settled.store(true, Ordering::Relaxed); + } } /// Where a replacement route should pick this track up: one past the last frame @@ -2501,8 +2508,6 @@ impl TrackState { Poll::Ready(Ok(end)) } else if let Some(err) = &self.abort { Poll::Ready(Err(err.clone())) - } else if self.sealed { - Poll::Ready(Err(Error::Closed)) } else { Poll::Pending } @@ -2512,14 +2517,58 @@ impl TrackState { producer.write().map_err(|r| r.abort.clone().unwrap_or(Error::Dropped)) } - /// Observe a shared track's terminal cause without extending its publisher lifetime. + /// Observe a shared track's abort, or every producer leaving, without extending its + /// publisher lifetime. A finish alone does not end it: lower groups may still arrive. // Sessions retain shared state while a FETCH owns its group writer. - pub(crate) fn poll_closed(producer: &kio::Producer, waiter: &kio::Waiter) -> Poll { + pub(crate) fn poll_aborted(producer: &kio::Producer, waiter: &kio::Waiter) -> Poll { producer .poll_closed(waiter) .map(|()| producer.read().abort.clone().unwrap_or(Error::Dropped)) } + /// The track's outcome once final: `Ok` once its declared end is reached with every + /// cached group below it finished, which a later abort keeps, or the abort error. + /// + /// A clean outcome latches once reported: a received copy's tail grace is judged when + /// polled, and a later subscription asking for a longer one must not reopen it. + fn poll_outcome(&self, waiter: &kio::Waiter) -> Poll> { + if self.settled.load(Ordering::Relaxed) { + return Poll::Ready(Ok(())); + } + if self.is_complete() { + // An abort after the end settled keeps the clean end; until then a group below it + // may still abort, which would cut the track off, so wait for each to finish. One + // that aborts leaves the outcome to the track's own abort or drop. + if self.abort.is_none() + && let Some(fin) = self.final_sequence() + { + for (_, slot) in self.lookup.range(..fin) { + // One poll, so a group finishing meanwhile is seen rather than slept on. + if !matches!(slot.group.poll_closed(waiter), Poll::Ready(Ok(()))) { + return Poll::Pending; + } + } + } + self.settled.store(true, Ordering::Relaxed); + Poll::Ready(Ok(())) + } else if let Some(err) = &self.abort { + Poll::Ready(Err(err.clone())) + } else { + self.watch_tail(waiter); + Poll::Pending + } + } + + /// [`Self::poll_outcome`] read through a handle: the channel closing short of an + /// outcome (every producer gone without an end or abort) reads as [`Error::Dropped`]. + fn settle(poll: Poll, kio::Ref<'_, Self>>>) -> Poll> { + Poll::Ready(match ready!(poll) { + Ok(outcome) => outcome, + Err(closed) if closed.is_complete() => Ok(()), + Err(closed) => Err(closed.abort.clone().unwrap_or(Error::Dropped)), + }) + } + /// Abort shared track state without extending its publisher lifetime. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn abort(producer: &kio::Producer, err: Error) -> Result<()> { @@ -2604,7 +2653,7 @@ fn commit_abort(mut state: kio::Mut<'_, TrackState>, err: Error) -> OpenGroups { // past the grace was lost before the abort rather than cut off by it. let now = state.cache.pool().now_wall(); state.fold_arrivals(now); - state.settled = state.is_settled(); + state.latch_settled(); state.abort = Some(err.clone()); // Keep what finished for consumers still draining: they get it, then the abort (or // the clean end, when the end settled). Clearing here would abort finished groups a @@ -2984,23 +3033,6 @@ impl Producer { Ok(()) } - /// End the track without declaring where it ends, as a closed session does. - /// - /// Consumers drain the groups that finished and then end cleanly. The open groups are - /// aborted with [`StreamError::Session`], so a consumer that already pulled one ends - /// with it and their writers fail. A reader following the track's routes resumes from - /// another one where this copy stopped. Use [`Self::finish`] or [`Self::finish_at`] - /// when the end is known. - #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - pub(crate) fn close(self) -> Result<()> { - let mut state = self.modify()?; - state.sealed = true; - let open = state.tear_down(Error::Stream(StreamError::Session(SessionError::Cancel))); - state.close(); - open.abort(); - Ok(()) - } - /// Mark the track as finished after the last appended group. /// /// Ends the track at the head of the group after the current max_sequence. @@ -3252,7 +3284,7 @@ impl Producer { /// /// `None` while the track is still open ended. Both methods reject a second end, so /// callers that may have already declared one check here first. - pub fn end(&self) -> Option { + pub fn finished(&self) -> Option { self.state.read().end } @@ -3313,15 +3345,24 @@ impl Producer { Ok(()) } - /// Block until the track is closed or aborted, returning the cause. + /// Block until the track's outcome is final: `Ok` once its declared end is reached, or + /// the abort error, or [`Error::Dropped`] once every producer left without either. #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { + pub async fn closed(&self) -> Result<()> { kio::wait(|waiter| self.poll_closed(waiter)).await } - /// Poll until the track is closed or aborted; ready with the cause. - pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { - TrackState::poll_closed(&self.state, waiter) + /// Poll until the track's outcome is final: `Ok` once its declared end is reached, or + /// the abort error, or [`Error::Dropped`] once every producer left without either. + pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { + TrackState::settle(self.state.poll_ref(waiter, |state| state.poll_outcome(waiter))) + } + + /// Poll until the track is aborted, or every producer left; ready with the cause. + // Sessions stop serving on an abort, but keep taking lower groups past a finish. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn poll_aborted(&self, waiter: &kio::Waiter) -> Poll { + TrackState::poll_aborted(&self.state, waiter) } /// Return true if the track has been closed. @@ -3940,11 +3981,17 @@ impl Demand { Poll::Ready(Ok(next)) } - /// Block until the track is closed or aborted, returning the cause. + /// Block until the track's outcome is final; see [`Producer::closed`]. #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { - self.state.closed().await; - self.abort_reason() + pub async fn closed(&self) -> Result<()> { + kio::wait(|waiter| { + TrackState::settle( + self.state + .consume_weak() + .poll(waiter, |state| state.poll_outcome(waiter)), + ) + }) + .await } /// The publisher's tie-break priority, as set in [`Info::priority`]. @@ -4812,15 +4859,15 @@ impl Consumer { /// Poll for the track reaching a terminal state: `Ok(())` once it is complete /// (the final group was produced), `Err` once it closed or aborted before /// completing. This tells a track that truly ended from one whose serving route - /// died mid-stream. A session closed locally, on purpose, is [`Error::Closed`]. + /// died mid-stream. A session closed locally, on purpose, aborts its tracks with + /// [`StreamError::Session`] carrying [`crate::SessionError::Cancel`]. pub(super) fn poll_complete(&self, waiter: &kio::Waiter) -> Poll> { self.with_state(|state| { let Some(state) = state else { return Poll::Ready(Err(Error::Dropped)); }; match ready!(state.poll(waiter, |state| { - // A local close without a declared end is a dead route, not finished content. - if state.end.is_some() && state.is_complete() { + if state.is_complete() { Poll::Ready(()) } else { state.watch_tail(waiter); @@ -4830,11 +4877,7 @@ impl Consumer { Ok(_) => Poll::Ready(Ok(())), // Closed before completing. Read through the returned guard: it holds // the lock, so re-locking the channel here would deadlock. - Err(closed) => Poll::Ready(Err(match (&closed.abort, closed.sealed) { - (Some(err), _) => err.clone(), - (None, true) => Error::Closed, - (None, false) => Error::Dropped, - })), + Err(closed) => Poll::Ready(Err(closed.abort.clone().unwrap_or(Error::Dropped))), } }) } @@ -12573,33 +12616,6 @@ mod test { assert!(matches!(write, Err(Error::Cancel)), "{write:?}"); } - /// Closing a track aborts its open groups like [`Producer::abort`], with - /// [`StreamError::Session`]. - #[test] - #[cfg(feature = "async")] - fn close_ends_a_pulled_open_group() { - let producer = track_producer("test", Info::timed()); - let mut consumer = producer.subscribe(None); - let mut writer = producer.append_group().unwrap(); - writer - .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"x")) - .unwrap(); - let mut group = consumer.assert_group(); - assert!(group.read_frame().now_or_never().unwrap().unwrap().is_some()); - - producer.close().unwrap(); - let read = group.read_frame().now_or_never().expect("should not block"); - assert!( - matches!(read, Err(Error::Stream(StreamError::Session(SessionError::Cancel)))), - "{read:?}" - ); - let write = writer.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"y")); - assert!( - matches!(write, Err(Error::Stream(StreamError::Session(SessionError::Cancel)))), - "{write:?}" - ); - } - /// Aborting an unused track aborts an open group whose reader outlived the track's /// consumers. #[test] @@ -12802,7 +12818,7 @@ mod test { assert!(producer.finish_at(Position::group(5)).is_err()); assert!(producer.finish_at(Position::group(6)).is_ok()); - assert_eq!(producer.end(), Some(Position::group(6))); + assert_eq!(producer.finished(), Some(Position::group(6))); // Repeating the end is a no-op, moving it is rejected, and no group at or above the // boundary can be created. @@ -12845,7 +12861,7 @@ mod test { producer.finish_at(Position::group(6)).unwrap(); producer.finish_at(Position { group: 5, frame: 5 }).unwrap(); producer.finish_at(Position { group: 5, frame: 5 }).unwrap(); - assert_eq!(producer.end(), Some(Position { group: 5, frame: 5 })); + assert_eq!(producer.finished(), Some(Position { group: 5, frame: 5 })); for moved in [Position { group: 5, frame: 4 }, Position::group(6), Position::group(5)] { assert!(matches!(producer.finish_at(moved), Err(Error::ProtocolViolation))); } @@ -12864,7 +12880,7 @@ mod test { producer.finish_at(Position { group: 5, frame: 5 }), Err(Error::ProtocolViolation) )); - assert_eq!(producer.end(), None, "a refused end declares nothing"); + assert_eq!(producer.finished(), None, "a refused end declares nothing"); producer.finish_at(Position { group: 5, frame: 6 }).unwrap(); } @@ -12910,13 +12926,13 @@ mod test { #[test] fn end_reports_the_declared_end() { let mut producer = track_producer("test", Info::timed()); - assert_eq!(producer.end(), None); + assert_eq!(producer.finished(), None); let _open = producer.create_group(group::Info { sequence: 5 }).unwrap(); - assert_eq!(producer.end(), None, "a group does not declare an end"); + assert_eq!(producer.finished(), None, "a group does not declare an end"); producer.finish_at(Position::group(9)).unwrap(); - assert_eq!(producer.end(), Some(Position::group(9))); + assert_eq!(producer.finished(), Some(Position::group(9))); // finish() would try to declare a second boundary, so callers check first. assert!(producer.finish().is_err()); @@ -12927,7 +12943,7 @@ mod test { let producer = track_producer("test", Info::timed()); let _open = producer.create_group(group::Info { sequence: 5 }).unwrap(); producer.finish().unwrap(); - assert_eq!(producer.end(), Some(Position::group(6))); + assert_eq!(producer.finished(), Some(Position::group(6))); } #[test] @@ -13396,7 +13412,7 @@ mod test { matches!(producer.finish_at(Position::group(2)), Err(Error::ProtocolViolation)), "a second end is refused" ); - assert_eq!(producer.end(), Some(Position::group(3)), "the first end stands"); + assert_eq!(producer.finished(), Some(Position::group(3)), "the first end stands"); let mut _low = Vec::new(); for sequence in [0, 1] { @@ -13425,55 +13441,10 @@ mod test { assert!(matches!(res, Err(Error::Timeout))); } - #[moq_clock::test] - #[cfg(feature = "async")] - async fn local_close_keeps_finished_groups_without_declaring_an_end() { - let producer = track_producer("local", Info::timed()); - let mut consumer = producer.consume().subscribe(None).await.unwrap(); - producer.append_group().unwrap().finish().unwrap(); - producer.clone().close().unwrap(); - assert!(producer.end().is_none()); - assert!(matches!(consumer.finished().await, Err(Error::Closed))); - assert!(consumer.recv_group().await.unwrap().is_some()); - assert!(consumer.recv_group().await.unwrap().is_none()); - } - - #[moq_clock::test] - #[cfg(feature = "async")] - async fn local_close_cannot_mask_an_abort_before_declared_end_settles() { - let mut producer = track_producer("local", Info::timed()); - let mut consumer = producer.consume().subscribe(None).await.unwrap(); - let _group = producer.append_group().unwrap(); - producer.finish_at(Position::group(2)).unwrap(); - producer.clone().abort(Error::Timeout).unwrap(); - assert!(producer.close().is_err()); - assert!(matches!(consumer.recv_group().await, Err(Error::Timeout))); - } - - /// A close drops a group still open that no reader pulled: readers drain the finished - /// groups, then end without a declared end. - #[moq_clock::test] - #[cfg(feature = "async")] - async fn local_close_drops_an_unpulled_open_group() { - let producer = track_producer("local", Info::timed()); - let mut consumer = producer.consume().subscribe(None).await.unwrap(); - producer.append_group().unwrap().finish().unwrap(); - let mut open = producer.append_group().unwrap(); - open.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"x")) - .unwrap(); - producer.clone().close().unwrap(); - assert_eq!( - consumer.recv_group().await.unwrap().map(|group| group.sequence), - Some(0) - ); - assert!(consumer.recv_group().await.unwrap().is_none()); - assert!(matches!(consumer.finished().await, Err(Error::Closed))); - } - - /// A local close is a dead route to the origin, not finished content, so a + /// A local session closing is a dead route to the origin, not finished content, so a /// replacement route can take over where it left off. #[test] - fn local_close_is_not_completion_and_keeps_the_resume_position() { + fn session_close_is_not_completion_and_keeps_the_resume_position() { let producer = track_producer("local", Info::timed()); let consumer = producer.consume(); producer.append_group().unwrap().finish().unwrap(); @@ -13484,7 +13455,9 @@ mod test { group .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"b")) .unwrap(); - producer.close().unwrap(); + producer + .abort(Error::Stream(StreamError::Session(crate::SessionError::Cancel))) + .unwrap(); assert!(matches!( consumer.poll_complete(&kio::Waiter::noop()), Poll::Ready(Err(_)) @@ -13492,6 +13465,125 @@ mod test { assert_eq!(consumer.resume_position(), Some(Position { group: 1, frame: 2 })); } + /// `closed` settles once the declared end is reached, not when it is declared, and an + /// abort after that keeps the clean outcome. + #[test] + #[cfg(feature = "async")] + fn closed_settles_on_a_reached_end() { + let waiter = kio::Waiter::noop(); + let mut producer = track_producer("test", Info::timed()); + let demand = producer.demand(); + producer.append_group().unwrap().finish().unwrap(); + producer.finish_at(Position::group(2)).unwrap(); + assert!(producer.poll_closed(&waiter).is_pending(), "group 1 is still to come"); + assert!(demand.closed().now_or_never().is_none()); + + producer.append_group().unwrap().finish().unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); + assert!( + producer.poll_aborted(&waiter).is_pending(), + "lower groups may still arrive" + ); + + producer.clone().abort(Error::Cancel).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); + } + + /// A group still open below a reached end keeps the outcome pending: it finishing settles + /// the end and wakes the wait, while an abort before then cuts the track off. + #[test] + #[cfg(feature = "async")] + fn closed_waits_for_open_groups_below_the_end() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let mut producer = track_producer("test", Info::timed()); + let mut open = producer.append_group().unwrap(); + producer.finish_at(Position::group(1)).unwrap(); + let woken = Arc::new(AtomicBool::new(false)); + let waiter = kio::Waiter::new(futures::task::waker(Arc::new(FlagWake(woken.clone())))); + assert!(producer.poll_closed(&waiter).is_pending(), "group 0 may still abort"); + open.finish().unwrap(); + assert!(woken.load(Ordering::SeqCst), "the group finishing wakes the wait"); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + + let mut cut = track_producer("cut", Info::timed()); + let _open = cut.append_group().unwrap(); + cut.finish_at(Position::group(1)).unwrap(); + assert!(cut.poll_closed(&kio::Waiter::noop()).is_pending()); + cut.clone().abort(Error::Cancel).unwrap(); + assert!(matches!( + cut.poll_closed(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Cancel)) + )); + } + + /// A received end whose tail grace passed is final once `closed()` reports it: a later + /// subscription with a longer grace, and an abort within it, leave the clean outcome. + #[test] + fn a_reported_end_survives_a_longer_grace() { + let waiter = kio::Waiter::noop(); + let mut producer = track_producer("test", None); + producer.received(); + let _short = producer.subscribe(None); + producer.start_at(0).unwrap(); + producer.finish_at(Position::group(3)).unwrap(); + for sequence in [0, 2] { + producer + .create_group(group::Info { sequence }) + .unwrap() + .finish() + .unwrap(); + } + assert!(producer.poll_closed(&waiter).is_pending(), "held at the hole"); + elapse(&producer, ledger::GRACE); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + + let _long = producer.subscribe(Subscription::default().with_max_delay(ledger::GRACE * 10)); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + producer.clone().abort(Error::Cancel).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + } + + /// A settled end is final: a late group below it and a later abort leave the outcome + /// `closed()` already saw. + #[test] + #[cfg(feature = "async")] + fn a_settled_end_survives_a_late_group_and_an_abort() { + let waiter = kio::Waiter::noop(); + let mut producer = track_producer("test", Info::timed()); + let demand = producer.demand(); + producer + .create_group(group::Info { sequence: 5 }) + .unwrap() + .finish() + .unwrap(); + producer.finish_at(Position::group(6)).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + + let _late = producer.create_group(group::Info { sequence: 4 }).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + producer.clone().abort(Error::Cancel).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); + } + + /// An abort short of the end, or the last producer leaving without one, is the outcome. + #[test] + #[cfg(feature = "async")] + fn closed_reports_an_unfinished_end() { + let waiter = kio::Waiter::noop(); + let aborted = track_producer("aborted", Info::timed()); + aborted.clone().abort(Error::Timeout).unwrap(); + assert!(matches!(aborted.poll_closed(&waiter), Poll::Ready(Err(Error::Timeout)))); + + let dropped = track_producer("dropped", Info::timed()); + let demand = dropped.demand(); + drop(dropped); + assert!(matches!(demand.closed().now_or_never(), Some(Err(Error::Dropped)))); + } + /// An abort short of the end keeps the groups that finished for a reader that has not /// pulled them yet: it gets them, then the abort. The open group nobody will finish /// is gone, on both cursors. @@ -15099,7 +15191,7 @@ mod test { producer.finish_at(Position { group: 0, frame: 2 }), Err(Error::ProtocolViolation) )); - assert_eq!(producer.end(), None); + assert_eq!(producer.finished(), None); live.write_frame(Timestamp::ZERO, b"delta".as_slice()).unwrap(); } diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 47d52a2b0e9..2b1bc7dea15 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -881,10 +881,10 @@ impl Fill { .map(drop) } - /// Poll until the track closes, with its abort error. + /// Poll until the track is aborted, with its abort error. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - pub(crate) fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { - TrackState::poll_closed(&self.inner.state, waiter) + pub(crate) fn poll_aborted(&self, waiter: &kio::Waiter) -> Poll { + TrackState::poll_aborted(&self.inner.state, waiter) } /// The track the fill serves. diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index 65997f936c9..068c2e33eb6 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -1036,7 +1036,7 @@ mod tests { ); source.close(); assert!(matches!(running.await, Ok(Ended::Unfinished))); - assert!(matches!(observed.closed().await, moq_net::Error::Cancel)); + assert!(matches!(observed.closed().await, Err(moq_net::Error::Cancel))); } /// The track is admitted before the serving future is spawned, so it also owns cleanup diff --git a/test/audio-quality/clients/js/src/capture.test.ts b/test/audio-quality/clients/js/src/capture.test.ts index b50854e607b..11b0b278d2e 100644 --- a/test/audio-quality/clients/js/src/capture.test.ts +++ b/test/audio-quality/clients/js/src/capture.test.ts @@ -32,7 +32,7 @@ test("arrival capture stamps a later group before a stalled earlier group", asyn timestamp: Time.Timestamp.fromMicros(20_000), payload: Legacy.encodeFrame(new Uint8Array([1]), Micro(20_000)), }); - late.close(); + late.finish(); await read(late.consume()); expect(captured).toEqual([[10, 20, 1, 20]]); now = 50; @@ -40,15 +40,15 @@ test("arrival capture stamps a later group before a stalled earlier group", asyn timestamp: Time.Timestamp.fromMicros(0), payload: Legacy.encodeFrame(new Uint8Array([1]), Micro(0)), }); - early.close(); + early.finish(); await waiting; expect(captured).toEqual([ [10, 20, 1, 20], [50, 0, 0, 20], ]); } finally { - early.close(); - late.close(); + early.finish(); + late.finish(); clock.mockRestore(); } }); @@ -59,7 +59,7 @@ test("arrival capture excludes duration markers", async () => { timestamp: Time.Timestamp.fromMicros(100_000), payload: Legacy.encodeFrame(new Uint8Array(), Micro(100_000)), }); - group.close(); + group.finish(); const captured: Arrival[] = []; for await (const arrival of arrivals(group.consume(), { decoder: new Legacy.Format("audio"), config })) captured.push(arrival); @@ -190,7 +190,7 @@ test("closing the observer waits for in-flight groups and keeps their decode fai }); test("a reset group truncates its arrivals without failing capture", async () => { - const error = await captureGroupEnd((group) => group.close(new NetError.TooFarBehind())); + const error = await captureGroupEnd((group) => group.abort(new NetError.TooFarBehind())); expect(error).toBeUndefined(); }); @@ -206,7 +206,7 @@ test("arrival durations come from Opus packets despite missing or differently si payload: Legacy.encodeFrame(new Uint8Array([toc, 0]), Micro(timestamp)), }); } - group.close(); + group.finish(); const captured: Arrival[] = []; for await (const arrival of arrivals(group.consume(), { decoder: new Legacy.Format("audio"), config })) { captured.push(arrival); @@ -224,7 +224,7 @@ test("arrival capture uses AAC's codec duration when the container leaves it imp timestamp: Time.Timestamp.fromMicros(0), payload: Legacy.encodeFrame(new Uint8Array([1]), Micro(0)), }); - group.close(); + group.finish(); const aac = Catalog.AudioConfigSchema.parse({ ...config, codec: "mp4a.40.2", sampleRate: 44100 }); const captured: Arrival[] = []; for await (const arrival of arrivals(group.consume(), { decoder: new Legacy.Format("audio"), config: aac })) { @@ -239,7 +239,7 @@ test("arrival capture refuses a packet with no usable duration", async () => { timestamp: Time.Timestamp.fromMicros(0), payload: Legacy.encodeFrame(new Uint8Array([0x7b]), Micro(0)), }); - group.close(); + group.finish(); const reader = arrivals(group.consume(), { decoder: new Legacy.Format("audio"), config }); await expect(reader.next()).rejects.toThrow("no encoded frame duration for opus"); }); diff --git a/test/drain/drain.ts b/test/drain/drain.ts index 7572e1dfb5a..2e5ce10382a 100644 --- a/test/drain/drain.ts +++ b/test/drain/drain.ts @@ -127,7 +127,7 @@ const ticker = setInterval(() => { payload: new TextEncoder().encode(String(group.sequence)), timestamp: Moq.Time.Timestamp.now(), }); - group.close(); + group.finish(); lastPublished = group.sequence; }, GROUP_INTERVAL_MS); diff --git a/test/interop/clients/js-native/control.ts b/test/interop/clients/js-native/control.ts index 35382ba4f1b..5db6b2776d7 100644 --- a/test/interop/clients/js-native/control.ts +++ b/test/interop/clients/js-native/control.ts @@ -100,12 +100,12 @@ if (mode === "status") { assert.equal((await commands.next()).value, "probe", "missing queued-request barrier"); const group = first.appendGroup(); group.writeFrame({ payload: new Uint8Array([1]) }); - group.close(); + group.finish(); const demand = second.demand(); while (demand.wanted.peek() === undefined) await demand.wanted.changed(); const next = second.appendGroup(); next.writeFrame({ payload: new Uint8Array([2]) }); - next.close(); + next.finish(); assert.equal((await commands.next()).value, "done"); console.error("request credit resumed"); } finally { diff --git a/test/interop/clients/js-native/server.ts b/test/interop/clients/js-native/server.ts index a362a808bcb..d98bfb74ded 100644 --- a/test/interop/clients/js-native/server.ts +++ b/test/interop/clients/js-native/server.ts @@ -33,7 +33,7 @@ const marker = markerPublication.broadcast; const ready = marker.createTrack("ready"); const markerGroup = ready.appendGroup(); markerGroup.writeFrame({ payload: new Uint8Array([42]) }); -markerGroup.close(); +markerGroup.finish(); markerPublication.announce(); type Socket = { @@ -175,7 +175,7 @@ if (mode === "credit") { try { const probe = firstTrack.appendGroup(); probe.writeFrame({ payload: new Uint8Array([1]) }); - probe.close(); + probe.finish(); const data = await first.recvGroup(); assert(data); assert.deepEqual((await data.readFrame())?.payload, new Uint8Array([1])); @@ -184,7 +184,7 @@ if (mode === "credit") { await queued; const next = secondTrack.appendGroup(); next.writeFrame({ payload: new Uint8Array([2]) }); - next.close(); + next.finish(); const resumed = await second.recvGroup(); assert(resumed); assert.deepEqual((await resumed.readFrame())?.payload, new Uint8Array([2])); @@ -217,7 +217,7 @@ if (mode === "credit") { while (demand.wanted.peek() === undefined) await demand.wanted.changed(); const group = video.appendGroup(); group.writeFrame({ payload: new Uint8Array(32) }); - group.close(); + group.finish(); assert.equal((await commands.next()).value, "first group"); await initial.control.writer.u53(GoAway.id); await new GoAway({ @@ -229,7 +229,7 @@ if (mode === "credit") { initial.connection.abort(); const next = video.appendGroup(); next.writeFrame({ payload: new Uint8Array(32).fill(1) }); - next.close(); + next.finish(); assert.equal((await commands.next()).value, "done"); console.error("js goaway redirect resumed"); } finally { diff --git a/test/interop/clients/js-native/tail.ts b/test/interop/clients/js-native/tail.ts index 7639b92543e..d630cde8cd2 100644 --- a/test/interop/clients/js-native/tail.ts +++ b/test/interop/clients/js-native/tail.ts @@ -63,14 +63,15 @@ try { for (let sequence = 0; sequence < GROUPS; sequence++) { const group = track.appendGroup(); if (drop && sequence === 1) { - group.close(new Error("cut")); + group.abort(new Error("cut")); continue; } for (const size of compress ? [BYTES / 2, 0, BYTES / 2] : [BYTES]) { group.writeFrame({ payload: new Uint8Array(size).fill(sequence) }); } - group.close(); + group.finish(); } + track.finish(); track.close(); } } diff --git a/test/interop/clients/js/open-gop/main.ts b/test/interop/clients/js/open-gop/main.ts index d851fc3dd8c..87d02089059 100644 --- a/test/interop/clients/js/open-gop/main.ts +++ b/test/interop/clients/js/open-gop/main.ts @@ -64,7 +64,7 @@ window.runRecovery = async (packets) => { let group: Moq.Group.Producer | undefined; for (const packet of packets) { if (group?.sequence !== packet.group) { - group?.close(); + group?.finish(); group = new Moq.Group.Producer(packet.group); track.writeGroup(group); } @@ -75,7 +75,8 @@ window.runRecovery = async (packets) => { timestamp: Moq.Time.Timestamp.fromMicros(timestamp), }); } - group?.close(); + group?.finish(); + track.finish(); track.close(); const media = producer.consume(); const config = Catalog.VideoConfigSchema.parse({ diff --git a/test/interop/clients/js/src/finish.ts b/test/interop/clients/js/src/finish.ts index 0e89dcf9fed..6e8c6e66443 100644 --- a/test/interop/clients/js/src/finish.ts +++ b/test/interop/clients/js/src/finish.ts @@ -144,7 +144,8 @@ export function finish( const healthTrack = health.createTrack("live"); const group = healthTrack.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode("session-alive") }); - group.close(); + group.finish(); + healthTrack.finish(); healthTrack.close(); healthPublication.announce(); state.ended = true; diff --git a/test/interop/compat/client.ts b/test/interop/compat/client.ts index 763411ee61c..0d8924d81c4 100644 --- a/test/interop/compat/client.ts +++ b/test/interop/compat/client.ts @@ -313,7 +313,7 @@ if (command === "sign") { payload: new TextEncoder().encode("compat-fetch"), timestamp: Moq.Time.Timestamp.fromMicros(1 as TimestampMicros), }); - group.close(); + group.finish(); await connection.closed; } else if (command === "subscribe") { // The flags of the interop subscriber this stands in for. diff --git a/test/interop/ietf-range-updates.ts b/test/interop/ietf-range-updates.ts index 91155c35cf8..5ab9f583a6b 100644 --- a/test/interop/ietf-range-updates.ts +++ b/test/interop/ietf-range-updates.ts @@ -34,7 +34,7 @@ const streams: number[][] = []; function publishGroup() { const group = track.appendGroup(); for (let object = 0; object < 2; object++) group.writeFrame({ payload: new Uint8Array([group.sequence, object]) }); - group.close(); + group.finish(); } async function response(expected: number) { const type = await client.reader.u53(); diff --git a/test/p2p/src/main.ts b/test/p2p/src/main.ts index 0b903812534..083fd0f65cd 100644 --- a/test/p2p/src/main.ts +++ b/test/p2p/src/main.ts @@ -111,6 +111,7 @@ window.checkTransport = async () => { client?.abort(); server?.abort(); transport.close(); + track.finish(); track.close(); broadcast.close(); publishing.close(); diff --git a/test/receive-credit/sender.ts b/test/receive-credit/sender.ts index ece438d052d..85ea5c7ba10 100644 --- a/test/receive-credit/sender.ts +++ b/test/receive-credit/sender.ts @@ -61,19 +61,21 @@ try { } else if (command === "high") { const group = high.appendGroup(); group.writeFrame({ payload: new Uint8Array(window / 2).fill(2) }); - group.close(); + group.finish(); } else if (command === "after") { const group = low.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode("after") }); - group.close(); + group.finish(); } else { throw new Error(`unknown command: ${command}`); } } } finally { // End the live tracks, or the close waits out its deadline for them. - partial?.close(); + partial?.finish(); + low.finish(); low.close(); + high.finish(); high.close(); await session.close(); origin.close(); diff --git a/test/video/src/captions.ts b/test/video/src/captions.ts index d23969bc455..df6cbc31935 100644 --- a/test/video/src/captions.ts +++ b/test/video/src/captions.ts @@ -23,7 +23,7 @@ export function captions() { const track = producer.createTrack(name); const group = track.appendGroup(); group.writeFrame({ payload: Legacy.encodeFrame(new TextEncoder().encode(text), Micro(1_000_000)) }); - group.close(); + group.finish(); } producerPublication.announce(); const broadcast = new Broadcast({ origin, name: Path.from("captions"), catalogFormat: "manual", catalog }); diff --git a/test/video/src/poster.ts b/test/video/src/poster.ts index 9d995eb9a80..370eb18a3ed 100644 --- a/test/video/src/poster.ts +++ b/test/video/src/poster.ts @@ -132,8 +132,8 @@ async function poster(audio: boolean, buffered: boolean) { }, close() { player.close(); - group.close(); - audioGroup?.close(); + group.finish(); + audioGroup?.finish(); producer.close(); origin.close(); canvas.remove(); diff --git a/test/wasm/baseline/publisher.ts b/test/wasm/baseline/publisher.ts index ec655d2a833..6758fe8bf95 100644 --- a/test/wasm/baseline/publisher.ts +++ b/test/wasm/baseline/publisher.ts @@ -19,6 +19,7 @@ async function prepare(config: Workload): Promise { websocket: { enabled: false }, }); cleanup.push(() => { + track.finish(); track.close(); broadcast.close(); connection.abort(); @@ -32,7 +33,7 @@ async function prepare(config: Workload): Promise { const group = new Moq.Group.Producer(config.sequence); for (const [frame, bytes] of fixtures.entries()) group.writeFrame({ payload: bytes, timestamp: Moq.Time.Timestamp.fromMillis(frame) }); - group.close(); + group.finish(); track.writeGroup(group); // Keep the track and broadcast served until every reader completes. No timing arm or tail-close race. })(), diff --git a/test/wasm/src/main.ts b/test/wasm/src/main.ts index d04db898604..701f01ec76c 100644 --- a/test/wasm/src/main.ts +++ b/test/wasm/src/main.ts @@ -173,22 +173,25 @@ async function withPublisher( for (const frame of FIXTURE) { group.writeFrame({ payload: frame, timestamp: Moq.Time.Timestamp.now() }); } - group.close(); + group.finish(); } + track.finish(); track.close(); })(); const rejecting = (async () => { while (missingUsed.peek() === undefined && missing.closed.peek() === undefined) { await Promise.race([missingUsed.changed(), missing.closed]); } - if (missingUsed.peek() !== undefined) missing.close(new Error("no such track: missing")); + if (missingUsed.peek() !== undefined) missing.abort(new Error("no such track: missing")); })(); try { return await run(advance); } finally { stopped = true; + track.finish(); track.close(); + missing.finish(); missing.close(); broadcast.close(); // Teardown, not a graceful end: the case is over and the harness is not waiting