Skip to content

Commit 3a78408

Browse files
committed
feat(catalog): retrieve the catalog via SUBSCRIBE + joining FETCH (MSF-01 §5)
MSF-01 §5 requires subscribers to obtain the catalog with a SUBSCRIBE paired with a relative Joining FETCH at offset 0. We previously subscribed with AbsoluteStart{0,0} and never issued a fetch, which depended on relay-side history replay and replayed every superseded group when that replay existed. Adds a catalog bootstrap coordinator that implements the mandated pattern with a diagnosed fallback ladder (standalone fetch bounded by the subscription's Largest Location, then legacy retrieval), exactly-once application with location dedup, and group-aware delta enforcement on every rung so a delta is never applied without its own group's base. `catalogBootstrap` selects the mode: auto (default), joining-fetch, strict, or subscribe for the legacy wire behavior. An MSF-01/CMSF-01 catalog acquired through a fallback rung is rejected unless the compatibility mode is chosen explicitly. Transport: - Save the Largest Location communicated in SUBSCRIBE_OK / PUBLISH / a d18 Forward-0→1 REQUEST_UPDATE_OK; resolve incoming joining fetches against that saved snapshot only, with no caller-supplied anchor. - Enforce the joining-fetch eligibility matrix per draft and initiator, and apply PUBLISH acceptance parameters (FORWARD, filter, timeouts) on both ends. - Carry the joining Request ID at its negotiated width; draft-18 defines it as vi64, so stop narrowing it to the QUIC varint range. WebTransport adapter: - Expose request ownership before emission so a zero-latency response cannot beat registration, plus an opt-in terminal drain so a subscription's late objects are delivered rather than discarded at PUBLISH_DONE. - Own publisher terminalization: transfer ownership synchronously before any await, then account for the §5.1.1 reset requirement. A reset that cannot be proven (a rejected or unsettled abort) forbids PUBLISH_DONE, so every cancellation path fails closed through one wrapper, and each phase of the failed-update transaction is bounded. Local failures close with INTERNAL_ERROR rather than blaming the peer. - closeSubgroup keeps the stream registered until its FIN settles (the publishDone gate must still count it), propagates a failed FIN instead of suppressing it, and shares one in-flight FIN among concurrent callers. - A draft-14/16 acceptance whose control write fails now rolls back and closes the session instead of leaving a subscription the peer never learned about. Player: - Route catalog objects through the coordinator with fail-closed parking, staged recovery for retriable terminals, and connection-scoped ownership. - Validate the GOAWAY New Session URI before creating a migration candidate; reject non-URLs, non-https schemes, and fragments, and never silently fall back to the current URL. Broadcast example: - Stop sending PUBLISH_DONE(TRACK_ENDED) on the catalog at startup. That terminates the subscription and states the track is no longer published, which forecloses delta updates and leaves later viewers without a catalog on relays that treat an ended upstream track as final. - Make each broadcast a generation-owned startup transaction: resources are owned at creation, cancellation reaches in-progress work, and a superseded attempt cannot touch its replacement's state. - Publish media through bounded queues with codec-safe overflow (video drops dependents until the next keyframe and restarts at object 0; audio drops oldest), publishing independent audio chunks concurrently so the encoder's output rate is actually sustained. - Treat catalog publication as a protocol transaction: build before accepting, and reach an explicit terminal outcome on any failure after acceptance. - Bind wire behavior to the negotiated draft: select the LOC property profile per draft and set draft-18's mandatory FIRST_OBJECT bit on every subgroup this publisher opens.
1 parent f6637b4 commit 3a78408

36 files changed

Lines changed: 13920 additions & 706 deletions

examples/broadcast/broadcast-attempt.test.ts

Lines changed: 399 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/**
2+
* A broadcast STARTUP as a generation-owned transaction, DOM-free.
3+
*
4+
* Starting a broadcast crosses several awaits (capture, encoders, transport,
5+
* connection handshake, namespace publication). With mutable globals,
6+
* stopping attempt A and starting attempt B lets A's continuations overwrite
7+
* or tear down B's resources.
8+
*
9+
* Ownership here is at RESOURCE-CREATION granularity, not step granularity:
10+
* a step adopts each resource through {@link AttemptResources.adopt} the
11+
* moment it exists, so a failure or cancellation *between* a step's internal
12+
* awaits — a transport built but its handshake never completed, encoders
13+
* created but audio config throwing — still disposes what was acquired.
14+
* Disposal is LIFO, every disposer is contained, and a rejecting disposer can
15+
* never surface as an unhandled rejection.
16+
*
17+
* WHICH promise is the completion barrier, precisely:
18+
* - `run()` IS the final barrier. It re-enters `cancel()` after the step
19+
* unwinds, so by the time it resolves `'cancelled'`, every disposal —
20+
* including one started by an acquisition that only completed after Stop —
21+
* has finished.
22+
* - `cancel()` is the barrier for everything outstanding AT THE MOMENT IT IS
23+
* CALLED. It cannot be a barrier for an acquisition still pending inside a
24+
* step (an open camera-permission prompt is not cancellable), so a `cancel()`
25+
* that resolves before such an acquisition lands is correct, not a leak: the
26+
* resource is disposed when it arrives (see {@link AttemptResources.adopt}),
27+
* and a subsequent `cancel()` — which `run()` performs — awaits that.
28+
*
29+
* Cancellation reaches in-progress work: {@link AttemptResources.onCancel}
30+
* callbacks fire synchronously inside `cancel()`, letting a step abort a
31+
* hanging handshake rather than leaving the teardown blocked on it.
32+
*/
33+
34+
export interface CaptureHandle { stop(): void }
35+
export interface EncoderHandle { destroy(): void }
36+
37+
/** Minimal shape this module needs from a session (kept structural so the
38+
* attempt has no import cycle with broadcast-session.ts). Callers keep their
39+
* concrete session type through the generic parameter below. */
40+
export interface SessionLike { shutdown(): Promise<void> }
41+
42+
/** Thrown by {@link AttemptResources.throwIfCancelled}. */
43+
export class AttemptCancelledError extends Error {
44+
constructor() { super('broadcast attempt cancelled'); this.name = 'AttemptCancelledError'; }
45+
}
46+
47+
/** The ownership context handed to every step. */
48+
export interface AttemptResources {
49+
/**
50+
* Adopt `resource` for this attempt IMMEDIATELY — before the step that
51+
* created it returns — so any later failure or cancellation disposes it.
52+
* Returns `resource` so it can be used inline. If the attempt is ALREADY
53+
* cancelled, the resource's disposal starts right away and is awaited by
54+
* whichever barrier comes next — a still-active quiescence pass, or else the
55+
* next `cancel()` (which `run()` performs as it unwinds). It is never leaked.
56+
*/
57+
adopt<T>(resource: T, dispose: (resource: T) => void | Promise<void>): T;
58+
/** Throw {@link AttemptCancelledError} if cancelled. Call after each await. */
59+
throwIfCancelled(): void;
60+
readonly cancelled: boolean;
61+
/** Run `cb` synchronously when cancel() is called — use it to abort an
62+
* in-progress handshake (e.g. close the transport) so the step's pending
63+
* await settles instead of hanging. */
64+
onCancel(cb: () => void): void;
65+
}
66+
67+
/** The attempt's steps, injected so the transaction is unit-testable. Each
68+
* receives the ownership context and MUST adopt what it creates. */
69+
export interface BroadcastAttemptDeps<TSession extends SessionLike = SessionLike> {
70+
startCapture(ctx: AttemptResources): Promise<CaptureHandle>;
71+
createEncoders(ctx: AttemptResources, capture: CaptureHandle): EncoderHandle;
72+
openSession(ctx: AttemptResources): Promise<TSession>;
73+
publishNamespace(ctx: AttemptResources, session: TSession): Promise<void>;
74+
wirePublication(session: TSession, capture: CaptureHandle, encoders: EncoderHandle): void;
75+
}
76+
77+
interface Adopted { resource: unknown; dispose: (r: never) => void | Promise<void>; disposed: boolean }
78+
79+
export class BroadcastAttempt<TSession extends SessionLike = SessionLike> {
80+
private readonly deps: BroadcastAttemptDeps<TSession>;
81+
private cancelled = false;
82+
private session: TSession | null = null;
83+
/** The in-flight quiescence pass, if any. Restartable: a disposal that
84+
* begins after a pass completes gets a NEW pass, so no caller can observe
85+
* cancellation as finished while a disposal is still running. */
86+
private quiescing: Promise<void> | null = null;
87+
/** Adoption order; disposed LIFO. */
88+
private readonly adopted: Adopted[] = [];
89+
/** Disposals currently running (from any pass or a post-cancel adoption). */
90+
private readonly runningDisposals = new Set<Promise<void>>();
91+
private readonly cancelCallbacks: Array<() => void> = [];
92+
private readonly ctx: AttemptResources;
93+
94+
constructor(deps: BroadcastAttemptDeps<TSession>) {
95+
this.deps = deps;
96+
const self = this; // the `cancelled` getter below cannot be an arrow
97+
this.ctx = {
98+
adopt: <T>(resource: T, dispose: (resource: T) => void | Promise<void>): T => {
99+
const entry: Adopted = {
100+
resource,
101+
dispose: dispose as (r: never) => void | Promise<void>,
102+
disposed: false,
103+
};
104+
this.adopted.push(entry);
105+
// Already cancelled: this resource's acquisition finished AFTER the
106+
// teardown began (a camera whose permission prompt resolved after
107+
// Stop), so dispose it now. Tracking it makes any cancel() — including
108+
// one whose earlier pass already finished — await this disposal.
109+
if (this.cancelled) this.beginDisposal(entry);
110+
return resource;
111+
},
112+
throwIfCancelled: () => { if (this.cancelled) throw new AttemptCancelledError(); },
113+
get cancelled(): boolean { return self.cancelled; },
114+
onCancel: (cb: () => void) => {
115+
if (this.cancelled) { try { cb(); } catch { /* contained */ } return; }
116+
this.cancelCallbacks.push(cb);
117+
},
118+
};
119+
}
120+
121+
get isCancelled(): boolean { return this.cancelled; }
122+
/** The session, once fully established (null before, and after teardown). */
123+
get currentSession(): TSession | null { return this.session; }
124+
125+
/**
126+
* Run the startup transaction. Steps adopt their own resources as they are
127+
* created; between steps a cancellation gate stops further progress. Any
128+
* failure — or cancellation — releases everything this attempt acquired,
129+
* awaited, before returning or rethrowing. A cancelled attempt's failure is
130+
* reported as `'cancelled'`, never rethrown: it is not the user's error.
131+
*/
132+
async run(): Promise<'completed' | 'cancelled'> {
133+
try {
134+
const capture = await this.deps.startCapture(this.ctx);
135+
this.ctx.throwIfCancelled();
136+
137+
const encoders = this.deps.createEncoders(this.ctx, capture);
138+
this.ctx.throwIfCancelled();
139+
140+
const session = await this.deps.openSession(this.ctx);
141+
this.ctx.throwIfCancelled();
142+
143+
await this.deps.publishNamespace(this.ctx, session);
144+
this.ctx.throwIfCancelled();
145+
146+
this.deps.wirePublication(session, capture, encoders);
147+
this.session = session;
148+
return 'completed';
149+
} catch (err) {
150+
const wasCancelled = this.cancelled || err instanceof AttemptCancelledError;
151+
// Release THIS attempt's resources — awaited, so nothing outlives run().
152+
await this.cancel();
153+
if (wasCancelled) return 'cancelled';
154+
throw err;
155+
}
156+
}
157+
158+
/**
159+
* Cancel synchronously (the flag flips and abort callbacks fire before any
160+
* await, so an in-flight step settles at its next gate), then dispose every
161+
* adopted resource LIFO. Single-flight and never rejects.
162+
*/
163+
cancel(): Promise<void> {
164+
this.cancelled = true;
165+
// Fire abort hooks FIRST — synchronously, before anything is awaited — so
166+
// local resources (capture, encoders) retire the instant Stop is pressed
167+
// rather than after a bounded network shutdown, and a hanging handshake
168+
// settles instead of blocking the disposal we are about to await.
169+
while (this.cancelCallbacks.length > 0) {
170+
const cb = this.cancelCallbacks.shift()!;
171+
try { cb(); } catch { /* contained */ }
172+
}
173+
// Restartable: if a previous pass already finished but a disposal has since
174+
// begun (post-cancel adoption), start a fresh pass covering it.
175+
if (this.quiescing === null) {
176+
this.quiescing = this.disposeUntilQuiet().then(() => { this.quiescing = null; });
177+
}
178+
return this.quiescing;
179+
}
180+
181+
/** Start one resource's disposal, tracked so quiescence awaits it. */
182+
private beginDisposal(entry: Adopted): void {
183+
if (entry.disposed) return;
184+
entry.disposed = true;
185+
const p = this.safeDispose(entry.resource as never, entry.dispose);
186+
this.runningDisposals.add(p);
187+
void p.then(() => this.runningDisposals.delete(p));
188+
}
189+
190+
/**
191+
* Dispose everything adopted, LIFO, and do not resolve until NOTHING is
192+
* outstanding — neither an undisposed entry nor a running disposal. A step
193+
* racing the teardown can adopt more resources mid-drain; the loop picks
194+
* them up rather than resolving early and leaking them.
195+
*/
196+
private async disposeUntilQuiet(): Promise<void> {
197+
for (;;) {
198+
for (let i = this.adopted.length - 1; i >= 0; i--) {
199+
const entry = this.adopted[i]!;
200+
if (entry.disposed) continue;
201+
entry.disposed = true;
202+
await this.safeDispose(entry.resource as never, entry.dispose);
203+
}
204+
if (this.runningDisposals.size > 0) {
205+
await Promise.all([...this.runningDisposals]);
206+
continue;
207+
}
208+
if (this.adopted.some((e) => !e.disposed)) continue;
209+
this.session = null;
210+
return;
211+
}
212+
}
213+
214+
private async safeDispose<T>(resource: T, dispose: (r: T) => void | Promise<void>): Promise<void> {
215+
try {
216+
await dispose(resource);
217+
} catch { /* contained: one bad disposer must not strand the rest */ }
218+
}
219+
}

0 commit comments

Comments
 (0)