diff --git a/README.md b/README.md index 817f3e22..291e42f6 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ player.on('catch_up_changed', ({ active, rate, latencyMs }) => { ... }); | Option | Type | Default | Description | |--------|------|---------|-------------| -| `draftVersion` | 14 \| 16 \| 18 | 16 | Protocol version (14 for moq-rs / Red5 compat, 18 for draft-18 relays) | +| `draftVersion` | 14 \| 16 \| 18 \| 21 | 16 | Protocol version (14 for moq-rs / Red5 compat, 18 or 21 for relays speaking those drafts) | | `maxRequestId` | number | 100 | Initial MOQT MAX_REQUEST_ID (auto-replenished) | | `knownTracks` | object | — | Pre-known codec metadata for TTFF optimization | | `catalog` | `{tracks}` | — | Inject catalog externally, skip catalog subscription | @@ -241,6 +241,7 @@ player.on('catch_up_changed', ({ active, rate, latencyMs }) => { ... }); ## Protocol Support +- **draft-ietf-moq-transport-21** - experimental on this development branch; opt-in (`draftVersion: 21` / `moqt-21`) - **draft-ietf-moq-transport-18** — uni control-stream pair + per-request bidi streams (`draftVersion: 18` / `moqt-18`) - **draft-ietf-moq-transport-16** — default supported transport draft - **draft-ietf-moq-transport-14** — Red5/moq-rs interop (`draftVersion: 14`) @@ -262,6 +263,7 @@ are not implemented. Callers with track-scoped defaults can pass `track` to Browser WebTransport may expose `transport.protocol`, enabling automatic draft detection from the negotiated `WT-Available-Protocols`: +- `moqt-21` → draft 21 - `moqt-18` → draft 18 - `moqt-16` → draft 16 - `moq-00` → draft 14 @@ -276,7 +278,7 @@ For **draft-14 relays** (moq-rs, Red5, moqtail), you must explicitly specify the const conn = new MoqtConnection(14); // required — CLIENT_SETUP is draft-specific ``` -`MoqtConnection` auto-detects the draft from **any** `WebTransportLike` whose `protocol` exposes a supported token (`moqt-18`, `moqt-16`, or `moq-00`) — there's nothing factory-specific about detection. The browser transport factory is just the convenience that sets the WebTransport `protocols` offer for you. If you construct your own `WebTransport`, pass the appropriate `protocols` option yourself and make sure `transport.protocol` is readable; Playa reads it the same way. Some Node/polyfill transports may not support `protocols` yet. +`MoqtConnection` auto-detects the draft from **any** `WebTransportLike` whose `protocol` exposes a supported token (`moqt-21`, `moqt-18`, `moqt-16`, or `moq-00`) — there's nothing factory-specific about detection. The browser transport factory is just the convenience that sets the WebTransport `protocols` offer for you. If you construct your own `WebTransport`, pass the appropriate `protocols` option yourself and make sure `transport.protocol` is readable; Playa reads it the same way. Some Node/polyfill transports may not support `protocols` yet. Node applications can use the experimental native QUIC binding for draft 18: @@ -290,9 +292,32 @@ await connection.connect(transport); ``` `@openmoq/quic` requires a Node build configured and launched with -`--experimental-quic`. It offers only the `moqt-18` ALPN, requires QUIC +`--experimental-quic`. It offers `moqt-18` by default, or `moqt-21` with +`{ draft: 21 }`, requires QUIC DATAGRAM negotiation, disables 0-RTT, and does not fall back to WebTransport. +#### draft 21 (experimental) + +Draft 21 (`new MoqtConnection(21)`, `draftVersion: 21`, ALPN/WT protocol `moqt-21`) keeps the draft-18 stream model and adds: + +- **Location filters.** SUBSCRIBE and FETCH carry their range in LOCATION_FILTER. `SubscriptionFilter` gains `RelativeStart` (`groups: N` starts at group Largest + 1 - N, so 1 is the current group) and an optional inclusive `endObject` on `AbsoluteRange`. +- **Fills instead of Joining FETCH.** Draft 21 has no Joining FETCH. `subscribe()` / `subscribeTrack()` take `fill: { filter?: SubscriptionFilter, groupOrder?: GroupOrder }`. Omitted fields inherit the subscription policy. The fill is bounded by the Largest Location in its response; Forward=0 or an empty range opens no fill stream. Its FETCH_HEADER carries the SUBSCRIBE's or REQUEST_UPDATE's Request ID. There is no FETCH_OK: FIN completes the fill and reset fails it. `cancelFill(requestId)` stops a fill without cancelling live delivery; passing the subscription ID stops all of its fills. The publisher uses `openFillStream(requestId)` to serve it. +- **Player.** The MSF-01 catalog bootstrap and warm start ask for the current group as a fill (`LOCATION_FILTER [1]`). A SUBSCRIBE_OK without a Largest Object means an empty track, so the player waits for the first live catalog object and does not expect a fill. +- **Other changes.** PUBLISH_STATE_NOTIFY on the subscription stream advances the Largest Location. GOAWAY has no Request ID. The End of Timed-Out Range fetch marker (`0x20C`) is accepted. PUBLISH_DONE `0x3` no longer exists. + +- **REQUEST_UPDATE.** `requestUpdate()` also takes `fill`; that fill stream carries the update's Request ID. `SetupOptions.maxRequestUpdates` advertises MAX_REQUEST_UPDATES. Updates never exceed the peer's limit, and a peer that exceeds ours closes the session with TOO_MANY_REQUEST_UPDATES. +- **Several subscriptions to one track.** Draft 21 allows them, and a publisher may give them one Track Alias. Each subscription's filter controls delivery. Ending one leaves the others running. Publisher calls to `openSubgroup()` and `sendDatagram()` must supply `requestId` when the alias has several active owners, so cancellation and terminal stream counts remain attributable to the right request. +- **Native QUIC.** `connectQuic(uri, { draft: 21 })` offers `moqt-21`. + +Range Filters beyond LOCATION_FILTER are not implemented. The endpoint advertises +the default MAX_FILTER_RANGES=0 and rejects requests that exceed it with +INVALID_FILTER rather than ignoring the filter. + +Main remains on drafts 14/16/18. Draft 21 stays opt-in on this branch while +conformance and independent interoperability are evaluated. See +[draft development](docs/draft-development.md) for branch policy and the unresolved +FETCH End-of-Range framing question. Existing draft defaults and framing are unchanged. + #### draft-18 known gaps (non-blocking) draft-18 support is functional for the subscriber and publisher paths. The deliberately deferred edges are documented as intentional gaps rather than silently dropped: diff --git a/docs/draft-development.md b/docs/draft-development.md new file mode 100644 index 00000000..28878c3c --- /dev/null +++ b/docs/draft-development.md @@ -0,0 +1,39 @@ +# Transport Draft Development + +Main supports transport drafts 14, 16, and 18. This branch adds experimental +draft-21 support without changing the default draft. Later drafts can develop +here without making main an experimental release channel. + +## Keeping Main and This Branch Aligned + +- Land fixes for existing drafts on main first, then merge main into this branch. +- Keep draft-only codecs, parameter definitions, and conformance tests separate. +- Share lifecycle machinery where the ownership model is the same. Gate changed + wire rules by draft and test both sides of each boundary. +- Avoid broad renames, formatting changes, or duplicate session implementations. +- Merge main regularly, including its regression tests. Do not wait for a draft + release to bring in player, browser, or catalog fixes. +- Keep draft-specific fixes separate from maintenance commits where practical. + Updating the experimental draft does not authorize changes to older drafts. + +The session and transport adapter still share stream ownership and cancellation +logic. Changes there need the old-draft suites as well as draft-21 loopback tests; +file separation alone does not prove compatibility. + +## FETCH End-of-Range Framing + +Draft 21 Figure 28 includes Object Payload Length, while section 11.4.1.2's +End-of-Range field omissions do not settle whether it is present. The same +ambiguity exists in draft 18. [Working-group issue 1861](https://github.com/moq-wg/moq-transport/issues/1861) +records the question but does not supply a technical resolution. + +This branch uses the marker-only draft-21 form from the initial implementation, +without a payload-length field. That is an explicit interoperability choice, not +a claim that the ambiguity has been resolved. Drafts 14/16/18 retain their +existing zero-length field. Literal-byte tests cover both forms and a following +object, so one draft's change cannot silently shift another draft's parser. + +Revisit the draft-21 choice when the text is clarified. Do not add a parser that +guesses between both forms: the optional zero would be ambiguous with subsequent +object bytes. Independent peer testing is still required before promoting the +experimental draft to main or a stable release. diff --git a/examples/broadcast/catalog-publisher.ts b/examples/broadcast/catalog-publisher.ts index b91fa89c..20e2bc89 100644 --- a/examples/broadcast/catalog-publisher.ts +++ b/examples/broadcast/catalog-publisher.ts @@ -17,7 +17,7 @@ * is included only when {@link BroadcastCatalogParams.audio} is present (a * screen capture without audio must not advertise an audio track). */ -import { varint, SubgroupIdMode, PublishDoneCode } from '@openmoq/transport'; +import { varint, SubgroupIdMode, PublishDoneCode, isRequestStreamDraft } from '@openmoq/transport'; import type { DraftVersion } from '@openmoq/transport'; import { buildCatalog } from '@openmoq/msf'; @@ -152,7 +152,7 @@ export async function acceptCatalogSubscribe( endOfGroup: true, defaultPriority: true, subgroupIdMode: SubgroupIdMode.ZERO, - ...(wire.draft === 18 ? { firstObject: true } : {}), + ...(isRequestStreamDraft(wire.draft) ? { firstObject: true } : {}), }, ); await connection.sendObject(streamId, varint(0), catalogPayload); diff --git a/examples/broadcast/media-publisher.test.ts b/examples/broadcast/media-publisher.test.ts index 626473e0..b55f1fc5 100644 --- a/examples/broadcast/media-publisher.test.ts +++ b/examples/broadcast/media-publisher.test.ts @@ -366,8 +366,8 @@ describe('MediaPublisher — broadcast generations', () => { }); describe('MediaPublisher — negotiated-draft wire binding', () => { - it('draft-18 subgroup opens set FIRST_OBJECT on video AND audio; 16/14 do not', async () => { - for (const draft of [14, 16, 18] as const) { + it('draft-18 and 21 subgroup opens set FIRST_OBJECT on video AND audio; 16/14 do not', async () => { + for (const draft of [14, 16, 18, 21] as const) { const conn = recordingConnection(); const pub = makePublisher(conn, { draft }); pub.setVideoAlias(2n); @@ -377,7 +377,7 @@ describe('MediaPublisher — negotiated-draft wire binding', () => { await settle(); expect(conn.opened).toHaveLength(2); for (const o of conn.opened) { - if (draft === 18) { + if (draft >= 18) { expect(o.options['firstObject']).toBe(true); // §2.2 MUST for the original publisher } else { expect('firstObject' in o.options).toBe(false); // d14/16 bytes preserved @@ -386,6 +386,17 @@ describe('MediaPublisher — negotiated-draft wire binding', () => { } }); + it('draft 21 keeps the draft-18 LOC wire profile', async () => { + const timestampUs = Date.now() * 1000; + const conn = recordingConnection(); + const pub = makePublisher(conn, { draft: 21, wallClockUs: () => timestampUs }); + pub.setVideoAlias(2n); + pub.publishVideo(chunk(7), { isKeyframe: true, timestampUs }); + await settle(); + const parsed = parseLocHeaders(conn.sends[0]!.extensions, { wireProfile: locWireProfileForDraft(18) }); + expect(parsed.captureTimestamp).toBe(BigInt(Math.round(timestampUs))); + }); + it('draft-18 LOC extensions use the vi64 profile: they parse as d18 and are NOT d16 bytes', async () => { const timestampUs = Date.now() * 1000; // a current capture timestamp const conn18 = recordingConnection(); diff --git a/examples/broadcast/media-publisher.ts b/examples/broadcast/media-publisher.ts index c16fbb1a..1f59a695 100644 --- a/examples/broadcast/media-publisher.ts +++ b/examples/broadcast/media-publisher.ts @@ -34,6 +34,7 @@ */ import { encodeLocHeaders, locWireProfileForDraft, type LocVersion } from '@openmoq/loc'; import type { DraftVersion } from '@openmoq/transport'; +import { isRequestStreamDraft } from '@openmoq/transport'; /** The subset of MoqtConnection the media publication path uses. */ export interface MediaPublishConnection { @@ -69,7 +70,7 @@ export interface AudioChunkMeta { export interface MediaPublisherOptions { /** Wraps a bigint as the wire integer type (the example passes `varint`). */ wrapInt: (n: bigint) => unknown; - /** NEGOTIATED MoQT draft — selects the LOC wire profile and, on draft-18, + /** NEGOTIATED MoQT draft — selects the LOC wire profile and, on draft 18+, * the mandatory FIRST_OBJECT subgroup bit. Typed (not `number`) so an * unsupported draft cannot silently inherit draft-16 LOC behavior. */ draft: DraftVersion; @@ -99,7 +100,7 @@ export interface MediaPublisherOptions { } /** Drafts whose wire behavior this publisher implements explicitly. */ -const SUPPORTED_DRAFTS: readonly DraftVersion[] = [14, 16, 18]; +const SUPPORTED_DRAFTS: readonly DraftVersion[] = [14, 16, 18, 21]; /** Validate a queue bound: NaN/Infinity would disable backpressure entirely * and a non-positive or fractional cap has no coherent meaning. */ @@ -296,7 +297,7 @@ export class MediaPublisher { hasExtensions: true, endOfGroup: true, publisherPriority: priority, - ...(this.draft === 18 ? { firstObject: true } : {}), + ...(isRequestStreamDraft(this.draft) ? { firstObject: true } : {}), }; } diff --git a/examples/catalog/index.html b/examples/catalog/index.html index 5ed4e4af..b562c7b1 100644 --- a/examples/catalog/index.html +++ b/examples/catalog/index.html @@ -184,6 +184,7 @@ +
diff --git a/examples/catalog/main.ts b/examples/catalog/main.ts index 4c43e1d9..cfe44a88 100644 --- a/examples/catalog/main.ts +++ b/examples/catalog/main.ts @@ -182,7 +182,7 @@ async function run(): Promise { const ns = (document.getElementById('namespace') as HTMLInputElement).value.trim(); const vRaw = (document.getElementById('draft-version') as HTMLSelectElement).value; const hashHex = (document.getElementById('cert-hash') as HTMLInputElement).value.trim(); - const v: 14 | 16 | 18 | undefined = vRaw === '14' ? 14 : vRaw === '16' ? 16 : vRaw === '18' ? 18 : undefined; + const v: 14 | 16 | 18 | 21 | undefined = vRaw === '14' ? 14 : vRaw === '16' ? 16 : vRaw === '18' ? 18 : vRaw === '21' ? 21 : undefined; if (!ns) { setStatus('Namespace required.', 'error'); return; } if (!url) { diff --git a/examples/node-publisher/fixtures/testsrc.mp4 b/examples/node-publisher/fixtures/testsrc.mp4 new file mode 100644 index 00000000..0cbf9781 Binary files /dev/null and b/examples/node-publisher/fixtures/testsrc.mp4 differ diff --git a/examples/node-relay/src/relay.ts b/examples/node-relay/src/relay.ts index e5fc509c..d77f2571 100644 --- a/examples/node-relay/src/relay.ts +++ b/examples/node-relay/src/relay.ts @@ -33,6 +33,7 @@ import type { MoqtConnection, IncomingPublish } from '@openmoq/webtransport'; import { MessageParam, RequestError18, SessionError, locationEncodingLength, varint, writeLocation, type Fetch, type Parameters, type StandaloneFetch } from '@openmoq/transport'; import { DEMO_NAMESPACE, DEMO_TRACK, MEDIA_TRACKS, td, nsStr, hex } from './demo.js'; import { SubgroupForwarder, type ForwardLimits } from './subgroup-forwarder.js'; +import { isRequestStreamDraft } from '@openmoq/transport'; const log = (...a: unknown[]) => console.log('[relay]', ...a); @@ -172,7 +173,7 @@ export class Relay { const cachedLargest = latestCached(this.tracks.get(key)); let acceptParams: Parameters | undefined; if (cachedLargest) { - const value = conn.draftVersion === 18 + const value = isRequestStreamDraft(conn.draftVersion) ? { group: cachedLargest.groupId, object: cachedLargest.objectId } : (() => { const loc = { group: varint(cachedLargest.groupId), object: varint(cachedLargest.objectId) }; diff --git a/examples/shared/cert.ts b/examples/shared/cert.ts index c5dc1c21..084b34f5 100644 --- a/examples/shared/cert.ts +++ b/examples/shared/cert.ts @@ -59,12 +59,13 @@ export const catalogBootstrap: 'auto' | 'joining-fetch' | 'strict' | 'subscribe' return v === 'auto' || v === 'joining-fetch' || v === 'strict' || v === 'subscribe' ? v : undefined; })(); -/** Draft version override (e.g. ?v=14 for draft-14 relays, ?v=18 for draft-18). */ -export const draftVersion: 14 | 16 | 18 | undefined = (() => { +/** Draft version override (e.g. ?v=14 for draft-14 relays, ?v=18 for draft-18, ?v=21 for draft-21). */ +export const draftVersion: 14 | 16 | 18 | 21 | undefined = (() => { const v = params.get('v'); if (v === '14') return 14; if (v === '16') return 16; if (v === '18') return 18; + if (v === '21') return 21; return undefined; })(); diff --git a/examples/shared/probe-transport.ts b/examples/shared/probe-transport.ts index 56bae11c..f43bb8df 100644 --- a/examples/shared/probe-transport.ts +++ b/examples/shared/probe-transport.ts @@ -20,7 +20,7 @@ export interface ProbeOptions { /** SHA-256 certificate hash for self-signed relays. */ readonly certHash?: ArrayBuffer; /** MOQT draft version, controlling the WT protocols offer. */ - readonly draftVersion?: 14 | 16 | 18; + readonly draftVersion?: 14 | 16 | 18 | 21; /** Abort the handshake: closes the connecting transport, rejects with `signal.reason`. */ readonly signal?: AbortSignal; } diff --git a/packages/browser/src/webtransport-factory.ts b/packages/browser/src/webtransport-factory.ts index d81d9926..7505ead0 100644 --- a/packages/browser/src/webtransport-factory.ts +++ b/packages/browser/src/webtransport-factory.ts @@ -38,6 +38,7 @@ export interface WebTransportFactoryOptions { * sends the `WT-Available-Protocols` header to the server. The server * selects a protocol and the result is available on `transport.protocol`. * + * - 21 → `["moqt-21"]` * - 18 → `["moqt-18"]` * - 16 → `["moqt-16"]` * - 14 → no protocols sent (h3 ALPN fallback, in-band CLIENT_SETUP negotiation) @@ -46,7 +47,7 @@ export interface WebTransportFactoryOptions { * @see draft-ietf-moq-transport-16 §3.1 (version negotiation) * @see W3C WebTransport §3.3 (WT-Available-Protocols) */ - readonly draftVersion?: 14 | 16 | 18; + readonly draftVersion?: 14 | 16 | 18 | 21; } /** diff --git a/packages/loc/src/headers.test.ts b/packages/loc/src/headers.test.ts index 7b48da94..d336f492 100644 --- a/packages/loc/src/headers.test.ts +++ b/packages/loc/src/headers.test.ts @@ -19,6 +19,7 @@ import { encodeLocHeaders, toVideoChunkInit, toAudioChunkInit, + locWireProfileForDraft, } from './headers.js'; import { LocExtensionId } from './types.js'; import type { LocHeaders } from './types.js'; @@ -69,6 +70,16 @@ function buildExtensionBytes( // ─── parseLocHeaders ────────────────────────────────────────────────── +describe('locWireProfileForDraft', () => { + it('maps each wired draft to its property wire profile', () => { + expect(locWireProfileForDraft(14)).toBe('d14-absolute-varint'); + expect(locWireProfileForDraft(16)).toBe('d16-delta-varint'); + expect(locWireProfileForDraft(18)).toBe('d18-delta-vi64'); + // draft 21 keeps draft 18's vi64 Object Properties encoding + expect(locWireProfileForDraft(21)).toBe('d18-delta-vi64'); + }); +}); + describe('parseLocHeaders', () => { it('returns empty headers for undefined extensions (§2.3)', () => { const headers = parseLocHeaders(undefined); diff --git a/packages/loc/src/headers.ts b/packages/loc/src/headers.ts index cd5434a3..4461a98d 100644 --- a/packages/loc/src/headers.ts +++ b/packages/loc/src/headers.ts @@ -93,6 +93,7 @@ export function locWireProfileForDraft(draft: number): PropertyWireProfile { case 14: return 'd14-absolute-varint'; case 18: + case 21: return 'd18-delta-vi64'; default: return 'd16-delta-varint'; diff --git a/packages/playa/src/types.ts b/packages/playa/src/types.ts index fb1c051a..49526c2e 100644 --- a/packages/playa/src/types.ts +++ b/packages/playa/src/types.ts @@ -29,7 +29,7 @@ export interface PlayerOptions { /** SHA-256 cert hash for self-signed certs (local dev). */ readonly certHash?: ArrayBuffer; /** MOQT draft version. Default: 16. */ - readonly draftVersion?: 14 | 16 | 18; + readonly draftVersion?: 14 | 16 | 18 | 21; /** Auth tokens for CLIENT_SETUP. */ readonly authTokens?: Uint8Array[]; diff --git a/packages/player/src/catalog-bootstrap.test.ts b/packages/player/src/catalog-bootstrap.test.ts index 0ce196d0..0383931e 100644 --- a/packages/player/src/catalog-bootstrap.test.ts +++ b/packages/player/src/catalog-bootstrap.test.ts @@ -56,7 +56,7 @@ interface Harness { }; } -function makeHarness(overrides?: { draft?: 14 | 16 | 18 }): Harness { +function makeHarness(overrides?: { draft?: 14 | 16 | 18 | 21 }): Harness { const manager = new CatalogManager('live/test'); const calls: Harness['calls'] = { joiningFetch: 0, standaloneFetches: [], cancels: 0, @@ -468,6 +468,45 @@ describe('CatalogBootstrap — failure ladder', () => { }); }); +describe('CatalogBootstrap — draft-21 fill', () => { + // draft-21 §3.4: a fill range that starts after Largest Object opens no fill + // stream. With no Largest Object the track is empty: wait for the first + // live head, exactly like a Joining FETCH answered with INVALID_RANGE. + it('SUBSCRIBE_OK without a Largest Object resolves the fill attempt as an empty track', () => { + vi.useFakeTimers(); + const h = makeHarness({ draft: 21 }); + h.coord.start(); + h.coord.onSubscribeOk(null); + expect(h.coord.phase).toBe('empty-wait'); + vi.advanceTimersByTime(60_000); + expect(h.calls.legacyResubscribes).toBe(0); + expect(h.calls.standaloneFetches).toHaveLength(0); + h.coord.onLiveCatalogObject({ location: { group: 0n, object: 0n }, kind: 'payload', payload: msf01Indep(['video']) }, 110n); + expect(h.calls.ready).toEqual([['video']]); + }); + + it('the same holds when SUBSCRIBE_OK arrives before the coordinator starts', () => { + const h = makeHarness({ draft: 21 }); + h.coord.onSubscribeOk(null); + h.coord.start(); + expect(h.coord.phase).toBe('empty-wait'); + }); + + it('with a Largest Object the fill is awaited', () => { + const h = makeHarness({ draft: 21 }); + h.coord.start(); + h.coord.onSubscribeOk({ group: 5n, object: 1n }); + expect(h.coord.phase).toBe('fetching'); + }); + + it('draft 18 keeps waiting for the Joining FETCH response', () => { + const h = makeHarness({ draft: 18 }); + h.coord.start(); + h.coord.onSubscribeOk(null); + expect(h.coord.phase).toBe('fetching'); + }); +}); + describe('CatalogBootstrap — PUBLISH_DONE reasons', () => { it('#21: TRACK_ENDED post-base is not a failure; drained finalizes with no error', () => { const h = makeHarness(); diff --git a/packages/player/src/catalog-bootstrap.ts b/packages/player/src/catalog-bootstrap.ts index b1561c07..18391a23 100644 --- a/packages/player/src/catalog-bootstrap.ts +++ b/packages/player/src/catalog-bootstrap.ts @@ -177,7 +177,7 @@ interface Attempt { export class CatalogBootstrap { private readonly cb: CatalogBootstrapCallbacks; - private readonly draft: 14 | 16 | 18; + private readonly draft: 14 | 16 | 18 | 21; private _phase: BootstrapPhase = 'idle'; private attemptSeq = 0; @@ -223,7 +223,7 @@ export class CatalogBootstrap { * degradation. */ private readonly strict: boolean; - constructor(callbacks: CatalogBootstrapCallbacks, options: { draft: 14 | 16 | 18; startMode?: 'joining' | 'legacy'; strict?: boolean }) { + constructor(callbacks: CatalogBootstrapCallbacks, options: { draft: 14 | 16 | 18 | 21; startMode?: 'joining' | 'legacy'; strict?: boolean }) { this.cb = callbacks; this.draft = options.draft; this.startMode = options.startMode ?? 'joining'; @@ -280,6 +280,22 @@ export class CatalogBootstrap { if (this.draft === 14 && this._phase === 'joining' && !this.attempt) { this.beginAttempt('joining'); } + this.resolveEmptyFill(); + } + + /** + * draft-21 §3.4: the fill range never extends beyond Largest Object, and a + * range starting after it opens no fill stream. A SUBSCRIBE_OK without a + * Largest Object therefore means no fill will come: resolve the attempt as + * an empty track, as INVALID_RANGE does for a Joining FETCH. + */ + private resolveEmptyFill(): void { + const attempt = this.attempt; + // `largest` is undefined until SUBSCRIBE_OK; null means it carried none. + if (this.draft !== 21 || this.largest !== null) return; + if (!attempt || attempt.kind !== 'joining' || attempt.cancelled) return; + this.cb.log('[catalog-bootstrap] SUBSCRIBE_OK has no Largest Object: the track is empty, no fill will open'); + this.onFetchError(attempt.id, 'invalid-range'); } // ─── FETCH side (attempt-token-guarded) ─────────────────────────── @@ -530,6 +546,7 @@ export class CatalogBootstrap { this._phase = 'fetching'; if (kind === 'joining') this.cb.issueJoiningFetch(this.attempt.id); this.armInactivity(); + if (kind === 'joining') this.resolveEmptyFill(); } /** First-payload / prefix ingestion with the profile-specific base rules. */ diff --git a/packages/player/src/player-d21-bootstrap.loopback.test.ts b/packages/player/src/player-d21-bootstrap.loopback.test.ts new file mode 100644 index 00000000..36ebf75b --- /dev/null +++ b/packages/player/src/player-d21-bootstrap.loopback.test.ts @@ -0,0 +1,187 @@ +/** + * Catalog bootstrap over the in-memory draft-21 loopback: a REAL MoqtPlayer + * drives a REAL MoqtConnection(21) client against a REAL server connection + * acting as a minimal catalog publisher. Draft 21 has no Joining FETCH: the + * catalog SUBSCRIBE carries FILL_PARAMETERS for the current group and the + * publisher answers on a fill fetch stream (no FETCH_OK; the FIN completes it). + * + * @see draft-ietf-moq-msf-01 §5, draft-ietf-moq-transport-21 §3.4 + */ + +import { describe, it, expect } from 'vitest'; +import { connectedPair } from '../../webtransport/src/testkit/pair.js'; +import { MoqtPlayer } from './player.js'; +import type { MoqtConnection } from '@openmoq/webtransport'; +import { varint } from '@openmoq/transport'; + +const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o)); +const td = new TextDecoder(); +const flush = () => new Promise((r) => setTimeout(r, 0)); +const settle = async (n = 8) => { for (let i = 0; i < n; i++) await flush(); }; +const FILL_PARAMETERS = 0x23n; + +const CATALOG = { + version: 'draft-01', + tracks: [ + { name: 'video', packaging: 'loc', renderGroup: 1, isLive: true, role: 'video', + codec: 'av01.0.08M.10', width: 1920, height: 1080, bitrate: 1_500_000 }, + ], +}; +const DELTA = { + deltaUpdate: [{ op: 'add', tracks: [{ name: 'audio', packaging: 'loc', renderGroup: 1, isLive: true, role: 'audio', codec: 'opus', samplerate: 48000, channelConfig: '2', bitrate: 32_000 }] }], +}; + +interface ServerState { + catalogAlias: bigint; + catalogFill: Uint8Array | null; + fetches: number; + mediaSubs: Array<{ reqId: bigint; name: string; fill: boolean }>; +} + +function wireServer(server: MoqtConnection, opts?: { resetFill?: boolean; empty?: boolean }): ServerState { + const state: ServerState = { catalogAlias: 40n, catalogFill: null, fetches: 0, mediaSubs: [] }; + let nextAlias = 41n; + server.onFetch = () => { state.fetches += 1; }; + server.onSubscribe = (requestId, _ns, trackName, params) => { + const name = td.decode(trackName); + const fill = (params as Map | undefined)?.get(FILL_PARAMETERS)?.[0] as Uint8Array | undefined; + void (async () => { + if (name !== 'catalog') { + state.mediaSubs.push({ reqId: requestId, name, fill: fill !== undefined }); + await server.acceptSubscribe(requestId, nextAlias++); + return; + } + state.catalogFill = fill ?? null; + if (opts?.empty) { + // No content yet: no LARGEST_OBJECT, so no fill stream (§3.4). + await server.acceptSubscribe(requestId, state.catalogAlias); + return; + } + await server.acceptSubscribe(requestId, state.catalogAlias, { + parameters: new Map([[0x09n, [{ group: 5n, object: 1n }]]]) as never, + }); + if (fill === undefined) return; + const sid = await server.openFillStream(requestId); + await server.sendFetchObject(sid, { groupId: 5n, subgroupId: 0n, objectId: 0n, publisherPriority: 5, payload: enc(CATALOG) }); + if (opts?.resetFill) { + // No public reset API for served fetch streams: abort the writer. + const streams = (server as unknown as { fetchOutgoingStreams: Map }) + .fetchOutgoingStreams; + await streams.get(sid)!.writer.abort(new Error('fill failed')); + return; + } + await server.sendFetchObject(sid, { groupId: 5n, subgroupId: 0n, objectId: 1n, publisherPriority: 5, payload: enc(DELTA) }); + await server.closeFetchStream(sid); + })(); + }; + return state; +} + +function newPlayer(client: unknown): MoqtPlayer { + return new MoqtPlayer({ + url: 'https://unused.example/moq', + namespace: 'live/broadcast', + connection: client as MoqtConnection, + createTransport: async () => ({}) as never, + draftVersion: 21, + }); +} + +describe('d21 loopback — catalog bootstrap from a SUBSCRIBE fill', () => { + it('the fill supplies head + delta, no FETCH is sent, and the live tail applies after readiness', async () => { + const { client, server, errors } = await connectedPair(21); + const state = wireServer(server as unknown as MoqtConnection); + const player = newPlayer(client); + const events: string[] = []; + const received: string[][] = []; + player.on('catalog_received', (e) => { events.push('received'); received.push(e.catalog.tracks.map((t) => t.name)); }); + player.on('catalog_updated', (e) => { events.push('updated'); received.push(e.catalog.tracks.map((t) => t.name)); }); + const playerErrors: unknown[] = []; + player.on('error', (e) => playerErrors.push(e.error)); + + await player.load(); + await settle(); + + // FILL_PARAMETERS = LOCATION_FILTER [1]: the current group. + expect(state.catalogFill && Array.from(state.catalogFill)).toEqual([0x21, 0x01, 0x01]); + expect(state.fetches).toBe(0); + expect(events).toEqual(['received']); + expect(received[0]).toEqual(['video', 'audio']); + + const gid = await (server as unknown as MoqtConnection).openSubgroup( + varint(state.catalogAlias), varint(5n), varint(0n), { endOfGroup: false, publisherPriority: 128 }); + await (server as unknown as MoqtConnection).sendObject(gid, varint(2n), enc({ + deltaUpdate: [{ op: 'add', tracks: [{ name: 'captions', packaging: 'loc', renderGroup: 1, isLive: true, role: 'caption', codec: 'wvtt' }] }], + })); + await (server as unknown as MoqtConnection).closeSubgroup(gid); + await settle(); + + expect(events).toEqual(['received', 'updated']); + expect(received[1]).toEqual(['video', 'audio', 'captions']); + // Media subscriptions ask for no fill unless warm start is on. + expect(state.mediaSubs.map((m) => [m.name, m.fill])).toContainEqual(['video', false]); + expect(playerErrors).toEqual([]); + expect(errors).toEqual([]); + + // PUBLISH_DONE statuses on draft 21: SUBSCRIPTION_ENDED (0x3) is gone. + const done = player as unknown as { normalizePublishDoneStatus(code: bigint): string }; + expect(done.normalizePublishDoneStatus(0x2n)).toBe('ended'); + expect(done.normalizePublishDoneStatus(0x3n)).toBe('retriable'); + expect(done.normalizePublishDoneStatus(0x12n)).toBe('fatal-track'); + await player.destroy(); + }); + + it('an empty track opens no fill: the first live catalog object completes the bootstrap', async () => { + const { client, server, errors } = await connectedPair(21); + const state = wireServer(server as unknown as MoqtConnection, { empty: true }); + let catalogSubs = 0; + const orig = server.onSubscribe!; + server.onSubscribe = (requestId, ns, trackName, params) => { + if (td.decode(trackName) === 'catalog') catalogSubs += 1; + orig(requestId, ns, trackName, params); + }; + const player = newPlayer(client); + const received: string[][] = []; + player.on('catalog_received', (e) => received.push(e.catalog.tracks.map((t) => t.name))); + + await player.load(); + await settle(); + expect(received).toEqual([]); + + const gid = await (server as unknown as MoqtConnection).openSubgroup( + varint(state.catalogAlias), varint(0n), varint(0n), { endOfGroup: false, publisherPriority: 128 }); + await (server as unknown as MoqtConnection).sendObject(gid, varint(0n), enc(CATALOG)); + await (server as unknown as MoqtConnection).closeSubgroup(gid); + await settle(); + + expect(received).toEqual([['video']]); + expect(catalogSubs).toBe(1); + expect(state.fetches).toBe(0); + expect(errors).toEqual([]); + await player.destroy(); + }); + + it('a reset fill fails the attempt and the fallback ladder recovers', async () => { + const { client, server, errors } = await connectedPair(21); + const state = wireServer(server as unknown as MoqtConnection, { resetFill: true }); + let catalogSubs = 0; + const orig = server.onSubscribe!; + server.onSubscribe = (requestId, ns, trackName, params) => { + if (td.decode(trackName) === 'catalog') catalogSubs += 1; + orig(requestId, ns, trackName, params); + }; + const player = newPlayer(client); + const received: string[][] = []; + player.on('catalog_received', (e) => received.push(e.catalog.tracks.map((t) => t.name))); + + await player.load(); + await settle(24); + + // The failed prefix is recovered by the fallback ladder; whichever rung + // served it, the session survives and the catalog is never half-applied. + expect(catalogSubs + state.fetches).toBeGreaterThan(1); + expect(received.every((r) => r.includes('video'))).toBe(true); + expect(errors).toEqual([]); + await player.destroy(); + }); +}); diff --git a/packages/player/src/player.ts b/packages/player/src/player.ts index 5b0e2f7c..4d77e285 100644 --- a/packages/player/src/player.ts +++ b/packages/player/src/player.ts @@ -93,12 +93,19 @@ import { getTimelineDuration, type TimelineState, } from './timeline-manager.js'; +import { isRequestStreamDraft } from '@openmoq/transport'; // ─── Constants ────────────────────────────────────────────────────── /** Max objects to stage during make-before-break switch before force-completing. */ const SWITCH_STAGING_MAX_OBJECTS = 100; +/** draft-21 FILL_PARAMETERS for joining the current group: a LOCATION_FILTER + * of [1] (the current group: Largest.Group + 1 - 1), the + * counterpart of a relative Joining FETCH at offset 0. */ +const CURRENT_GROUP_FILL = { filter: { type: 'RelativeStart' as const, groups: 1n } }; +const CATALOG_FILL = CURRENT_GROUP_FILL; + /** Max time (ms) to wait for a keyframe during make-before-break switch. */ const SWITCH_STAGING_TIMEOUT_MS = 3_000; @@ -570,6 +577,9 @@ export class MoqtPlayer { private bootstrapGeneration = 0; /** The coordinator's CURRENT fetch: connection-scoped ownership. */ private bootstrapFetch: { conn: MoqtConnection; reqId: bigint; attempt: number; gen: number } | null = null; + /** draft-21 catalog SUBSCRIBEs whose fill stands in for the Joining FETCH: + * retiring one cancels the fill, never the subscription it rides on. */ + private readonly catalogFillRequests = new Set(); /** Fetch data streams belonging to the bootstrap — CONNECTION-SCOPED: an * old session's stream 0 must never be read as the new session's stream 0 * during migration. */ @@ -3523,6 +3533,7 @@ export class MoqtPlayer { this.fetchStreamAliases.clear(); this.fetchStreamRequestIds.clear(); this.pendingFetchStreams.clear(); + this.catalogFillRequests.clear(); this.refusedFetchRequests.clear(); this.quarantinedFetchRequests.clear(); this.droppedFetchStreams.clear(); @@ -3939,6 +3950,13 @@ export class MoqtPlayer { * Profile is unknowable before the first catalog parse, so this depends * only on configuration. */ + private usesCatalogFill(conn: MoqtConnection): boolean { + // draft 21 has no Joining FETCH: the SUBSCRIBE carries FILL_PARAMETERS + // and the fill fetch stream (FETCH_HEADER with the SUBSCRIBE's Request ID) + // is the MSF-01 §5 prefix. There is no FETCH_OK; a FIN completes it. + return conn.draftVersion === 21; + } + private resolvedCatalogMode(_conn: MoqtConnection): 'joining-fetch' | 'subscribe' { const mode = this.config.catalogBootstrap ?? 'auto'; if (mode === 'subscribe') return 'subscribe'; @@ -4007,6 +4025,7 @@ export class MoqtPlayer { const gen = this.bootstrapGeneration; return { subscriptionFilter: { type: 'LargestObject' as const }, + ...(this.usesCatalogFill(conn) ? { fill: CATALOG_FILL } : {}), onRequestId: (id: bigint) => { if (gen !== this.bootstrapGeneration) return; this.catalogRequestId = id; @@ -4044,6 +4063,17 @@ export class MoqtPlayer { resetManager: () => this.catalogManager?.reset(), currentState: () => this.catalogManager?.currentState ?? null, issueJoiningFetch: (attempt) => { + if (this.usesCatalogFill(conn)) { + void Promise.resolve().then(() => { + if (!live()) return; + const subReqId = this.catalogRequestId; + if (subReqId === null) { coord.onFetchError(attempt, 'refused'); return; } + this.catalogFillRequests.add(subReqId); + coord.onFetchOk(attempt, { group: 0n, object: 0n }, false); + this.registerBootstrapFetch(conn, subReqId, attempt, gen); + }); + return; + } void (async () => { let myFetchReqId: bigint | null = null; try { @@ -4189,7 +4219,7 @@ export class MoqtPlayer { }, log: (msg, ...args) => this.log.debug(msg, ...args), }, { - draft: (conn.draftVersion ?? this.config.draftVersion ?? 16) as 14 | 16 | 18, + draft: (conn.draftVersion ?? this.config.draftVersion ?? 16) as 14 | 16 | 18 | 21, strict: this.strictCatalogMode(), }); @@ -4222,6 +4252,20 @@ export class MoqtPlayer { } } + /** A recovery candidate's draft-21 fill stream can arrive before the + * coordinator asks for it (the fill rides on the SUBSCRIBE): claim it. */ + private claimParkedRecoveryFill( + recovery: NonNullable, reqId: bigint, attempt: number, + ): void { + for (const [streamId, parked] of [...this.pendingFetchStreams]) { + if (parked.requestId !== reqId) continue; + this.pendingFetchStreams.delete(streamId); + recovery.fetchStreams.set(streamId, attempt); + for (const obj of parked.objects) recovery.coord.onFetchObject(attempt, this.toCatalogEvent(obj)); + if (parked.terminal !== undefined) recovery.coord.onFetchStreamClosed(attempt, parked.terminal === 'fin'); + } + } + /** Emit the raw-catalog diagnostic exactly as the legacy path does. */ private emitCatalogRaw(obj: MoqtObject): void { if (obj.kind !== 'data') return; @@ -4336,7 +4380,11 @@ export class MoqtPlayer { * supersession, rung transitions, and candidate teardown. */ private retireBootstrapFetch(conn: MoqtConnection, reqId: bigint, opts?: { attempt?: number; extraStreams?: Iterable }): void { - void conn.fetchCancel(varint(reqId)).catch(() => { /* gone */ }); + if (this.catalogFillRequests.delete(reqId)) { + void conn.cancelFill(reqId).catch(() => { /* gone */ }); + } else { + void conn.fetchCancel(varint(reqId)).catch(() => { /* gone */ }); + } this.quarantineFetchRequest(reqId); for (const [sid, entry] of this.bootstrapFetchStreams) { // Attempt-scoped: retiring attempt A must never sweep streams that a @@ -4396,6 +4444,22 @@ export class MoqtPlayer { resetManager: () => manager.reset(), currentState: () => manager.currentState, issueJoiningFetch: (attempt) => { + if (this.usesCatalogFill(conn)) { + void Promise.resolve().then(() => { + if (!live()) return; + const subReqId = ownedAsCandidate() ? this.catalogRecovery!.reqId : this.catalogRequestId; + if (subReqId == null) { coord.onFetchError(attempt, 'refused'); return; } + this.catalogFillRequests.add(subReqId); + coord.onFetchOk(attempt, { group: 0n, object: 0n }, false); + if (ownedAsCandidate()) { + this.catalogRecovery!.fetch = { reqId: subReqId, attempt }; + this.claimParkedRecoveryFill(this.catalogRecovery!, subReqId, attempt); + } else if (ownedAsMain()) { + this.registerBootstrapFetch(conn, subReqId, attempt, this.bootstrapGeneration); + } + }); + return; + } void (async () => { let myFetchReqId: bigint | null = null; try { @@ -4562,7 +4626,7 @@ export class MoqtPlayer { }, log: (msg, ...args) => this.log.debug(msg, ...args), }, { - draft: (conn.draftVersion ?? this.config.draftVersion ?? 16) as 14 | 16 | 18, + draft: (conn.draftVersion ?? this.config.draftVersion ?? 16) as 14 | 16 | 18 | 21, // Legacy catalog mode (the explicit 'subscribe' compatibility option): // the candidate is a fresh AbsoluteStart subscribe — subscription-only // retrieval, ready on the first acceptable base; NEVER a Joining FETCH. @@ -4594,6 +4658,7 @@ export class MoqtPlayer { : { type: 'LargestObject' as const }; const reqId = await conn.subscribe(nsBytes, nameBytes, { subscriptionFilter: candidateFilter, + ...(candidateFilter.type === 'LargestObject' && this.usesCatalogFill(conn) ? { fill: CATALOG_FILL } : {}), onRequestId: (id: bigint) => { const r = this.catalogRecovery; if (!live() || !r) return; @@ -4924,13 +4989,14 @@ export class MoqtPlayer { // NEGOTIATED draft: the status tables differ per draft, and the session's // actual draft can diverge from configuration under auto-negotiation. const draft = this.connection?.draftVersion ?? this.config.draftVersion ?? 16; - if (code === 0x2n || code === 0x3n) return 'ended'; // TRACK_ENDED / SUBSCRIPTION_ENDED + if (code === 0x2n) return 'ended'; // TRACK_ENDED + if (code === 0x3n && draft !== 21) return 'ended'; // SUBSCRIPTION_ENDED (gone in draft 21) if (code === 0x4n) return 'going-away'; // GOING_AWAY if (code === 0x1n) return 'fatal-track'; // UNAUTHORIZED if (draft === 14 && code === 0x7n) return 'fatal-track'; // d14 MALFORMED_TRACK - if (draft !== 14 && code === 0x12n) return 'fatal-track'; // d16/18 MALFORMED_TRACK + if (draft !== 14 && code === 0x12n) return 'fatal-track'; // d16+ MALFORMED_TRACK // INTERNAL_ERROR(0x0), EXPIRED/TOO_FAR_BEHIND (0x5/0x6, either draft - // orientation), UPDATE_FAILED(0x8), d18 EXCESSIVE_LOAD(0x9), unknown. + // orientation), UPDATE_FAILED(0x8), d18/21 EXCESSIVE_LOAD(0x9), unknown. if (code !== 0x0n && code !== 0x5n && code !== 0x6n && code !== 0x8n && code !== 0x9n) { this.log.warn('[catalog-bootstrap] unknown PUBLISH_DONE status 0x%s — treated as retriable', code.toString(16)); } @@ -5230,6 +5296,7 @@ export class MoqtPlayer { this.fetchStreamAliases.clear(); this.fetchStreamRequestIds.clear(); this.pendingFetchStreams.clear(); + this.catalogFillRequests.clear(); this.refusedFetchRequests.clear(); this.quarantinedFetchRequests.clear(); this.droppedFetchStreams.clear(); @@ -6286,7 +6353,7 @@ export class MoqtPlayer { // uses 0x5 (§15.10.3), draft-14/16 use 0x6 (§13.4.3). On draft-18, 0x6 is // EXPIRED, so comparing against the wrong table would both miss real // TOO_FAR_BEHIND and mis-fire recovery on EXPIRED. - const tooFarBehind = this.connection?.draftVersion === 18 + const tooFarBehind = isRequestStreamDraft(this.connection?.draftVersion) ? PublishDoneCode18.TOO_FAR_BEHIND : PublishDoneCode.TOO_FAR_BEHIND; if (statusCode === BigInt(tooFarBehind)) { @@ -7503,8 +7570,14 @@ export class MoqtPlayer { } // Warm start overrides ONLY the filter — configured subscribe options // (deliveryTimeout, subscriberPriority, groupOrder) are preserved. + // draft 21: the SUBSCRIBE itself asks for the current group as a fill. + const warmFill = warmStart && this.connection.draftVersion === 21; const mediaOptions = warmStart - ? { ...(subscribeOptions ?? {}), subscriptionFilter: { type: 'LargestObject' as const } } + ? { + ...(subscribeOptions ?? {}), + subscriptionFilter: { type: 'LargestObject' as const }, + ...(warmFill ? { fill: CURRENT_GROUP_FILL } : {}), + } : (subscribeOptions ?? defaultMediaSubscriptionFilter(track?.isLive === true)); // Pre-send ownership (§9.10): register inside onRequestId — a // zero-latency SUBSCRIBE_OK must find the pending entry already in place. Adapters @@ -7539,7 +7612,14 @@ export class MoqtPlayer { // Re-check after await — destroy() may have been called if (!this.subscriptionManager) return; - if (warmStart) { + if (warmFill) { + // The fill stream carries the SUBSCRIBE's Request ID: route it under + // that ID like a warm-start joining FETCH (parked until the alias binds). + this.registerMediaFetch(reqIdBigInt, { + trackName: name, mediaType, subscriptionRequestId: reqIdBigInt, trackAlias: null, warmStart: true, + }); + this.log.info('[warm-start] fill requested on subscribe %s for %s "%s"', reqIdBigInt, mediaType, name); + } else if (warmStart) { // §9.16.2 / §10.12.2: a Joining Fetch may reference a PENDING // subscription, so this is issued immediately after SUBSCRIBE without // awaiting SUBSCRIBE_OK. Registering the fetch under the LIVE track's diff --git a/packages/quic/src/connect.test.ts b/packages/quic/src/connect.test.ts index afb99bb7..fd35d9ed 100644 --- a/packages/quic/src/connect.test.ts +++ b/packages/quic/src/connect.test.ts @@ -299,6 +299,21 @@ describe('connectQuicWithRuntime', () => { expect(await transport.datagrams.readable.getReader().read()).toEqual({ done: false, value: new Uint8Array([4, 5]) }); }); + it('offers moqt-21 when asked for draft 21 and reports it as the protocol', async () => { + const session = new FakeSession({ protocol: 'moqt-21' }); + const fake = fakeRuntime(session); + const transport = await connectQuicWithRuntime('moqt://relay.example/moq', { draft: 21 }, fake.runtime); + expect(fake.options()).toMatchObject({ alpn: 'moqt-21' }); + expect(transport.protocol).toBe('moqt-21'); + }); + + it('refuses a handshake that negotiated a different draft than the one offered', async () => { + const session = new FakeSession({ protocol: 'moqt-18' }); + const fake = fakeRuntime(session); + await expect(connectQuicWithRuntime('moqt://relay.example/moq', { draft: 21 }, fake.runtime)) + .rejects.toThrow(/negotiated ALPN "moqt-18"; expected "moqt-21"/); + }); + it('fails startup even when rejecting an invalid incoming stream throws', async () => { const session = new FakeSession(); const fake = fakeRuntime(session); diff --git a/packages/quic/src/connect.ts b/packages/quic/src/connect.ts index 7261d7fc..e32b012d 100644 --- a/packages/quic/src/connect.ts +++ b/packages/quic/src/connect.ts @@ -5,11 +5,11 @@ import { BoundedReadableQueue, NodeQuicTransport, routeNativeStream, + type MoqtQuicProtocol, type MoqtQuicTransport, } from './transport.js'; import type { WebTransportBidirectionalStream } from '@openmoq/webtransport'; -const ALPN = 'moqt-18'; const DEFAULT_PORT = '443'; const DEFAULT_MAX_DATAGRAM_FRAME_SIZE = 1200; const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000; @@ -24,6 +24,8 @@ export interface QuicConnectOptions { readonly handshakeTimeoutMs?: number; /** Maximum QUIC DATAGRAM frame size advertised to the peer. */ readonly maxDatagramFrameSize?: number; + /** MOQT draft to offer: ALPN `moqt-18` (default) or `moqt-21`. */ + readonly draft?: 18 | 21; } export interface ParsedMoqtUri { @@ -96,7 +98,8 @@ export function parseMoqtUri(input: string | URL): ParsedMoqtUri { * Connect to a native-QUIC MoQT endpoint using Node's experimental QUIC API. * * Requires a Node build configured with `--experimental-quic`, launched with - * `--experimental-quic`. The returned transport is draft-18 only. + * `--experimental-quic`. The returned transport speaks the one draft offered in + * `options.draft` (18 by default, or 21). */ export async function connectQuic( uri: string | URL, @@ -188,6 +191,9 @@ export async function connectQuicWithRuntime( runtime: NativeQuicRuntime, ): Promise { const parsed = parseMoqtUri(uri); + const draft = options.draft ?? 18; + if (draft !== 18 && draft !== 21) throw new RangeError(`native QUIC supports drafts 18 and 21, not ${String(draft)}`); + const ALPN: MoqtQuicProtocol = `moqt-${draft}`; const maxDatagramFrameSize = options.maxDatagramFrameSize ?? DEFAULT_MAX_DATAGRAM_FRAME_SIZE; const handshakeTimeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS; positiveInteger(maxDatagramFrameSize, 'maxDatagramFrameSize'); @@ -296,6 +302,7 @@ export async function connectQuicWithRuntime( bidirectional, datagrams, () => observedSessionError, + ALPN, ); } catch (error) { const reported = observedSessionError ?? closedFailure ?? error; diff --git a/packages/quic/src/index.ts b/packages/quic/src/index.ts index 44d6055c..06ed14a1 100644 --- a/packages/quic/src/index.ts +++ b/packages/quic/src/index.ts @@ -2,12 +2,13 @@ * Experimental native QUIC binding for Node.js. * * This package requires Node's experimental `node:quic` module and currently - * supports MOQT draft 18 only. Browser applications should continue to use - * `@openmoq/webtransport` with a WebTransport implementation. + * supports MOQT drafts 18 and 21 (one per connection, chosen with `draft`). + * Browser applications should continue to use `@openmoq/webtransport` with a + * WebTransport implementation. * * @module */ export { connectQuic, parseMoqtUri } from './connect.js'; export type { ParsedMoqtUri, QuicConnectOptions } from './connect.js'; -export type { MoqtQuicTransport } from './transport.js'; +export type { MoqtQuicProtocol, MoqtQuicTransport } from './transport.js'; diff --git a/packages/quic/src/transport.ts b/packages/quic/src/transport.ts index 8446e319..fb562fdb 100644 --- a/packages/quic/src/transport.ts +++ b/packages/quic/src/transport.ts @@ -13,6 +13,7 @@ const STREAM_RESET_CODES = new Set([0n, 1n, 2n, 3n, 4n, 5n, 6n, 7n, 9n, 0x12n]); const SESSION_ERROR_CODES = new Set([ 0n, 1n, 2n, 3n, 4n, 5n, 6n, 8n, 9n, 0x10n, 0x11n, 0x12n, 0x13n, 0x14n, 0x15n, 0x16n, 0x17n, 0x18n, 0x19n, 0x1an, + 0x1bn, // draft-21 TOO_MANY_REQUEST_UPDATES ]); interface MoqtSetupRouting { @@ -20,10 +21,13 @@ interface MoqtSetupRouting { readonly path: string; } +/** The MOQT ALPN tokens the native binding can negotiate. */ +export type MoqtQuicProtocol = 'moqt-18' | 'moqt-21'; + /** Native QUIC transport plus the routing fields required in MOQT SETUP. */ export interface MoqtQuicTransport extends WebTransportLike { readonly kind: 'quic'; - readonly protocol: 'moqt-18'; + readonly protocol: MoqtQuicProtocol; readonly maxDatagramSize: number; readonly setupOptions: MoqtSetupRouting; } @@ -100,7 +104,6 @@ export class BoundedReadableQueue { /** Web-Streams facade over one Node QUIC session. */ export class NodeQuicTransport implements MoqtQuicTransport { readonly kind = 'quic' as const; - readonly protocol = 'moqt-18' as const; readonly maxDatagramSize: number; readonly incomingUnidirectionalStreams: ReadableStream>; readonly incomingBidirectionalStreams: ReadableStream; @@ -119,6 +122,7 @@ export class NodeQuicTransport implements MoqtQuicTransport { private readonly incomingBidi: BoundedReadableQueue, private readonly incomingDatagrams: BoundedReadableQueue, sessionError: () => unknown, + readonly protocol: MoqtQuicProtocol = 'moqt-18', ) { this.maxDatagramSize = session.maxDatagramSize; this.incomingUnidirectionalStreams = incomingUni.readable; diff --git a/packages/transport/README.md b/packages/transport/README.md index 075c5a1b..8f152508 100644 --- a/packages/transport/README.md +++ b/packages/transport/README.md @@ -4,7 +4,7 @@ # @openmoq/transport -Sans-I/O protocol core for Media over QUIC Transport (MoQT): wire codec and session state machine for draft-14/16/18, with no I/O. +Sans-I/O protocol core for Media over QUIC Transport (MoQT): wire codec and session state machine for draft-14/16/18/21, with no I/O. Part of [Playa](https://github.com/openmoq/moq-playa) — a TypeScript implementation of Media over QUIC (MoQ). See the [project README](https://github.com/openmoq/moq-playa#readme) for architecture and API documentation. diff --git a/packages/transport/package.json b/packages/transport/package.json index b768eabf..dc29b438 100644 --- a/packages/transport/package.json +++ b/packages/transport/package.json @@ -1,7 +1,7 @@ { "name": "@openmoq/transport", "version": "0.5.9", - "description": "Sans-I/O protocol core for Media over QUIC Transport (MoQT): wire codec and session state machine for draft-14/16/18, with no I/O.", + "description": "Sans-I/O protocol core for Media over QUIC Transport (MoQT): wire codec and session state machine for draft-14/16/18/21, with no I/O.", "license": "Apache-2.0", "author": "OpenMOQ (https://github.com/openmoq)", "contributors": [ diff --git a/packages/transport/src/control/codec.ts b/packages/transport/src/control/codec.ts index d9b45ca2..b8761d17 100644 --- a/packages/transport/src/control/codec.ts +++ b/packages/transport/src/control/codec.ts @@ -87,6 +87,8 @@ export function createControlCodec(version: DraftVersion = 16): ControlCodec { return new Draft14Codec(); case 18: return new Draft18Codec(); + case 21: + return new Draft18Codec(21); default: // Unknown/unwired draft: a programming error, not a silent fallback. throw new Error(`createControlCodec: draft-${version as number} control codec is not yet implemented`); diff --git a/packages/transport/src/control/codes-18.ts b/packages/transport/src/control/codes-18.ts index 956d81e0..d4c1b4c2 100644 --- a/packages/transport/src/control/codes-18.ts +++ b/packages/transport/src/control/codes-18.ts @@ -44,6 +44,8 @@ export const ControlMessageType18 = { FETCH: 0x16, FETCH_OK: 0x18, PUBLISH: 0x1d, + /** draft-21 §9.10 only: a publisher's report of its subscription state. */ + PUBLISH_STATE_NOTIFY: 0x22, // PUBLISH_OK (0x1E) intentionally omitted — see the note above (REQUEST_OK shorthand). SUBSCRIBE_NAMESPACE: 0x50, SUBSCRIBE_TRACKS: 0x51, @@ -63,6 +65,13 @@ export const SetupOption18 = { MOQT_IMPLEMENTATION: 0x07, } as const; +/** draft-21 Setup Options added to the draft-18 set (§9.1.6, §9.1.7). */ +export const SetupOption21 = { + ...SetupOption18, + MAX_FILTER_RANGES: 0x06, + MAX_REQUEST_UPDATES: 0x08, +} as const; + /** * Value-encoding kind for a draft-18 message parameter (§10.2). * `namespace` is a Track Namespace structure (§2.4.1): a vi64 field count @@ -92,6 +101,35 @@ export const MessageParam18: Record = { + OBJECT_DELIVERY_TIMEOUT: { type: 0x02, kind: 'varint' }, + AUTHORIZATION_TOKEN: { type: 0x03, kind: 'bytes' }, + RENDEZVOUS_TIMEOUT: { type: 0x04, kind: 'varint' }, + SUBGROUP_DELIVERY_TIMEOUT: { type: 0x06, kind: 'varint' }, + EXPIRES: { type: 0x08, kind: 'varint' }, + LARGEST_OBJECT: { type: 0x09, kind: 'location' }, + FILL_TIMEOUT: { type: 0x0a, kind: 'varint' }, + FORWARD: { type: 0x10, kind: 'uint8' }, + SUBSCRIBER_PRIORITY: { type: 0x20, kind: 'uint8' }, + LOCATION_FILTER: { type: 0x21, kind: 'bytes' }, + GROUP_ORDER: { type: 0x22, kind: 'uint8' }, + FILL_PARAMETERS: { type: 0x23, kind: 'bytes' }, + SUBGROUP_FILTER: { type: 0x25, kind: 'bytes' }, + OBJECTID_FILTER: { type: 0x26, kind: 'bytes' }, + PRIORITY_FILTER: { type: 0x27, kind: 'bytes' }, + OBJECT_PROPERTY_FILTER: { type: 0x28, kind: 'bytes' }, + TRACK_PROPERTY_FILTER: { type: 0x29, kind: 'bytes' }, + NEW_GROUP_REQUEST: { type: 0x32, kind: 'varint' }, + TRACK_NAMESPACE_PREFIX: { type: 0x34, kind: 'namespace' }, + INCLUDE_PROPERTIES: { type: 0x35, kind: 'uint8' }, +} as const; + /** Property type ranges (§2.5, §12). Properties are carried by control messages * (Track Properties) and data objects (Object Properties); the type-space is shared. */ export const PropertyRange18 = { diff --git a/packages/transport/src/control/decoder.ts b/packages/transport/src/control/decoder.ts index 05c18cba..c662c88d 100644 --- a/packages/transport/src/control/decoder.ts +++ b/packages/transport/src/control/decoder.ts @@ -445,6 +445,7 @@ function decodePayloadFields( // Draft-18-only types — not decodable by the draft-14/16 decoder case 'SUBSCRIBE_TRACKS': case 'PUBLISH_BLOCKED': + case 'PUBLISH_STATE_NOTIFY': throw new ProtocolViolationError(`Draft-18-only message type "${typeName}" cannot be decoded by draft-16 decoder`); } } diff --git a/packages/transport/src/control/draft18-codec.ts b/packages/transport/src/control/draft18-codec.ts index 3ed6df25..932785a8 100644 --- a/packages/transport/src/control/draft18-codec.ts +++ b/packages/transport/src/control/draft18-codec.ts @@ -67,9 +67,12 @@ import { decodeMessageParams18, messageParams18EncodingLength, DEFAULT_MESSAGE_PARAM_REGISTRY, + DRAFT21_MESSAGE_PARAM_REGISTRY, type MessageParams18, + type MessageParamRegistry, type MessageParamValue, } from './message-params-18.js'; +import { encodeLocationFilterFields, decodeLocationFilterFields } from './subscription-filter.js'; import { encodeTrackProperties18, decodeTrackProperties18, @@ -160,6 +163,9 @@ const TEXT_DECODER = new TextDecoder(); /** §10.4: maximum New Session URI length; longer → PROTOCOL_VIOLATION. */ const GOAWAY_MAX_URI_LENGTH = 8192; +/** draft-21 LOCATION_FILTER (§9.20.10), the 0x21 message parameter. */ +const LOCATION_FILTER_TYPE = 0x21n; + /** §1.4.2: maximum Reason Phrase length in bytes; longer → PROTOCOL_VIOLATION. */ const REASON_PHRASE_MAX_LENGTH = 1024; @@ -280,8 +286,8 @@ function isLocation(v: ParameterValue): v is Location { return typeof v === 'object' && v !== null && !(v instanceof Uint8Array) && !Array.isArray(v); } -function toTypedParam(type: bigint, v: ParameterValue): MessageParamValue { - const kind = DEFAULT_MESSAGE_PARAM_REGISTRY.get(type); +function toTypedParam(type: bigint, v: ParameterValue, registry: MessageParamRegistry): MessageParamValue { + const kind = registry.get(type); if (kind === undefined) { throw new ProtocolViolationError(`Unknown draft-18 message parameter 0x${type.toString(16)}`); } @@ -318,7 +324,7 @@ function toTypedParam(type: bigint, v: ParameterValue): MessageParamValue { } } -function paramsToTyped(params: Parameters): MessageParams18 { +function paramsToTyped(params: Parameters, registry: MessageParamRegistry): MessageParams18 { const out = new Map(); for (const [type, values] of params) { if (type === MessageParam.FORWARD) { @@ -328,7 +334,7 @@ function paramsToTyped(params: Parameters): MessageParams18 { throw new ProtocolViolationError('FORWARD must be exactly one value, 0 or 1'); } } - out.set(type, values.map((v) => toTypedParam(type, v))); + out.set(type, values.map((v) => toTypedParam(type, v, registry))); } return out; } @@ -363,7 +369,16 @@ const notImplemented = (what: string): never => { }; export class Draft18Codec implements ControlCodec { - readonly version = 18 as const; + /** + * @param version 18, or 21 for the draft-21 variant of the same stream + * model (its differing messages branch on this). + */ + constructor(readonly version: 18 | 21 = 18) {} + + /** The message-parameter table of this codec's draft. */ + private get paramRegistry(): MessageParamRegistry { + return this.version === 21 ? DRAFT21_MESSAGE_PARAM_REGISTRY : DEFAULT_MESSAGE_PARAM_REGISTRY; + } encode(msg: ControlMessage): Uint8Array { switch (msg.type) { @@ -380,7 +395,7 @@ export class Draft18Codec implements ControlCodec { case 'REQUEST_ERROR': return this.frame(ControlMessageType18.REQUEST_ERROR, this.encodeRequestError(msg)); case 'FETCH': - return this.frame(ControlMessageType18.FETCH, this.encodeFetch(msg)); + return this.frame(ControlMessageType18.FETCH, this.version === 21 ? this.encodeFetch21(msg) : this.encodeFetch(msg)); case 'FETCH_OK': return this.frame(ControlMessageType18.FETCH_OK, this.encodeFetchOk(msg)); case 'TRACK_STATUS': @@ -403,6 +418,11 @@ export class Draft18Codec implements ControlCodec { return this.frame(ControlMessageType18.PUBLISH_BLOCKED, this.encodePublishBlocked(msg)); case 'GOAWAY': return this.frame(ControlMessageType18.GOAWAY, this.encodeGoaway(msg)); + case 'PUBLISH_STATE_NOTIFY': + if (this.version !== 21) { + throw new ProtocolViolationError('Draft18Codec: PUBLISH_STATE_NOTIFY exists only in draft 21'); + } + return this.frame(ControlMessageType18.PUBLISH_STATE_NOTIFY, this.encodeParamsOnly(msg.parameters)); case 'PUBLISH_NAMESPACE_DONE': // draft-18 §3.3.2 removed this message — withdrawal is a request-stream // cancellation. It must never reach the wire. @@ -436,7 +456,7 @@ export class Draft18Codec implements ControlCodec { case ControlMessageType18.REQUEST_ERROR: return { message: this.decodeRequestError(payload), bytesRead: total }; case ControlMessageType18.FETCH: - return { message: this.decodeFetch(payload), bytesRead: total }; + return { message: this.version === 21 ? this.decodeFetch21(payload) : this.decodeFetch(payload), bytesRead: total }; case ControlMessageType18.FETCH_OK: return { message: this.decodeFetchOk(payload), bytesRead: total }; case ControlMessageType18.TRACK_STATUS: @@ -459,6 +479,12 @@ export class Draft18Codec implements ControlCodec { return { message: this.decodePublishBlocked(payload), bytesRead: total }; case ControlMessageType18.GOAWAY: return { message: this.decodeGoaway(payload), bytesRead: total }; + case ControlMessageType18.PUBLISH_STATE_NOTIFY: + if (this.version !== 21) return notImplemented(`decode of type 0x${type.toString(16)}`); + return { + message: { type: 'PUBLISH_STATE_NOTIFY', parameters: this.decodeParamsOnly(payload, 'PUBLISH_STATE_NOTIFY') }, + bytesRead: total, + }; default: return notImplemented(`decode of type 0x${type.toString(16)}`); } @@ -494,17 +520,17 @@ export class Draft18Codec implements ControlCodec { private encodeSubscribe(msg: Subscribe): Uint8Array { // §2.4.1: Track Namespace (0-32 non-empty fields) + Full Track Name ≤ 4096 bytes. validateFullTrackName(msg.trackNamespace, msg.trackName, { allowEmptyNamespace: true }); - const typed = paramsToTyped(msg.parameters); + const typed = paramsToTyped(msg.parameters, this.paramRegistry); const len = vi64EncodingLength(msg.requestId) + vi64TupleLength(msg.trackNamespace) + vi64BytesLength(msg.trackName) + - messageParams18EncodingLength(typed); + messageParams18EncodingLength(typed, this.paramRegistry); const buf = new Uint8Array(len); let p = writeVi64(msg.requestId, buf, 0); p += writeVi64Tuple(msg.trackNamespace, buf, p); p += writeVi64Bytes(msg.trackName, buf, p); - buf.set(encodeMessageParams18(typed), p); + buf.set(encodeMessageParams18(typed, this.paramRegistry), p); return buf; } @@ -513,7 +539,7 @@ export class Draft18Codec implements ControlCodec { const rid = readVi64(payload, p); p += rid.bytesRead; const ns = readVi64Tuple(payload, p); p += ns.bytesRead; const name = readVi64Bytes(payload, p); p += name.bytesRead; - const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY); + const params = decodeMessageParams18(payload, p, this.paramRegistry); p += params.bytesRead; // SUBSCRIBE has no field after Parameters; any trailing bytes are malformed. if (p !== payload.length) { @@ -537,17 +563,17 @@ export class Draft18Codec implements ControlCodec { private encodeTrackStatus(msg: TrackStatus): Uint8Array { // §2.4.1: Track Namespace (0-32 non-empty fields) + Full Track Name ≤ 4096 bytes. validateFullTrackName(msg.trackNamespace, msg.trackName, { allowEmptyNamespace: true }); - const typed = paramsToTyped(msg.parameters); + const typed = paramsToTyped(msg.parameters, this.paramRegistry); const len = vi64EncodingLength(msg.requestId) + vi64TupleLength(msg.trackNamespace) + vi64BytesLength(msg.trackName) + - messageParams18EncodingLength(typed); + messageParams18EncodingLength(typed, this.paramRegistry); const buf = new Uint8Array(len); let p = writeVi64(msg.requestId, buf, 0); p += writeVi64Tuple(msg.trackNamespace, buf, p); p += writeVi64Bytes(msg.trackName, buf, p); - buf.set(encodeMessageParams18(typed), p); + buf.set(encodeMessageParams18(typed, this.paramRegistry), p); return buf; } @@ -556,7 +582,7 @@ export class Draft18Codec implements ControlCodec { const rid = readVi64(payload, p); p += rid.bytesRead; const ns = readVi64Tuple(payload, p); p += ns.bytesRead; const name = readVi64Bytes(payload, p); p += name.bytesRead; - const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY); + const params = decodeMessageParams18(payload, p, this.paramRegistry); p += params.bytesRead; if (p !== payload.length) { throw new ProtocolViolationError('TRACK_STATUS: unexpected trailing bytes after parameters'); @@ -581,15 +607,15 @@ export class Draft18Codec implements ControlCodec { // §2.4.1: PUBLISH_NAMESPACE carries a full Track Namespace (no track name): // 0-32 non-empty fields, total ≤ 4096 bytes. validateTrackNamespace(msg.trackNamespace, { allowEmpty: true }); - const typed = paramsToTyped(msg.parameters); + const typed = paramsToTyped(msg.parameters, this.paramRegistry); const len = vi64EncodingLength(msg.requestId) + vi64TupleLength(msg.trackNamespace) + - messageParams18EncodingLength(typed); + messageParams18EncodingLength(typed, this.paramRegistry); const buf = new Uint8Array(len); let p = writeVi64(msg.requestId, buf, 0); p += writeVi64Tuple(msg.trackNamespace, buf, p); - buf.set(encodeMessageParams18(typed), p); + buf.set(encodeMessageParams18(typed, this.paramRegistry), p); return buf; } @@ -597,7 +623,7 @@ export class Draft18Codec implements ControlCodec { let p = 0; const rid = readVi64(payload, p); p += rid.bytesRead; const ns = readVi64Tuple(payload, p); p += ns.bytesRead; - const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY); + const params = decodeMessageParams18(payload, p, this.paramRegistry); p += params.bytesRead; if (p !== payload.length) { throw new ProtocolViolationError('PUBLISH_NAMESPACE: unexpected trailing bytes after parameters'); @@ -620,20 +646,20 @@ export class Draft18Codec implements ControlCodec { private encodePublish(msg: Publish): Uint8Array { // §2.4.1: Track Namespace (0-32 non-empty fields) + Full Track Name ≤ 4096 bytes. validateFullTrackName(msg.trackNamespace, msg.trackName, { allowEmptyNamespace: true }); - const typed = paramsToTyped(msg.parameters); + const typed = paramsToTyped(msg.parameters, this.paramRegistry); const len = vi64EncodingLength(msg.requestId) + vi64TupleLength(msg.trackNamespace) + vi64BytesLength(msg.trackName) + vi64EncodingLength(msg.trackAlias) + - messageParams18EncodingLength(typed) + + messageParams18EncodingLength(typed, this.paramRegistry) + trackProperties18EncodingLength(trackPropsOf(msg)); const buf = new Uint8Array(len); let p = writeVi64(msg.requestId, buf, 0); p += writeVi64Tuple(msg.trackNamespace, buf, p); p += writeVi64Bytes(msg.trackName, buf, p); p += writeVi64(msg.trackAlias, buf, p); - const params = encodeMessageParams18(typed); + const params = encodeMessageParams18(typed, this.paramRegistry); buf.set(params, p); p += params.length; buf.set(encodeTrackProperties18(trackPropsOf(msg)), p); return buf; @@ -645,7 +671,7 @@ export class Draft18Codec implements ControlCodec { const ns = readVi64Tuple(payload, p); p += ns.bytesRead; const name = readVi64Bytes(payload, p); p += name.bytesRead; const alias = readVi64(payload, p); p += alias.bytesRead; - const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY); + const params = decodeMessageParams18(payload, p, this.paramRegistry); p += params.bytesRead; const props = decodeTrackProperties18(payload, p); // remaining bytes (empty → {}) p += props.bytesRead; @@ -709,15 +735,15 @@ export class Draft18Codec implements ControlCodec { prefix: Uint8Array[], parameters: Parameters, ): Uint8Array { - const typed = paramsToTyped(parameters); + const typed = paramsToTyped(parameters, this.paramRegistry); const len = vi64EncodingLength(requestId) + vi64TupleLength(prefix) + - messageParams18EncodingLength(typed); + messageParams18EncodingLength(typed, this.paramRegistry); const buf = new Uint8Array(len); let p = writeVi64(requestId, buf, 0); p += writeVi64Tuple(prefix, buf, p); - buf.set(encodeMessageParams18(typed), p); + buf.set(encodeMessageParams18(typed, this.paramRegistry), p); return buf; } @@ -729,7 +755,7 @@ export class Draft18Codec implements ControlCodec { let p = 0; const rid = readVi64(payload, p); p += rid.bytesRead; const prefix = readVi64Tuple(payload, p); p += prefix.bytesRead; // 0 fields allowed - const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY); + const params = decodeMessageParams18(payload, p, this.paramRegistry); p += params.bytesRead; if (p !== payload.length) { throw new ProtocolViolationError(`${name}: unexpected trailing bytes after parameters`); @@ -781,12 +807,14 @@ export class Draft18Codec implements ControlCodec { throw new ProtocolViolationError(`GOAWAY New Session URI length ${uri.length} exceeds maximum ${GOAWAY_MAX_URI_LENGTH} bytes`); } const timeout = msg.timeout ?? 0n; + // draft-21 §9.2 dropped the Request ID. + const requestId = this.version === 21 ? undefined : msg.requestId; let len = vi64BytesLength(uri) + vi64EncodingLength(timeout); - if (msg.requestId !== undefined) len += vi64EncodingLength(msg.requestId); + if (requestId !== undefined) len += vi64EncodingLength(requestId); const buf = new Uint8Array(len); let p = writeVi64Bytes(uri, buf, 0); p += writeVi64(timeout, buf, p); - if (msg.requestId !== undefined) writeVi64(msg.requestId, buf, p); + if (requestId !== undefined) writeVi64(requestId, buf, p); return buf; } @@ -800,12 +828,14 @@ export class Draft18Codec implements ControlCodec { // Request ID is present only on the control stream (§10.4). The codec is // context-free: read it iff bytes remain, and expose it optionally. let requestId: bigint | undefined; - if (p < payload.length) { + if (p < payload.length && this.version !== 21) { const rid = readVi64(payload, p); p += rid.bytesRead; requestId = rid.value; } if (p !== payload.length) { - throw new ProtocolViolationError(`GOAWAY: ${payload.length - p} trailing bytes after Request ID`); + throw new ProtocolViolationError(this.version === 21 + ? `GOAWAY: ${payload.length - p} trailing bytes after Timeout` + : `GOAWAY: ${payload.length - p} trailing bytes after Request ID`); } const msg: Goaway = { type: 'GOAWAY', @@ -834,18 +864,18 @@ export class Draft18Codec implements ControlCodec { // the bidirectional request stream it is sent on identifies the target. private encodeRequestUpdate(msg: RequestUpdate): Uint8Array { - const typed = paramsToTyped(msg.parameters); - const len = vi64EncodingLength(msg.requestId) + messageParams18EncodingLength(typed); + const typed = paramsToTyped(msg.parameters, this.paramRegistry); + const len = vi64EncodingLength(msg.requestId) + messageParams18EncodingLength(typed, this.paramRegistry); const buf = new Uint8Array(len); let p = writeVi64(msg.requestId, buf, 0); - buf.set(encodeMessageParams18(typed), p); + buf.set(encodeMessageParams18(typed, this.paramRegistry), p); return buf; } private decodeRequestUpdate(payload: Uint8Array): DecodedControlMessage { let p = 0; const rid = readVi64(payload, p); p += rid.bytesRead; - const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY); + const params = decodeMessageParams18(payload, p, this.paramRegistry); p += params.bytesRead; if (p !== payload.length) { throw new ProtocolViolationError('REQUEST_UPDATE: unexpected trailing bytes after parameters'); @@ -857,14 +887,14 @@ export class Draft18Codec implements ControlCodec { // ── SUBSCRIBE_OK (response, §10.8 — no Request ID) ───────────────── private encodeSubscribeOk(msg: SubscribeOk): Uint8Array { - const typed = paramsToTyped(msg.parameters); + const typed = paramsToTyped(msg.parameters, this.paramRegistry); const len = vi64EncodingLength(msg.trackAlias) + - messageParams18EncodingLength(typed) + + messageParams18EncodingLength(typed, this.paramRegistry) + trackProperties18EncodingLength(trackPropsOf(msg)); const buf = new Uint8Array(len); let p = writeVi64(msg.trackAlias, buf, 0); - const params = encodeMessageParams18(typed); + const params = encodeMessageParams18(typed, this.paramRegistry); buf.set(params, p); p += params.length; buf.set(encodeTrackProperties18(trackPropsOf(msg)), p); return buf; @@ -873,7 +903,7 @@ export class Draft18Codec implements ControlCodec { private decodeSubscribeOk(payload: Uint8Array): DecodedControlMessage { let p = 0; const alias = readVi64(payload, p); p += alias.bytesRead; - const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY); + const params = decodeMessageParams18(payload, p, this.paramRegistry); p += params.bytesRead; const props = decodeTrackProperties18(payload, p); // remaining bytes (empty → {}) p += props.bytesRead; @@ -889,9 +919,9 @@ export class Draft18Codec implements ControlCodec { // ── REQUEST_OK (response, §10.5 — no Request ID) ─────────────────── private encodeRequestOk(msg: RequestOk): Uint8Array { - const typed = paramsToTyped(msg.parameters); + const typed = paramsToTyped(msg.parameters, this.paramRegistry); const props = trackPropsOf(msg); - const params = encodeMessageParams18(typed); + const params = encodeMessageParams18(typed, this.paramRegistry); const propBytes = encodeTrackProperties18(props); const buf = new Uint8Array(params.length + propBytes.length); buf.set(params, 0); @@ -900,7 +930,7 @@ export class Draft18Codec implements ControlCodec { } private decodeRequestOk(payload: Uint8Array): DecodedControlMessage { - const params = decodeMessageParams18(payload, 0, DEFAULT_MESSAGE_PARAM_REGISTRY); + const params = decodeMessageParams18(payload, 0, this.paramRegistry); // Track Properties are CONTEXT-dependent on a REQUEST_OK: valid for a // TRACK_STATUS_OK, but not for PUBLISH_NAMESPACE / SUBSCRIBE_NAMESPACE / // SUBSCRIBE_TRACKS responses. The codec is context-free, so it decodes them @@ -989,7 +1019,7 @@ export class Draft18Codec implements ControlCodec { // Standalone body uses vi64 Location fields (full uint64, no QUIC cap). private encodeFetch(msg: Fetch): Uint8Array { - const typed = paramsToTyped(msg.parameters); + const typed = paramsToTyped(msg.parameters, this.paramRegistry); const f = msg.fetch; let bodyLen: number; if (f.fetchType === 0x1) { @@ -1009,7 +1039,7 @@ export class Draft18Codec implements ControlCodec { vi64EncodingLength(msg.requestId) + vi64EncodingLength(BigInt(f.fetchType)) + bodyLen + - messageParams18EncodingLength(typed); + messageParams18EncodingLength(typed, this.paramRegistry); const buf = new Uint8Array(len); let p = writeVi64(msg.requestId, buf, 0); p += writeVi64(BigInt(f.fetchType), buf, p); @@ -1022,7 +1052,7 @@ export class Draft18Codec implements ControlCodec { p += writeVi64(f.joiningRequestId, buf, p); p += writeVi64(f.joiningStart, buf, p); } - buf.set(encodeMessageParams18(typed), p); + buf.set(encodeMessageParams18(typed, this.paramRegistry), p); return buf; } @@ -1057,7 +1087,7 @@ export class Draft18Codec implements ControlCodec { throw new ProtocolViolationError(`FETCH: invalid Fetch Type 0x${ft.value.toString(16)}`); } - const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY); + const params = decodeMessageParams18(payload, p, this.paramRegistry); p += params.bytesRead; if (p !== payload.length) { throw new ProtocolViolationError('FETCH: unexpected trailing bytes after parameters'); @@ -1065,6 +1095,106 @@ export class Draft18Codec implements ControlCodec { return { type: 'FETCH', requestId: rid.value, fetch, parameters: typedToParams(params.params) }; } + // ── draft-21 FETCH (§9.11): Request ID, Full Track Name, Parameters ── + // The range is a LOCATION_FILTER parameter. A StandaloneFetch's draft-18 + // range maps onto it: the draft-18 End Location's Object is exclusive, with 0 + // meaning the whole End Group, while the LOCATION_FILTER's EndObject is + // inclusive and omitted for a whole group. There is no Joining FETCH: a + // subscriber asks for the prefix with FILL_PARAMETERS on its SUBSCRIBE. + + private encodeFetch21(msg: Fetch): Uint8Array { + const f = msg.fetch; + if (f.fetchType !== 0x1) { + throw new ProtocolViolationError('draft-21 has no Joining FETCH; request the prefix with FILL_PARAMETERS'); + } + validateFullTrackName(f.trackNamespace, f.trackName, { allowEmptyNamespace: true }); + const parameters = new Map(msg.parameters); + if (!parameters.has(LOCATION_FILTER_TYPE)) { + const { startLocation: start, endLocation: end } = f; + if (end.group < start.group) { + throw new ProtocolViolationError(`FETCH End Group ${end.group} < Start Group ${start.group}`); + } + const fields = [start.group, start.object, end.group - start.group]; + if (end.object > 0n) fields.push(end.object - 1n); + parameters.set(LOCATION_FILTER_TYPE, [encodeLocationFilterFields(fields)]); + } + const typed = paramsToTyped(parameters, this.paramRegistry); + const len = + vi64EncodingLength(msg.requestId) + + vi64TupleLength(f.trackNamespace) + + vi64BytesLength(f.trackName) + + messageParams18EncodingLength(typed, this.paramRegistry); + const buf = new Uint8Array(len); + let p = writeVi64(msg.requestId, buf, 0); + p += writeVi64Tuple(f.trackNamespace, buf, p); + p += writeVi64Bytes(f.trackName, buf, p); + buf.set(encodeMessageParams18(typed, this.paramRegistry), p); + return buf; + } + + /** + * Decodes a draft-21 FETCH into a StandaloneFetch. An absolute LOCATION_FILTER + * with an end maps back to the draft-18 range; a filter that depends on the + * Largest Object (one field, or two fields with no end) or no filter at all + * gets a start of {0, 0} or its StartGroup/StartObject and an open end + * ({2^64-1, 0}), and the LOCATION_FILTER stays in `parameters` for the + * receiver to resolve. + */ + private decodeFetch21(payload: Uint8Array): DecodedControlMessage { + let p = 0; + const rid = readVi64(payload, p); p += rid.bytesRead; + const ns = readVi64Tuple(payload, p); p += ns.bytesRead; + const name = readVi64Bytes(payload, p); p += name.bytesRead; + validateFullTrackName(ns.value, name.value, { allowEmptyNamespace: true }); + const params = decodeMessageParams18(payload, p, this.paramRegistry); + p += params.bytesRead; + if (p !== payload.length) { + throw new ProtocolViolationError('FETCH: unexpected trailing bytes after parameters'); + } + const parameters = typedToParams(params.params); + const filter = parameters.get(LOCATION_FILTER_TYPE)?.[0]; + let fields: bigint[] = []; + if (filter !== undefined) { + if (!(filter instanceof Uint8Array)) throw new ProtocolViolationError('FETCH: LOCATION_FILTER is not bytes'); + try { + fields = decodeLocationFilterFields(filter); + } catch (e) { + throw new ProtocolViolationError(`FETCH: ${(e as Error).message}`); + } + } + const openEnd = { group: MAX_VI64, object: 0n }; + let startLocation = { group: 0n, object: 0n }; + let endLocation = openEnd; + if (fields.length === 2) { + startLocation = { group: fields[0]!, object: fields[1]! }; + } else if (fields.length >= 3) { + startLocation = { group: fields[0]!, object: fields[1]! }; + const endGroup = fields[0]! + fields[2]!; + endLocation = { group: endGroup, object: fields.length === 4 ? fields[3]! + 1n : 0n }; + } + return { + type: 'FETCH', + requestId: rid.value, + fetch: { fetchType: 0x1, trackNamespace: ns.value, trackName: name.value, startLocation, endLocation }, + parameters, + }; + } + + // ── draft-21 PUBLISH_STATE_NOTIFY (§9.10): Parameters only ────────── + + private encodeParamsOnly(parameters: Parameters): Uint8Array { + const typed = paramsToTyped(parameters, this.paramRegistry); + return encodeMessageParams18(typed, this.paramRegistry); + } + + private decodeParamsOnly(payload: Uint8Array, what: string): Parameters { + const params = decodeMessageParams18(payload, 0, this.paramRegistry); + if (params.bytesRead !== payload.length) { + throw new ProtocolViolationError(`${what}: unexpected trailing bytes after parameters`); + } + return typedToParams(params.params); + } + // ── FETCH_OK (response, §10.13 — no Request ID) ──────────────────── // Sent as the first message on the same bidi stream; the topology stamps the // Request ID from the stream context. Track Properties (§2.5) trail the params. @@ -1073,16 +1203,16 @@ export class Draft18Codec implements ControlCodec { if (msg.endOfTrack !== 0 && msg.endOfTrack !== 1) { throw new ProtocolViolationError(`FETCH_OK End Of Track must be 0 or 1, got ${msg.endOfTrack}`); } - const typed = paramsToTyped(msg.parameters); + const typed = paramsToTyped(msg.parameters, this.paramRegistry); const len = 1 + vi64LocationLength(msg.endLocation) + - messageParams18EncodingLength(typed) + + messageParams18EncodingLength(typed, this.paramRegistry) + trackProperties18EncodingLength(trackPropsOf(msg)); const buf = new Uint8Array(len); let p = 0; buf[p++] = msg.endOfTrack; p += writeVi64Location(msg.endLocation, buf, p); - const params = encodeMessageParams18(typed); + const params = encodeMessageParams18(typed, this.paramRegistry); buf.set(params, p); p += params.length; buf.set(encodeTrackProperties18(trackPropsOf(msg)), p); return buf; @@ -1096,7 +1226,7 @@ export class Draft18Codec implements ControlCodec { throw new ProtocolViolationError(`FETCH_OK End Of Track must be 0 or 1, got ${endOfTrack}`); } const end = readVi64Location(payload, p); p += end.bytesRead; - const params = decodeMessageParams18(payload, p, DEFAULT_MESSAGE_PARAM_REGISTRY); + const params = decodeMessageParams18(payload, p, this.paramRegistry); p += params.bytesRead; const props = decodeTrackProperties18(payload, p); // remaining bytes (empty → {}) p += props.bytesRead; diff --git a/packages/transport/src/control/draft21-codec.test.ts b/packages/transport/src/control/draft21-codec.test.ts new file mode 100644 index 00000000..e4637e94 --- /dev/null +++ b/packages/transport/src/control/draft21-codec.test.ts @@ -0,0 +1,152 @@ +/** + * The draft-21 variant of the draft-18 control codec (`createControlCodec(21)`) + * and the draft-21 LOCATION_FILTER mapping of {@link SubscriptionFilter}. + */ +import { describe, it, expect } from 'vitest'; +import { createControlCodec } from './codec.js'; +import { + decodeLocationFilterFields, decodeSubscriptionFilter, encodeFillParameters, encodeSubscriptionFilter, + subscriptionWindow, validateSubscriptionFilter, windowContains, type SubscriptionFilter, +} from './subscription-filter.js'; +import { bytesToHex } from '../vectors/load-vectors.js'; +import type { ControlMessage, Fetch, Goaway } from './messages.js'; + +const codec18 = createControlCodec(18); +const codec21 = createControlCodec(21); +const enc = (s: string) => new TextEncoder().encode(s); + +describe('draft-21 FETCH', () => { + it('encodes a standalone range as a LOCATION_FILTER (the relay/moqxr vector)', () => { + const fetch: Fetch = { + type: 'FETCH', + requestId: 7n, + fetch: { + fetchType: 0x1, trackNamespace: [enc('live')], trackName: enc('video'), + startLocation: { group: 12n, object: 5n }, endLocation: { group: 15n, object: 10n }, + }, + parameters: new Map(), + }; + expect(bytesToHex(codec21.encode(fetch))).toBe('1600140701046c69766505766964656f0121040c050309'); + }); + + it('an End Object of 0 is the whole End Group: EndObject is omitted', () => { + const fetch: Fetch = { + type: 'FETCH', requestId: 1n, + fetch: { + fetchType: 0x1, trackNamespace: [enc('live')], trackName: enc('video'), + startLocation: { group: 2n, object: 0n }, endLocation: { group: 4n, object: 0n }, + }, + parameters: new Map(), + }; + const decoded = codec21.decode(codec21.encode(fetch), 0).message as Fetch; + expect(decodeLocationFilterFields(decoded.parameters.get(0x21n)![0] as Uint8Array)).toEqual([2n, 0n, 2n]); + expect(decoded.fetch).toMatchObject({ startLocation: { group: 2n, object: 0n }, endLocation: { group: 4n, object: 0n } }); + }); + + it('has no Joining FETCH', () => { + const joining: Fetch = { + type: 'FETCH', requestId: 1n, + fetch: { fetchType: 0x3, joiningRequestId: 0n, joiningStart: 0n }, + parameters: new Map(), + }; + expect(() => codec21.encode(joining)).toThrow(/FILL_PARAMETERS/); + expect(() => codec18.encode(joining)).not.toThrow(); + }); +}); + +describe('draft-21 GOAWAY and PUBLISH_STATE_NOTIFY', () => { + it('GOAWAY drops the Request ID', () => { + const goaway: Goaway = { type: 'GOAWAY', newSessionUri: '', timeout: 5000n, requestId: 3n }; + expect(bytesToHex(codec21.encode(goaway))).toBe('100003009388'); + expect((codec18.decode(codec18.encode(goaway), 0).message as Goaway).requestId).toBe(3n); + }); + + it('PUBLISH_STATE_NOTIFY exists only on draft 21', () => { + const notify = { type: 'PUBLISH_STATE_NOTIFY', parameters: new Map([[0x10n, [1n]]]) } as ControlMessage; + const bytes = codec21.encode(notify); + expect(codec21.decode(bytes, 0).message).toMatchObject({ type: 'PUBLISH_STATE_NOTIFY' }); + expect(() => codec18.encode(notify)).toThrow(); + expect(() => codec18.decode(bytes, 0)).toThrow(); + }); +}); + +describe('draft-21 LOCATION_FILTER mapping', () => { + const fields = (filter: SubscriptionFilter) => decodeLocationFilterFields(encodeSubscriptionFilter(filter, 21)); + + it('maps each filter to its draft-21 form', () => { + expect(fields({ type: 'NextGroupStart' })).toEqual([0n]); + expect(fields({ type: 'LargestObject' })).toEqual([0n, 0n]); + expect(fields({ type: 'RelativeStart', groups: 1n })).toEqual([1n]); + expect(fields({ type: 'AbsoluteStart', startGroup: 12n, startObject: 5n })).toEqual([12n, 5n]); + expect(fields({ type: 'AbsoluteRange', startGroup: 12n, startObject: 5n, endGroup: 15n })).toEqual([12n, 5n, 3n]); + expect(fields({ type: 'AbsoluteRange', startGroup: 12n, startObject: 5n, endGroup: 15n, endObject: 7n })) + .toEqual([12n, 5n, 3n, 7n]); + }); + + it('an open filter from {0, 0} is the zero-length "no filter", not the Next Object', () => { + expect(encodeSubscriptionFilter({ type: 'AbsoluteStart', startGroup: 0n, startObject: 0n }, 21).length).toBe(0); + }); + + it('decodes back to the same filter', () => { + const filters: SubscriptionFilter[] = [ + { type: 'NextGroupStart' }, + { type: 'LargestObject' }, + { type: 'RelativeStart', groups: 3n }, + { type: 'AbsoluteStart', startGroup: 12n, startObject: 5n }, + { type: 'AbsoluteStart', startGroup: 0n, startObject: 0n }, + { type: 'AbsoluteRange', startGroup: 12n, startObject: 5n, endGroup: 15n }, + { type: 'AbsoluteRange', startGroup: 12n, startObject: 5n, endGroup: 15n, endObject: 7n }, + ]; + for (const filter of filters) { + expect(decodeSubscriptionFilter(encodeSubscriptionFilter(filter, 21), 21)).toEqual(filter); + } + }); + + it('rejects malformed filters', () => { + expect(validateSubscriptionFilter(new Uint8Array([1, 2, 3, 4, 5]), 21)).toMatch(/more than four/); + expect(validateSubscriptionFilter(new Uint8Array([0x80]), 21)).toMatch(/truncated/); // vi64: a 2-byte value cut short + }); + + it('draft 21 forms have no draft-18 encoding', () => { + expect(() => encodeSubscriptionFilter({ type: 'RelativeStart', groups: 1n }, 18)).toThrow(RangeError); + expect(() => encodeSubscriptionFilter( + { type: 'AbsoluteRange', startGroup: 1n, startObject: 0n, endGroup: 2n, endObject: 3n }, 18)).toThrow(RangeError); + }); + + it('FILL_PARAMETERS nests a LOCATION_FILTER in a bare parameter sequence', () => { + expect(bytesToHex(encodeFillParameters({ type: 'RelativeStart', groups: 1n }))).toBe('210101'); + expect(encodeFillParameters().length).toBe(0); + }); +}); + +describe('subscription windows (re-applying a filter to a shared Track Alias, §3.1)', () => { + const L = { group: 5n, object: 3n }; + const inWin = (filter: SubscriptionFilter | undefined, largest: typeof L | undefined, g: bigint, o: bigint) => + windowContains(subscriptionWindow(filter, largest), g, o); + + it('resolves the relative forms against Largest Object', () => { + expect(subscriptionWindow({ type: 'NextGroupStart' }, L)).toEqual({ start: { group: 6n, object: 0n } }); + expect(subscriptionWindow({ type: 'LargestObject' }, L)).toEqual({ start: { group: 5n, object: 4n } }); + expect(subscriptionWindow({ type: 'RelativeStart', groups: 1n }, L)).toEqual({ start: { group: 5n, object: 0n } }); + expect(subscriptionWindow({ type: 'RelativeStart', groups: 9n }, L)).toEqual({ start: { group: 0n, object: 0n } }); + }); + + it('with no Largest Object (empty track) every relative start is the beginning', () => { + expect(subscriptionWindow({ type: 'NextGroupStart' }, undefined)).toEqual({ start: { group: 0n, object: 0n } }); + expect(subscriptionWindow({ type: 'LargestObject' }, undefined)).toEqual({ start: { group: 0n, object: 0n } }); + }); + + it('checks start and inclusive end', () => { + expect(inWin(undefined, L, 0n, 0n)).toBe(true); + expect(inWin({ type: 'LargestObject' }, L, 5n, 3n)).toBe(false); + expect(inWin({ type: 'LargestObject' }, L, 5n, 4n)).toBe(true); + const range = { type: 'AbsoluteRange', startGroup: 2n, startObject: 1n, endGroup: 4n } as const; + expect(inWin(range, L, 2n, 0n)).toBe(false); + expect(inWin(range, L, 2n, 1n)).toBe(true); + expect(inWin(range, L, 4n, 999n)).toBe(true); // whole end group + expect(inWin(range, L, 5n, 0n)).toBe(false); + const withEndObject = { ...range, endObject: 7n }; + expect(inWin(withEndObject, L, 4n, 7n)).toBe(true); + expect(inWin(withEndObject, L, 4n, 8n)).toBe(false); + }); +}); diff --git a/packages/transport/src/control/encoder.ts b/packages/transport/src/control/encoder.ts index b01d30c3..9811dfec 100644 --- a/packages/transport/src/control/encoder.ts +++ b/packages/transport/src/control/encoder.ts @@ -199,6 +199,7 @@ function messageTypeCode(type: ControlMessage['type']): Varint { // Draft-18-only types — not valid in the draft-14/16 encoder case 'SUBSCRIBE_TRACKS': case 'PUBLISH_BLOCKED': + case 'PUBLISH_STATE_NOTIFY': throw new Error(`Cannot encode draft-18-only message type "${type}" with draft-16 encoder`); } } @@ -403,6 +404,7 @@ function payloadLength(msg: ControlMessage): number { // Draft-18-only types — unreachable (messageTypeCode throws first) case 'SUBSCRIBE_TRACKS': case 'PUBLISH_BLOCKED': + case 'PUBLISH_STATE_NOTIFY': throw new Error(`Cannot encode draft-18-only message type "${msg.type}" with draft-16 encoder`); } } @@ -557,6 +559,7 @@ function writePayload(msg: ControlMessage, buf: Uint8Array, offset: number): num // Draft-18-only types — unreachable (messageTypeCode throws first) case 'SUBSCRIBE_TRACKS': case 'PUBLISH_BLOCKED': + case 'PUBLISH_STATE_NOTIFY': throw new Error(`Cannot encode draft-18-only message type "${msg.type}" with draft-16 encoder`); } diff --git a/packages/transport/src/control/fill-parameters.ts b/packages/transport/src/control/fill-parameters.ts new file mode 100644 index 00000000..83971b26 --- /dev/null +++ b/packages/transport/src/control/fill-parameters.ts @@ -0,0 +1,45 @@ +import { decodeParameterSequence, DRAFT21_MESSAGE_PARAM_REGISTRY, type MessageParams18 } from './message-params-18.js'; +import { decodeSubscriptionFilter, subscriptionWindow, type SubscriptionFilter, type SubscriptionWindow } from './subscription-filter.js'; +import type { GroupOrder } from '../data/types.js'; +import { ProtocolViolationError } from '../errors.js'; + +/** Per-message overrides; omitted values inherit the subscription (9.20.16). */ +export interface FillOptions { + readonly filter?: SubscriptionFilter; + readonly groupOrder?: GroupOrder; +} + +const allowed = new Set([0x0an, 0x20n, 0x21n, 0x22n, 0x25n, 0x26n, 0x27n, 0x28n]); +const repeatable = new Set([0x25n, 0x26n, 0x27n, 0x28n]); + +export function decodeFillParameters(bytes: Uint8Array): FillOptions & { parameters: MessageParams18 } { + const parameters = decodeParameterSequence(bytes, DRAFT21_MESSAGE_PARAM_REGISTRY); + const result: { filter?: SubscriptionFilter; groupOrder?: GroupOrder; parameters: MessageParams18 } = { parameters }; + for (const [type, values] of parameters) { + if (!allowed.has(type)) throw new ProtocolViolationError(`Parameter 0x${type.toString(16)} is not allowed in FILL_PARAMETERS (9.20.16)`); + if (!repeatable.has(type) && values.length > 1) throw new ProtocolViolationError(`Duplicate fill parameter 0x${type.toString(16)}`); + const value = values[0]!; + if (type === 0x21n && value.kind === 'bytes') result.filter = decodeSubscriptionFilter(value.value, 21); + if (type === 0x22n && value.kind === 'uint8') { + if (value.value !== 1 && value.value !== 2) throw new ProtocolViolationError('Fill GROUP_ORDER must be 1 or 2'); + result.groupOrder = value.value === 2 ? 'descending' : 'ascending'; + } + } + return result; +} + +/** A fill is bounded by the Largest Object in its own response (3.4). */ +export function resolveFillWindow( + filter: SubscriptionFilter | undefined, + largest: { group: bigint; object: bigint } | undefined, +): SubscriptionWindow | null { + if (!largest) return null; + const window = subscriptionWindow(filter, largest); + let end = largest; + if (window.end && (window.end.group < largest.group + || (window.end.group === largest.group && window.end.object !== undefined && window.end.object < largest.object))) { + end = { group: window.end.group, object: window.end.object ?? 0xffffffffffffffffn }; + } + if (window.start.group > end.group || (window.start.group === end.group && window.start.object > end.object)) return null; + return { start: window.start, end }; +} diff --git a/packages/transport/src/control/message-params-18.ts b/packages/transport/src/control/message-params-18.ts index 96f639d9..6d42d275 100644 --- a/packages/transport/src/control/message-params-18.ts +++ b/packages/transport/src/control/message-params-18.ts @@ -21,7 +21,7 @@ import { readVi64, writeVi64, vi64EncodingLength, MAX_VI64 } from '../primitives/vi64.js'; import { readUint8, writeUint8, validateTrackNamespacePrefix } from '../primitives/bytes.js'; import { ProtocolViolationError } from '../errors.js'; -import { MessageParam18, type ParamValueKind } from './codes-18.js'; +import { MessageParam18, MessageParam21, type ParamValueKind } from './codes-18.js'; export type { ParamValueKind }; @@ -104,6 +104,11 @@ export const DEFAULT_MESSAGE_PARAM_REGISTRY: MessageParamRegistry = new Map( Object.values(MessageParam18).map((d) => [BigInt(d.type), d.kind] as const), ); +/** Registry built from the draft-21 message-parameter type table (draft-21 §16.7). */ +export const DRAFT21_MESSAGE_PARAM_REGISTRY: MessageParamRegistry = new Map( + Object.values(MessageParam21).map((d) => [BigInt(d.type), d.kind] as const), +); + // ─── value length / write / read ───────────────────────────────────── function paramValueLength(v: MessageParamValue): number { @@ -281,14 +286,28 @@ export function decodeMessageParams18( buf: Uint8Array, offset: number, registry: MessageParamRegistry, +): { params: MessageParams18; bytesRead: number } { + const count = readVi64(buf, offset); + const decoded = readParameterSequence(buf, offset + count.bytesRead, registry, count.value); + return { params: decoded.params, bytesRead: count.bytesRead + decoded.bytesRead }; +} + +/** Decode a length-bounded parameter sequence without a count (draft-21 fills). */ +export function decodeParameterSequence(buf: Uint8Array, registry: MessageParamRegistry): MessageParams18 { + return readParameterSequence(buf, 0, registry).params; +} + +function readParameterSequence( + buf: Uint8Array, + offset: number, + registry: MessageParamRegistry, + count?: bigint, ): { params: MessageParams18; bytesRead: number } { let p = offset; - const count = readVi64(buf, p); - p += count.bytesRead; const params = new Map(); let prevType = 0n; - for (let i = 0n; i < count.value; i++) { + for (let i = 0n; count === undefined ? p < buf.length : i < count; i++) { const delta = readVi64(buf, p); p += delta.bytesRead; const type = prevType + delta.value; diff --git a/packages/transport/src/control/messages.ts b/packages/transport/src/control/messages.ts index 86a96e2f..d46a460d 100644 --- a/packages/transport/src/control/messages.ts +++ b/packages/transport/src/control/messages.ts @@ -343,6 +343,17 @@ export interface Fetch { readonly parameters: Parameters; } +/** + * draft-21 §9.10 PUBLISH_STATE_NOTIFY: a publisher's report, on a subscription's + * request stream, of LARGEST_OBJECT, FORWARD and the LOCATION_FILTER in effect. + * Parameters only; the topology stamps the Request ID from the stream context. + */ +export interface PublishStateNotify { + readonly type: 'PUBLISH_STATE_NOTIFY'; + readonly requestId?: RequestId; + readonly parameters: Parameters; +} + /** @see draft-ietf-moq-transport-16 §9.17 */ export interface FetchOk { readonly type: 'FETCH_OK'; @@ -561,4 +572,5 @@ export type ControlMessage = | PublishBlocked | UnsubscribeNamespace | PublishNamespaceOk - | PublishNamespaceError; + | PublishNamespaceError + | PublishStateNotify; diff --git a/packages/transport/src/control/subscription-filter.ts b/packages/transport/src/control/subscription-filter.ts index d5c78dfe..f82c9018 100644 --- a/packages/transport/src/control/subscription-filter.ts +++ b/packages/transport/src/control/subscription-filter.ts @@ -21,6 +21,9 @@ import { varint, writeVarint, varintEncodingLength, readVarint } from '../primitives/varint.js'; import { readLocation } from '../primitives/location.js'; import { readVi64, writeVi64, vi64EncodingLength, MAX_VI64 } from '../primitives/vi64.js'; +import { isDraft21, isRequestStreamDraft } from '../versions.js'; +import { encodeMessageParams18, DRAFT21_MESSAGE_PARAM_REGISTRY, type MessageParamValue } from './message-params-18.js'; +import type { GroupOrder } from '../data/types.js'; /** * Subscription filter — controls which objects pass through a subscription. @@ -41,10 +44,25 @@ export type SubscriptionFilter = | { readonly type: 'LatestObject' } /** §5.1.2: Start at an explicit location (open-ended). */ | { readonly type: 'AbsoluteStart'; readonly startGroup: bigint; readonly startObject: bigint } - /** §5.1.2: Explicit start and (absolute) end group. */ - | { readonly type: 'AbsoluteRange'; readonly startGroup: bigint; readonly startObject: bigint; readonly endGroup: bigint }; + /** + * §5.1.2: Explicit start and (absolute) end group. `endObject` (draft 21 only) + * is the inclusive last object of the end group; without it the whole end + * group is included. + */ + | { + readonly type: 'AbsoluteRange'; + readonly startGroup: bigint; + readonly startObject: bigint; + readonly endGroup: bigint; + readonly endObject?: bigint; + } + /** + * draft-21 §9.20.10 only: start `groups` groups back from the Next Group + * (1 = the current group), open-ended. The one-field LOCATION_FILTER. + */ + | { readonly type: 'RelativeStart'; readonly groups: bigint }; -const FILTER_TYPE: Record = { +const FILTER_TYPE: Record, bigint> = { NextGroupStart: 0x1n, LargestObject: 0x2n, LatestObject: 0x2n, // deprecated alias @@ -52,6 +70,131 @@ const FILTER_TYPE: Record = { AbsoluteRange: 0x4n, }; +// ─── draft-21 LOCATION_FILTER (§9.20.10) ───────────────────────────── + +/** A LOCATION_FILTER carries at most StartGroup, StartObject, EndGroupDelta, EndObject. */ +const MAX_LOCATION_FIELDS = 4; + +/** Encode 0-4 LOCATION_FILTER fields: bare vi64s, the count implied by the length. */ +export function encodeLocationFilterFields(fields: readonly bigint[]): Uint8Array { + if (fields.length > MAX_LOCATION_FIELDS) { + throw new RangeError(`LOCATION_FILTER has ${fields.length} fields; at most ${MAX_LOCATION_FIELDS}`); + } + let size = 0; + for (const f of fields) size += vi64EncodingLength(f); + const buf = new Uint8Array(size); + let offset = 0; + for (const f of fields) offset += writeVi64(f, buf, offset); + return buf; +} + +/** + * Decode LOCATION_FILTER fields. The Length decides how many are present; more + * than four, a truncated field, or StartGroup + EndGroupDelta above 2^64-1 is + * malformed. + * @throws {RangeError} on malformed bytes. + */ +export function decodeLocationFilterFields(bytes: Uint8Array): bigint[] { + const reason = validateLocationFilter(bytes); + if (reason !== undefined) throw new RangeError(`decodeLocationFilterFields: ${reason}`); + const fields: bigint[] = []; + let pos = 0; + while (pos < bytes.length) { + const r = readVi64(bytes, pos); + fields.push(r.value); + pos += r.bytesRead; + } + return fields; +} + +function validateLocationFilter(bytes: Uint8Array): string | undefined { + const fields: bigint[] = []; + let pos = 0; + while (pos < bytes.length) { + if (fields.length === MAX_LOCATION_FIELDS) return 'LOCATION_FILTER has more than four fields'; + try { + const r = readVi64(bytes, pos); + fields.push(r.value); + pos += r.bytesRead; + } catch { + return 'LOCATION_FILTER has a truncated field'; + } + } + if (fields.length >= 3 && fields[0]! + fields[2]! > MAX_VI64) { + return `LOCATION_FILTER overflows uint64: StartGroup ${fields[0]} + EndGroupDelta ${fields[2]}`; + } + return undefined; +} + +/** The LOCATION_FILTER fields for a {@link SubscriptionFilter} (draft 21). */ +function locationFilterFields(filter: SubscriptionFilter): bigint[] { + switch (filter.type) { + case 'NextGroupStart': + return [0n]; + case 'LargestObject': + case 'LatestObject': + return [0n, 0n]; // the Next Object + case 'RelativeStart': + if (filter.groups < 1n) throw new RangeError(`RelativeStart groups ${filter.groups} < 1 (0 is NextGroupStart)`); + return [filter.groups]; + case 'AbsoluteStart': + // {0, 0} as two fields would mean the Next Object; an open filter from the + // start of the track is the zero-length "no filter" instead. + return filter.startGroup === 0n && filter.startObject === 0n ? [] : [filter.startGroup, filter.startObject]; + case 'AbsoluteRange': { + if (filter.endGroup < filter.startGroup) { + throw new RangeError(`AbsoluteRange endGroup ${filter.endGroup} < startGroup ${filter.startGroup}`); + } + if (filter.endGroup > MAX_VI64) { + throw new RangeError(`AbsoluteRange endGroup ${filter.endGroup} exceeds 2^64-1`); + } + const fields = [filter.startGroup, filter.startObject, filter.endGroup - filter.startGroup]; + if (filter.endObject !== undefined) fields.push(filter.endObject); + return fields; + } + } +} + +/** The {@link SubscriptionFilter} a draft-21 LOCATION_FILTER expresses. */ +function filterFromLocationFields(fields: readonly bigint[]): SubscriptionFilter { + switch (fields.length) { + case 0: + return { type: 'AbsoluteStart', startGroup: 0n, startObject: 0n }; + case 1: + return fields[0] === 0n ? { type: 'NextGroupStart' } : { type: 'RelativeStart', groups: fields[0]! }; + case 2: + return fields[0] === 0n && fields[1] === 0n + ? { type: 'LargestObject' } + : { type: 'AbsoluteStart', startGroup: fields[0]!, startObject: fields[1]! }; + default: { + const range = { + type: 'AbsoluteRange' as const, + startGroup: fields[0]!, + startObject: fields[1]!, + endGroup: fields[0]! + fields[2]!, + }; + return fields.length === 4 ? { ...range, endObject: fields[3]! } : range; + } + } +} + +/** + * The FILL_PARAMETERS value (draft-21 §9.20.16) for a fill over `filter`: a bare + * parameter sequence (no count, bounded by the Length) holding its + * LOCATION_FILTER. An omitted filter inherits the subscription's filter. + */ +export function encodeFillParameters(filter?: SubscriptionFilter, groupOrder?: GroupOrder): Uint8Array { + const params = new Map(); + if (filter !== undefined) params.set(0x21n, [{ kind: 'bytes', value: encodeLocationFilterFields(locationFilterFields(filter)) }]); + if (groupOrder !== undefined) { + if (groupOrder !== 'ascending' && groupOrder !== 'descending') throw new RangeError('Invalid fill Group Order'); + params.set(0x22n, [{ kind: 'uint8', value: groupOrder === 'descending' ? 2 : 1 }]); + } + const block = encodeMessageParams18(params, DRAFT21_MESSAGE_PARAM_REGISTRY); + // The enclosing FILL_PARAMETERS length replaces the message parameter count. + return block.slice(vi64EncodingLength(BigInt(params.size))); +} + /** * Encode a {@link SubscriptionFilter} into the inner wire bytes of a * SUBSCRIPTION_FILTER parameter (the message-parameter codec adds the outer @@ -63,10 +206,18 @@ const FILTER_TYPE: Record = { * valid but `Start Group + Delta` overflows uint64). */ export function encodeSubscriptionFilter(filter: SubscriptionFilter, draftVersion: number): Uint8Array { + // draft-21 §9.20.10: the 0x21 parameter is a LOCATION_FILTER. + if (isDraft21(draftVersion)) return encodeLocationFilterFields(locationFilterFields(filter)); + if (filter.type === 'RelativeStart') { + throw new RangeError(`RelativeStart has no draft-${draftVersion} SUBSCRIPTION_FILTER form`); + } + if (filter.type === 'AbsoluteRange' && filter.endObject !== undefined) { + throw new RangeError(`AbsoluteRange endObject has no draft-${draftVersion} SUBSCRIPTION_FILTER form`); + } const filterType = FILTER_TYPE[filter.type]; // draft-18 §5.1.2: vi64 internals; AbsoluteRange carries an End Group DELTA. - if (draftVersion === 18) { + if (isRequestStreamDraft(draftVersion)) { let endGroupDelta = 0n; if (filter.type === 'AbsoluteRange') { if (filter.endGroup < filter.startGroup) { @@ -139,8 +290,9 @@ export function decodeSubscriptionFilter(bytes: Uint8Array, draftVersion: number if (reason !== undefined) { throw new RangeError(`decodeSubscriptionFilter: ${reason}`); } + if (isDraft21(draftVersion)) return filterFromLocationFields(decodeLocationFilterFields(bytes)); - const read = draftVersion === 18 + const read = isRequestStreamDraft(draftVersion) ? (pos: number) => readVi64(bytes, pos) : (pos: number) => { const { value, bytesRead } = readVarint(bytes, pos); @@ -165,7 +317,7 @@ export function decodeSubscriptionFilter(bytes: Uint8Array, draftVersion: number // AbsoluteRange (0x4): draft-18 carries an End Group DELTA, 14/16 the absolute value. const e = read(pos); - const endGroup = draftVersion === 18 ? g.value + e.value : e.value; + const endGroup = isRequestStreamDraft(draftVersion) ? g.value + e.value : e.value; return { type: 'AbsoluteRange', startGroup: g.value, startObject: o.value, endGroup }; } @@ -175,7 +327,8 @@ export function decodeSubscriptionFilter(bytes: Uint8Array, draftVersion: number * The caller maps a returned reason to a PROTOCOL_VIOLATION close. */ export function validateSubscriptionFilter(bytes: Uint8Array, draftVersion: number): string | undefined { - return draftVersion === 18 + if (isDraft21(draftVersion)) return validateLocationFilter(bytes); + return isRequestStreamDraft(draftVersion) ? validateSubscriptionFilter18(bytes) : validateSubscriptionFilterLegacy(bytes); } @@ -307,3 +460,57 @@ function validateSubscriptionFilter18(bytes: Uint8Array): string | undefined { return undefined; } + +// ─── Subscription windows (draft-21 §3.1 shared Track Aliases) ──────── + +/** The absolute Locations a subscription's filter selects. `end` is inclusive; without `end.object` the whole end group. */ +export interface SubscriptionWindow { + readonly start: { readonly group: bigint; readonly object: bigint }; + readonly end?: { readonly group: bigint; readonly object?: bigint }; +} + +/** + * Resolve a subscription's Location filter against the Largest Object it was + * established with (SUBSCRIBE_OK's LARGEST_OBJECT; `undefined` for an empty + * track). A subscriber sharing one Track Alias across several subscriptions to + * the same track re-applies each window to attribute an object (draft-21 §3.1). + * No filter selects the whole track. + */ +export function subscriptionWindow( + filter: SubscriptionFilter | undefined, + largest: { readonly group: bigint; readonly object: bigint } | undefined, +): SubscriptionWindow { + const origin = { group: 0n, object: 0n }; + switch (filter?.type) { + case undefined: + return { start: origin }; + case 'NextGroupStart': + return { start: largest ? { group: largest.group < MAX_VI64 ? largest.group + 1n : MAX_VI64, object: 0n } : origin }; + case 'LargestObject': + case 'LatestObject': + return { start: largest ? { group: largest.group, object: largest.object + 1n } : origin }; + case 'RelativeStart': { + if (!largest) return { start: origin }; + const group = largest.group + 1n - filter.groups; + return { start: { group: group < 0n ? 0n : group > MAX_VI64 ? MAX_VI64 : group, object: 0n } }; + } + case 'AbsoluteStart': + return { start: { group: filter.startGroup, object: filter.startObject } }; + case 'AbsoluteRange': + return { + start: { group: filter.startGroup, object: filter.startObject }, + end: filter.endObject === undefined + ? { group: filter.endGroup } + : { group: filter.endGroup, object: filter.endObject }, + }; + } +} + +/** Whether Location {group, object} falls inside `window`. */ +export function windowContains(window: SubscriptionWindow, group: bigint, object: bigint): boolean { + const { start, end } = window; + if (group < start.group || (group === start.group && object < start.object)) return false; + if (end === undefined) return true; + if (group > end.group) return false; + return group < end.group || end.object === undefined || object <= end.object; +} diff --git a/packages/transport/src/data/data-codec-18.ts b/packages/transport/src/data/data-codec-18.ts index abeae3a1..f6e8117b 100644 --- a/packages/transport/src/data/data-codec-18.ts +++ b/packages/transport/src/data/data-codec-18.ts @@ -22,9 +22,9 @@ import { import { classifyStream18, classifyDatagram18 } from './stream-type-18.js'; /** Create the draft-18 {@link DataCodec}. */ -export function createDataCodec18(): DataCodec { +export function createDataCodec18(version: 18 | 21 = 18): DataCodec { return { - version: 18, + version, decodeSubgroupHeader: (buf, offset) => decodeSubgroupHeader18(buf, offset), @@ -38,7 +38,7 @@ export function createDataCodec18(): DataCodec { }, decodeFetchObject18: (buf, offset, prior, isFirstObject, groupOrder) => - decodeFetchObject18(buf, offset, prior, isFirstObject, groupOrder), + decodeFetchObject18(buf, offset, prior, isFirstObject, groupOrder, version), decodeObjectDatagram: (buf, offset) => decodeObjectDatagram18(buf, offset), diff --git a/packages/transport/src/data/data-codec.ts b/packages/transport/src/data/data-codec.ts index e170a723..ae5883ff 100644 --- a/packages/transport/src/data/data-codec.ts +++ b/packages/transport/src/data/data-codec.ts @@ -116,8 +116,8 @@ export interface DataCodec { * @throws {Error} for draft versions without a wired data codec. */ export function createDataCodec(version: DraftVersion = 16): DataCodec { - if (version === 18) { - return createDataCodec18(); + if (version === 18 || version === 21) { + return createDataCodec18(version); } if (!isWiredDraft(version)) { throw new Error(`DataCodec: draft-${version} data plane is not yet implemented`); diff --git a/packages/transport/src/data/decoder-18.ts b/packages/transport/src/data/decoder-18.ts index 3494b79b..8e12e7ca 100644 --- a/packages/transport/src/data/decoder-18.ts +++ b/packages/transport/src/data/decoder-18.ts @@ -305,17 +305,25 @@ export function decodeFetchHeader18( return { header: { requestId: rid.value }, bytesRead: pos - offset }; } -/** Whether a Serialization Flags value is an End-of-Range marker. */ -function isEndOfRange18(flags: bigint): boolean { - return flags === BigInt(FetchSpecialFlags.END_NON_EXISTENT) || flags === BigInt(FetchSpecialFlags.END_UNKNOWN); +/** draft-21 §11.4.4.2: End of Timed-Out Range, the objects' delivery timeout expired. */ +const END_TIMED_OUT_21 = 0x20cn; + +/** Whether a Serialization Flags value is an End-of-Range marker on `version`. */ +function isEndOfRange18(flags: bigint, version: 18 | 21 = 18): boolean { + return flags === BigInt(FetchSpecialFlags.END_NON_EXISTENT) || flags === BigInt(FetchSpecialFlags.END_UNKNOWN) + || (version === 21 && flags === END_TIMED_OUT_21); } -/** Validate Serialization Flags: 0x00–0x7F, 0x8C, or 0x10C; else PROTOCOL_VIOLATION. */ -function validateFetchFlags18(flags: bigint): void { - if (isEndOfRange18(flags)) return; +/** + * Validate Serialization Flags: 0x00–0x7F, 0x8C, 0x10C, and on draft 21 0x20C; + * else PROTOCOL_VIOLATION. + */ +function validateFetchFlags18(flags: bigint, version: 18 | 21 = 18): void { + if (isEndOfRange18(flags, version)) return; if (flags >= 0n && flags <= 0x7fn) return; throw new ProtocolViolationError( - `Invalid draft-18 fetch Serialization Flags 0x${flags.toString(16)} (allowed: 0x00-0x7F, 0x8C, 0x10C)`, + `Invalid draft-${version} fetch Serialization Flags 0x${flags.toString(16)} (allowed: 0x00-0x7F, 0x8C, 0x10C` + + `${version === 21 ? ', 0x20C' : ''})`, ); } @@ -334,21 +342,26 @@ export function decodeFetchObject18( prior: FetchObjectPrior18 | undefined, isFirstObject: boolean, groupOrder: GroupOrder, + version: 18 | 21 = 18, ): { item: DecodedFetchItem; bytesRead: number; nextPrior: FetchObjectPrior18 } { let pos = offset; const fl = readVi64(buf, pos); pos += fl.bytesRead; const flags = fl.value; - validateFetchFlags18(flags); + validateFetchFlags18(flags, version); // ── End-of-Range marker (§11.4.4.2) ────────────────────────────────── - if (isEndOfRange18(flags)) { + // A draft-21 End of Timed-Out Range (0x20C) is, like 0x10C, a range of unknown + // status: the objects may exist but will not be delivered. + if (isEndOfRange18(flags, version)) { const gid = readVi64(buf, pos); pos += gid.bytesRead; const oid = readVi64(buf, pos); pos += oid.bytesRead; - const pl = readVi64(buf, pos); pos += pl.bytesRead; - // §11.4.4.2: an End-of-Range marker carries no Object Payload — the always- - // present Object Payload Length MUST be 0. - if (pl.value !== 0n) { - throw new ProtocolViolationError(`End-of-Range marker has a non-zero payload length (${pl.value})`); + // Keep the established draft-18 framing separate from the draft-21 + // marker-only form. Never guess the form from the following byte. + if (version === 18) { + const pl = readVi64(buf, pos); pos += pl.bytesRead; + if (pl.value !== 0n) { + throw new ProtocolViolationError(`End-of-Range marker has a non-zero payload length (${pl.value})`); + } } const item: FetchEndOfRange = { flags: varint(flags), diff --git a/packages/transport/src/data/encoder-18.test.ts b/packages/transport/src/data/encoder-18.test.ts index 4adaf74b..5953c99e 100644 --- a/packages/transport/src/data/encoder-18.test.ts +++ b/packages/transport/src/data/encoder-18.test.ts @@ -84,6 +84,20 @@ describe('encodeObjectDatagram18 ↔ decodeObjectDatagram18', () => { }); import { encodeFetchHeader18, encodeFetchObject18, encodeFetchEndOfRange18 } from './encoder-18.js'; + +describe('End-of-Range draft isolation', () => { + it.each([18, 21] as const)('pins the draft-%s marker bytes', (draft) => { + const expected = draft === 18 ? [0x80, 0x8c, 5, 10, 0] : [0x80, 0x8c, 5, 10]; + const { bytes } = encodeFetchEndOfRange18(true, 5n, 10n, undefined, draft); + expect(bytes).toEqual(new Uint8Array(expected)); + const wire = new Uint8Array([...expected, 0x1c, 1, 0, 7, 1, 0xa3]); + const first = decodeFetchObject18(wire, 0, undefined, true, 'ascending', draft); + expect(first.bytesRead).toBe(expected.length); + const next = decodeFetchObject18(wire, first.bytesRead, first.nextPrior, false, 'ascending', draft); + expect(next.item).toMatchObject({ groupId: 7n, objectId: 0n, payload: new Uint8Array([0xa3]) }); + expect(first.bytesRead + next.bytesRead).toBe(wire.length); + }); +}); import { decodeFetchHeader18, decodeFetchObject18, type FetchObjectPrior18 } from './decoder-18.js'; import type { FetchObject, FetchEndOfRange } from './types.js'; diff --git a/packages/transport/src/data/encoder-18.ts b/packages/transport/src/data/encoder-18.ts index ca4489a4..73e5b00d 100644 --- a/packages/transport/src/data/encoder-18.ts +++ b/packages/transport/src/data/encoder-18.ts @@ -261,23 +261,25 @@ export function encodeFetchObject18( } /** - * Encode a draft-18 End-of-Range marker (§11.4.4.2): Serialization Flags 0x8C - * (non-existent) or 0x10C (unknown), Group ID, Object ID, and a zero Payload - * Length. Position prior advances to the marker; field priors are unchanged. + * Encode an End-of-Range marker: Serialization Flags 0x8C (non-existent) or + * 0x10C (unknown), Group ID and Object ID. Draft 18 retains its zero Payload + * Length field; the draft-21 form omits it. Position prior advances to the + * marker; field priors are unchanged. */ export function encodeFetchEndOfRange18( nonExistent: boolean, groupId: bigint, objectId: bigint, prior: FetchObjectPrior18 | undefined, + version: 18 | 21 = 18, ): { bytes: Uint8Array; nextPrior: FetchObjectPrior18 } { const flags = BigInt(nonExistent ? FetchSpecialFlags.END_NON_EXISTENT : FetchSpecialFlags.END_UNKNOWN); - const size = vi64EncodingLength(flags) + vi64EncodingLength(groupId) + vi64EncodingLength(objectId) + vi64EncodingLength(0n); + const size = vi64EncodingLength(flags) + vi64EncodingLength(groupId) + vi64EncodingLength(objectId) + (version === 18 ? 1 : 0); const buf = new Uint8Array(size); let p = writeVi64(flags, buf, 0); p += writeVi64(groupId, buf, p); p += writeVi64(objectId, buf, p); - writeVi64(0n, buf, p); // §11.4.4.2: payload length MUST be 0 + if (version === 18) writeVi64(0n, buf, p); const nextPrior: FetchObjectPrior18 = { groupId, objectId, lastObjectSubgroupId: prior?.lastObjectSubgroupId, diff --git a/packages/transport/src/data/types.ts b/packages/transport/src/data/types.ts index e7806333..76c2135f 100644 --- a/packages/transport/src/data/types.ts +++ b/packages/transport/src/data/types.ts @@ -146,7 +146,12 @@ export interface ObjectDatagram { */ export type DataStreamHeader = | { readonly type: 'subgroup'; readonly header: SubgroupHeader } - | { readonly type: 'fetch'; readonly header: FetchHeader }; + /** + * `fill` (draft 21 §3.4): a fill fetch stream, whose FETCH_HEADER carries the + * Request ID of the SUBSCRIBE (or REQUEST_UPDATE) that asked for the fill. It + * has no FETCH_OK: a FIN completes it, a reset fails it. + */ + | { readonly type: 'fetch'; readonly header: FetchHeader; readonly fill?: boolean }; /** * Discriminated union for objects delivered via subscription. diff --git a/packages/transport/src/errors.ts b/packages/transport/src/errors.ts index 86675aac..5d80decf 100644 --- a/packages/transport/src/errors.ts +++ b/packages/transport/src/errors.ts @@ -32,6 +32,8 @@ export const SessionError = { EXPIRED_AUTH_TOKEN: varint(0x18), INVALID_AUTHORITY: varint(0x19), MALFORMED_AUTHORITY: varint(0x1a), + /** draft-21: more than MAX_REQUEST_UPDATES updates outstanding on one request. */ + TOO_MANY_REQUEST_UPDATES: varint(0x1b), } as const; /** @@ -126,6 +128,31 @@ export const RequestError18 = { REDIRECT: varint(0x34), } as const; +/** + * REQUEST_ERROR codes — **draft-21** (§12.3): draft 18's without + * DUPLICATE_SUBSCRIPTION and INVALID_JOINING_REQUEST_ID, plus the filter codes. + */ +export const RequestError21 = { + INTERNAL_ERROR: varint(0x0), + UNAUTHORIZED: varint(0x1), + TIMEOUT: varint(0x2), + NOT_SUPPORTED: varint(0x3), + MALFORMED_AUTH_TOKEN: varint(0x4), + EXPIRED_AUTH_TOKEN: varint(0x5), + GOING_AWAY: varint(0x6), + EXCESSIVE_LOAD: varint(0x9), + DOES_NOT_EXIST: varint(0x10), + INVALID_RANGE: varint(0x11), + MALFORMED_TRACK: varint(0x12), + UNINTERESTED: varint(0x20), + PREFIX_OVERLAP: varint(0x30), + NAMESPACE_TOO_LARGE: varint(0x31), + UNSUPPORTED_EXTENSION: varint(0x33), + REDIRECT: varint(0x34), + CONFLICTING_FILTERS: varint(0x35), + INVALID_FILTER: varint(0x36), +} as const; + /** * PUBLISH_DONE Status Codes — **draft-18** (§15.10.3). Differs from legacy: * TOO_FAR_BEHIND = 0x5 and EXPIRED = 0x6 (swapped), plus EXCESSIVE_LOAD (0x9). diff --git a/packages/transport/src/index.ts b/packages/transport/src/index.ts index 12e56a63..951a5532 100644 --- a/packages/transport/src/index.ts +++ b/packages/transport/src/index.ts @@ -16,6 +16,7 @@ export { ControlMessageType18, SetupOption18, MessageParam18, + MessageParam21, PropertyRange18, isMandatoryProperty, } from './control/codes-18.js'; @@ -39,12 +40,15 @@ export { decodeMessageParams18, messageParams18EncodingLength, DEFAULT_MESSAGE_PARAM_REGISTRY, + DRAFT21_MESSAGE_PARAM_REGISTRY, } from './control/message-params-18.js'; export type { MessageParamValue, MessageParams18, MessageParamRegistry, } from './control/message-params-18.js'; +export { decodeFillParameters, resolveFillWindow } from './control/fill-parameters.js'; +export type { FillOptions } from './control/fill-parameters.js'; export { encodeTrackProperties18, decodeTrackProperties18, @@ -81,7 +85,7 @@ export { readReasonPhrase, writeReasonPhrase, reasonPhraseEncodingLength } from // ─── Error Codes ───────────────────────────────────────────────────── export { SessionError, RequestError, PublishDoneCode, DataStreamError, ProtocolViolationError } from './errors.js'; // draft-18 error code registries (canonical; the legacy exports above stay draft-14/16). -export { RequestError18, PublishDoneCode18, StreamResetCode18, DataStreamError18 } from './errors.js'; +export { RequestError18, RequestError21, PublishDoneCode18, StreamResetCode18, DataStreamError18 } from './errors.js'; // ─── Control Messages ──────────────────────────────────────────────── export { MessageType } from './control/codes.js'; @@ -125,6 +129,7 @@ export type { SubscribeNamespace, SubscribeTracks, PublishBlocked, + PublishStateNotify, UnsubscribeNamespace, PublishNamespaceOk, PublishNamespaceError, @@ -143,7 +148,7 @@ export { createControlCodec } from './control/codec.js'; export type { ControlCodec, DraftVersion, DecodedControlMessage } from './control/codec.js'; // ─── Profile (per-draft behavior bundle) ───────────────────────────── -export { isWiredDraft, WIRED_DRAFTS } from './versions.js'; +export { isDraft21, isRequestStreamDraft, isWiredDraft, WIRED_DRAFTS } from './versions.js'; export { getProtocolProfile } from './profile.js'; export type { ProtocolProfile, ProfileCapabilities } from './profile.js'; export { getRequestPolicy } from './session/request-policy.js'; @@ -323,5 +328,12 @@ export { Session } from './session/session.js'; export { SessionError as SessionProtocolError, SessionDrainingError } from './session/session.js'; export type { SetupOptions, SubscribeOptions, RequestUpdateOptions, FetchOptions, JoiningFetchOptions, FetchAcceptOptions, TrackStatusAcceptOptions, RequestResult } from './session/session.js'; export { resolveJoiningFetchRange, type JoiningFetchFields } from './session/joining.js'; -export { decodeSubscriptionFilter } from './control/subscription-filter.js'; -export type { SubscriptionFilter } from './control/subscription-filter.js'; +export { + decodeSubscriptionFilter, + encodeFillParameters, + encodeLocationFilterFields, + decodeLocationFilterFields, + subscriptionWindow, + windowContains, +} from './control/subscription-filter.js'; +export type { SubscriptionFilter, SubscriptionWindow } from './control/subscription-filter.js'; diff --git a/packages/transport/src/profile.test.ts b/packages/transport/src/profile.test.ts index e4cbb54e..dc7a4576 100644 --- a/packages/transport/src/profile.test.ts +++ b/packages/transport/src/profile.test.ts @@ -9,7 +9,7 @@ import { createDataCodec } from './data/data-codec.js'; import { getRequestPolicy } from './session/request-policy.js'; describe('getProtocolProfile', () => { - for (const version of [14, 16, 18] as const) { + for (const version of [14, 16, 18, 21] as const) { it(`bundles control + data + policy for draft-${version}`, () => { const profile = getProtocolProfile(version); expect(profile.version).toBe(version); @@ -67,3 +67,10 @@ describe('createDataCodec — version selection', () => { expect(createDataCodec(18).version).toBe(18); }); }); + +describe('draft 21', () => { + it('shares the draft-18 stream model capabilities and request policy', () => { + expect(getProtocolProfile(21).capabilities).toEqual(getProtocolProfile(18).capabilities); + expect(getRequestPolicy(21)).toEqual(getRequestPolicy(18)); + }); +}); diff --git a/packages/transport/src/profile.ts b/packages/transport/src/profile.ts index 08754132..f4eaf9b5 100644 --- a/packages/transport/src/profile.ts +++ b/packages/transport/src/profile.ts @@ -25,6 +25,7 @@ import { createControlCodec, type ControlCodec } from './control/codec.js'; import { createDataCodec, type DataCodec } from './data/data-codec.js'; import { getRequestPolicy, type RequestPolicy } from './session/request-policy.js'; import type { DraftVersion } from './versions.js'; +import { isRequestStreamDraft } from './versions.js'; /** * Coarse, session/adapter-facing per-draft semantic facts. @@ -97,7 +98,7 @@ const D18_CAPABILITIES: ProfileCapabilities = { /** Resolve the {@link ProfileCapabilities} for a draft version. */ function getCapabilities(version: DraftVersion): ProfileCapabilities { - return version === 18 ? D18_CAPABILITIES : LEGACY_CAPABILITIES; + return isRequestStreamDraft(version) ? D18_CAPABILITIES : LEGACY_CAPABILITIES; } /** diff --git a/packages/transport/src/session/boundary.test.ts b/packages/transport/src/session/boundary.test.ts index 75c73d7a..b63581a8 100644 --- a/packages/transport/src/session/boundary.test.ts +++ b/packages/transport/src/session/boundary.test.ts @@ -63,10 +63,11 @@ const FORBIDDEN_IMPORT_SUBSTRINGS = [ * * `control/codes-18.js` is a code-point enum table; `SetupOption18` is a set of * well-known setup-option codes (PATH, AUTHORITY, …) the SetupGate negotiates - * by name. That is semantic, not serialization. Keep this map tiny + justified. + * by name, and `SetupOption21` extends it with the draft-21 options + * (MAX_FILTER_RANGES, MAX_REQUEST_UPDATES). That is semantic, not serialization. Keep this map tiny + justified. */ const NAME_SPECIFIC_ALLOW: Readonly>> = { - '../control/codes-18.js': ['SetupOption18'], + '../control/codes-18.js': ['SetupOption18', 'SetupOption21'], }; /** Raw draft-18 wire details that must never appear in session source code. */ diff --git a/packages/transport/src/session/request-policy.ts b/packages/transport/src/session/request-policy.ts index bb47fbab..f2761a3f 100644 --- a/packages/transport/src/session/request-policy.ts +++ b/packages/transport/src/session/request-policy.ts @@ -20,6 +20,7 @@ */ import type { DraftVersion } from '../versions.js'; +import { isRequestStreamDraft } from '../versions.js'; /** How a receiver validates an inbound Request ID. */ export type InboundValidation = 'strict-sequence' | 'parity-and-duplicate'; @@ -51,5 +52,5 @@ const STREAM_LIMITED: RequestPolicy = { * @param version Draft version (default: 16). */ export function getRequestPolicy(version: DraftVersion = 16): RequestPolicy { - return version === 18 ? STREAM_LIMITED : CREDITED; + return isRequestStreamDraft(version) ? STREAM_LIMITED : CREDITED; } diff --git a/packages/transport/src/session/session-d21-conformance.test.ts b/packages/transport/src/session/session-d21-conformance.test.ts new file mode 100644 index 00000000..d306e72c --- /dev/null +++ b/packages/transport/src/session/session-d21-conformance.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, it } from 'vitest'; +import { Session } from './session.js'; +import { EndpointRole, SessionState } from './types.js'; +import { varint } from '../primitives/varint.js'; +import type { Parameters } from '../control/messages.js'; +import { decodeSubscriptionFilter, subscriptionWindow } from '../control/subscription-filter.js'; + +const namespace = [new TextEncoder().encode('live')]; +const name = new TextEncoder().encode('video'); + +describe('draft-21 SUBSCRIBE_TRACKS Forward updates (9.20.19)', () => { + it.each(['unknown', 'pending', 'outside prefix', 'cancelled', 'older draft'] as const)( + 'refuses a PUBLISH association with %s before allocating a request', (condition) => { + const session = established(condition === 'older draft' ? 18 : 21); + if (condition !== 'unknown') { + session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: 1n, + trackNamespacePrefix: namespace, parameters: new Map() }); + if (condition !== 'pending') session.acceptSubscribeTracks(1n); + if (condition === 'cancelled') session.handleInboundSubscribeTracksClosed(1n); + } + const prefix = condition === 'outside prefix' ? [new TextEncoder().encode('other')] : namespace; + expect(() => session.publish(prefix, name, 10n, { subscribeTracksRequestId: 1n })).toThrow(/active matching draft-21/); + expect(session.publish(prefix, name, 10n).requestId).toBe(0n); + }, + ); + it.each([0n, 1n])('preserves independent PUBLISH Forward=%s inside a subscribed prefix', (forward) => { + const session = established(); + session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: 1n, + trackNamespacePrefix: namespace, parameters: new Map([[0x10n, [1n - forward]]]) }); + session.acceptSubscribeTracks(1n); + const pub = session.publish(namespace, name, 10n, { parameters: new Map([[0x10n, [forward]]]) }); + expect(session.getOutgoingPublish(pub.requestId)!.forwardState).toBe(Number(forward)); + const action = pub.actions.find((a) => a.type === 'send_control')!; + if (action.type !== 'send_control' || action.message.type !== 'PUBLISH') throw new Error('missing publish'); + expect(action.message.parameters.get(0x10n)).toEqual([forward]); + }); + it('leaves the previous Forward State intact when an outgoing update is rejected', () => { + const session = established(); + const id = session.subscribeTracks(namespace).requestId; + session.handleControlMessage({ type: 'REQUEST_OK', requestId: id, parameters: new Map() }); + const update = session.requestUpdate(id, { forward: 0 }); + session.handleControlMessage({ type: 'REQUEST_ERROR', requestId: update.requestId, + errorCode: 16n, retryInterval: 0n, errorReason: 'rejected' }); + expect(session.getTrackSubscription(id)).toHaveProperty('forward', 1); + }); + + it('does not apply Forward when a combined prefix update overlaps another request', () => { + const session = established(); + const other = [new TextEncoder().encode('other')]; + for (const [id, prefix] of [[1n, namespace], [3n, other]] as const) { + session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: id, + trackNamespacePrefix: prefix, parameters: new Map() }); + session.acceptSubscribeTracks(id); + } + const actions = session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: 5n, + existingRequestId: 1n, parameters: new Map([[0x34n, [other]], [0x10n, [0n]]]) }); + expect(actions).toContainEqual(expect.objectContaining({ type: 'send_control', + message: expect.objectContaining({ type: 'REQUEST_ERROR' }) })); + expect(session.getIncomingTrackSubscription(1n)).toMatchObject({ trackNamespacePrefix: namespace, forward: 1 }); + }); + + it.each([0n, 1n])('retains initial Forward=%s and omission leaves it unchanged', (forward) => { + const session = established(); + session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: 1n, + trackNamespacePrefix: namespace, parameters: new Map([[0x10n, [forward]]]) }); + session.acceptSubscribeTracks(1n); + session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: 3n, existingRequestId: 1n, + parameters: new Map([[0x34n, [namespace]]]) }); + expect(session.getIncomingTrackSubscription(1n)).toHaveProperty('forward', Number(forward)); + }); + + it('rejects an invalid incoming Forward before applying the prefix', () => { + const session = established(); + session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: 1n, + trackNamespacePrefix: namespace, parameters: new Map() }); + session.acceptSubscribeTracks(1n); + const actions = session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: 3n, + existingRequestId: 1n, parameters: new Map([[0x34n, [[]]], [0x10n, [2n]]]) }); + expect(actions).toContainEqual(expect.objectContaining({ type: 'close_connection', error: 3n })); + }); + + it('does not allocate a request for an out-of-scope draft-18 Forward update', () => { + const session = established(18); + const id = session.subscribeTracks(namespace).requestId; + session.handleControlMessage({ type: 'REQUEST_OK', requestId: id, parameters: new Map() }); + expect(() => session.requestUpdate(id, { forward: 0, trackNamespacePrefix: namespace })).toThrow(/draft-21/); + expect(session.requestUpdate(id, { trackNamespacePrefix: namespace }).requestId).toBe(2n); + }); + + it.each([false, true])('emits Forward with a prefix change=%s and commits only on acknowledgment', (withPrefix) => { + const session = established(); + const id = session.subscribeTracks(namespace).requestId; + session.handleControlMessage({ type: 'REQUEST_OK', requestId: id, parameters: new Map() }); + const update = session.requestUpdate(id, { forward: 0, ...(withPrefix ? { trackNamespacePrefix: namespace } : {}) }); + expect(update.actions).toContainEqual(expect.objectContaining({ + type: 'send_control', message: expect.objectContaining({ parameters: expect.any(Map) }), + })); + const action = update.actions.find((a) => a.type === 'send_control')!; + if (action.type !== 'send_control' || action.message.type !== 'REQUEST_UPDATE') throw new Error('missing update'); + expect(action.message.parameters.get(0x10n)).toEqual([0n]); + expect(session.getTrackSubscription(id)).toHaveProperty('forward', 1); + session.handleControlMessage({ type: 'REQUEST_OK', requestId: update.requestId, parameters: new Map() }); + expect(session.getTrackSubscription(id)).toHaveProperty('forward', 0); + }); + + it('applies Forward to future matching PUBLISHes, not existing or unrelated subscriptions', () => { + const session = established(); + const id = varint(1n); + session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: id, trackNamespacePrefix: namespace, parameters: new Map() }); + session.acceptSubscribeTracks(id); + const first = session.publish(namespace, name, 10n, { subscribeTracksRequestId: id }); + session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: varint(3n), existingRequestId: id, parameters: new Map([[0x10n, [0n]]]) }); + expect(session.getIncomingTrackSubscription(id)).toHaveProperty('forward', 0); + expect(session.getOutgoingPublish(first.requestId)!.forwardState).toBe(1); + const next = session.publish(namespace, name, 11n, { subscribeTracksRequestId: id }); + expect(session.getOutgoingPublish(next.requestId)!.forwardState).toBe(0); + const action = next.actions.find((a) => a.type === 'send_control')!; + if (action.type !== 'send_control' || action.message.type !== 'PUBLISH') throw new Error('missing publish'); + expect(action.message.parameters.get(0x10n)).toEqual([0n]); + const unrelated = session.publish([new TextEncoder().encode('other')], name, 12n); + expect(session.getOutgoingPublish(unrelated.requestId)!.forwardState).toBe(1); + }); +}); + +function established(draft: 18 | 21 = 21): Session { + const session = new Session(EndpointRole.CLIENT, draft); + session.initiateSetup(); + session.handleControlMessage({ type: 'SETUP', setupOptions: new Map() }); + expect(session.state).toBe(SessionState.ESTABLISHED); + return session; +} + +describe('draft-21 fill parameter scope (9.20.16)', () => { + it.each([0, 1] as const)('evaluates a pipelined fill after an earlier Forward=%s update', (forward) => { + const session = established(); + const id = session.subscribe(namespace, name, { forward: forward === 0 ? 1 : 0 }).requestId; + session.handleControlMessage({ type: 'SUBSCRIBE_OK', requestId: id, trackAlias: 4n, parameters: new Map(), trackProperties: new Map() }); + const first = session.requestUpdate(id, { forward }); + const second = session.requestUpdate(id, { fill: {} }); + const parameters: Parameters = new Map([[0x09n, [{ group: 5n, object: 0n }]]]); + session.handleControlMessage({ type: 'REQUEST_OK', requestId: first.requestId, parameters }); + session.handleControlMessage({ type: 'REQUEST_OK', requestId: second.requestId, parameters }); + expect(session.expectedFillsOf(id)).toEqual(forward === 1 ? [second.requestId] : []); + }); + + it('a pipelined fill inherits the preceding accepted filter update', () => { + const session = established(); + const id = session.subscribe(namespace, name).requestId; + session.handleControlMessage({ type: 'SUBSCRIBE_OK', requestId: id, trackAlias: 4n, parameters: new Map(), trackProperties: new Map() }); + const first = session.requestUpdate(id, { subscriptionFilter: { type: 'NextGroupStart' } }); + const second = session.requestUpdate(id, { fill: {} }); + const parameters: Parameters = new Map([[0x09n, [{ group: 5n, object: 0n }]]]); + session.handleControlMessage({ type: 'REQUEST_OK', requestId: first.requestId, parameters }); + session.handleControlMessage({ type: 'REQUEST_OK', requestId: second.requestId, parameters }); + expect(session.expectedFillsOf(id)).toEqual([]); + }); + it.each(['empty track', 'empty range'] as const)('does not retain an expected fill for an %s', (kind) => { + const session = established(); + const { requestId } = session.subscribe(namespace, name, { fill: {}, subscriptionFilter: { type: 'NextGroupStart' } }); + session.handleControlMessage({ type: 'SUBSCRIBE_OK', requestId, trackAlias: 4n, + parameters: kind === 'empty track' ? new Map() : new Map([[0x09n, [{ group: 5n, object: 0n }]]]), + trackProperties: new Map(), + }); + expect(session.expectedFillsOf(requestId)).toEqual([]); + expect(session.getSubscription(requestId)!.state).toBe('established'); + }); + it.each(['rejected', 'cancelled', 'closed'] as const)('reclaims a %s subscription fill', (terminal) => { + const session = established(); + const { requestId } = session.subscribe(namespace, name, { fill: {} }); + if (terminal === 'rejected') session.handleControlMessage({ type: 'REQUEST_ERROR', requestId, errorCode: 16n, retryInterval: 0n, errorReason: 'gone' }); + else if (terminal === 'cancelled') session.unsubscribe(requestId); + else session.close(); + expect(session.fillSubscriptionFor(requestId)).toBeUndefined(); + }); + it.each([ + { description: 'FORWARD is not a fill parameter', bytes: [0x10, 1] }, + { description: 'recursive FILL_PARAMETERS is forbidden', bytes: [0x23, 0] }, + { description: 'GROUP_ORDER must be 1 or 2', bytes: [0x22, 3] }, + { description: 'duplicate GROUP_ORDER is forbidden', bytes: [0x22, 1, 0, 2] }, + { description: 'truncated GROUP_ORDER is malformed', bytes: [0x22] }, + { description: 'LOCATION_FILTER has at most four fields', bytes: [0x21, 5, 0, 0, 0, 0, 0] }, + ])('$description', ({ bytes }) => { + const session = established(); + const actions = session.handleControlMessage({ + type: 'SUBSCRIBE', requestId: varint(1n), trackNamespace: namespace, trackName: name, + parameters: new Map([[0x23n, [new Uint8Array(bytes)]]]), + }); + expect(actions).toContainEqual(expect.objectContaining({ type: 'close_connection', error: 3n })); + expect(session.getIncomingSubscription(1n)).toBeUndefined(); + }); + + it('allows an independent nested GROUP_ORDER and LOCATION_FILTER', () => { + const session = established(); + const parameters: Parameters = new Map([ + [0x21n, [new Uint8Array([0])]], + [0x22n, [1n]], + [0x23n, [new Uint8Array([0x21, 0, 1, 2])]], + ]); + expect(session.handleControlMessage({ + type: 'SUBSCRIBE', requestId: varint(1n), trackNamespace: namespace, trackName: name, parameters, + })).toEqual([]); + expect(session.getIncomingSubscription(1n)).toBeDefined(); + }); + + it('does not allocate a subscription when a local fill option is invalid', () => { + const session = established(18); + expect(() => session.subscribe(namespace, name, { fill: {} })).toThrow(/draft 21/); + expect(session.getSubscription(0n)).toBeUndefined(); + expect(session.subscribe(namespace, name).requestId).toBe(0n); + }); +}); + +describe('draft-21 filter updates', () => { + it.each([{ type: 'NextGroupStart' as const }, { type: 'RelativeStart' as const, groups: 0n }])('clamps a relative group above uint64: $type', (filter) => { + const max = 0xffffffffffffffffn; + expect(subscriptionWindow(filter, { group: max, object: 0n }).start).toEqual({ group: max, object: 0n }); + }); + + it('does not consume a Request ID for an invalid local update filter', () => { + const session = established(); + const { requestId } = session.subscribe(namespace, name); + session.handleControlMessage({ type: 'SUBSCRIBE_OK', requestId, trackAlias: 4n, parameters: new Map(), trackProperties: new Map() }); + expect(() => session.requestUpdate(requestId, { + subscriptionFilter: { type: 'AbsoluteStart', startGroup: -1n, startObject: 0n }, + })).toThrow(); + expect(session.requestUpdate(requestId, { forward: 0 }).requestId).toBe(2n); + }); + + it('rejects Track Property Filter on an individual subscription update', () => { + const session = established(); + session.handleControlMessage({ type: 'SUBSCRIBE', requestId: 1n, trackNamespace: namespace, trackName: name, parameters: new Map() }); + session.acceptSubscribe(1n, 4n); + const actions = session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: 3n, existingRequestId: 1n, + parameters: new Map([[0x29n, [new Uint8Array([0, 2, 0, 0])]]]), + }); + expect(actions).toContainEqual(expect.objectContaining({ type: 'close_connection', error: 3n })); + }); + + it('rejects FILL_PARAMETERS on a namespace publication update', () => { + const session = established(); + session.handleControlMessage({ type: 'PUBLISH_NAMESPACE', requestId: 1n, trackNamespace: namespace, parameters: new Map() }); + const actions = session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: 3n, existingRequestId: 1n, + parameters: new Map([[0x23n, [new Uint8Array()]]]), + }); + expect(actions).toContainEqual(expect.objectContaining({ type: 'close_connection', error: 3n })); + }); + it.each([0x25n, 0x26n, 0x27n, 0x28n])('rejects unadvertised range filter %s with INVALID_FILTER', (type) => { + const session = established(); + const actions = session.handleControlMessage({ + type: 'SUBSCRIBE', requestId: varint(1n), trackNamespace: namespace, trackName: name, + parameters: new Map([[type, [new Uint8Array([0, 0])]]]), + }); + expect(actions).toContainEqual(expect.objectContaining({ type: 'send_control', message: expect.objectContaining({ + type: 'REQUEST_ERROR', requestId: 1n, errorCode: 0x36n, + }) })); + expect(session.state).toBe(SessionState.ESTABLISHED); + expect(session.getIncomingSubscription(1n)).toBeUndefined(); + }); + + it('rejects an unadvertised Track Property Filter on SUBSCRIBE_TRACKS', () => { + const session = established(); + const actions = session.handleControlMessage({ type: 'SUBSCRIBE_TRACKS', requestId: varint(1n), + trackNamespacePrefix: namespace, parameters: new Map([[0x29n, [new Uint8Array([0, 2, 0, 0])]]]), + }); + expect(actions).toContainEqual(expect.objectContaining({ type: 'send_control', message: expect.objectContaining({ + type: 'REQUEST_ERROR', errorCode: 0x36n, + }) })); + expect(session.state).toBe(SessionState.ESTABLISHED); + }); + + it('enforces MAX_FILTER_RANGES inside a fill without changing the subscription on rejection', () => { + const session = established(); + session.handleControlMessage({ type: 'SUBSCRIBE', requestId: varint(1n), trackNamespace: namespace, trackName: name, parameters: new Map() }); + session.acceptSubscribe(1n, 4n); + session.setLargestLocationProvider(() => null); + const actions = session.handleControlMessage({ + type: 'REQUEST_UPDATE', requestId: varint(3n), existingRequestId: 1n, + parameters: new Map([[0x10n, [0n]], [0x23n, [new Uint8Array([0x25, 2, 0, 0])]]]), + }); + expect(actions).toContainEqual(expect.objectContaining({ type: 'send_control', message: expect.objectContaining({ + type: 'REQUEST_ERROR', requestId: 3n, errorCode: 0x36n, + }) })); + expect(session.getIncomingSubscription(1n)!.forwardState).toBe(1); + expect(session.state).toBe(SessionState.ESTABLISHED); + }); + + it('reports the current Largest Object on a filter-only update', () => { + const session = established(); + session.handleControlMessage({ type: 'SUBSCRIBE', requestId: varint(1n), trackNamespace: namespace, trackName: name, parameters: new Map() }); + session.acceptSubscribe(1n, 4n, { parameters: new Map([[0x09n, [{ group: 1n, object: 0n }]]]) }); + session.setLargestLocationProvider(() => ({ group: 7n, object: 2n })); + const actions = session.handleControlMessage({ + type: 'REQUEST_UPDATE', requestId: varint(3n), existingRequestId: 1n, + parameters: new Map([[0x21n, [new Uint8Array([0])]]]), + }); + expect(actions).toContainEqual(expect.objectContaining({ type: 'send_control', message: expect.objectContaining({ + type: 'REQUEST_OK', parameters: new Map([[0x09n, [{ group: 7n, object: 2n }]]]), + }) })); + expect(session.getIncomingSubscription(1n)!.locationWindow).toEqual({ start: { group: 8n, object: 0n } }); + }); + it('keeps the committed filter until REQUEST_OK and ignores a rejected replacement', () => { + const session = established(); + const initial = { type: 'AbsoluteRange' as const, startGroup: 1n, startObject: 0n, endGroup: 2n }; + const replacement = { ...initial, endGroup: 9n }; + const { requestId } = session.subscribe(namespace, name, { subscriptionFilter: initial }); + session.handleControlMessage({ type: 'SUBSCRIBE_OK', requestId, trackAlias: 4n, parameters: new Map(), trackExtensions: new Map() }); + const update = session.requestUpdate(requestId, { subscriptionFilter: replacement }); + expect(decodeSubscriptionFilter(session.getSubscription(requestId)!.currentFilter!, 21)).toEqual(initial); + session.handleControlMessage({ type: 'REQUEST_ERROR', requestId: update.requestId, errorCode: 0n, errorReason: 'no', retryInterval: varint(0n) }); + expect(decodeSubscriptionFilter(session.getSubscription(requestId)!.currentFilter!, 21)).toEqual(initial); + const retry = session.requestUpdate(requestId, { subscriptionFilter: replacement }); + session.handleControlMessage({ type: 'REQUEST_OK', requestId: retry.requestId, parameters: new Map() }); + expect(decodeSubscriptionFilter(session.getSubscription(requestId)!.currentFilter!, 21)).toEqual(replacement); + }); + + it('does not move an existing relative filter when an update omits it', () => { + const session = established(); + session.handleControlMessage({ type: 'SUBSCRIBE', requestId: 1n, trackNamespace: namespace, trackName: name, + parameters: new Map([[0x21n, [new Uint8Array([0])]]]), + }); + session.acceptSubscribe(1n, 4n, { parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]) }); + session.setLargestLocationProvider(() => ({ group: 9n, object: 0n })); + session.handleControlMessage({ type: 'REQUEST_UPDATE', requestId: 3n, existingRequestId: 1n, + parameters: new Map([[0x10n, [0n]]]), + }); + expect(session.getIncomingSubscription(1n)!.locationWindow).toEqual({ start: { group: 6n, object: 0n } }); + }); +}); + +describe('draft-21 PUBLISH_STATE_NOTIFY', () => { + it.each(['subscribe', 'publish'] as const)('preserves full-width Locations for %s-initiated subscriptions', (origin) => { + const session = established(); + let requestId: bigint; + if (origin === 'subscribe') { + requestId = session.subscribe(namespace, name).requestId; + session.handleControlMessage({ type: 'SUBSCRIBE_OK', requestId, trackAlias: 4n, parameters: new Map(), trackExtensions: new Map() }); + } else { + requestId = 1n; + session.handleControlMessage({ type: 'PUBLISH', requestId, trackAlias: 4n, + trackNamespace: namespace, trackName: name, parameters: new Map(), trackProperties: new Map(), + }); + session.acceptSubscribe(requestId, 4n); + } + const largest = { group: 0xffffffffffffffffn, object: 0xfffffffffffffffen }; + expect(session.handleControlMessage({ type: 'PUBLISH_STATE_NOTIFY', requestId, parameters: new Map([[0x09n, [largest]]]) })).toEqual([]); + const sub = session.getSubscription(requestId) ?? session.getIncomingSubscription(requestId); + expect(sub!.largestLocation).toEqual({ groupId: largest.group, objectId: largest.object }); + expect(session.state).toBe(SessionState.ESTABLISHED); + }); +}); + +describe('draft-21 parameter contracts', () => { + it('retains the initial PUBLISH Location Filter and requested Group Order', () => { + const session = established(); + session.handleControlMessage({ type: 'PUBLISH', requestId: 1n, trackAlias: 4n, + trackNamespace: namespace, trackName: name, + parameters: new Map([[0x21n, [new Uint8Array([2, 1])]], [0x22n, [2n]]]), trackProperties: new Map(), + }); + session.acceptSubscribe(1n, 4n); + const sub = session.getIncomingSubscription(1n)!; + expect(sub.locationWindow).toEqual({ start: { group: 2n, object: 1n } }); + expect(sub.groupOrder).toBe('descending'); + }); + + it('refuses to publish two different tracks on the same alias before allocating a request', () => { + const session = established(); + session.publish(namespace, name, 4n); + const other = new TextEncoder().encode('audio'); + expect(() => session.publish(namespace, other, 4n)).toThrow(/different track/); + expect(session.publish(namespace, other, 5n).requestId).toBe(2n); + }); + + it('refuses acceptance on another live track alias without establishing the request', () => { + const session = established(); + session.publish(namespace, name, 4n); + session.handleControlMessage({ type: 'SUBSCRIBE', requestId: 1n, trackNamespace: namespace, + trackName: new TextEncoder().encode('audio'), parameters: new Map(), + }); + expect(() => session.acceptSubscribe(1n, 4n)).toThrow(/different track/); + expect(session.getIncomingSubscription(1n)!.state).toBe('pending'); + }); + it('keeps PUBLISH Forward=0 on both sides of its parameterless acceptance', () => { + const publisher = established(); + const { requestId } = publisher.publish(namespace, name, 4n, { parameters: new Map([[0x10n, [0n]]]) }); + publisher.handleControlMessage({ type: 'REQUEST_OK', requestId, parameters: new Map() }); + expect(publisher.getOutgoingPublish(requestId)!.forwardState).toBe(0); + + const subscriber = established(); + subscriber.handleControlMessage({ type: 'PUBLISH', requestId: 1n, trackAlias: 4n, + trackNamespace: namespace, trackName: name, parameters: new Map([[0x10n, [0n]]]), trackProperties: new Map(), + }); + subscriber.acceptSubscribe(1n, 4n); + expect(subscriber.getIncomingSubscription(1n)!.forwardState).toBe(0); + }); + + it('rejects an invalid INCLUDE_PROPERTIES value', () => { + const session = established(); + const actions = session.handleControlMessage({ type: 'SUBSCRIBE', requestId: 1n, + trackNamespace: namespace, trackName: name, parameters: new Map([[0x35n, [2n]]]), + }); + expect(actions).toContainEqual(expect.objectContaining({ type: 'close_connection', error: 3n })); + }); + + it('accepts EXPIRES on PUBLISH_NAMESPACE_OK', () => { + const session = established(); + const { requestId } = session.publishNamespace(namespace); + expect(session.handleControlMessage({ type: 'REQUEST_OK', requestId, parameters: new Map([[0x08n, [1000n]]]) })).toEqual([]); + expect(session.state).toBe(SessionState.ESTABLISHED); + }); +}); diff --git a/packages/transport/src/session/session-d21.test.ts b/packages/transport/src/session/session-d21.test.ts new file mode 100644 index 00000000..2139bb94 --- /dev/null +++ b/packages/transport/src/session/session-d21.test.ts @@ -0,0 +1,197 @@ +/** + * Session rules for draft 21: the draft-18 stream model with draft-21 parameter + * scopes, FILL_PARAMETERS on SUBSCRIBE, GOAWAY without a Request ID and + * PUBLISH_STATE_NOTIFY. + */ +import { describe, it, expect } from 'vitest'; +import { Session } from './session.js'; +import { EndpointRole, SessionState, type SendControlAction, type CloseConnectionAction } from './types.js'; +import { varint } from '../primitives/varint.js'; +import { SessionError as SessionErrorCode } from '../errors.js'; +import { bytesToHex } from '../vectors/load-vectors.js'; +import type { ControlMessage, Goaway, PublishStateNotify, Setup, Subscribe, SubscribeOk } from '../control/messages.js'; + +const NS = [new TextEncoder().encode('live')]; +const NAME = new TextEncoder().encode('catalog'); +const FILL_PARAMETERS = 0x23n; + +function established(version: 18 | 21): Session { + const session = new Session(EndpointRole.CLIENT, version); + session.initiateSetup(); + session.handleControlMessage({ type: 'SETUP', setupOptions: new Map() } as Setup); + expect(session.state).toBe(SessionState.ESTABLISHED); + return session; +} + +function closeOf(actions: ReturnType): CloseConnectionAction | undefined { + return actions.find((a) => a.type === 'close_connection') as CloseConnectionAction | undefined; +} + +function subscribed(session: Session): bigint { + const { requestId } = session.subscribe(NS, NAME); + session.handleControlMessage({ + type: 'SUBSCRIBE_OK', requestId, trackAlias: varint(4n), parameters: new Map(), trackExtensions: [], + } as SubscribeOk); + return requestId; +} + +describe('draft-21 SUBSCRIBE with a fill', () => { + it('sends FILL_PARAMETERS with the fill Location filter and marks the subscription', () => { + const session = established(21); + const { requestId, actions } = session.subscribe(NS, NAME, { + subscriptionFilter: { type: 'LargestObject' }, + fill: { filter: { type: 'RelativeStart', groups: 1n } }, + }); + const msg = (actions[0] as SendControlAction).message as Subscribe; + expect(bytesToHex(msg.parameters.get(FILL_PARAMETERS)![0] as Uint8Array)).toBe('210101'); + expect(bytesToHex(msg.parameters.get(0x21n)![0] as Uint8Array)).toBe('0000'); + expect(session.getSubscription(requestId)!.fillRequested).toBe(true); + }); + + it('is refused before draft 21', () => { + const session = established(18); + expect(() => session.subscribe(NS, NAME, { fill: {} })).toThrow(/draft 21/); + }); +}); + +describe('draft-21 GOAWAY', () => { + it('needs no Request ID', () => { + const session = established(21); + const actions = session.handleControlMessage({ type: 'GOAWAY', newSessionUri: '', timeout: 5000n } as Goaway); + expect(closeOf(actions)).toBeUndefined(); + expect(session.state).toBe(SessionState.DRAINING); + }); + + it('draft 18 still requires one', () => { + const session = established(18); + const actions = session.handleControlMessage({ type: 'GOAWAY', newSessionUri: '', timeout: 5000n } as Goaway); + expect(closeOf(actions)?.reason).toMatch(/missing the Request ID/); + }); +}); + +describe('draft-21 PUBLISH_STATE_NOTIFY', () => { + it('advances the subscription Largest Location', () => { + const session = established(21); + const requestId = subscribed(session); + const notify: PublishStateNotify = { + type: 'PUBLISH_STATE_NOTIFY', requestId, + parameters: new Map([[0x09n, [{ group: 3n, object: 7n }]], [0x10n, [0n]]]), + }; + const actions = session.handleControlMessage(notify as ControlMessage); + expect(closeOf(actions)).toBeUndefined(); + expect(session.getSubscription(requestId)!.largestLocation).toEqual({ groupId: 3n, objectId: 7n }); + }); + + it('a parameter out of its scope is a PROTOCOL_VIOLATION', () => { + const session = established(21); + const requestId = subscribed(session); + const notify: PublishStateNotify = { + type: 'PUBLISH_STATE_NOTIFY', requestId, parameters: new Map([[0x08n, [10n]]]), // EXPIRES + }; + expect(closeOf(session.handleControlMessage(notify as ControlMessage))?.reason).toMatch(/out of scope/); + }); + + it('one that crosses our cancellation is ignored', () => { + const session = established(21); + const notify: PublishStateNotify = { type: 'PUBLISH_STATE_NOTIFY', requestId: 99n, parameters: new Map() }; + expect(session.handleControlMessage(notify as ControlMessage)).toEqual([]); + }); +}); + +describe('draft-21 parameter scopes', () => { + it('SUBSCRIBE_OK may carry EXPIRES and LARGEST_OBJECT', () => { + const session = established(21); + const { requestId } = session.subscribe(NS, NAME); + const actions = session.handleControlMessage({ + type: 'SUBSCRIBE_OK', requestId, trackAlias: varint(4n), + parameters: new Map([[0x08n, [0n]], [0x09n, [{ group: 1n, object: 2n }]]]), + trackExtensions: [], + } as SubscribeOk); + expect(closeOf(actions)).toBeUndefined(); + }); +}); + +const MAX_REQUEST_UPDATES = 0x08n; +const TOO_MANY_REQUEST_UPDATES = 0x1bn; + +function establishedWithPeerOptions(version: 18 | 21, setupOptions: Map): Session { + const session = new Session(EndpointRole.CLIENT, version); + session.initiateSetup(); + session.handleControlMessage({ type: 'SETUP', setupOptions } as unknown as Setup); + expect(session.state).toBe(SessionState.ESTABLISHED); + return session; +} + +describe('draft-21 MAX_REQUEST_UPDATES (§9.1.7)', () => { + it('advertises our limit on draft 21 only', () => { + const s21 = new Session(EndpointRole.CLIENT, 21); + const setup21 = (s21.initiateSetup({ maxRequestUpdates: 2n })[0] as SendControlAction).message as Setup; + expect(setup21.setupOptions.get(MAX_REQUEST_UPDATES)).toEqual([2n]); + const s18 = new Session(EndpointRole.CLIENT, 18); + const setup18 = (s18.initiateSetup({ maxRequestUpdates: 2n })[0] as SendControlAction).message as Setup; + expect(setup18.setupOptions.has(MAX_REQUEST_UPDATES)).toBe(false); + }); + + it('never exceeds the peer limit of outstanding updates on one request', () => { + const session = establishedWithPeerOptions(21, new Map([[MAX_REQUEST_UPDATES, [1n]]])); + const requestId = subscribed(session); + const first = session.requestUpdate(requestId, { forward: 0 }); + expect(() => session.requestUpdate(requestId, { forward: 1 })).toThrow(/MAX_REQUEST_UPDATES/); + // The response restores the credit. + session.handleControlMessage({ type: 'REQUEST_OK', requestId: first.requestId, parameters: new Map() } as unknown as ControlMessage); + expect(() => session.requestUpdate(requestId, { forward: 1 })).not.toThrow(); + }); + + it('a limit of 0, or none, does not limit', () => { + const session = establishedWithPeerOptions(21, new Map()); + const requestId = subscribed(session); + for (let i = 0; i < 5; i++) session.requestUpdate(requestId, { forward: 0 }); + }); + + it('reports our advertised limit so the receiver can enforce it', () => { + const session = new Session(EndpointRole.CLIENT, 21); + session.initiateSetup({ maxRequestUpdates: 3n }); + expect(session.ownMaxRequestUpdates).toBe(3n); + expect(SessionErrorCode.TOO_MANY_REQUEST_UPDATES).toBe(TOO_MANY_REQUEST_UPDATES); + }); +}); + +describe('draft-21 fill on REQUEST_UPDATE (§3.4)', () => { + it('carries FILL_PARAMETERS and expects a fill stream under the update Request ID', () => { + const session = established(21); + const requestId = subscribed(session); + const { requestId: updateId, actions } = session.requestUpdate(requestId, { + fill: { filter: { type: 'AbsoluteRange', startGroup: 0n, startObject: 0n, endGroup: 1n } }, + }); + const msg = (actions[0] as SendControlAction).message as ControlMessage & { parameters: Map }; + expect(msg.parameters.has(FILL_PARAMETERS)).toBe(true); + expect(session.fillSubscriptionFor(updateId)?.requestId).toBe(requestId); + expect(session.takeFill(updateId)?.requestId).toBe(requestId); + expect(session.fillSubscriptionFor(updateId)).toBeUndefined(); + }); + + it('a refused update opens no fill', () => { + const session = established(21); + const requestId = subscribed(session); + const { requestId: updateId } = session.requestUpdate(requestId, { fill: {} }); + session.handleControlMessage({ + type: 'REQUEST_ERROR', requestId: updateId, errorCode: 0x0n, errorReason: 'no', retryInterval: 0n, + } as unknown as ControlMessage); + expect(session.fillSubscriptionFor(updateId)).toBeUndefined(); + }); + + it('is refused before draft 21', () => { + const session = established(18); + const requestId = subscribed(session); + expect(() => session.requestUpdate(requestId, { fill: {} })).toThrow(/draft 21/); + }); + + it('the SUBSCRIBE fill is tracked the same way', () => { + const session = established(21); + const { requestId } = session.subscribe(NS, NAME, { fill: {} }); + expect(session.fillSubscriptionFor(requestId)?.requestId).toBe(requestId); + expect(session.cancelFillRequest(requestId)).toBe(true); + expect(session.getSubscription(requestId)!.fillRequested).toBe(false); + expect(session.cancelFillRequest(requestId)).toBe(false); + }); +}); diff --git a/packages/transport/src/session/session.ts b/packages/transport/src/session/session.ts index 0c2252d0..eafc40b9 100644 --- a/packages/transport/src/session/session.ts +++ b/packages/transport/src/session/session.ts @@ -19,8 +19,11 @@ import { readLocation } from '../primitives/location.js'; * encode; resume-provider results above it are unencodable, so they fail * closed (kept local: the sans-I/O session imports no wire modules). */ const MAX_VI64 = 18446744073709551615n; -import { encodeSubscriptionFilter, validateSubscriptionFilter, decodeSubscriptionFilter, type SubscriptionFilter } from '../control/subscription-filter.js'; +import { + encodeFillParameters, encodeSubscriptionFilter, validateSubscriptionFilter, decodeSubscriptionFilter, subscriptionWindow, type SubscriptionFilter, +} from '../control/subscription-filter.js'; import { resolveJoiningFetchRange } from './joining.js'; +import { decodeFillParameters, resolveFillWindow, type FillOptions } from '../control/fill-parameters.js'; import { validateTrackNamespace, validateTrackNamespacePrefix, validateFullTrackName, isReservedSessionNamespace, isReservedDotNamespace } from '../primitives/bytes.js'; import { SessionError as SessionErrorCode, RequestError as RequestErrorCode } from '../errors.js'; import type { @@ -42,6 +45,7 @@ import type { FetchOk, FetchCancel, PublishDone, + PublishStateNotify, Unsubscribe, Goaway, MaxRequestId, @@ -87,6 +91,7 @@ import { MessageParam } from '../control/parameters.js'; import type { Parameters, ParameterValue, TrackProperties } from '../control/messages.js'; import { AuthTokenCache, AuthCacheError } from './auth-cache.js'; import { AliasType, parseAuthorizationToken, parseAuthorizationToken18, type AuthorizationToken, type ResolvedToken } from '../control/auth-token.js'; +import { isDraft21, isRequestStreamDraft } from '../versions.js'; /** * Set of known message parameter type codes. @@ -203,6 +208,61 @@ const VALID_PARAMS_FOR_MESSAGE_TYPE_18: Map> = new Map([ [MessageParam.TRACK_NAMESPACE_PREFIX as bigint, new Set(['REQUEST_UPDATE'])], ]); +// ─── draft-21 message parameters (draft-21 §9.20, §16.7) ───────────── + +/** draft-21 FILL_PARAMETERS (§9.20.16). */ +const FILL_PARAMETERS = 0x23n; + +/** draft-21 Range Filters (§3.3.2): SUBGROUP, OBJECTID, PRIORITY, OBJECT_PROPERTY, TRACK_PROPERTY. */ +const RANGE_FILTER_PARAMS_21 = new Set([0x25n, 0x26n, 0x27n, 0x28n, 0x29n]); + +/** Every draft-21 message parameter (draft-21 §16.7). */ +const KNOWN_MESSAGE_PARAMS_21 = new Set([ + ...KNOWN_MESSAGE_PARAMS_18, + FILL_PARAMETERS, + ...RANGE_FILTER_PARAMS_21, + 0x35n, // INCLUDE_PROPERTIES +]); + +/** + * draft-21 message-parameter scope table (§9.20). The same keys as the draft-18 + * table plus PUBLISH_STATE_NOTIFY; PUBLISH_OK carries only EXPIRES (the + * subscription parameters moved to PUBLISH and REQUEST_UPDATE), 0x21 is the + * LOCATION_FILTER, which FETCH and PUBLISH_STATE_NOTIFY also carry, and FETCH + * gains GROUP_ORDER and SUBSCRIBER_PRIORITY. SUBSCRIBE_TRACKS inherits the + * SUBSCRIBE parameter scope under 9.18.1. + */ +const VALID_PARAMS_FOR_MESSAGE_TYPE_21: Map> = new Map([ + [MessageParam.OBJECT_DELIVERY_TIMEOUT as bigint, new Set(['SUBSCRIBE', 'PUBLISH', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])], + [MessageParam.AUTHORIZATION_TOKEN as bigint, new Set([ + 'PUBLISH', 'SUBSCRIBE', 'REQUEST_UPDATE', 'SUBSCRIBE_NAMESPACE', 'SUBSCRIBE_TRACKS', + 'PUBLISH_NAMESPACE', 'TRACK_STATUS', 'FETCH', + ])], + [MessageParam.RENDEZVOUS_TIMEOUT as bigint, new Set(['SUBSCRIBE', 'SUBSCRIBE_TRACKS'])], + [MessageParam.SUBGROUP_DELIVERY_TIMEOUT as bigint, new Set(['SUBSCRIBE', 'PUBLISH', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])], + [MessageParam.EXPIRES as bigint, new Set(['SUBSCRIBE_OK', 'PUBLISH', 'PUBLISH_OK', 'REQUEST_UPDATE_OK', + 'SUBSCRIBE_NAMESPACE_OK', 'SUBSCRIBE_TRACKS_OK', 'PUBLISH_NAMESPACE_OK'])], + [MessageParam.LARGEST_OBJECT as bigint, new Set([ + 'SUBSCRIBE_OK', 'PUBLISH', 'REQUEST_UPDATE_OK', 'TRACK_STATUS_OK', 'PUBLISH_STATE_NOTIFY', + ])], + [MessageParam.FILL_TIMEOUT as bigint, new Set(['FETCH'])], + [MessageParam.FORWARD as bigint, new Set(['SUBSCRIBE', 'REQUEST_UPDATE', 'PUBLISH', 'SUBSCRIBE_TRACKS', 'PUBLISH_STATE_NOTIFY'])], + [MessageParam.SUBSCRIBER_PRIORITY as bigint, new Set(['SUBSCRIBE', 'FETCH', 'REQUEST_UPDATE', 'PUBLISH', 'SUBSCRIBE_TRACKS'])], + [MessageParam.SUBSCRIPTION_FILTER as bigint, new Set([ + 'SUBSCRIBE', 'REQUEST_UPDATE', 'PUBLISH', 'FETCH', 'SUBSCRIBE_TRACKS', 'PUBLISH_STATE_NOTIFY', + ])], + [MessageParam.GROUP_ORDER as bigint, new Set(['SUBSCRIBE', 'FETCH', 'PUBLISH', 'SUBSCRIBE_TRACKS'])], + [FILL_PARAMETERS, new Set(['SUBSCRIBE', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])], + [0x25n, new Set(['SUBSCRIBE', 'FETCH', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])], + [0x26n, new Set(['SUBSCRIBE', 'FETCH', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])], + [0x27n, new Set(['SUBSCRIBE', 'FETCH', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])], + [0x28n, new Set(['SUBSCRIBE', 'FETCH', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])], + [0x29n, new Set(['REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])], + [MessageParam.NEW_GROUP_REQUEST as bigint, new Set(['SUBSCRIBE', 'REQUEST_UPDATE', 'SUBSCRIBE_TRACKS'])], + [MessageParam.TRACK_NAMESPACE_PREFIX as bigint, new Set(['REQUEST_UPDATE'])], + [0x35n, new Set(['SUBSCRIBE', 'FETCH', 'TRACK_STATUS', 'SUBSCRIBE_TRACKS'])], +]); + /** * Whether two Track Namespace Prefixes overlap: one is a (field-wise) prefix of * the other, so a single PUBLISH_NAMESPACE could match both. Used to reject an @@ -279,6 +339,12 @@ export interface SetupOptions { * @see draft-ietf-moq-transport-16 §9.3.1.5 */ authTokens?: Uint8Array[]; + /** + * draft-21 §9.1.7: the MAX_REQUEST_UPDATES to advertise, the number of + * unanswered REQUEST_UPDATEs per request stream we accept. Omitted (or 0) + * means no limit; ignored before draft 21. + */ + maxRequestUpdates?: bigint; } /** @@ -305,6 +371,15 @@ export interface SubscribeOptions { * If omitted, the subscription is unfiltered (all objects pass). */ subscriptionFilter?: SubscriptionFilter; + /** + * draft-21 §3.4 only: FILL_PARAMETERS. The publisher delivers the fill range on + * a fill fetch stream whose FETCH_HEADER carries this SUBSCRIBE's Request ID; + * the stream ends with a FIN when complete, or is reset when the fill fails. + * `filter` is the fill's Location filter (e.g. `RelativeStart` 1 for the + * current group); without it the fill inherits the subscription filter. This replaces + * the Joining FETCH, which draft 21 removed. + */ + fill?: FillOptions; } /** @@ -333,6 +408,11 @@ export interface RequestUpdateOptions { * a draft-18-only concept; there is no such update in draft-14/16). */ trackNamespacePrefix?: Uint8Array[]; + /** + * draft-21 §3.4: FILL_PARAMETERS. The publisher opens another fill fetch stream + * whose FETCH_HEADER carries this REQUEST_UPDATE's Request ID. Draft 21 only. + */ + fill?: FillOptions; } /** @@ -408,6 +488,8 @@ export interface TrackSubscriptionState { /** Mutable: a §10.9.2 prefix update replaces it on REQUEST_OK. */ trackNamespacePrefix: Uint8Array[]; state: 'pending' | 'active' | 'terminated'; + /** draft-21 Forward State for future matching subscriptions. */ + forward: ForwardStateValue; /** Tracks the publisher reported it cannot serve, via PUBLISH_BLOCKED. */ readonly blockedTracks: Array<{ trackNamespaceSuffix: Uint8Array[]; trackName: Uint8Array }>; } @@ -555,6 +637,7 @@ export class Session { // means the update did not carry that parameter (leave the current value). objectDeliveryTimeoutMs?: number; subgroupDeliveryTimeoutMs?: number; + subscriptionFilter?: Uint8Array; }>(); /** @@ -609,7 +692,7 @@ export class Session { * OUTBOUND, subscriber side) AND from {@link incomingNamespaceSubscriptions} * (SUBSCRIBE_NAMESPACE) so prefix-overlap checks never cross request types. */ - private readonly incomingTrackSubscriptions = new Map(); + private readonly incomingTrackSubscriptions = new Map(); /** * Auth token alias cache for tokens the peer registers with us. @@ -635,6 +718,16 @@ export class Session { private _state: SessionStateValue = SessionState.IDLE; private _newSessionUri: string | undefined; private _peerMaxRequestId: Varint = varint(0n); + private _ownMaxRequestUpdates = 0n; + private _peerMaxRequestUpdates = 0n; + /** draft-21 fills still expected: fill Request ID → subscription Request ID. */ + private readonly fillRequests = new Map(); private _goawayReceived: boolean = false; constructor( @@ -688,6 +781,77 @@ export class Session { return this._peerMaxAuthTokenCacheSize; } + /** draft-21 §9.1.7: the MAX_REQUEST_UPDATES we advertised (0 = no limit). */ + get ownMaxRequestUpdates(): bigint { + return this._ownMaxRequestUpdates; + } + + /** draft-21 §9.1.7: the peer's MAX_REQUEST_UPDATES (0 = no limit). */ + get peerMaxRequestUpdates(): bigint { + return this._peerMaxRequestUpdates; + } + + private registerFillRequest(requestId: bigint, sub: SubscriptionStateMachine, options: FillOptions, filter?: SubscriptionFilter): void { + const groupOrder = options.groupOrder ?? sub.requestedGroupOrder; + this.fillRequests.set(requestId, { + subscriptionId: sub.requestId, subscription: sub, ready: false, + filter: options.filter ?? filter, + ...(groupOrder === undefined ? {} : { groupOrder }), + }); + sub.fillRequested = true; + } + + private settleFillRequest(requestId: bigint, parameters: Parameters): void { + const fill = this.fillRequests.get(requestId); + if (!fill) return; + const filter = fill.filter ?? (fill.subscription.currentFilter === undefined + ? undefined : decodeSubscriptionFilter(fill.subscription.currentFilter, 21)); + if (fill.subscription.forwardState !== ForwardState.ACTIVE + || resolveFillWindow(filter, this.extractLargestObjectParam(parameters) ?? undefined) === null) { + this.cancelFillRequest(requestId); + return; + } + fill.ready = true; + } + + /** The subscription a fill with this Request ID (a SUBSCRIBE's or a REQUEST_UPDATE's) belongs to, while one is expected. */ + fillSubscriptionFor(requestId: bigint): SubscriptionStateMachine | undefined { + // A clean terminal response can overtake its fill data. The I/O owner + // releases this context after consuming or explicitly cancelling the fill. + return this.fillRequests.get(requestId)?.subscription; + } + + /** Effective order for one fill, including its per-message override. */ + fillGroupOrderFor(requestId: bigint): GroupOrder | undefined { + const fill = this.fillRequests.get(requestId); + return fill === undefined ? undefined : fill.groupOrder ?? this.fillSubscriptionFor(requestId)?.groupOrder; + } + + isFillResponsePending(requestId: bigint): boolean { + return this.fillRequests.get(requestId)?.ready === false; + } + + /** Accept the fill stream for `requestId`: it stops being expected. */ + takeFill(requestId: bigint): SubscriptionStateMachine | undefined { + const sub = this.fillSubscriptionFor(requestId); + this.cancelFillRequest(requestId); + return sub; + } + + /** Stop expecting the fill for `requestId`; true when one was expected. */ + cancelFillRequest(requestId: bigint): boolean { + const fill = this.fillRequests.get(requestId); + if (fill === undefined) return false; + this.fillRequests.delete(requestId); + fill.subscription.fillRequested = this.expectedFillsOf(fill.subscriptionId).length > 0; + return true; + } + + /** Request IDs of the fills still expected for subscription `subscriptionId`. */ + expectedFillsOf(subscriptionId: bigint): bigint[] { + return [...this.fillRequests].filter(([, fill]) => fill.subscriptionId === subscriptionId).map(([id]) => id); + } + // ─── Setup Handshake ────────────────────────────────────────────────── /** @@ -697,9 +861,10 @@ export class Session { initiateSetup(options: SetupOptions = {}): SessionOutboundAction[] { this.assertState(SessionState.IDLE, 'initiateSetup'); - if (this._draftVersion === 18) { + if (isRequestStreamDraft(this._draftVersion)) { // draft-18: send the unified SETUP (no MAX_REQUEST_ID — QUIC stream limits). const setup = this.setupGate.createSetup18(options); + if (isDraft21(this._draftVersion)) this._ownMaxRequestUpdates = options.maxRequestUpdates ?? 0n; if (options.maxAuthTokenCacheSize !== undefined) { this._ownMaxAuthTokenCacheSize = Number(options.maxAuthTokenCacheSize); } @@ -735,8 +900,9 @@ export class Session { throw new SessionError('Only server can call completeSetup', 'INVALID_STATE'); } - if (this._draftVersion === 18) { + if (isRequestStreamDraft(this._draftVersion)) { const setup = this.setupGate.createSetup18(options); + if (isDraft21(this._draftVersion)) this._ownMaxRequestUpdates = options.maxRequestUpdates ?? 0n; if (options.maxAuthTokenCacheSize !== undefined) { this._ownMaxAuthTokenCacheSize = Number(options.maxAuthTokenCacheSize); } @@ -816,6 +982,8 @@ export class Session { return this.handleInboundPublishOk(msg as PublishOk); case 'PUBLISH_DONE': return this.handlePublishDone(msg); + case 'PUBLISH_STATE_NOTIFY': + return this.handlePublishStateNotify(msg); case 'UNSUBSCRIBE': return this.handleUnsubscribe(msg); case 'SUBSCRIBE': @@ -836,14 +1004,14 @@ export class Session { // draft-18 only: inbound SUBSCRIBE_NAMESPACE is a publisher-side // continuing request stream (§10.18). For draft-14/16 it is not handled // as an inbound message — fall through to the unsupported-type path. - if (this._draftVersion === 18) { + if (isRequestStreamDraft(this._draftVersion)) { return this.handleIncomingSubscribeNamespace(msg as SubscribeNamespace); } return this.handleUnsupportedControlMessage(msg); case 'SUBSCRIBE_TRACKS': // draft-18 only: inbound SUBSCRIBE_TRACKS is a publisher-side continuing // request stream (§10.19). It has no draft-14/16 inbound form. - if (this._draftVersion === 18) { + if (isRequestStreamDraft(this._draftVersion)) { return this.handleIncomingSubscribeTracks(msg as SubscribeTracks); } return this.handleUnsupportedControlMessage(msg); @@ -978,6 +1146,7 @@ export class Session { // draft-18 unified SETUP (role-neutral wire; this side interprets it). const result = this.setupGate.handleSetup18(msg as Setup); this._peerMaxRequestId = result.peerMaxRequestId; // 0 — draft-18 has no credit + this._peerMaxRequestUpdates = result.peerMaxRequestUpdates ?? 0n; // No MAX_REQUEST_ID in draft-18: do NOT update the request-id allocator credit. if (result.peerMaxAuthTokenCacheSize !== undefined) { this._peerMaxAuthTokenCacheSize = result.peerMaxAuthTokenCacheSize; @@ -1127,7 +1296,7 @@ export class Session { // draft-18 Token internals (Alias Type / Token Alias / Token Type) are vi64 // (full uint64); draft-14/16 use QUIC varint. Pick the wire parser by version. - const parseToken = this._draftVersion === 18 ? parseAuthorizationToken18 : parseAuthorizationToken; + const parseToken = isRequestStreamDraft(this._draftVersion) ? parseAuthorizationToken18 : parseAuthorizationToken; for (const rawValue of values) { if (!(rawValue instanceof Uint8Array)) continue; @@ -1209,8 +1378,9 @@ export class Session { // draft-18 §10.4: a control-stream GOAWAY MUST carry the Request ID, and its // parity MUST match the receiver's own request-id parity (the GOAWAY refers - // to the smallest of OUR requests the peer may not have processed). - if (this._draftVersion === 18) { + // to the smallest of OUR requests the peer may not have processed). Draft 21 + // (§9.2) dropped the Request ID. + if (isRequestStreamDraft(this._draftVersion) && !isDraft21(this._draftVersion)) { if (msg.requestId === undefined) { return this.closeWithError( SessionErrorCode.PROTOCOL_VIOLATION, @@ -1330,6 +1500,15 @@ export class Session { } sub.handleSubscribeOk(msg.trackAlias); + this.settleFillRequest(msg.requestId as bigint, msg.parameters); + if (isDraft21(this._draftVersion)) { + const properties = msg.trackProperties ?? msg.trackExtensions; + if (properties instanceof Map) sub.publisherGroupOrder = properties.get(0x22n)?.[0] === 2n ? 'descending' : 'ascending'; + sub.locationWindow = subscriptionWindow( + sub.currentFilter === undefined ? undefined : decodeSubscriptionFilter(sub.currentFilter, 21), + this.extractLargestObjectParam(msg.parameters) ?? undefined, + ); + } // Register track alias - §9.10/§10.1: duplicate alias must close connection const trackInfo = this.subscriptionTracks.get(msg.requestId as bigint); @@ -1360,7 +1539,8 @@ export class Session { const pending = this.pendingUpdates.get(msg.requestId as bigint); if (pending) { this.pendingUpdates.delete(msg.requestId as bigint); - // Don't apply the update — it was rejected + // Don't apply the update — it was rejected, and it opens no fill. + this.fillRequests.delete(msg.requestId as bigint); return []; } @@ -1375,6 +1555,7 @@ export class Session { const sub = this.subscriptions.get(msg.requestId as bigint); if (sub) { sub.handleRequestError(msg.errorCode, msg.errorReason); + for (const fillId of this.expectedFillsOf(sub.requestId)) this.cancelFillRequest(fillId); // §5.1: REQUEST_ERROR is terminal for the (pending) subscribe — RECLAIM the // full state (bounded). A second REQUEST_ERROR then takes the unknown-request // path below. @@ -1593,7 +1774,7 @@ export class Session { // draft-18 §10.2.1: validate the REQUEST_OK's Message Parameters against the // scope of the RESPONSE it stands in for, resolved from the request stream // (PUBLISH_OK / REQUEST_UPDATE_OK / TRACK_STATUS_OK / namespace responses). - if (this._draftVersion === 18 && (msg.parameters?.size ?? 0) > 0) { + if (isRequestStreamDraft(this._draftVersion) && (msg.parameters?.size ?? 0) > 0) { const context = this.d18RequestOkContext(msg.requestId as bigint); if (context) { const paramError = this.validateMessageParams(msg.parameters, context); @@ -1609,7 +1790,21 @@ export class Session { // Apply the update to the subscription (outgoing or, for a draft-18 // PUBLISH-initiated subscription, the incoming one). + if (pending.subscriptionFilter !== undefined) { + const sub = this.subscriptions.get(pending.existingRequestId) ?? this.incomingSubscriptions.get(pending.existingRequestId); + if (sub && sub.state !== SubscriptionState.TERMINATED) { + sub.currentFilter = pending.subscriptionFilter; + sub.locationWindow = subscriptionWindow( + decodeSubscriptionFilter(pending.subscriptionFilter, 21), + this.extractLargestObjectParam(msg.parameters) ?? undefined, + ); + } + } if (pending.forward !== undefined) { + if (pending.prefixTarget === 'tracks') { + const tracks = this.trackSubscriptions.get(pending.existingRequestId); + if (tracks?.state === 'active') tracks.forward = pending.forward; + } const sub = this.subscriptions.get(pending.existingRequestId) ?? this.incomingSubscriptions.get(pending.existingRequestId); if (sub) { @@ -1624,6 +1819,9 @@ export class Session { // terminated: settle without applying. } } + // Earlier FIFO responses can change the inherited filter and Forward + // state. Decide whether this fill exists only after committing this ack. + this.settleFillRequest(msg.requestId as bigint, msg.parameters); // §8: COMMIT the staged delivery timeouts now that the update is confirmed // (never at send — see requestUpdate). Omitted parameters leave the current @@ -1703,6 +1901,9 @@ export class Session { * delivery-timeout parameters apply only when present. */ private applyPublishAcceptanceParams(sub: SubscriptionStateMachine, parameters: Parameters | undefined): void { + // Draft 21 PUBLISH_OK carries only EXPIRES; subscription parameters remain + // those sent in PUBLISH until an accepted REQUEST_UPDATE changes them. + if (isDraft21(this._draftVersion)) return; const fwd = parameters?.get(MessageParam.FORWARD)?.[0]; // FORWARD omitted from an acceptance defaults to 1. Only omission from a // REQUEST_UPDATE preserves the existing state (§9.2.2.8 / d18 §10.2.12). @@ -1736,6 +1937,28 @@ export class Session { ); } + /** + * draft-21 §9.10: the publisher reports, on a subscription's request stream, + * the Largest Object it has sent, its Forward State and the Location Filter in + * effect. The Largest Object advances the subscription's; the rest is the + * publisher's view and is surfaced to the application as the message itself. + * The topology accepts the message only on a SUBSCRIBE (or inbound PUBLISH) + * stream; one that crosses our own cancellation of the request is ignored. + */ + private handlePublishStateNotify(msg: PublishStateNotify): SessionOutboundAction[] { + if (!isDraft21(this._draftVersion)) return this.handleUnsupportedControlMessage(msg); + const requestId = msg.requestId; + const incoming = requestId !== undefined ? this.incomingSubscriptions.get(requestId) : undefined; + const sub = requestId !== undefined + ? this.subscriptions.get(requestId) ?? (incoming?.isPublishInitiated ? incoming : undefined) : undefined; + if (sub === undefined) return []; + const largest = msg.parameters.get(MessageParam.LARGEST_OBJECT)?.[0]; + if (largest !== undefined && typeof largest === 'object' && 'group' in largest) { + sub.updateLargestLocation(largest.group, largest.object); + } + return []; + } + private handlePublishDone(msg: PublishDone): SessionOutboundAction[] { // §9.19: "The publisher does not send PUBLISH_DONE for this request" if (this.pendingTrackStatuses.has(msg.requestId as bigint)) { @@ -1801,7 +2024,7 @@ export class Session { * behavior unchanged). An empty namespace is permitted (allowEmptyNamespace). */ private validateFullName18(namespace: Uint8Array[], trackName: Uint8Array): SessionOutboundAction[] | null { - if (this._draftVersion !== 18) return null; + if (!isRequestStreamDraft(this._draftVersion)) return null; try { validateFullTrackName(namespace, trackName, { allowEmptyNamespace: true }); } catch (e) { @@ -1816,7 +2039,7 @@ export class Session { /** Draft-18 §2.4.1 defensive validation for a full Track Namespace (no track name, * e.g. PUBLISH_NAMESPACE). Same contract as {@link validateFullName18}. */ private validateNamespace18(namespace: Uint8Array[]): SessionOutboundAction[] | null { - if (this._draftVersion !== 18) return null; + if (!isRequestStreamDraft(this._draftVersion)) return null; try { validateTrackNamespace(namespace, { allowEmpty: true }); } catch (e) { @@ -1837,6 +2060,9 @@ export class Session { const validated = this.validateAndReplenish(msg.requestId); if (validated.error) return validated.error; + const filterError = this.rejectUnsupportedRangeFilters(msg.requestId, msg.parameters); + if (filterError) return filterError; + // §3.2: reserved `.`/`.session` namespace → REQUEST_ERROR DOES_NOT_EXIST. const reserved = this.rejectReservedNamespace(msg.trackNamespace, msg.requestId, validated.replenish); if (reserved) return reserved; @@ -1849,12 +2075,13 @@ export class Session { ); // §5.1: at most ONE subscription per Track per ROLE (drafts 16/18 only — - // draft-14 defines neither this rule nor DUPLICATE_SUBSCRIPTION 0x19). A peer + // draft-14 defines neither this rule nor DUPLICATE_SUBSCRIPTION 0x19, and + // draft 21 §3.1 allows concurrent subscriptions to one Track). A peer // SUBSCRIBE makes US the publisher; if we already hold a publisher-role // subscription for the same Full Track Name — an inbound SUBSCRIBE (incl. one // Pending) OR a LOCAL outbound PUBLISH — the REQUEST MUST fail with // DUPLICATE_SUBSCRIPTION. Request-level, not a session close. - if (this._draftVersion !== 14 && sub.trackKey !== undefined + if (this._draftVersion !== 14 && !isDraft21(this._draftVersion) && sub.trackKey !== undefined && (this.findLiveIncomingByTrack(sub.trackKey, /* publishInitiated */ false) || this.findLiveOutboundPublishByTrack(sub.trackKey))) { const errorMsg: RequestErrorMsg = { @@ -1875,6 +2102,7 @@ export class Session { const filterValues = msg.parameters.get(MessageParam.SUBSCRIPTION_FILTER); const filterBytes = filterValues?.[0]; if (filterBytes instanceof Uint8Array) { + sub.currentFilter = filterBytes; try { sub.setRemoteFilterType(decodeSubscriptionFilter(filterBytes, this._draftVersion).type); } catch { /* malformed — leave unfiltered */ } @@ -1887,6 +2115,8 @@ export class Session { if (typeof forwardVal === 'bigint') { sub.setInitialForwardState(forwardVal === 0n ? ForwardState.PAUSED : ForwardState.ACTIVE); } + const order = msg.parameters.get(MessageParam.GROUP_ORDER)?.[0]; + if (order !== undefined) sub.requestedGroupOrder = order === 2n ? 'descending' : 'ascending'; this.incomingSubscriptions.set(msg.requestId as bigint, sub); @@ -1926,12 +2156,16 @@ export class Session { if (typeof publishForward === 'bigint') { sub.setInitialForwardState(publishForward === 0n ? ForwardState.PAUSED : ForwardState.ACTIVE); } + if (isDraft21(this._draftVersion)) { + this.initializePublishPolicy21(sub, msg.parameters, msg.trackProperties); + } // §5.1: an endpoint may hold at most ONE subscription per Track per ROLE // (drafts 16/18 only — draft-14 defines neither this rule nor - // DUPLICATE_SUBSCRIPTION 0x19). A PUBLISH makes US the subscriber. Checked - // BEFORE touching the alias registry. - if (this._draftVersion !== 14 && sub.trackKey !== undefined) { + // DUPLICATE_SUBSCRIPTION 0x19, and draft 21 §3.1 allows concurrent + // subscriptions). A PUBLISH makes US the subscriber. Checked BEFORE + // touching the alias registry. + if (this._draftVersion !== 14 && !isDraft21(this._draftVersion) && sub.trackKey !== undefined) { const outboundSub = this.findLiveOutboundSubscribeByTrack(sub.trackKey); // (a) A committed subscriber-role subscription — an earlier PUBLISH we // accepted, or a local ESTABLISHED SUBSCRIBE — is a duplicate: reject. @@ -2009,6 +2243,32 @@ export class Session { return undefined; } + private assertPublisherAliasAvailable(alias: bigint, namespace: Uint8Array[], name: Uint8Array): void { + if (!isDraft21(this._draftVersion)) return; + for (const requests of [this.incomingSubscriptions, this.outgoingPublishes]) { + for (const sub of requests.values()) { + if (!sub.isPublisher || sub.isTerminated || sub.trackAlias !== alias) continue; + const sameTrack = sub.trackNamespace?.length === namespace.length + && prefixesOverlap(sub.trackNamespace, namespace) + && sub.trackName?.length === name.length + && sub.trackName.every((byte, index) => byte === name[index]); + if (!sameTrack) throw new SessionError(`Track alias ${alias} already belongs to a different track`, 'INVALID_STATE'); + } + } + } + + private initializePublishPolicy21(sub: SubscriptionStateMachine, parameters: Parameters, properties: TrackProperties | undefined): void { + const filter = parameters.get(MessageParam.SUBSCRIPTION_FILTER)?.[0]; + if (filter instanceof Uint8Array) sub.currentFilter = filter; + const order = parameters.get(MessageParam.GROUP_ORDER)?.[0]; + if (order !== undefined) sub.requestedGroupOrder = order === 2n ? 'descending' : 'ascending'; + sub.publisherGroupOrder = properties?.get(0x22n)?.[0] === 2n ? 'descending' : 'ascending'; + sub.locationWindow = subscriptionWindow( + sub.currentFilter === undefined ? undefined : decodeSubscriptionFilter(sub.currentFilter, 21), + this.extractLargestObjectParam(parameters) ?? undefined, + ); + } + /** * §5.1: perform a STAGED cancellation of a local SUBSCRIBE superseded by an * inbound PUBLISH, at the moment we accept that PUBLISH (before PUBLISH_OK). No @@ -2035,7 +2295,7 @@ export class Session { requestId: localReqId, reason: 'local SUBSCRIBE superseded by a peer PUBLISH for the same track (§5.1)', }; - if (this._draftVersion === 18) return [cancel]; + if (isRequestStreamDraft(this._draftVersion)) return [cancel]; return [this.sendControl({ type: 'UNSUBSCRIBE', requestId: localReqId } as Unsubscribe), cancel]; } @@ -2048,6 +2308,7 @@ export class Session { */ private terminateLocalSubscriberRequest(sub: SubscriptionStateMachine): void { const requestId = sub.requestId; + for (const fillId of this.expectedFillsOf(requestId)) this.cancelFillRequest(fillId); // Capture the phase BEFORE superseding: if it was already ESTABLISHED, no crossed // SUBSCRIBE_OK is legal (§5.1), so the shadow must start ESTABLISHED. (At cancel // time the SM is PENDING or ESTABLISHED — never yet TERMINATED.) @@ -2148,6 +2409,9 @@ export class Session { const validated = this.validateAndReplenish(msg.requestId); if (validated.error) return validated.error; + const filterError = this.rejectUnsupportedRangeFilters(msg.requestId, msg.parameters); + if (filterError) return filterError; + // Joining Fetch (0x2/0x3): enforce the MUSTs decidable from protocol state // BEFORE creating any fetch state (§9.16.2 / draft-18 §10.12.2). if (msg.fetch.fetchType !== 0x1) { @@ -2190,7 +2454,7 @@ export class Session { return [this.sendControl(errorMsg), ...(validated.replenish ?? [])]; } - if (this._draftVersion === 18) { + if (isRequestStreamDraft(this._draftVersion)) { // draft-18 §10.12.2: "A Joining Fetch is only permitted when the // associated subscription has Forward State 1; otherwise the publisher // MUST respond with a REQUEST_ERROR with error code INVALID_RANGE." @@ -2336,7 +2600,7 @@ export class Session { // ONLY when it targets a SUBSCRIBE_NAMESPACE / SUBSCRIBE_TRACKS (handled // above). On a REQUEST_UPDATE for a normal subscription/fetch/publish it is // out of scope → PROTOCOL_VIOLATION. - if (this._draftVersion === 18 && msg.parameters.has(MessageParam.TRACK_NAMESPACE_PREFIX as bigint)) { + if (isRequestStreamDraft(this._draftVersion) && msg.parameters.has(MessageParam.TRACK_NAMESPACE_PREFIX as bigint)) { return this.closeWithError( SessionErrorCode.PROTOCOL_VIOLATION, `TRACK_NAMESPACE_PREFIX is out of scope for a REQUEST_UPDATE that does not target a SUBSCRIBE_NAMESPACE/SUBSCRIBE_TRACKS (§10.2.14)`, @@ -2365,6 +2629,10 @@ export class Session { // draft-18 §10.9: a PUBLISH_NAMESPACE may be updated on its request stream. // We have no per-namespace mutable state to apply yet — acknowledge with // REQUEST_OK (or, for v14, no response). Params are accepted as-is. + const contextError = this.validateUpdateContext21(msg.parameters, sub ? 'SUBSCRIBE' : fetch ? 'FETCH' : 'PUBLISH_NAMESPACE'); + if (contextError) return contextError; + const filterError = this.rejectUnsupportedRangeFilters(msg.requestId, msg.parameters); + if (filterError) return filterError; if (pubNs && !sub && !fetch) { if (this._draftVersion === 14) return validated.replenish ?? []; const requestOk: RequestOk = { type: 'REQUEST_OK', requestId: msg.requestId, parameters: new Map() }; @@ -2377,7 +2645,7 @@ export class Session { // cover publish-initiated subscriptions: an update acknowledgement before // PUBLISH_OK has ambiguous correlation on the publish stream. if (sub && sub.state !== 'established' - && !(this._draftVersion === 18 && sub.state === 'pending' && !sub.isPublishInitiated)) { + && !(isRequestStreamDraft(this._draftVersion) && sub.state === 'pending' && !sub.isPublishInitiated)) { return this.closeWithError( SessionErrorCode.PROTOCOL_VIOLATION, `REQUEST_UPDATE for subscription ${msg.existingRequestId} in state ${sub.state}; expected established`, @@ -2438,12 +2706,12 @@ export class Session { // emitted after them carries the current largest and satisfies the save // requirement for the resumed state. Failing such an update closed here // would reject the very races §10.12.2 requires publishers to tolerate. - const wantsResume = this._draftVersion === 18 && isSubscriptionScope + const wantsResume = isRequestStreamDraft(this._draftVersion) && isSubscriptionScope && sub!.state === 'established' && prevForwardState === ForwardState.PAUSED && forwardValues !== undefined && forwardValues.length > 0 && typeof forwardValues[0] === 'bigint' && forwardValues[0] !== 0n; - if (wantsResume) { + if (wantsResume || (isDraft21(this._draftVersion) && isSubscriptionScope)) { // d18 §10.11: "PUBLISH_DONE ... UPDATE_FAILED": an unsuccessful // subscription update terminates the subscription. The SESSION emits // only the REQUEST_ERROR here — the I/O layer owns the termination @@ -2452,7 +2720,7 @@ export class Session { // stream, none of which bare Session actions can do). The adapter // detects the REQUEST_ERROR update response and runs its publishDone. const failClosed = (why: string): SessionOutboundAction[] => { - const reason = `cannot resume subscription ${msg.existingRequestId}: ${why} (§5.1 requires the REQUEST_UPDATE_OK to carry the current Largest Location)`; + const reason = `cannot update subscription ${msg.existingRequestId}: ${why} (REQUEST_UPDATE_OK requires the current Largest Location)`; return [this.sendControl({ type: 'REQUEST_ERROR', requestId: msg.requestId, @@ -2500,10 +2768,17 @@ export class Session { // Omitted ⇒ unchanged (per the same section). const updatedFilter = msg.parameters.get(MessageParam.SUBSCRIPTION_FILTER)?.[0]; if (updatedFilter instanceof Uint8Array && sub) { + sub.currentFilter = updatedFilter; try { sub.setRemoteFilterType(decodeSubscriptionFilter(updatedFilter, this._draftVersion).type); } catch { /* malformed bytes were already rejected by parameter validation */ } } + if (isDraft21(this._draftVersion) && isSubscriptionScope && sub && updatedFilter instanceof Uint8Array) { + sub.locationWindow = subscriptionWindow( + sub.currentFilter === undefined ? undefined : decodeSubscriptionFilter(sub.currentFilter, 21), + resumeLocation ?? undefined, + ); + } // §8: OBJECT/SUBGROUP_DELIVERY_TIMEOUT MAY appear in REQUEST_UPDATE — the receiver // records the peer's new value on the subscription (omitted ⇒ unchanged, §9.11). @@ -2566,11 +2841,19 @@ export class Session { ); } + const contextError = this.validateUpdateContext21(msg.parameters, incNs ? 'SUBSCRIBE_NAMESPACE' : 'SUBSCRIBE_TRACKS'); + if (contextError) return contextError; + const filterError = this.rejectUnsupportedRangeFilters(msg.requestId, msg.parameters); + if (filterError) return filterError; + // §10.9: a parameter absent from REQUEST_UPDATE leaves its value unchanged. - // No TRACK_NAMESPACE_PREFIX → nothing to change; acknowledge. + // Forward on SUBSCRIBE_TRACKS changes future subscriptions only (§9.20.19). + const forward = isDraft21(this._draftVersion) && incTracks + ? msg.parameters.get(MessageParam.FORWARD)?.[0] : undefined; const prefixVals = msg.parameters.get(MessageParam.TRACK_NAMESPACE_PREFIX as bigint); const newPrefix = prefixVals && prefixVals.length > 0 ? prefixVals[prefixVals.length - 1] : undefined; if (newPrefix === undefined) { + if (incTracks && typeof forward === 'bigint') incTracks.forward = Number(forward) as ForwardStateValue; const ok: RequestOk = { type: 'REQUEST_OK', requestId: msg.requestId, parameters: new Map() }; return [this.sendControl(ok), ...(replenish ?? [])]; } @@ -2607,6 +2890,7 @@ export class Session { } } incTracks.trackNamespacePrefix = newPrefix; + if (typeof forward === 'bigint') incTracks.forward = Number(forward) as ForwardStateValue; } const ok: RequestOk = { type: 'REQUEST_OK', requestId: msg.requestId, parameters: new Map() }; @@ -2695,6 +2979,14 @@ export class Session { } const requestedForward = this.normalizeLocalForwardState(options.forward, 'SUBSCRIBE'); + if (options.fill !== undefined && !isDraft21(this._draftVersion)) { + throw new RangeError(`FILL_PARAMETERS needs draft 21; this session speaks draft ${this._draftVersion}`); + } + // Encode before allocating: bad local options must not leave a pending request. + const filterBytes = options.subscriptionFilter === undefined + ? undefined : encodeSubscriptionFilter(options.subscriptionFilter, this._draftVersion); + const fillBytes = options.fill === undefined + ? undefined : encodeFillParameters(options.fill.filter, options.fill.groupOrder); const requestId = this.requestIdAllocator.allocate(); @@ -2718,6 +3010,7 @@ export class Session { } if (options.groupOrder !== undefined) { parameters.set(MessageParam.GROUP_ORDER, [options.groupOrder]); + sub.requestedGroupOrder = options.groupOrder === 2n ? 'descending' : 'ascending'; } if (requestedForward !== undefined) { // §9.2.2.8: FORWARD MAY appear in SUBSCRIBE — initial forward state. @@ -2725,12 +3018,17 @@ export class Session { parameters.set(MessageParam.FORWARD, [varint(BigInt(requestedForward))]); sub.setInitialForwardState(requestedForward); } - if (options.subscriptionFilter !== undefined) { - const filterBytes = encodeSubscriptionFilter(options.subscriptionFilter, this._draftVersion); + if (filterBytes !== undefined) { parameters.set(MessageParam.SUBSCRIPTION_FILTER, [filterBytes]); // Store for draft-14 SUBSCRIBE_UPDATE replay sub.currentFilter = filterBytes; } + if (fillBytes !== undefined) { + parameters.set(FILL_PARAMETERS, [fillBytes]); + if (sub.forwardState === ForwardState.ACTIVE) { + this.registerFillRequest(requestId, sub, options.fill!, options.subscriptionFilter); + } + } const subscribeMsg: Subscribe = { type: 'SUBSCRIBE', @@ -2769,7 +3067,12 @@ export class Session { namespace: Uint8Array[], name: Uint8Array, trackAlias: bigint, - options: { parameters?: Parameters; trackProperties?: TrackProperties } = {}, + options: { + parameters?: Parameters; + trackProperties?: TrackProperties; + /** draft-21: the accepted SUBSCRIBE_TRACKS that caused this PUBLISH. */ + subscribeTracksRequestId?: bigint; + } = {}, ): RequestResult { this.assertEstablishedOrDraining('publish'); this.assertNotReservedNamespace(namespace, 'publish'); @@ -2787,6 +3090,24 @@ export class Session { // PUBLISH and leave a locally-authorized subscription behind if encoding // later rejected it. this.assertLocalForwardParameter(options.parameters, 'PUBLISH'); + if (options.subscribeTracksRequestId !== undefined) { + const tracks = this.incomingTrackSubscriptions.get(options.subscribeTracksRequestId); + if (!isDraft21(this._draftVersion) || tracks?.state !== 'active' + || tracks.trackNamespacePrefix.length > namespace.length + || !prefixesOverlap(tracks.trackNamespacePrefix, namespace)) { + throw new SessionError('PUBLISH requires an active matching draft-21 SUBSCRIBE_TRACKS', 'INVALID_STATE'); + } + // Inherit only when the caller identifies this as a resulting PUBLISH. + // Independent publications retain publisher authority (§9.8 / §9.18.1). + const parameters = new Map(options.parameters); + parameters.set(MessageParam.FORWARD, [varint(BigInt(tracks.forward))]); + options = { ...options, parameters }; + } + this.assertPublisherAliasAvailable(trackAlias, namespace, name); + if (isDraft21(this._draftVersion)) { + const error = this.validateMessageParams(options.parameters ?? new Map(), 'PUBLISH'); + if (error) throw new SessionError(error.reason, 'INVALID_STATE'); + } const trackProperties = this.resolveTrackProperties(options.trackProperties, 'PUBLISH'); @@ -2799,6 +3120,7 @@ export class Session { // Location, exactly as with SUBSCRIBE_OK. const communicated = this.extractLargestObjectParam(options.parameters); if (communicated) this.incomingJoiningLocations.set(requestId as bigint, communicated); + if (isDraft21(this._draftVersion)) this.initializePublishPolicy21(sub, options.parameters ?? new Map(), trackProperties); // The PUBLISH's FORWARD parameter sets the subscription's initial Forward // State (§10.10) — the d18 joining-fetch gate consults it at establish. const initialForward = options.parameters?.get(MessageParam.FORWARD)?.[0]; @@ -2866,7 +3188,7 @@ export class Session { // draft-18 removed the UNSUBSCRIBE message; the subscriber cancels by tearing // down the request stream (RESET_STREAM + STOP_SENDING, §3.3.2) — the I/O layer // does that; emit NO control message. draft-14/16 send UNSUBSCRIBE. - if (this._draftVersion === 18) return []; + if (isRequestStreamDraft(this._draftVersion)) return []; return [this.sendControl({ type: 'UNSUBSCRIBE', requestId } as Unsubscribe)]; } @@ -2883,6 +3205,24 @@ export class Session { */ rollbackRequestUpdate(requestId: bigint): void { this.pendingUpdates.delete(requestId as bigint); + this.fillRequests.delete(requestId as bigint); + } + + private validateUpdateContext21(params: Parameters, requestType: string): SessionOutboundAction[] | undefined { + if (!isDraft21(this._draftVersion)) return undefined; + for (const key of params.keys()) { + const scopes = VALID_PARAMS_FOR_MESSAGE_TYPE_21.get(key); + if (!scopes) continue; + const prefixUpdate = key === MessageParam.TRACK_NAMESPACE_PREFIX + && (requestType === 'SUBSCRIBE_NAMESPACE' || requestType === 'SUBSCRIBE_TRACKS'); + const subscriptionOnly = key === FILL_PARAMETERS || key === MessageParam.SUBSCRIPTION_FILTER + || (key >= 0x25n && key <= 0x28n); + if (!prefixUpdate && (!scopes.has(requestType) || (requestType === 'FETCH' && subscriptionOnly))) { + return this.closeWithError(SessionErrorCode.PROTOCOL_VIOLATION, + `Parameter 0x${key.toString(16)} is out of scope for REQUEST_UPDATE on ${requestType} (9.20)`); + } + } + return undefined; } /** @@ -2896,6 +3236,23 @@ export class Session { ): RequestResult { this.assertEstablishedOrDraining('requestUpdate'); const requestedForward = this.normalizeLocalForwardState(options.forward, 'REQUEST_UPDATE'); + // draft-21 §9.1.7: never more than the peer's MAX_REQUEST_UPDATES unanswered + // updates on one request stream; each REQUEST_OK / REQUEST_ERROR restores one. + if (isDraft21(this._draftVersion) && this._peerMaxRequestUpdates > 0n) { + let outstanding = 0n; + for (const pending of this.pendingUpdates.values()) { + if (pending.existingRequestId === (existingRequestId as bigint)) outstanding += 1n; + } + if (outstanding >= this._peerMaxRequestUpdates) { + throw new SessionError( + `${outstanding} REQUEST_UPDATEs on request ${existingRequestId} are unanswered; the peer's MAX_REQUEST_UPDATES is ${this._peerMaxRequestUpdates}`, + 'INVALID_STATE', + ); + } + } + if (options.fill !== undefined && !isDraft21(this._draftVersion)) { + throw new RangeError(`FILL_PARAMETERS needs draft 21; this session speaks draft ${this._draftVersion}`); + } // draft-18 §10.9.2: a prefix update targets an outbound SUBSCRIBE_NAMESPACE / // SUBSCRIBE_TRACKS request (tracked in separate maps, not `subscriptions`). @@ -2925,7 +3282,7 @@ export class Session { // draft-18 §10.12.2 contemplates REQUEST_UPDATE racing establishment // ("process any pending REQUEST_UPDATE messages ... before evaluating"), // so a d18 subscriber may update a still-PENDING subscription. - const updatableWhilePending = this._draftVersion === 18 && sub.state === 'pending'; + const updatableWhilePending = isRequestStreamDraft(this._draftVersion) && sub.state === 'pending'; if (sub.state !== 'established' && !updatableWhilePending) { throw new SessionError( `Cannot update subscription in state ${sub.state}; expected established`, @@ -2933,6 +3290,13 @@ export class Session { ); } + const filterBytes = options.subscriptionFilter === undefined ? undefined + : encodeSubscriptionFilter(options.subscriptionFilter, this._draftVersion); + const fillBytes = options.fill === undefined ? undefined + : encodeFillParameters(options.fill.filter, options.fill.groupOrder); + if (options.subgroupDeliveryTimeout !== undefined && !isRequestStreamDraft(this._draftVersion)) { + throw new SessionError('subgroupDeliveryTimeout (SUBGROUP_DELIVERY_TIMEOUT, 0x06) is draft-18 only', 'INVALID_STATE'); + } const requestId = this.requestIdAllocator.allocate(); // Build parameters @@ -2959,26 +3323,16 @@ export class Session { } // §9.2.2.5: SUBSCRIPTION_FILTER MAY appear in REQUEST_UPDATE - if (options.subscriptionFilter !== undefined) { - const filterBytes = encodeSubscriptionFilter(options.subscriptionFilter, this._draftVersion); + if (filterBytes !== undefined) { parameters.set(MessageParam.SUBSCRIPTION_FILTER, [filterBytes]); // Update stored filter for future draft-14 replays - sub.currentFilter = filterBytes; + if (!isDraft21(this._draftVersion)) sub.currentFilter = filterBytes; } else if (this._draftVersion === 14 && sub.currentFilter) { // Draft-14 §9.10: Start Location and End Group are mandatory inline fields. // If no new filter specified, replay the current filter to avoid widening. parameters.set(MessageParam.SUBSCRIPTION_FILTER, [sub.currentFilter]); } - // §8 / §10.2.3: SUBGROUP_DELIVERY_TIMEOUT (0x06) is a draft-18-only Message - // Parameter — emitting it on draft-14/16 would make a conformant peer close on - // the unknown parameter, so reject it locally outside draft-18. - if (options.subgroupDeliveryTimeout !== undefined && this._draftVersion !== 18) { - throw new SessionError( - 'subgroupDeliveryTimeout (SUBGROUP_DELIVERY_TIMEOUT, 0x06) is draft-18 only', - 'INVALID_STATE', - ); - } // §8: OBJECT/SUBGROUP_DELIVERY_TIMEOUT MAY appear in REQUEST_UPDATE. STAGE the new // values on the pending record (drafts 16/18 apply on REQUEST_OK; draft-14 inline // below) so a rejected update / write rollback cannot commit them prematurely. An @@ -2989,6 +3343,10 @@ export class Session { if (options.subgroupDeliveryTimeout !== undefined) { parameters.set(MessageParam.SUBGROUP_DELIVERY_TIMEOUT, [options.subgroupDeliveryTimeout]); } + if (fillBytes !== undefined) { + parameters.set(FILL_PARAMETERS, [fillBytes]); + this.registerFillRequest(requestId, sub, options.fill!, options.subscriptionFilter); + } if (this._draftVersion === 14) { // Draft-14 §9.10: "There is no control message in response to a @@ -3005,12 +3363,16 @@ export class Session { const pending: { existingRequestId: bigint; forward?: ForwardStateValue; objectDeliveryTimeoutMs?: number; subgroupDeliveryTimeoutMs?: number; + subscriptionFilter?: Uint8Array; } = { existingRequestId: existingRequestId as bigint }; if (requestedForward !== undefined) { pending.forward = requestedForward; } if (options.objectDeliveryTimeout !== undefined) pending.objectDeliveryTimeoutMs = Number(options.objectDeliveryTimeout); if (options.subgroupDeliveryTimeout !== undefined) pending.subgroupDeliveryTimeoutMs = Number(options.subgroupDeliveryTimeout); + if (isDraft21(this._draftVersion) && options.subscriptionFilter !== undefined) { + pending.subscriptionFilter = parameters.get(MessageParam.SUBSCRIPTION_FILTER)![0] as Uint8Array; + } this.pendingUpdates.set(requestId as bigint, pending); } @@ -3037,7 +3399,7 @@ export class Session { existingRequestId: bigint, options: RequestUpdateOptions, ): RequestResult { - if (this._draftVersion !== 18) { + if (!isRequestStreamDraft(this._draftVersion)) { throw new SessionError( `Track Namespace Prefix REQUEST_UPDATE requires draft-18 (current draft-${this._draftVersion})`, 'INVALID_STATE', @@ -3055,24 +3417,32 @@ export class Session { } const prefix = options.trackNamespacePrefix; - if (!prefix) { + const forward = this.normalizeLocalForwardState(options.forward, 'REQUEST_UPDATE'); + const supportsForward = isDraft21(this._draftVersion) && trackSub !== undefined; + if (!prefix && !(supportsForward && forward !== undefined)) { throw new SessionError( `REQUEST_UPDATE for a ${target === 'namespace' ? 'SUBSCRIBE_NAMESPACE' : 'SUBSCRIBE_TRACKS'} requires trackNamespacePrefix`, 'INVALID_STATE', ); } + if (forward !== undefined && !supportsForward) { + throw new SessionError('FORWARD prefix updates require draft-21 SUBSCRIBE_TRACKS', 'INVALID_STATE'); + } try { - validateTrackNamespacePrefix(prefix); + if (prefix) validateTrackNamespacePrefix(prefix); } catch (e) { throw new SessionError(e instanceof Error ? e.message : 'Malformed Track Namespace Prefix', 'PROTOCOL_VIOLATION'); } const requestId = this.requestIdAllocator.allocate(); - const parameters: Parameters = new Map([[MessageParam.TRACK_NAMESPACE_PREFIX as bigint, [prefix]]]); + const parameters: Parameters = new Map(); + if (prefix) parameters.set(MessageParam.TRACK_NAMESPACE_PREFIX as bigint, [prefix]); + if (forward !== undefined) parameters.set(MessageParam.FORWARD, [varint(BigInt(forward))]); this.pendingUpdates.set(requestId as bigint, { existingRequestId: existingRequestId as bigint, - namespacePrefix: prefix, + ...(prefix === undefined ? {} : { namespacePrefix: prefix }), prefixTarget: target, + ...(forward === undefined ? {} : { forward }), }); const updateMsg: RequestUpdate = { type: 'REQUEST_UPDATE', requestId, existingRequestId, parameters }; return { requestId, actions: [this.sendControl(updateMsg)] }; @@ -3346,7 +3716,7 @@ export class Session { this.fetches.delete(requestId as bigint); if (!established) this.recordCancellation(requestId, 'fetch', false); - if (this._draftVersion === 18) { + if (isRequestStreamDraft(this._draftVersion)) { // draft-18 removed the FETCH_CANCEL control message (§3.3.2): cancellation // is STOP_SENDING / RESET_STREAM on the request + data streams, performed by // the I/O layer. No control message is emitted. @@ -3397,10 +3767,20 @@ export class Session { /** * Get track info by alias. */ - getTrackByAlias(alias: Varint) { + getTrackByAlias(alias: bigint) { return this.trackAliases.getByAlias(alias); } + /** Live subscriber-side requests sharing one peer-assigned Track Alias. */ + getSubscriberRequestsForAlias(alias: bigint): SubscriptionStateMachine[] { + const result: SubscriptionStateMachine[] = []; + for (const id of this.trackAliases.owners(alias)) { + const sub = this.subscriptions.get(id) ?? this.incomingSubscriptions.get(id); + if (sub && !sub.isTerminated) result.push(sub); + } + return result; + } + // ─── Namespace Discovery Operations ────────────────────────────────── /** @@ -3474,7 +3854,7 @@ export class Session { this._newSessionUri ?? '', ); } - if (this._draftVersion !== 18) { + if (!isRequestStreamDraft(this._draftVersion)) { throw new SessionError('SUBSCRIBE_TRACKS is a draft-18 message', 'INVALID_STATE'); } @@ -3483,6 +3863,7 @@ export class Session { trackNamespacePrefix: namespacePrefix, state: 'pending', blockedTracks: [], + forward: ForwardState.ACTIVE, }); const msg: SubscribeTracks = { @@ -3616,7 +3997,7 @@ export class Session { // draft-18 §3.3.2: no PUBLISH_NAMESPACE_DONE on the wire — withdrawal is a // request-stream cancellation handled by the I/O layer. Terminate state only. - if (this._draftVersion === 18) { + if (isRequestStreamDraft(this._draftVersion)) { return []; } @@ -3684,7 +4065,7 @@ export class Session { */ private resolveTrackProperties(trackProperties: TrackProperties | undefined, context: string): TrackProperties { const props = trackProperties ?? new Map(); - if (this._draftVersion !== 18 && props.size > 0) { + if (!isRequestStreamDraft(this._draftVersion) && props.size > 0) { throw new SessionError( `Track Properties on ${context} require draft-18 (current draft-${this._draftVersion})`, 'INVALID_STATE', @@ -3947,7 +4328,7 @@ export class Session { message: msg, }; // draft-18: track the inbound announce so a stream FIN/reset can withdraw it. - if (this._draftVersion === 18) { + if (isRequestStreamDraft(this._draftVersion)) { this.incomingPublishNamespaces.set(msg.requestId as bigint, { namespace: msg.trackNamespace }); } return [this.sendControl(okMsg), notifyAction, ...(validated.replenish ?? [])]; @@ -3955,7 +4336,7 @@ export class Session { // Match found — record the announced namespace. match.nsSm.handleNamespace(msg.trackNamespace); - if (this._draftVersion === 18) { + if (isRequestStreamDraft(this._draftVersion)) { this.incomingPublishNamespaces.set(msg.requestId as bigint, { namespace: msg.trackNamespace }); } @@ -4150,6 +4531,9 @@ export class Session { const validated = this.validateAndReplenish(msg.requestId); if (validated.error) return validated.error; + const filterError = this.rejectUnsupportedRangeFilters(msg.requestId, msg.parameters); + if (filterError) return filterError; + try { validateTrackNamespacePrefix(msg.trackNamespacePrefix); } catch (e) { @@ -4181,6 +4565,8 @@ export class Session { this.incomingTrackSubscriptions.set(msg.requestId as bigint, { trackNamespacePrefix: msg.trackNamespacePrefix, state: 'pending', + forward: isDraft21(this._draftVersion) && msg.parameters.get(MessageParam.FORWARD)?.[0] === 0n + ? ForwardState.PAUSED : ForwardState.ACTIVE, }); return validated.replenish ?? []; } @@ -4237,7 +4623,7 @@ export class Session { } /** Whether we are serving an incoming SUBSCRIBE_TRACKS (pending or active). */ - getIncomingTrackSubscription(requestId: bigint): { trackNamespacePrefix: Uint8Array[]; state: 'pending' | 'active' } | undefined { + getIncomingTrackSubscription(requestId: bigint): { trackNamespacePrefix: Uint8Array[]; state: 'pending' | 'active'; forward: ForwardStateValue } | undefined { return this.incomingTrackSubscriptions.get(requestId as bigint); } @@ -4606,6 +4992,10 @@ export class Session { // Reject local misuse before sendSubscribeOk() establishes the state // machine or installs any alias authority. this.assertLocalForwardParameter(options.parameters, 'PUBLISH acceptance'); + if (isDraft21(this._draftVersion)) { + const error = this.validateMessageParams(options.parameters ?? new Map(), 'PUBLISH_OK'); + if (error) throw new SessionError(error.reason, 'INVALID_STATE'); + } } // Validate ALL misuse up-front, BEFORE any state mutation: draft-14/16 Track @@ -4621,6 +5011,12 @@ export class Session { ); } + if (!sub.isPublishInitiated && isDraft21(this._draftVersion)) { + this.assertPublisherAliasAvailable(trackAlias, sub.trackNamespace!, sub.trackName!); + const error = this.validateMessageParams(options.parameters ?? new Map(), 'SUBSCRIBE_OK'); + if (error) throw new SessionError(error.reason, 'INVALID_STATE'); + } + sub.sendSubscribeOk(trackAlias); // PUBLISH-initiated subscriptions respond with a publish-acceptance message @@ -4634,7 +5030,7 @@ export class Session { // capacity), return ONLY the close — do NOT also append an acceptance. if (preActions.some((a) => a.type === 'close_connection')) return preActions; let responseParameters: Parameters; - if (this._draftVersion === 18) { + if (isRequestStreamDraft(this._draftVersion)) { responseParameters = options.parameters ?? new Map(); } else { // Draft-14/16 §9.14: derived initial-state fields first, with caller- @@ -4648,13 +5044,15 @@ export class Session { // Apply the exact value communicated on the wire, including the default // of 1 when FORWARD is absent, so the two endpoints cannot disagree. const acceptFwd = responseParameters.get(MessageParam.FORWARD)?.[0]; - sub.updateForwardState( - typeof acceptFwd === 'bigint' && acceptFwd === 0n - ? ForwardState.PAUSED - : ForwardState.ACTIVE, - ); + if (!isDraft21(this._draftVersion)) { + sub.updateForwardState( + typeof acceptFwd === 'bigint' && acceptFwd === 0n + ? ForwardState.PAUSED + : ForwardState.ACTIVE, + ); + } - if (this._draftVersion === 18) { + if (isRequestStreamDraft(this._draftVersion)) { // draft-18 §10.10: PUBLISH_OK is REQUEST_OK shorthand (wire 0x07, no // Request ID); the I/O layer writes it on the inbound PUBLISH request // stream, not the control stream. Caller-supplied response parameters @@ -4680,6 +5078,13 @@ export class Session { const communicated = this.extractLargestObjectParam(options.parameters); if (communicated) this.incomingJoiningLocations.set(requestId as bigint, communicated); + if (isDraft21(this._draftVersion)) { + sub.publisherGroupOrder = trackProperties.get(0x22n)?.[0] === 2n ? 'descending' : 'ascending'; + sub.locationWindow = subscriptionWindow( + sub.currentFilter === undefined ? undefined : decodeSubscriptionFilter(sub.currentFilter, 21), communicated ?? undefined, + ); + } + const subscribeOk: SubscribeOk = { type: 'SUBSCRIBE_OK', requestId, @@ -4782,6 +5187,7 @@ export class Session { existingRequestId: bigint, options: RequestUpdateOptions = {}, ): RequestResult { + this.assertEstablishedOrDraining('updateIncomingSubscription'); const sub = this.incomingSubscriptions.get(existingRequestId as bigint); if (!sub) { throw new SessionError( @@ -4797,9 +5203,22 @@ export class Session { } const requestedForward = this.normalizeLocalForwardState(options.forward, 'REQUEST_UPDATE'); - + if (options.fill !== undefined && !isDraft21(this._draftVersion)) { + throw new RangeError(`FILL_PARAMETERS needs draft 21; this session speaks draft ${this._draftVersion}`); + } + if (isDraft21(this._draftVersion) && this._peerMaxRequestUpdates > 0n) { + const pending = [...this.pendingUpdates.values()].filter((p) => p.existingRequestId === existingRequestId).length; + if (BigInt(pending) >= this._peerMaxRequestUpdates) throw new SessionError('MAX_REQUEST_UPDATES exceeded', 'INVALID_STATE'); + } + const filterBytes = options.subscriptionFilter === undefined ? undefined : encodeSubscriptionFilter(options.subscriptionFilter, this._draftVersion); + const fillBytes = options.fill === undefined ? undefined : encodeFillParameters(options.fill.filter, options.fill.groupOrder); const requestId = this.requestIdAllocator.allocate(); const parameters: Parameters = new Map(); + if (filterBytes !== undefined) parameters.set(MessageParam.SUBSCRIPTION_FILTER, [filterBytes]); + if (fillBytes !== undefined) { + parameters.set(FILL_PARAMETERS, [fillBytes]); + this.registerFillRequest(requestId, sub, options.fill!, options.subscriptionFilter); + } if (requestedForward !== undefined) { parameters.set(MessageParam.FORWARD, [varint(BigInt(requestedForward))]); } @@ -4817,6 +5236,7 @@ export class Session { // Stage ALL changes on the pending record — applied on REQUEST_OK, never at send. this.pendingUpdates.set(requestId as bigint, { existingRequestId, + ...(isDraft21(this._draftVersion) && filterBytes !== undefined ? { subscriptionFilter: filterBytes } : {}), ...(requestedForward !== undefined ? { forward: requestedForward } : {}), @@ -4956,6 +5376,7 @@ export class Session { */ close(error?: Varint, reason?: string): SessionOutboundAction[] { this._state = SessionState.CLOSED; + this.fillRequests.clear(); const closeAction: CloseConnectionAction = { type: 'close_connection', @@ -4977,6 +5398,7 @@ export class Session { */ handleTransportClosed(): void { this._state = SessionState.CLOSED; + this.fillRequests.clear(); } /** @@ -4985,6 +5407,7 @@ export class Session { */ private closeWithError(error: Varint, reason: string): SessionOutboundAction[] { this._state = SessionState.CLOSED; + this.fillRequests.clear(); return [{ type: 'close_connection', @@ -5053,7 +5476,7 @@ export class Session { requestId: bigint, replenish: SessionOutboundAction[] | undefined, ): SessionOutboundAction[] | undefined { - if (this._draftVersion !== 18) return undefined; + if (!isRequestStreamDraft(this._draftVersion)) return undefined; const isDot = isReservedDotNamespace(namespace); if (!isDot && !isReservedSessionNamespace(namespace)) return undefined; const errorMsg: RequestErrorMsg = { @@ -5083,7 +5506,7 @@ export class Session { const combined = [...prefix, ...suffix]; try { // §2.4.1: draft-18 permits a zero-field full namespace; draft-14/16 do not. - validateTrackNamespace(combined, { allowEmpty: this._draftVersion === 18 }); + validateTrackNamespace(combined, { allowEmpty: isRequestStreamDraft(this._draftVersion) }); } catch (e) { return this.closeWithError( SessionErrorCode.PROTOCOL_VIOLATION, @@ -5114,22 +5537,31 @@ export class Session { return true; } - const table = this._draftVersion === 18 - ? VALID_PARAMS_FOR_MESSAGE_TYPE_18 - : VALID_PARAMS_FOR_MESSAGE_TYPE; + const table = isDraft21(this._draftVersion) + ? VALID_PARAMS_FOR_MESSAGE_TYPE_21 + : isRequestStreamDraft(this._draftVersion) + ? VALID_PARAMS_FOR_MESSAGE_TYPE_18 + : VALID_PARAMS_FOR_MESSAGE_TYPE; return table.get(key as bigint)?.has(messageType) ?? false; } - /** - * Validate message parameters: check for unknown types, invalid duplicates, and value constraints. - * AUTHORIZATION_TOKEN may repeat (§9.2.2.1), other known types must be unique. - * Parameters not valid for the given message type are ignored per §9.2.2. - * @see draft-ietf-moq-transport-16 §9.2 - * @returns Error with code and reason if validation fails, undefined if valid - */ + /** We advertise the default MAX_FILTER_RANGES=0; never silently ignore a filter. */ + private rejectUnsupportedRangeFilters(requestId: bigint, params: Parameters): SessionOutboundAction[] | undefined { + if (!isDraft21(this._draftVersion)) return undefined; + const nested = params.get(FILL_PARAMETERS)?.[0]; + const fill = nested instanceof Uint8Array ? decodeFillParameters(nested).parameters : undefined; + if (![...RANGE_FILTER_PARAMS_21].some((type) => params.has(type) || fill?.has(type))) return undefined; + return [this.sendControl({ type: 'REQUEST_ERROR', requestId, errorCode: varint(0x36n), + retryInterval: varint(0n), errorReason: 'Range filters exceed MAX_FILTER_RANGES=0 (9.1.6)', + })]; + } + + /** Validate parameter scope, multiplicity, and values using the selected draft. */ private validateMessageParams(params: Parameters, messageType: string): { error: Varint; reason: string } | undefined { - const isDraft18 = this._draftVersion === 18; - const knownParams = isDraft18 ? KNOWN_MESSAGE_PARAMS_18 : KNOWN_MESSAGE_PARAMS; + const isDraft18 = isRequestStreamDraft(this._draftVersion); + const knownParams = isDraft21(this._draftVersion) + ? KNOWN_MESSAGE_PARAMS_21 + : isDraft18 ? KNOWN_MESSAGE_PARAMS_18 : KNOWN_MESSAGE_PARAMS; for (const [key, values] of params) { // §9.2: Unknown message parameters are a protocol violation (draft-16/18). // Draft-14: ignore unknown params — different param sets between versions. @@ -5152,7 +5584,9 @@ export class Session { // §9.2.2.1: AUTHORIZATION_TOKEN may appear multiple times // All other known message parameters must be unique - if (key !== MessageParam.AUTHORIZATION_TOKEN && values.length > 1) { + // draft-21 §3.3.2: Range Filters may repeat (one per SetID / Property Type). + if (key !== MessageParam.AUTHORIZATION_TOKEN && values.length > 1 + && !(isDraft21(this._draftVersion) && RANGE_FILTER_PARAMS_21.has(key as bigint))) { return { error: SessionErrorCode.PROTOCOL_VIOLATION, reason: `Duplicate message parameter type: ${key}` }; } @@ -5210,6 +5644,11 @@ export class Session { // Even-type parameters: varint value constraint checks → PROTOCOL_VIOLATION if (typeof value === 'bigint') { switch (key) { + case 0x35n: + if (isDraft21(this._draftVersion) && value !== 0n && value !== 1n) { + return { error: SessionErrorCode.PROTOCOL_VIOLATION, reason: `INCLUDE_PROPERTIES must be 0 or 1, got ${value}` }; + } + break; // DELIVERY_TIMEOUT (0x02). draft-16 §9.2.2.2: MUST be > 0. draft-14 allows // 0; draft-18 §10.2.4 renames it OBJECT_DELIVERY_TIMEOUT where 0 means // "no timeout" (and SUBGROUP_DELIVERY_TIMEOUT 0x06 is unconstrained) — so @@ -5259,6 +5698,14 @@ export class Session { // a typed Location for LARGEST_OBJECT, which is valid by construction. if (value instanceof Uint8Array) { switch (key) { + case FILL_PARAMETERS: + if (isDraft21(this._draftVersion)) { + try { decodeFillParameters(value); } + catch (err) { + return { error: SessionErrorCode.PROTOCOL_VIOLATION, reason: `Invalid FILL_PARAMETERS: ${String(err)}` }; + } + } + break; // §9.2.2.7: LARGEST_OBJECT is a Location structure (two varints). case MessageParam.LARGEST_OBJECT: return this.validateLargestObject(value); @@ -5336,7 +5783,7 @@ export class Session { // scope depends on WHICH request it answers, which is only known once the // request stream is resolved — so defer to handleRequestOk. (draft-14/16 // REQUEST_OK keeps the by-type scope here.) - if (this._draftVersion === 18 && msg.type === 'REQUEST_OK') { + if (isRequestStreamDraft(this._draftVersion) && msg.type === 'REQUEST_OK') { return undefined; } return this.validateMessageParams(msg.parameters, msg.type); diff --git a/packages/transport/src/session/setup.ts b/packages/transport/src/session/setup.ts index 569cfba5..7dc7d4fd 100644 --- a/packages/transport/src/session/setup.ts +++ b/packages/transport/src/session/setup.ts @@ -12,10 +12,11 @@ import { varint, type Varint } from '../primitives/varint.js'; import type { ControlMessage, ClientSetup, ServerSetup, Parameters, Setup, SetupOptionMap } from '../control/messages.js'; import { SetupParam } from '../control/parameters.js'; -import { SetupOption18 } from '../control/codes-18.js'; +import { SetupOption18, SetupOption21 } from '../control/codes-18.js'; import type { DraftVersion } from '../control/codec.js'; import { EndpointRole, type EndpointRoleValue, SessionState, type SessionStateValue } from './types.js'; import { AliasType, parseAuthorizationToken, parseAuthorizationToken18, type AuthorizationToken } from '../control/auth-token.js'; +import { isDraft21, isRequestStreamDraft } from '../versions.js'; /** * Error thrown for setup handshake violations. @@ -57,6 +58,8 @@ export interface SetupResult { * @see draft-ietf-moq-transport-18 §10.3.1.3 */ readonly peerMaxAuthTokenCacheSize?: bigint; + /** draft-21 §9.1.7 MAX_REQUEST_UPDATES from the peer's SETUP (absent = 0, no limit). */ + readonly peerMaxRequestUpdates?: bigint; /** * Parsed AUTHORIZATION_TOKEN parameters from the peer's setup message. * These are the raw parsed tokens — caller must process through AuthTokenCache. @@ -148,7 +151,7 @@ export class SetupGate { * @throws {SetupError} If message violates handshake rules */ validateMessage(msg: ControlMessage): void { - if (this.draftVersion === 18) { + if (isRequestStreamDraft(this.draftVersion)) { // draft-18: each side sends a single unified SETUP on its uni control // stream; the first message received before ESTABLISHED must be SETUP. if (this.state !== SessionState.ESTABLISHED && msg.type !== 'SETUP') { @@ -203,6 +206,8 @@ export class SetupGate { // §10.3.1.3: vi64 (full uint64) — emitted as a vi64 Setup Option below. maxAuthTokenCacheSize?: bigint; authTokens?: Uint8Array[]; + /** draft-21 §9.1.7: our MAX_REQUEST_UPDATES; ignored before draft 21. */ + maxRequestUpdates?: bigint; } = {}): Setup { const enc = new TextEncoder(); const setupOptions: SetupOptionMap = new Map(); @@ -221,6 +226,9 @@ export class SetupGate { if (options.authTokens !== undefined && options.authTokens.length > 0) { setupOptions.set(BigInt(SetupOption18.AUTHORIZATION_TOKEN), options.authTokens); } + if (options.maxRequestUpdates !== undefined && isDraft21(this.draftVersion)) { + setupOptions.set(BigInt(SetupOption21.MAX_REQUEST_UPDATES), [options.maxRequestUpdates]); + } // Deliberately NO MAX_REQUEST_ID for draft-18. this.state = this.state === SessionState.IDLE ? SessionState.SETUP_PENDING : SessionState.ESTABLISHED; return { type: 'SETUP', setupOptions }; @@ -267,6 +275,7 @@ export class SetupGate { // the raw bigint without folding it through the QUIC-varint range. let peerMaxAuthTokenCacheSize: bigint | undefined; let authTokens: AuthorizationToken[] | undefined; + let peerMaxRequestUpdates: bigint | undefined; for (const [type, values] of options) { // §10.3 / §10.3.1: a known singleton Setup Option MUST NOT be repeated @@ -298,6 +307,14 @@ export class SetupGate { .filter((v): v is Uint8Array => v instanceof Uint8Array) .map((v) => parseAuthorizationToken18(v)); break; + case SetupOption21.MAX_REQUEST_UPDATES: + // A draft-18 peer would not send it; there it is an unknown option. + if (!isDraft21(this.draftVersion)) break; + if (values.length > 1) { + throw new SetupError('Repeated Setup Option MAX_REQUEST_UPDATES (singleton)', 'PROTOCOL_VIOLATION'); + } + if (typeof first === 'bigint') peerMaxRequestUpdates = first; + break; // Unknown Setup Options are ignored (§10.3). } } @@ -309,6 +326,7 @@ export class SetupGate { ...(authority !== undefined ? { authority } : {}), ...(peerMaxAuthTokenCacheSize !== undefined ? { peerMaxAuthTokenCacheSize } : {}), ...(authTokens !== undefined ? { authTokens } : {}), + ...(peerMaxRequestUpdates !== undefined ? { peerMaxRequestUpdates } : {}), }; } diff --git a/packages/transport/src/session/subscription.ts b/packages/transport/src/session/subscription.ts index 02f1b483..6f30e191 100644 --- a/packages/transport/src/session/subscription.ts +++ b/packages/transport/src/session/subscription.ts @@ -19,6 +19,8 @@ import { varint, type Varint } from '../primitives/varint.js'; import type { Parameters } from '../control/messages.js'; +import type { GroupOrder } from '../data/types.js'; +import type { SubscriptionWindow } from '../control/subscription-filter.js'; import { SubscriptionState, ForwardState, @@ -30,14 +32,22 @@ import { * Location within a track (group + object ID). */ export interface Location { - readonly groupId: Varint; - readonly objectId: Varint; + readonly groupId: bigint; + readonly objectId: bigint; } /** * Manages the state machine for a single subscription. */ export class SubscriptionStateMachine { + requestedGroupOrder: GroupOrder | undefined; + publisherGroupOrder: GroupOrder = 'ascending'; + locationWindow: SubscriptionWindow | undefined; + + get groupOrder(): GroupOrder { + return this.requestedGroupOrder ?? this.publisherGroupOrder; + } + private _state: SubscriptionStateValue = SubscriptionState.PENDING; private _forwardState: ForwardStateValue = ForwardState.ACTIVE; private _remoteFilterType: string | undefined; @@ -69,6 +79,11 @@ export class SubscriptionStateMachine { * @see draft-ietf-moq-transport-14 §9.10 */ private _currentFilter: Uint8Array | undefined; + /** + * draft-21 §3.4: the SUBSCRIBE asked for a fill (FILL_PARAMETERS), so a fill + * fetch stream whose FETCH_HEADER carries this Request ID belongs to it. + */ + fillRequested = false; /** * Last-sent SUBSCRIBER_PRIORITY for this subscription. * Used by draft-14 to replay unchanged values in SUBSCRIBE_UPDATE. @@ -514,7 +529,7 @@ export class SubscriptionStateMachine { * Update largest location seen on this subscription. * Only updates if the new location is larger. */ - updateLargestLocation(groupId: Varint, objectId: Varint): void { + updateLargestLocation(groupId: bigint, objectId: bigint): void { if (!this._largestLocation) { this._largestLocation = { groupId, objectId }; return; diff --git a/packages/transport/src/session/track-alias.ts b/packages/transport/src/session/track-alias.ts index d87f2b7a..30f4085e 100644 --- a/packages/transport/src/session/track-alias.ts +++ b/packages/transport/src/session/track-alias.ts @@ -173,6 +173,11 @@ export class TrackAliasManager { return this.aliasToTrack.has(alias as bigint); } + /** Request identities retaining this alias, without transferring ownership. */ + owners(alias: bigint): Iterable { + return this.aliasToOwner.get(alias) ?? []; + } + /** * Check if a track is registered. * diff --git a/packages/transport/src/vectors/vectors-d21.test.ts b/packages/transport/src/vectors/vectors-d21.test.ts new file mode 100644 index 00000000..fe0c53a8 --- /dev/null +++ b/packages/transport/src/vectors/vectors-d21.test.ts @@ -0,0 +1,143 @@ +/** + * Draft-21 cross-implementation wire vectors. + * + * `packages/transport/vectors/d21/wire-vectors.txt` is shared with red5-moq-relay: + * the vectors were produced by (or verified against) moqxr's draft-21 codec, so + * decoding them here checks Playa against two other implementations. Every + * control vector must decode with the draft-21 codec and re-encode to the same + * bytes; the frames playback depends on are also checked field by field. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { vectorsDir, bytesToHex, hexToBytes } from './load-vectors.js'; +import { createControlCodec } from '../control/codec.js'; +import { decodeMessageParams18, DRAFT21_MESSAGE_PARAM_REGISTRY } from '../control/message-params-18.js'; +import { decodeLocationFilterFields } from '../control/subscription-filter.js'; +import { decodeFetchObject18 } from '../data/decoder-18.js'; +import type { + ControlMessage, Fetch, FetchOk, Goaway, PublishStateNotify, Subscribe, SubscribeOk, PublishDone, +} from '../control/messages.js'; + +interface Vector { + readonly name: string; + readonly kind: 'control' | 'parameter' | 'stream'; + readonly bytes: Uint8Array; +} + +function loadD21(): Map { + const text = readFileSync(join(vectorsDir('d21'), 'wire-vectors.txt'), 'utf8'); + const vectors = new Map(); + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('#')) continue; + const [name, kind, , hex] = trimmed.split(/\s+/); + vectors.set(name!, { name: name!, kind: kind as Vector['kind'], bytes: hexToBytes(hex!) }); + } + return vectors; +} + +const VECTORS = loadD21(); +const codec21 = createControlCodec(21); +const vector = (name: string): Uint8Array => { + const v = VECTORS.get(name); + if (v === undefined) throw new Error(`no draft-21 vector ${name}`); + return v.bytes; +}; +const decode = (name: string): T => codec21.decode(vector(name), 0).message as T; +const LOCATION_FILTER = 0x21n; +const FILL_PARAMETERS = 0x23n; +const INCLUDE_PROPERTIES = 0x35n; +const LARGEST_OBJECT = 0x09n; +const FORWARD = 0x10n; + +describe('draft-21 wire vectors (shared with red5-moq-relay and moqxr)', () => { + it('loads the full set', () => { + expect(VECTORS.size).toBeGreaterThanOrEqual(29); + }); + + for (const v of VECTORS.values()) { + if (v.kind !== 'control') continue; + it(`${v.name} decodes and re-encodes byte for byte`, () => { + const { message, bytesRead } = codec21.decode(v.bytes, 0); + expect(bytesRead).toBe(v.bytes.length); + expect(bytesToHex(codec21.encode(message))).toBe(bytesToHex(v.bytes)); + }); + } + + it('SUBSCRIBE LOCATION_FILTER forms', () => { + const filter = (name: string) => + decodeLocationFilterFields(decode(name).parameters.get(LOCATION_FILTER)![0] as Uint8Array); + expect(filter('subscribe_location_filter_empty')).toEqual([]); + expect(filter('subscribe_location_filter_next_group')).toEqual([0n]); + expect(filter('subscribe_location_filter_next_object')).toEqual([0n, 0n]); + expect(filter('subscribe_location_filter_relative_3')).toEqual([3n]); + expect(filter('subscribe_location_filter_start_12_5')).toEqual([12n, 5n]); + expect(filter('subscribe_location_filter_range_12_5_delta_3')).toEqual([12n, 5n, 3n]); + expect(filter('subscribe_location_filter_range_12_5_delta_3_end_object_7')).toEqual([12n, 5n, 3n, 7n]); + }); + + it('SUBSCRIBE that joins the current group with a fill (the catalog recipe)', () => { + const sub = decode('subscribe_join_current_group_with_fill_include_properties_0'); + expect(decodeLocationFilterFields(sub.parameters.get(LOCATION_FILTER)![0] as Uint8Array)).toEqual([0n, 0n]); + // FILL_PARAMETERS: a bare parameter sequence holding LOCATION_FILTER [1] (the current group). + expect(bytesToHex(sub.parameters.get(FILL_PARAMETERS)![0] as Uint8Array)).toBe('210101'); + expect(sub.parameters.get(INCLUDE_PROPERTIES)).toEqual([0n]); + }); + + it('SUBSCRIBE_OK carries LARGEST_OBJECT when content exists', () => { + expect(decode('subscribe_ok_no_largest').parameters.has(LARGEST_OBJECT)).toBe(false); + expect(decode('subscribe_ok_largest_3_7').parameters.get(LARGEST_OBJECT)).toEqual([{ group: 3n, object: 7n }]); + }); + + it('FETCH carries its range as a LOCATION_FILTER with an inclusive End Object', () => { + const fetch = decode('fetch_range_12_5_to_15_9'); + expect(fetch.fetch).toMatchObject({ + fetchType: 0x1, + startLocation: { group: 12n, object: 5n }, + // the draft-18 model's End Object is exclusive (9 inclusive becomes 10) + endLocation: { group: 15n, object: 10n }, + }); + }); + + it('FETCH_OK End Location', () => { + expect(decode('fetch_ok_inclusive_end_15_9').endLocation).toEqual({ group: 15n, object: 9n }); + }); + + it('GOAWAY has no Request ID', () => { + const goaway = decode('goaway_no_uri_timeout_5000'); + expect(goaway).toMatchObject({ newSessionUri: '', timeout: 5000n }); + expect(goaway.requestId).toBeUndefined(); + }); + + it('PUBLISH_STATE_NOTIFY reports LARGEST_OBJECT and FORWARD', () => { + const notify = decode('publish_state_notify_largest_3_7_forward_0'); + expect(notify.type).toBe('PUBLISH_STATE_NOTIFY'); + expect(notify.parameters.get(LARGEST_OBJECT)).toEqual([{ group: 3n, object: 7n }]); + expect(notify.parameters.get(FORWARD)).toEqual([0n]); + }); + + it('PUBLISH_DONE with an unknown Stream Count (2^64-1)', () => { + const done = decode('publish_done_track_ended_unknown_count'); + expect(done.streamCount).toBe((1n << 64n) - 1n); + }); + + it('End of Timed-Out Range: Group ID and Object ID, no payload length', () => { + const bytes = vector('fetch_object_end_of_timed_out_range_g2_o4'); + const { item, bytesRead } = decodeFetchObject18(bytes, 0, undefined, true, 'ascending', 21); + expect(bytesRead).toBe(bytes.length); + expect(item).toMatchObject({ flags: 0x20cn, groupId: 2n, objectId: 4n, nonExistent: false }); + expect(() => decodeFetchObject18(bytes, 0, undefined, true, 'ascending', 18)).toThrow(); + }); + + it('range-filter parameters are known to the draft-21 registry', () => { + for (const v of VECTORS.values()) { + if (v.kind !== 'parameter') continue; + const counted = new Uint8Array(v.bytes.length + 1); + counted[0] = 1; + counted.set(v.bytes, 1); + const { bytesRead } = decodeMessageParams18(counted, 0, DRAFT21_MESSAGE_PARAM_REGISTRY); + expect(bytesRead, v.name).toBe(counted.length); + } + }); +}); diff --git a/packages/transport/src/versions.test.ts b/packages/transport/src/versions.test.ts new file mode 100644 index 00000000..51ad4e04 --- /dev/null +++ b/packages/transport/src/versions.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { isDraft21, isRequestStreamDraft, isWiredDraft, WIRED_DRAFTS } from './versions.js'; + +describe('draft versions', () => { + it('wires drafts 14, 16, 18 and 21 only', () => { + expect(WIRED_DRAFTS).toEqual([14, 16, 18, 21]); + for (const v of [14, 16, 18, 21]) expect(isWiredDraft(v)).toBe(true); + for (const v of [15, 17, 19, 20, 22]) expect(isWiredDraft(v)).toBe(false); + }); + + it('uses the request-stream model from draft 18 on', () => { + expect(isRequestStreamDraft(14)).toBe(false); + expect(isRequestStreamDraft(16)).toBe(false); + expect(isRequestStreamDraft(18)).toBe(true); + expect(isRequestStreamDraft(21)).toBe(true); + expect(isRequestStreamDraft(undefined)).toBe(false); + }); + + it('identifies draft 21', () => { + expect(isDraft21(18)).toBe(false); + expect(isDraft21(21)).toBe(true); + expect(isDraft21(undefined)).toBe(false); + }); +}); diff --git a/packages/transport/src/versions.ts b/packages/transport/src/versions.ts index d02d450c..2f5124f2 100644 --- a/packages/transport/src/versions.ts +++ b/packages/transport/src/versions.ts @@ -15,13 +15,30 @@ * - `16` — draft-ietf-moq-transport-16 (default) * - `18` — draft-ietf-moq-transport-18 (fully wired: control + data codecs, * uni-pair topology, request profile) + * - `21` — draft-ietf-moq-transport-21 (the draft-18 stream model with + * LOCATION_FILTER, fill fetch streams, PUBLISH_STATE_NOTIFY and a + * FETCH that carries its range as a LOCATION_FILTER) */ -export type DraftVersion = 14 | 16 | 18; +export type DraftVersion = 14 | 16 | 18 | 21; /** Draft versions with a fully-wired wire codec today. */ -export const WIRED_DRAFTS: readonly DraftVersion[] = [14, 16, 18]; +export const WIRED_DRAFTS: readonly DraftVersion[] = [14, 16, 18, 21]; /** Whether `v` has a fully-wired control + data codec. */ export function isWiredDraft(v: number): v is DraftVersion { - return v === 14 || v === 16 || v === 18; + return v === 14 || v === 16 || v === 18 || v === 21; +} + +/** + * Whether `v` uses the draft-18 stream model: a unified SETUP on a uni + * control-stream pair, one bidi stream per request, stream-correlated + * responses and vi64 integers. True for draft 18 and every later draft. + */ +export function isRequestStreamDraft(v: number | undefined): boolean { + return v !== undefined && v >= 18; +} + +/** Whether `v` is draft 21 or later (LOCATION_FILTER, fills, no Joining FETCH). */ +export function isDraft21(v: number | undefined): boolean { + return v !== undefined && v >= 21; } diff --git a/packages/transport/vectors/d21/wire-vectors.txt b/packages/transport/vectors/d21/wire-vectors.txt new file mode 100644 index 00000000..6010e62e --- /dev/null +++ b/packages/transport/vectors/d21/wire-vectors.txt @@ -0,0 +1,39 @@ +# Draft-21 (draft-ietf-moq-transport-21, version 0xff000015, ALPN moqt-21) wire vectors. +# Format: +# kind: control = one framed control message {Type vi64, Length (16), Body} +# parameter = one message parameter {Type, Length, Value} +# stream = bytes at the start of a data/fetch stream +# source: moqxr-encoder = produced by moqxr (openmoq/moqxr#45, feature/draft-21 @ 478d6c0) +# moqxr-decoder-verified = hand-built, accepted by moqxr's draft-21 decoders +# draft-text = hand-built from docs/draft-ietf-moq-transport-21.txt only +# Copied from red5-moq-relay (src/test/resources/draft21/wire-vectors.txt), which regenerates +# them with scripts/draft21-vectors/gen.cpp against moqxr. Keep the vector lines identical. +client_setup control moqxr-encoder af00001901042f6d6f71041172656c61792e6578616d706c652e636f6d +publish_namespace control moqxr-encoder 0600080001046c69766500 +publish control moqxr-encoder 1d000f0201046c69766505766964656f0100 +subscribe_ok_no_largest control moqxr-encoder 0400020100 +subscribe_ok_largest_3_7 control moqxr-encoder 0400050101090307 +request_update_ok_largest_3_7 control moqxr-encoder 07000401090307 +publish_done_track_ended_unknown_count control moqxr-encoder 0b000f02ffffffffffffffffff04646f6e65 +fetch_header_fill_req_95 stream moqxr-encoder 055f +subgroup_header_alias1_g3_sg0 stream moqxr-encoder 300103 +server_setup_filter_ranges_16_request_updates_4 control moqxr-decoder-verified af00000406100204 +subscribe_location_filter_empty control moqxr-decoder-verified 0300100901046c69766505766964656f012100 +subscribe_location_filter_next_group control moqxr-decoder-verified 0300110901046c69766505766964656f01210100 +subscribe_location_filter_relative_3 control moqxr-decoder-verified 0300110901046c69766505766964656f01210103 +subscribe_location_filter_next_object control moqxr-decoder-verified 0300120901046c69766505766964656f0121020000 +subscribe_location_filter_start_12_5 control moqxr-decoder-verified 0300120901046c69766505766964656f0121020c05 +subscribe_location_filter_range_12_5_delta_3 control moqxr-decoder-verified 0300130901046c69766505766964656f0121030c0503 +subscribe_location_filter_range_12_5_delta_3_end_object_7 control moqxr-decoder-verified 0300140901046c69766505766964656f0121040c050307 +subscribe_join_current_group_with_fill_include_properties_0 control moqxr-decoder-verified 0300190901046c69766505766964656f032102000002032101011200 +subscribe_tracks_next_object_group_order_descending control moqxr-decoder-verified 51000e5d01046c69766502210200000102 +request_update_fill_whole_track control moqxr-decoder-verified 0200045f012300 +publish_ok_expires_30000 control moqxr-decoder-verified 0700050108c07530 +fetch_range_12_5_to_15_9 control draft-text 1600140701046c69766505766964656f0121040c050309 +fetch_ok_inclusive_end_15_9 control draft-text 180004000f0900 +goaway_no_uri_timeout_5000 control draft-text 100003009388 +publish_state_notify_largest_3_7_forward_0 control draft-text 220006020903070700 +publish_skipped_cam2_video control draft-text 0f000c010463616d3205766964656f +param_subgroup_filter_set0_3to5_10to15 parameter draft-text 25050003020505 +param_object_property_filter_set1_prop16_from100 parameter draft-text 2803011064 +fetch_object_end_of_timed_out_range_g2_o4 stream draft-text 820c0204 diff --git a/packages/webtransport/src/adapter-d21-conformance.test.ts b/packages/webtransport/src/adapter-d21-conformance.test.ts new file mode 100644 index 00000000..392ffa8e --- /dev/null +++ b/packages/webtransport/src/adapter-d21-conformance.test.ts @@ -0,0 +1,788 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + createControlCodec, + SessionState, + varint, + type MoqtObject, + type Parameters, + type ControlMessage, +} from '@openmoq/transport'; +import { MoqtConnection } from './adapter.js'; +import { connectedPair, nm, ns, type ConnectedPair } from './testkit/pair.js'; +import { TransportSim, flush } from './testkit/stream-sim.js'; + +const connections: MoqtConnection[] = []; +const codec = createControlCodec(21); +const ALIAS = 50n; + +afterEach(async () => { + for (const conn of connections.splice(0)) await conn.close(); +}); + +async function settle(): Promise { + for (let i = 0; i < 10; i++) await flush(); +} + +async function pair(draft: 18 | 21 = 21): Promise { + const result = await connectedPair(draft); + connections.push(result.client, result.server); + return result; +} + +function serveSubscriptions({ server, errors }: ConnectedPair): void { + server.setLargestLocationProvider(() => ({ group: 5n, object: 0n })); + server.onSubscribe = (requestId) => { + const parameters: Parameters = new Map([[0x09n, [{ group: 5n, object: 0n }]]]); + void server.acceptSubscribe(requestId, ALIAS, { parameters }).catch((err: Error) => errors.push(err)); + }; +} + +async function publish(server: MoqtConnection, group: bigint, requestId?: bigint): Promise { + const stream = await server.openSubgroup(ALIAS, group, 0n, { publisherPriority: 1, ...(requestId === undefined ? {} : { requestId }) }); + await server.sendObject(stream, 0n, new Uint8Array([1])); + await server.closeSubgroup(stream); + await settle(); +} + +describe('draft-21 GOAWAY through the public adapter', () => { + it.each(['client', 'server'] as const)('%s sends GOAWAY without a Request ID (9.2)', async (role) => { + const p = await pair(); + const sender = p[role]; + const receiver = role === 'client' ? p.server : p.client; + + await expect(sender.sendGoaway({ timeout: 1000n })).resolves.toBeUndefined(); + await settle(); + + expect(receiver.session.state).toBe(SessionState.DRAINING); + expect(p.errors).toEqual([]); + }); + + it('draft 18 still requires a Request ID with the peer parity', async () => { + const { client, server, errors } = await pair(18); + + await expect(client.sendGoaway()).rejects.toThrow(/requires a Request ID/); + await expect(client.sendGoaway({ requestId: 0n })).rejects.toThrow(/wrong parity/); + await client.sendGoaway({ requestId: 1n }); + await settle(); + + expect(server.session.state).toBe(SessionState.DRAINING); + expect(errors).toEqual([]); + }); + + it('draft 21 still prohibits a client from redirecting the server', async () => { + const { client, server } = await pair(); + await expect(client.sendGoaway({ newSessionUri: 'https://relay.example/moq', requestId: 1n })) + .rejects.toThrow(/client MUST send a zero-length/); + expect(server.session.state).toBe(SessionState.ESTABLISHED); + }); +}); + +describe('draft-21 SUBSCRIBE_TRACKS Forward through the public adapter', () => { + it('inherits the accepted update only on an explicitly associated PUBLISH', async () => { + const p = await pair(); + p.server.onSubscribeTracks = (id) => { + void p.server.acceptSubscribeTracks(id).catch((e: Error) => p.errors.push(e)); + }; + const forwards: number[] = []; + p.client.onPublish = (pub) => { + forwards.push(p.client.session.getIncomingSubscription(pub.requestId)!.forwardState); + void p.client.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => p.errors.push(e)); + }; + const id = await p.client.subscribeTracks(ns('live')); + await settle(); + await p.client.requestUpdate(id, { forward: 0 }); + await settle(); + await p.server.publish(ns('live'), nm('video'), ALIAS, { subscribeTracksRequestId: id }); + await settle(); + await p.server.publish(ns('live'), nm('audio'), ALIAS + 1n, { parameters: new Map([[0x10n, [1n]]]) }); + await settle(); + expect(forwards).toEqual([0, 1]); + expect(p.errors).toEqual([]); + }); +}); + +describe('draft-21 fill group order (3.4 and 9.20.9)', () => { + it('inherits the publisher Group Order advertised in SUBSCRIBE_OK', async () => { + const p = await pair(); + p.server.onSubscribe = (id) => { void p.server.acceptSubscribe(id, ALIAS, { + parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]), trackProperties: new Map([[0x22n, [2n]]]), + }); }; + const groups: bigint[] = []; + p.client.onObject = (_sid, o) => groups.push(o.groupId); + const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} }); + await settle(); + const stream = await p.server.openFillStream(id); + for (const groupId of [5n, 4n]) await p.server.sendFetchObject(stream, { + groupId, subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]), + }); + await p.server.closeFetchStream(stream); + await settle(); + expect(groups).toEqual([5n, 4n]); + expect(p.errors).toEqual([]); + }); + it('a fill override takes precedence over the subscription order', async () => { + const p = await pair(); + serveSubscriptions(p); + const objects: MoqtObject[] = []; + p.client.onObject = (_stream, object) => objects.push(object); + const id = await p.client.subscribe(ns('live'), nm('video'), { + groupOrder: varint(1n), fill: { groupOrder: 'descending' }, + }); + await settle(); + const stream = await p.server.openFillStream(id); + const fields = { subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]) }; + await p.server.sendFetchObject(stream, { ...fields, groupId: 5n }); + await expect(p.server.sendFetchObject(stream, { ...fields, groupId: 4n })).resolves.toBeUndefined(); + await p.server.closeFetchStream(stream); + await settle(); + expect(objects.map((o) => o.groupId)).toEqual([5n, 4n]); + expect(p.errors).toEqual([]); + }); + + it('Forward=0 does not authorize a fill, even after a later resume', async () => { + const p = await pair(); + serveSubscriptions(p); + const id = await p.client.subscribe(ns('live'), nm('video'), { forward: 0, fill: {} }); + await settle(); + expect(p.client.session.fillSubscriptionFor(id)).toBeUndefined(); + await p.client.requestUpdate(id, { forward: 1 }); + await settle(); + await expect(p.server.openFillStream(id)).rejects.toThrow(/No pending fill/); + expect(p.errors).toEqual([]); + }); + + it.each([ + { order: 1n, groups: [5n, 6n] }, + { order: 2n, groups: [5n, 4n] }, + ])('decodes a fill with inherited GROUP_ORDER=$order', async ({ order, groups }) => { + const conn = new MoqtConnection(21); + connections.push(conn); + const transport = new TransportSim(); + transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() })); + const objects: MoqtObject[] = []; + const errors: Error[] = []; + conn.onObject = (_stream, object) => objects.push(object); + conn.onError = (err) => errors.push(err); + await conn.connect(transport); + + const requestId = await conn.subscribe(ns('live'), nm('video'), { groupOrder: varint(order), fill: {} }); + expect(requestId).toBe(0n); + await settle(); + transport.bidi[0]!.push(codec.encode({ + type: 'SUBSCRIBE_OK', requestId, trackAlias: ALIAS, + parameters: new Map([[0x09n, [{ group: 6n, object: 0n }]]]), trackExtensions: new Map(), + })); + await settle(); + + // 11.4.1: a literal FETCH_HEADER followed by groups 5 and delta 0. + // The second group is 6 ascending, 4 descending. This fixture does not + // use our object encoder, so matching encoder/decoder mistakes cannot pass. + transport.pushIncomingUni(new Uint8Array([ + 0x05, 0x00, + 0x1c, 0x05, 0x00, 0x03, 0x01, 0xa5, + 0x0c, 0x00, 0x00, 0x01, 0xa4, + ])); + await settle(); + + expect(errors).toEqual([]); + expect(objects.map((o) => o.groupId)).toEqual(groups); + }); + + it('publishes consecutive descending groups on a fill', async () => { + const p = await pair(); + serveSubscriptions(p); + const objects: MoqtObject[] = []; + p.client.onObject = (_stream, object) => objects.push(object); + const requestId = await p.client.subscribe(ns('live'), nm('video'), { groupOrder: varint(2n), fill: {} }); + await settle(); + const stream = await p.server.openFillStream(requestId); + const fields = { subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]) }; + + await p.server.sendFetchObject(stream, { ...fields, groupId: 5n }); + await expect(p.server.sendFetchObject(stream, { ...fields, groupId: 4n })).resolves.toBeUndefined(); + await p.server.closeFetchStream(stream); + await settle(); + + expect(objects.map((o) => o.groupId)).toEqual([5n, 4n]); + expect(p.errors).toEqual([]); + }); +}); + +describe('draft-21 shared-alias subscription ownership (3.1)', () => { + it.each(['stream', 'datagram'] as const)('does not send %s objects outside the accepted Location Filter', async (delivery) => { + const p = await pair(); + serveSubscriptions(p); + const id = await p.client.subscribe(ns('live'), nm('video'), { + subscriptionFilter: { type: 'AbsoluteRange', startGroup: 2n, startObject: 1n, endGroup: 2n, endObject: 3n }, + }); + await settle(); + if (delivery === 'stream') { + const stream = await p.server.openSubgroup(ALIAS, 2n, 0n); + await expect(p.server.sendObject(stream, 0n, new Uint8Array([1]))).rejects.toThrow(/Location Filter/); + await p.server.sendObject(stream, 1n, new Uint8Array([1])); + await p.server.closeSubgroup(stream); + } else { + await expect(p.server.sendDatagram(ALIAS, 2n, 0n, new Uint8Array([1]))).rejects.toThrow(/Location Filter/); + await p.server.sendDatagram(ALIAS, 2n, 1n, new Uint8Array([1])); + } + expect(p.server.session.getIncomingSubscription(id)).toBeDefined(); + expect(p.errors).toEqual([]); + }); + it('drains each shared subscription and sums their terminal stream counts', async () => { + const conn = new MoqtConnection(21); + connections.push(conn); + const transport = new TransportSim(); + transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() })); + await conn.connect(transport); + const objects: bigint[] = []; + conn.onObject = (_sid, o) => objects.push(o.groupId); + const drained: bigint[] = []; + for (let i = 0; i < 2; i++) { + const requestId = await conn.subscribe(ns('live'), nm('video'), { + subscriptionFilter: { type: 'AbsoluteRange', startGroup: BigInt(i + 1), startObject: 0n, endGroup: BigInt(i + 1) }, + terminalDelivery: 'drain', onDrained: (id) => drained.push(id), + }); + await settle(); + transport.bidi[i]!.push(codec.encode({ type: 'SUBSCRIBE_OK', requestId, trackAlias: ALIAS, + parameters: new Map(), trackProperties: new Map(), + })); + await settle(); + } + for (let i = 0; i < 2; i++) { + transport.bidi[i]!.push(codec.encode({ type: 'PUBLISH_DONE', requestId: BigInt(i * 2), statusCode: 2n, streamCount: 1n, errorReason: 'done' })); + await settle(); + expect(drained).toEqual([]); + transport.pushIncomingUni(new Uint8Array([0x10, Number(ALIAS), i + 1, 1, 0, 1, 1])); + await settle(); + } + expect(objects).toEqual([1n, 2n]); + }); + it('delivers subgroup FIN to both SUBSCRIBE and PUBLISH owners', async () => { + const p = await pair(); + serveSubscriptions(p); + const closed: string[] = []; + const sub = await p.client.subscribeTrack(ns('live'), nm('video'), { + onSubgroupClosed: () => closed.push('subscribe'), + }); + p.client.onPublish = (pub) => { + pub.onSubgroupClosed = () => closed.push('publish'); + void p.client.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => p.errors.push(e)); + }; + await p.server.publish(ns('live'), nm('video'), ALIAS); + await settle(); + await publish(p.server, 6n, sub.requestId); + expect(closed).toEqual(['subscribe', 'publish']); + expect(p.errors).toEqual([]); + }); + + it('does not call another alias owner after a callback closes the connection', async () => { + const p = await pair(); + const objects: bigint[] = []; + let count = 0; + p.client.onPublish = (pub) => { + const first = count++ === 0; + pub.onObject = () => { + objects.push(pub.requestId); + if (first) void p.client.close(); + }; + void p.client.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => p.errors.push(e)); + }; + const first = await p.server.publish(ns('live'), nm('video'), ALIAS); + const second = await p.server.publish(ns('live'), nm('video'), ALIAS); + await settle(); + const stream = await p.server.openSubgroup(ALIAS, 6n, 0n, { requestId: second }); + await p.server.sendObject(stream, 0n, new Uint8Array([1])); + await settle(); + expect(objects).toEqual([first]); + }); + + it('honors a reentrant cancellation before calling the next shared owner', async () => { + const p = await pair(); + serveSubscriptions(p); + const first = await p.client.subscribeTrack(ns('live'), nm('video')); + const objects: bigint[] = []; + const second = await p.client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => objects.push(o.objectId) }); + first.onObject = () => { void second.unsubscribe(); }; + await publish(p.server, 6n, first.requestId); + expect(objects).toEqual([]); + }); + it('keeps each inbound PUBLISH owner when its peer on the same alias ends', async () => { + const p = await pair(); + const delivered: bigint[][] = []; + p.client.onPublish = (pub) => { + const objects: bigint[] = []; + delivered.push(objects); + pub.onObject = (o) => objects.push(o.objectId); + void p.client.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => p.errors.push(e)); + }; + const first = await p.server.publish(ns('live'), nm('video'), ALIAS); + const second = await p.server.publish(ns('live'), nm('video'), ALIAS); + await settle(); + const stream = await p.server.openSubgroup(ALIAS, 6n, 0n, { requestId: second }); + await p.server.sendObject(stream, 0n, new Uint8Array([1])); + await settle(); + expect(delivered).toEqual([[0n], [0n]]); + await publish(p.server, 6n, first); + expect(delivered).toEqual([[0n], [0n]]); + await p.server.publishDone(first, varint(2n), 'done'); + await settle(); + await p.server.sendObject(stream, 1n, new Uint8Array([1])); + await p.server.closeSubgroup(stream); + await settle(); + expect(delivered).toEqual([[0n], [0n, 1n]]); + expect(p.errors).toEqual([]); + }); + + it.each(['raw', 'generic'] as const)('ending the %s member preserves a mixed shared alias', async (retired) => { + const p = await pair(); + serveSubscriptions(p); + const rawObjects: bigint[] = []; + const genericObjects: bigint[] = []; + p.client.onObject = (_sid, object) => genericObjects.push(object.objectId); + const raw = await p.client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => rawObjects.push(o.objectId) }); + const generic = await p.client.subscribe(ns('live'), nm('video')); + await settle(); + const survivor = retired === 'raw' ? generic : raw.requestId; + const stream = await p.server.openSubgroup(ALIAS, 6n, 0n, { requestId: survivor }); + await p.server.sendObject(stream, 0n, new Uint8Array([1])); + await settle(); + expect(rawObjects).toEqual([0n]); + expect(genericObjects).toEqual([0n]); + await p.client.unsubscribe(retired === 'raw' ? raw.requestId : generic); + await p.server.sendObject(stream, 1n, new Uint8Array([1])); + await p.server.closeSubgroup(stream); + await settle(); + expect(retired === 'raw' ? genericObjects : rawObjects).toEqual([0n, 1n]); + expect(retired === 'raw' ? rawObjects : genericObjects).toEqual([0n]); + expect(p.errors).toEqual([]); + }); + + it.each([0, 1])('cancelling member %i resets only its already-open streams', async (retired) => { + const p = await pair(); + serveSubscriptions(p); + const delivered: bigint[][] = [[], []]; + const first = await p.client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => delivered[0]!.push(o.objectId) }); + const second = await p.client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => delivered[1]!.push(o.objectId) }); + const subscribers = [first, second]; + const streams: bigint[] = []; + for (const sub of subscribers) { + streams.push(await p.server.openSubgroup(ALIAS, 6n, 0n, { requestId: sub.requestId })); + } + for (const stream of streams) await p.server.sendObject(stream, 0n, new Uint8Array([1])); + await settle(); + expect(p.server.session.getIncomingSubscription(first.requestId)!.streamCount).toBe(1n); + expect(p.server.session.getIncomingSubscription(second.requestId)!.streamCount).toBe(1n); + await subscribers[retired]!.unsubscribe(); + await settle(); + await expect(p.server.sendObject(streams[retired]!, 0n, new Uint8Array([1]))).rejects.toThrow(); + await expect(p.server.sendObject(streams[1 - retired]!, 1n, new Uint8Array([1]))).resolves.toBeUndefined(); + await p.server.closeSubgroup(streams[1 - retired]!); + await settle(); + expect(delivered[retired]).toEqual([0n]); + expect(delivered[1 - retired]).toEqual([0n, 1n]); + expect(p.errors).toEqual([]); + }); + + it('requires an explicit request when publisher stream ownership is ambiguous', async () => { + const p = await pair(); + serveSubscriptions(p); + await p.client.subscribeTrack(ns('live'), nm('video')); + await p.client.subscribeTrack(ns('live'), nm('video')); + await expect(p.server.openSubgroup(ALIAS, 6n, 0n)).rejects.toThrow(/requestId/); + }); + + it('uses the accepted updated Location Filter for attribution', async () => { + const p = await pair(); + serveSubscriptions(p); + const a: MoqtObject[] = []; + const b: MoqtObject[] = []; + const first = await p.client.subscribeTrack(ns('live'), nm('video'), { + filter: { type: 'AbsoluteRange', startGroup: 0n, startObject: 0n, endGroup: 1n }, + onObject: (object) => a.push(object), + }); + const second = await p.client.subscribeTrack(ns('live'), nm('video'), { + filter: { type: 'AbsoluteStart', startGroup: 2n, startObject: 0n }, + onObject: (object) => b.push(object), + }); + await publish(p.server, 1n, first.requestId); + expect(a.map((o) => o.groupId)).toEqual([1n]); + expect(b).toEqual([]); + + const responses: string[] = []; + p.client.onMessage = (message) => responses.push(message.type); + await p.client.requestUpdate(first.requestId, { + subscriptionFilter: { type: 'AbsoluteRange', startGroup: 0n, startObject: 0n, endGroup: 2n }, + }); + await settle(); + expect(responses).toContain('REQUEST_OK'); + await publish(p.server, 2n, first.requestId); + await publish(p.server, 2n, second.requestId); + + expect(p.errors).toEqual([]); + expect(b.map((o) => o.groupId)).toEqual([2n]); + expect(a.map((o) => o.groupId)).toEqual([1n, 2n]); + }); + + it.each([0, 1])('unsubscribing member %i preserves the other member', async (retired) => { + const p = await pair(); + serveSubscriptions(p); + const delivered: MoqtObject[][] = [[], []]; + const first = await p.client.subscribeTrack(ns('live'), nm('video'), { + onObject: (object) => delivered[0]!.push(object), + }); + const second = await p.client.subscribeTrack(ns('live'), nm('video'), { + onObject: (object) => delivered[1]!.push(object), + }); + + await [first, second][retired]!.unsubscribe(); + await settle(); + await publish(p.server, 6n); + + expect(p.errors).toEqual([]); + expect(delivered[retired]).toEqual([]); + expect(delivered[1 - retired]!.map((o) => o.groupId)).toEqual([6n]); + }); +}); + +describe('draft-21 fill lifecycle (3.4 and 9.9)', () => { + it('reclaims empty publisher fills across repeated updates', async () => { + const p = await pair(); + serveSubscriptions(p); + const id = await p.client.subscribe(ns('live'), nm('video'), { subscriptionFilter: { type: 'NextGroupStart' }, fill: {} }); + await settle(); + for (let i = 0; i < 3; i++) { + await p.client.requestUpdate(id, { fill: {} }); + await settle(); + } + const state = p.server as unknown as { inboundFillRequests: Map }; + expect(state.inboundFillRequests.size).toBe(0); + expect(p.client.session.expectedFillsOf(id)).toEqual([]); + expect(p.errors).toEqual([]); + }); + + it('bounds unserved publisher fills across sequential updates', async () => { + const p = await pair(); + serveSubscriptions(p); + const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} }); + await settle(); + for (let i = 0; i < 255; i++) { + await p.client.requestUpdate(id, { fill: {} }); + await settle(); + expect(p.server.session.state).toBe(SessionState.ESTABLISHED); + } + await p.client.requestUpdate(id, { fill: {} }); + await settle(); + expect(p.server.session.state).toBe(SessionState.CLOSED); + }); + it('keeps a draining fill when a shared inbound PUBLISH is cancelled', async () => { + const conn = new MoqtConnection(21); + connections.push(conn); + const transport = new TransportSim(); + transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() })); + const groups: bigint[] = []; + const errors: Error[] = []; + conn.onObject = (_sid, o) => groups.push(o.groupId); + conn.onError = (e) => errors.push(e); + conn.onPublish = (pub) => { void conn.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => errors.push(e)); }; + await conn.connect(transport); + const id = await conn.subscribe(ns('live'), nm('video'), { fill: {}, terminalDelivery: 'drain' }); + transport.bidi[0]!.push(codec.encode({ type: 'SUBSCRIBE_OK', requestId: id, trackAlias: ALIAS, + parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]), trackProperties: new Map(), + })); + const pub = transport.pushIncomingBidi(); + pub.push(codec.encode({ type: 'PUBLISH', requestId: 1n, trackAlias: ALIAS, + trackNamespace: ns('live'), trackName: nm('video'), parameters: new Map(), trackProperties: new Map(), + })); + await settle(); + transport.bidi[0]!.push(codec.encode({ type: 'PUBLISH_DONE', requestId: id, statusCode: 2n, streamCount: 1n, errorReason: 'done' })); + await settle(); + pub.closeReadable(); + await settle(); + transport.pushIncomingUni(new Uint8Array([5, 0, 0x1c, 5, 0, 3, 1, 0xa5])); + await settle(); + expect(groups).toEqual([5n]); + expect(errors).toEqual([]); + }); + it('counts late fills against the terminal drain cap', async () => { + const conn = new MoqtConnection(21); + connections.push(conn); + const transport = new TransportSim(); + transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() })); + await conn.connect(transport); + const objects: bigint[] = []; + conn.onObject = (_sid, o) => objects.push(o.groupId); + const requestId = await conn.subscribe(ns('live'), nm('video'), { fill: {}, terminalDelivery: 'drain' }); + transport.bidi[0]!.push(codec.encode({ type: 'SUBSCRIBE_OK', requestId, trackAlias: ALIAS, + parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]), trackProperties: new Map(), + })); + await settle(); + const update = await conn.requestUpdate(requestId, { fill: {} }); + expect(update).toBe(2n); + transport.bidi[0]!.push(codec.encode({ type: 'REQUEST_OK', requestId: update, parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]) })); + await settle(); + transport.bidi[0]!.push(codec.encode({ type: 'PUBLISH_DONE', requestId, statusCode: 2n, streamCount: 1n, errorReason: 'done' })); + await settle(); + transport.pushIncomingUni(new Uint8Array([5, 0, 0x1c, 5, 0, 3, 1, 0xa5])); + transport.pushIncomingUni(new Uint8Array([5, 2, 0x1c, 4, 0, 3, 1, 0xa4])); + await settle(); + expect(objects).toEqual([5n]); + }); + it.each(['discard', 'drain'] as const)('handles a fill crossing PUBLISH_DONE with %s delivery', async (terminalDelivery) => { + const conn = new MoqtConnection(21); + connections.push(conn); + const transport = new TransportSim(); + transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() })); + const objects: MoqtObject[] = []; + const errors: Error[] = []; + conn.onObject = (_sid, o) => objects.push(o); + conn.onError = (e) => errors.push(e); + await conn.connect(transport); + const id = await conn.subscribe(ns('live'), nm('video'), { fill: {}, ...(terminalDelivery === 'drain' ? { terminalDelivery } : {}) }); + transport.bidi[0]!.push(codec.encode({ type: 'SUBSCRIBE_OK', requestId: id, trackAlias: ALIAS, + parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]), trackExtensions: new Map(), + })); + await settle(); + transport.bidi[0]!.push(codec.encode({ type: 'PUBLISH_DONE', requestId: id, statusCode: varint(2n), streamCount: 1n, errorReason: 'done' })); + await settle(); + transport.pushIncomingUni(new Uint8Array([0x05, 0, 0x1c, 5, 0, 3, 1, 0xa5])); + await settle(); + expect(errors).toEqual([]); + expect(objects.map((o) => o.groupId)).toEqual(terminalDelivery === 'drain' ? [5n] : []); + expect(conn.session.state).toBe(SessionState.ESTABLISHED); + }); + + it('revokes a pending fill open when an update terminates its subscription', async () => { + const p = await pair(); + serveSubscriptions(p); + const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} }); + await settle(); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const create = p.b.createUnidirectionalStream.bind(p.b); + p.b.createUnidirectionalStream = async () => { await gate; return create(); }; + const opening = p.server.openFillStream(id).then(() => 'opened', () => 'rejected'); + p.server.setLargestLocationProvider(() => { throw new Error('unavailable'); }); + await p.client.requestUpdate(id, { forward: 0 }); + await settle(); + release(); + expect(await opening).toBe('rejected'); + await settle(); + expect(p.server.session.getIncomingSubscription(id)).toBeUndefined(); + expect(p.server.session.state).toBe(SessionState.ESTABLISHED); + }); + + it('keeps a fill retryable when transport stream allocation rejects', async () => { + const p = await pair(); + serveSubscriptions(p); + const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} }); + await settle(); + const create = p.b.createUnidirectionalStream.bind(p.b); + const failure = new Error('stream allocation failed'); + p.b.createUnidirectionalStream = async () => { throw failure; }; + for (let i = 0; i < 3; i++) { + await expect(p.server.openFillStream(id)).rejects.toBe(failure); + } + p.b.createUnidirectionalStream = create; + const stream = await p.server.openFillStream(id); + await p.server.sendFetchObject(stream, { + groupId: 5n, subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]), + }); + await p.server.closeFetchStream(stream); + await expect(p.server.openFillStream(id)).rejects.toThrow(/No pending fill/); + expect(p.server.session.state).toBe(SessionState.ESTABLISHED); + expect(p.errors).toEqual([]); + }); + + it('does not restore a cancelled fill when its allocation later rejects', async () => { + const p = await pair(); + serveSubscriptions(p); + const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} }); + await settle(); + let reject!: (error: Error) => void; + p.b.createUnidirectionalStream = () => new Promise((_resolve, fail) => { reject = fail; }); + const opening = p.server.openFillStream(id).then(() => 'opened', () => 'rejected'); + await expect(p.server.openFillStream(id)).rejects.toThrow(/No pending fill/); + await p.client.unsubscribe(id); + await settle(); + reject(new Error('allocation failed after cancellation')); + expect(await opening).toBe('rejected'); + await expect(p.server.openFillStream(id)).rejects.toThrow(/No pending fill/); + expect(p.server.session.getIncomingSubscription(id)).toBeUndefined(); + }); + + it.each([18, 21] as const)('releases a failed ordinary FETCH allocation in draft %s', async (draft) => { + const p = await pair(draft); + p.server.onFetch = (id) => { void p.server.acceptFetch(id, { + endOfTrack: 0, endLocation: { group: 5n, object: 0n }, + }).catch((e: Error) => p.errors.push(e)); }; + const id = await p.client.fetch(ns('live'), nm('video'), { + startGroup: 0n, startObject: 0n, endGroup: 5n, endObject: 1n, + }); + await settle(); + const create = p.b.createUnidirectionalStream.bind(p.b); + p.b.createUnidirectionalStream = async () => { throw new Error('allocation failed'); }; + await expect(p.server.openFetchStream(id)).rejects.toThrow(/allocation failed/); + p.b.createUnidirectionalStream = create; + const stream = await p.server.openFetchStream(id); + await p.server.closeFetchStream(stream); + await expect(p.server.openFetchStream(id)).rejects.toThrow(/No admitted inbound FETCH|one response stream/); + expect(p.errors).toEqual([]); + }); + + it('serves a REQUEST_UPDATE fill on a publisher-initiated subscription', async () => { + const p = await pair(); + p.server.setLargestLocationProvider(() => ({ group: 5n, object: 0n })); + p.client.onPublish = (pub) => { void p.client.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => p.errors.push(e)); }; + const sub = await p.server.publish(ns('live'), nm('video'), ALIAS); + await settle(); + const delivered: bigint[] = []; + p.client.onObject = (_sid, o) => delivered.push(o.groupId); + const update = await p.client.requestUpdate(sub, { fill: { groupOrder: 'descending' } }); + await settle(); + const stream = await p.server.openFillStream(update); + for (const groupId of [5n, 4n]) { + await p.server.sendFetchObject(stream, { groupId, subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]) }); + } + await p.server.closeFetchStream(stream); + await settle(); + expect(delivered).toEqual([5n, 4n]); + expect(p.errors).toEqual([]); + }); + + it.each(['no objects', 'inherited Next Group'] as const)('does not open an empty fill: %s', async (shape) => { + const p = await pair(); + serveSubscriptions(p); + if (shape === 'no objects') p.server.onSubscribe = (id) => { void p.server.acceptSubscribe(id, ALIAS); }; + const id = await p.client.subscribe(ns('live'), nm('video'), { subscriptionFilter: { type: 'NextGroupStart' }, fill: {} }); + await settle(); + await expect(p.server.openFillStream(id)).rejects.toThrow(/No pending fill/); + expect(p.errors).toEqual([]); + }); + + it('an explicit unfiltered fill overrides Next Group, but cannot exceed Largest Object', async () => { + const p = await pair(); + serveSubscriptions(p); + const id = await p.client.subscribe(ns('live'), nm('video'), { + subscriptionFilter: { type: 'NextGroupStart' }, + fill: { filter: { type: 'AbsoluteStart', startGroup: 0n, startObject: 0n } }, + }); + await settle(); + const stream = await p.server.openFillStream(id); + const fields = { subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]) }; + await expect(p.server.sendFetchObject(stream, { ...fields, groupId: 6n })).rejects.toThrow(/fill range/); + await p.server.sendFetchObject(stream, { ...fields, groupId: 5n }); + await p.server.closeFetchStream(stream); + expect(p.errors).toEqual([]); + }); + it('counts a fill and prevents PUBLISH_DONE while its stream is open', async () => { + const p = await pair(); + serveSubscriptions(p); + const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} }); + await settle(); + const stream = await p.server.openFillStream(id); + const responses: ControlMessage[] = []; + p.client.onMessage = (message) => responses.push(message); + await expect(p.server.publishDone(id, varint(2n), 'done')).rejects.toThrow(/open/); + await p.server.closeFetchStream(stream); + await p.server.publishDone(id, varint(2n), 'done'); + await settle(); + expect(responses).toContainEqual(expect.objectContaining({ type: 'PUBLISH_DONE', streamCount: 1n })); + expect(p.errors).toEqual([]); + }); + + it('does not write an End-of-Range marker outside the fill snapshot', async () => { + const p = await pair(); + serveSubscriptions(p); + const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} }); + await settle(); + const stream = await p.server.openFillStream(id); + await expect(p.server.sendFetchEndOfRange(stream, false, 6n, 0n)).rejects.toThrow(/fill range/); + await p.server.sendFetchEndOfRange(stream, false, 5n, 0n); + await p.server.closeFetchStream(stream); + await settle(); + expect(p.errors).toEqual([]); + }); + + it.each(['rejects', 'hangs'] as const)('fails closed if a failed fill header reset %s', async (outcome) => { + const p = await pair(); + serveSubscriptions(p); + const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} }); + await settle(); + p.b.createUnidirectionalStream = async () => ({ getWriter: () => ({ + write: async () => { throw new Error('header failed'); }, + abort: async () => { + if (outcome === 'hangs') return new Promise(() => {}); + throw new Error('reset failed'); + }, + }) }) as unknown as WritableStream; + await expect(p.server.openFillStream(id)).rejects.toThrow(/header failed/); + expect(p.server.session.state).toBe(SessionState.CLOSED); + }, 3000); + + it('counts a completed fill even after its writer is reclaimed', async () => { + const p = await pair(); + serveSubscriptions(p); + const id = await p.client.subscribe(ns('live'), nm('video'), { fill: {} }); + await settle(); + const stream = await p.server.openFillStream(id); + await p.server.closeFetchStream(stream); + expect(p.server.session.getIncomingSubscription(id)!.streamCount).toBe(1n); + const state = p.server as unknown as { + servedFills: Map; inboundFetchGroupOrder: Map; fetchServeReserved: Set; + }; + expect(state.servedFills.has(id)).toBe(false); + expect(state.inboundFetchGroupOrder.has(id)).toBe(false); + expect(state.fetchServeReserved.has(id)).toBe(false); + await expect(p.server.openFillStream(id)).rejects.toThrow(); + await expect(p.server.openFetchStream(id)).rejects.toThrow(); + }); + + it('waits for the response before interpreting an early fill with publisher-preferred order', async () => { + const conn = new MoqtConnection(21); + connections.push(conn); + const transport = new TransportSim(); + transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() })); + const objects: MoqtObject[] = []; + const errors: Error[] = []; + conn.onObject = (_stream, object) => objects.push(object); + conn.onError = (error) => errors.push(error); + await conn.connect(transport); + const requestId = await conn.subscribe(ns('live'), nm('video'), { fill: {} }); + transport.pushIncomingUni(new Uint8Array([ + 0x05, 0x00, 0x1c, 0x05, 0x00, 0x03, 0x01, 0xa5, 0x0c, 0x00, 0x00, 0x01, 0xa4, + ])); + await settle(); + expect(objects).toEqual([]); + transport.bidi[0]!.push(codec.encode({ + type: 'SUBSCRIBE_OK', requestId, trackAlias: ALIAS, + parameters: new Map([[0x09n, [{ group: 5n, object: 0n }]]]), + trackProperties: new Map([[0x22n, [2n]]]), + })); + await settle(); + expect(objects.map((object) => object.groupId)).toEqual([5n, 4n]); + expect(errors).toEqual([]); + }); +}); + +describe('draft-21 inbound publication notifications', () => { + it('accepts PUBLISH_STATE_NOTIFY on an established inbound PUBLISH stream', async () => { + const conn = new MoqtConnection(21); + connections.push(conn); + const transport = new TransportSim(); + transport.openIncomingUni().push(codec.encode({ type: 'SETUP', setupOptions: new Map() })); + const errors: Error[] = []; + conn.onError = (error) => errors.push(error); + conn.onPublish = (pub) => { void conn.acceptSubscribe(pub.requestId, pub.trackAlias).catch((e: Error) => errors.push(e)); }; + await conn.connect(transport); + const request = transport.pushIncomingBidi(); + request.push(codec.encode({ type: 'PUBLISH', requestId: 1n, trackAlias: ALIAS, + trackNamespace: ns('live'), trackName: nm('video'), parameters: new Map(), trackProperties: new Map(), + })); + await settle(); + request.push(codec.encode({ type: 'PUBLISH_STATE_NOTIFY', parameters: new Map([[0x09n, [{ group: 8n, object: 2n }]]]) })); + await settle(); + expect(conn.session.getIncomingSubscription(1n)?.largestLocation).toEqual({ groupId: 8n, objectId: 2n }); + expect(conn.session.state).toBe(SessionState.ESTABLISHED); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/webtransport/src/adapter-d21.test.ts b/packages/webtransport/src/adapter-d21.test.ts new file mode 100644 index 00000000..6134bc33 --- /dev/null +++ b/packages/webtransport/src/adapter-d21.test.ts @@ -0,0 +1,428 @@ +/** + * MoqtConnection at draft 21: the draft-18 stream model (unified SETUP on a uni + * control pair, one bidi stream per request) with the draft-21 message changes. + */ +import { describe, it, expect } from 'vitest'; +import { MoqtConnection } from './adapter.js'; +import { TransportSim } from './testkit/stream-sim.js'; +import { createControlCodec } from '@openmoq/transport'; + +const codec21 = createControlCodec(21); +const setupBytes = (): Uint8Array => codec21.encode({ type: 'SETUP', setupOptions: new Map() }); + +describe('MoqtConnection(21) negotiation', () => { + it('connects with the unified SETUP on a uni control stream', async () => { + const conn = new MoqtConnection(21); + const transport = new TransportSim(); + transport.openIncomingUni().push(setupBytes()); + + await conn.connect(transport); + + expect(conn.draftVersion).toBe(21); + expect(transport.uniOut[0]!.writtenBytes()[0]).toBe(0xaf); + }); + + it('a connection without an explicit draft adopts a negotiated moqt-21', async () => { + const conn = new MoqtConnection(); + const transport = Object.assign(new TransportSim(), { protocol: 'moqt-21' }); + transport.openIncomingUni().push(setupBytes()); + + await conn.connect(transport); + + expect(conn.draftVersion).toBe(21); + }); +}); + +// ─── fills (draft-21 §3.4) and PUBLISH_STATE_NOTIFY (§9.10) ──────────── + +import { writeVi64, vi64EncodingLength, varint, SessionState } from '@openmoq/transport'; +import type { ControlMessage, MoqtObject, DataStreamHeader, SubscribeOk } from '@openmoq/transport'; +import { flush } from './testkit/stream-sim.js'; + +const ns = (s: string) => [new TextEncoder().encode(s)]; +const nm = (s: string) => new TextEncoder().encode(s); +const okBytes = (alias: bigint): Uint8Array => + codec21.encode({ type: 'SUBSCRIBE_OK', requestId: 0n, trackAlias: alias, parameters: new Map(), trackExtensions: new Map() } as SubscribeOk); +const vi = (...values: bigint[]): Uint8Array => { + const buf = new Uint8Array(values.reduce((n, v) => n + vi64EncodingLength(v), 0)); + let p = 0; + for (const v of values) p += writeVi64(v, buf, p); + return buf; +}; +const concat = (...as: Uint8Array[]): Uint8Array => { + const out = new Uint8Array(as.reduce((n, a) => n + a.length, 0)); + let p = 0; + for (const a of as) { out.set(a, p); p += a.length; } + return out; +}; +const fetchHeader = (requestId: bigint) => vi(0x05n, requestId); +// First fetch object: flags GROUP|OBJECT|PRIORITY (0x1C), group, object, priority, payload. +const firstFetchObj = (group: bigint, object: bigint, payload: number[]) => + concat(vi(0x1cn, group, object), new Uint8Array([3]), vi(BigInt(payload.length)), new Uint8Array(payload)); + +async function connected21(): Promise<{ conn: MoqtConnection; transport: TransportSim }> { + const conn = new MoqtConnection(21); + const transport = new TransportSim(); + transport.openIncomingUni().push(setupBytes()); + await conn.connect(transport); + return { conn, transport }; +} + +async function subscribedWithFill(conn: MoqtConnection, transport: TransportSim) { + const p = conn.subscribeTrack(ns('live'), nm('catalog'), { + filter: { type: 'LargestObject' }, + fill: { filter: { type: 'RelativeStart', groups: 1n } }, + }); + await flush(); + transport.bidi[0]!.push(codec21.encode({ type: 'SUBSCRIBE_OK', requestId: 0n, trackAlias: 7n, + parameters: new Map([[0x09n, [{ group: 4n, object: 0n }]]]), trackProperties: new Map(), + })); // a non-empty track has a Largest Object and keeps its request stream open + return p; +} + +describe('MoqtConnection(21) fill fetch streams', () => { + it('routes the fill stream by the SUBSCRIBE Request ID and leaves the subscription open', async () => { + const { conn, transport } = await connected21(); + const objects: MoqtObject[] = []; + const streams: DataStreamHeader[] = []; + const closed: string[] = []; + conn.onObject = (_sid, o) => objects.push(o); + conn.onDataStream = (_sid, h) => streams.push(h); + conn.onStreamClosed = (_sid, _code, terminal) => closed.push(String(terminal)); + const sub = await subscribedWithFill(conn, transport); + + transport.pushIncomingUni(concat(fetchHeader(sub.requestId), firstFetchObj(4n, 0n, [0xaa]))); + await flush(); + + expect(streams).toEqual([expect.objectContaining({ type: 'fetch', fill: true })]); + expect(objects.map((o) => [o.groupId, o.objectId])).toEqual([[4n, 0n]]); + expect(closed).toEqual(['fin']); + expect(conn.session.state).toBe(SessionState.ESTABLISHED); + expect(transport.bidi[0]!.writeClosed).toBe(false); // the SUBSCRIBE stream is not finished + }); + + it('accepts one fill stream per request', async () => { + const { conn, transport } = await connected21(); + const sub = await subscribedWithFill(conn, transport); + transport.pushIncomingUni(concat(fetchHeader(sub.requestId), firstFetchObj(4n, 0n, [0xaa]))); + await flush(); + + transport.pushIncomingUni(concat(fetchHeader(sub.requestId), firstFetchObj(4n, 0n, [0xaa]))); + await flush(); + + expect(conn.session.state).toBe(SessionState.CLOSED); + }); + + it('a FETCH_HEADER for a subscription that asked for no fill is still a protocol violation', async () => { + const { conn, transport } = await connected21(); + const p = conn.subscribeTrack(ns('live'), nm('video')); + await flush(); + transport.bidi[0]!.push(okBytes(8n)); + const sub = await p; + + transport.pushIncomingUni(concat(fetchHeader(sub.requestId), firstFetchObj(1n, 0n, [0x01]))); + await flush(); + + expect(conn.session.state).toBe(SessionState.CLOSED); + }); + + it('cancelFill discards a fill stream that arrives later, keeping the session and subscription', async () => { + const { conn, transport } = await connected21(); + const objects: MoqtObject[] = []; + conn.onObject = (_sid, o) => objects.push(o); + const sub = await subscribedWithFill(conn, transport); + + await conn.cancelFill(sub.requestId); + transport.pushIncomingUni(concat(fetchHeader(sub.requestId), firstFetchObj(4n, 0n, [0xaa]))); + await flush(); + + expect(objects).toEqual([]); + expect(conn.session.state).toBe(SessionState.ESTABLISHED); + expect(transport.bidi[0]!.writeClosed).toBe(false); + }); +}); + +describe('MoqtConnection(21) PUBLISH_STATE_NOTIFY', () => { + it('is surfaced on the subscription stream and advances its Largest Location', async () => { + const { conn, transport } = await connected21(); + const seen: ControlMessage[] = []; + conn.onMessage = (m) => seen.push(m); + const p = conn.subscribeTrack(ns('live'), nm('video')); + await flush(); + transport.bidi[0]!.push(okBytes(9n)); + const sub = await p; + + transport.bidi[0]!.push(codec21.encode({ + type: 'PUBLISH_STATE_NOTIFY', + parameters: new Map([[0x09n, [{ group: 3n, object: 7n }]], [0x10n, [varint(0n)]]]), + } as ControlMessage)); + await flush(); + + expect(seen).toContainEqual(expect.objectContaining({ type: 'PUBLISH_STATE_NOTIFY', requestId: sub.requestId })); + expect(conn.session.getSubscription(sub.requestId)!.largestLocation).toEqual({ groupId: 3n, objectId: 7n }); + expect(conn.session.state).toBe(SessionState.ESTABLISHED); + }); + + it('before SUBSCRIBE_OK it is a protocol violation', async () => { + const { conn, transport } = await connected21(); + void conn.subscribeTrack(ns('live'), nm('video')).catch(() => undefined); + await flush(); + + transport.bidi[0]!.push(codec21.encode({ type: 'PUBLISH_STATE_NOTIFY', parameters: new Map() } as ControlMessage)); + await flush(); + + expect(conn.session.state).toBe(SessionState.CLOSED); + }); +}); + +describe('MoqtConnection(21) serving a fill (publisher side)', () => { + it('openFillStream answers a SUBSCRIBE fill on one fetch stream and leaves the subscription up', async () => { + const { connectedPair } = await import('./testkit/pair.js'); + const { client, server, errors } = await connectedPair(21); + let fillRefusedWithoutRequest = false; + let alias = 12n; + server.onSubscribe = (requestId, _ns, trackName) => { + void (async () => { + await server.acceptSubscribe(requestId, alias++, { parameters: new Map([[0x09n, [{ group: 4n, object: 1n }]]]) }); + if (new TextDecoder().decode(trackName) !== 'catalog') { + await server.openFillStream(requestId).catch(() => { fillRefusedWithoutRequest = true; }); + return; + } + const sid = await server.openFillStream(requestId); + await server.sendFetchObject(sid, { groupId: 4n, subgroupId: 0n, objectId: 0n, publisherPriority: 3, payload: new Uint8Array([1]) }); + await server.sendFetchObject(sid, { groupId: 4n, subgroupId: 0n, objectId: 1n, publisherPriority: 3, payload: new Uint8Array([2]) }); + await server.closeFetchStream(sid); + })(); + }; + const objects: MoqtObject[] = []; + const streams: DataStreamHeader[] = []; + client.onObject = (_sid, o) => objects.push(o); + client.onDataStream = (_sid, h) => streams.push(h); + + const sub = await client.subscribeTrack(ns('live'), nm('catalog'), { + filter: { type: 'LargestObject' }, + fill: { filter: { type: 'RelativeStart', groups: 1n } }, + }); + await client.subscribeTrack(ns('live'), nm('video')); + for (let i = 0; i < 10; i++) await flush(); + + expect(streams).toEqual([expect.objectContaining({ fill: true })]); + expect(objects.map((o) => o.objectId)).toEqual([0n, 1n]); + expect(fillRefusedWithoutRequest).toBe(true); + expect(client.session.getSubscription(sub.requestId)).toBeDefined(); + expect(server.session.state).toBe(SessionState.ESTABLISHED); + expect(errors).toEqual([]); + }); +}); + +describe('MoqtConnection(21) fills on REQUEST_UPDATE (§3.4)', () => { + it('routes the fill stream carrying the REQUEST_UPDATE Request ID to the subscription', async () => { + const { client, server, errors } = await connectedPair21(); + server.setLargestLocationProvider(() => ({ group: 1n, object: 0n })); + let subReq = -1n; + server.onSubscribe = (requestId) => { subReq = requestId; void server.acceptSubscribe(requestId, 30n); }; + const streams: DataStreamHeader[] = []; + const objects: MoqtObject[] = []; + client.onDataStream = (_sid, h) => streams.push(h); + client.onObject = (_sid, o) => objects.push(o); + const sub = await client.subscribeTrack(ns('live'), nm('video'), { filter: { type: 'LargestObject' } }); + + const updateId = await client.requestUpdate(sub.requestId, { + fill: { filter: { type: 'AbsoluteRange', startGroup: 1n, startObject: 0n, endGroup: 1n } }, + }); + await flushN(); + const sid = await server.openFillStream(updateId); + await server.sendFetchObject(sid, { groupId: 1n, subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([9]) }); + await server.closeFetchStream(sid); + await flushN(); + + expect(subReq).toBe(sub.requestId); + expect(streams).toEqual([expect.objectContaining({ fill: true, header: expect.objectContaining({ requestId: updateId }) })]); + expect(objects.map((o) => [o.groupId, o.objectId])).toEqual([[1n, 0n]]); + expect(client.session.state).toBe(SessionState.ESTABLISHED); + expect(errors).toEqual([]); + }); + + it('cancelFill on the subscription also drops a fill its update asked for', async () => { + const { client, server } = await connectedPair21(); + server.setLargestLocationProvider(() => ({ group: 0n, object: 0n })); + server.onSubscribe = (requestId) => { void server.acceptSubscribe(requestId, 31n); }; + const objects: MoqtObject[] = []; + client.onObject = (_sid, o) => objects.push(o); + const sub = await client.subscribeTrack(ns('live'), nm('video')); + const updateId = await client.requestUpdate(sub.requestId, { fill: {} }); + await client.cancelFill(sub.requestId); + + const sid = await server.openFillStream(updateId); + await server.sendFetchObject(sid, { groupId: 0n, subgroupId: 0n, objectId: 0n, publisherPriority: 1, payload: new Uint8Array([1]) }); + await server.closeFetchStream(sid); + await flushN(); + + expect(objects).toEqual([]); + expect(client.session.state).toBe(SessionState.ESTABLISHED); + }); +}); + +describe('MoqtConnection(21) MAX_REQUEST_UPDATES (§9.1.7)', () => { + it('the client keeps within the limit the server advertised', async () => { + const { client, server } = await connectedPair21({ serverSetup: { maxRequestUpdates: 1n } }); + server.onSubscribe = () => { /* leave it pending: updates stay unanswered */ }; + const pending = client.subscribeTrack(ns('live'), nm('video')); + pending.catch(() => undefined); + await flushN(); + const subId = [...(client.session as unknown as { subscriptions: Map }).subscriptions.keys()][0]!; + await client.requestUpdate(subId, { forward: 0 }); + await expect(client.requestUpdate(subId, { forward: 1 })).rejects.toThrow(/MAX_REQUEST_UPDATES/); + }); + + it('a peer exceeding our limit closes the session with TOO_MANY_REQUEST_UPDATES', async () => { + const { client, server } = await connectedPair21({ serverSetup: { maxRequestUpdates: 1n } }); + server.onSubscribe = () => { /* pending: updates are queued, not answered */ }; + let closedWith: bigint | undefined; + server.onClose = (code) => { closedWith = BigInt(code as never); }; + // A client that ignores the advertised limit. + (client.session as unknown as { _peerMaxRequestUpdates: bigint })._peerMaxRequestUpdates = 0n; + const pending = client.subscribeTrack(ns('live'), nm('video')); + pending.catch(() => undefined); + await flushN(); + const subId = [...(client.session as unknown as { subscriptions: Map }).subscriptions.keys()][0]!; + await client.requestUpdate(subId, { forward: 0 }); + await flushN(); + expect(server.session.state).toBe(SessionState.ESTABLISHED); // one outstanding is allowed + await client.requestUpdate(subId, { forward: 1 }).catch(() => undefined); + await flushN(); + expect(server.session.state).toBe(SessionState.CLOSED); + expect(closedWith).toBe(0x1bn); + }); +}); + +async function connectedPair21(opts: Parameters[1] = {}) { + const { connectedPair } = await import('./testkit/pair.js'); + return connectedPair(21, opts); +} + +async function flushN(n = 10): Promise { + for (let i = 0; i < n; i++) await flush(); +} + + +describe('MoqtConnection native QUIC at draft 21', () => { + const quicTransport = (protocol: string) => Object.assign(new TransportSim(), { + kind: 'quic' as const, + protocol, + maxDatagramSize: 1200, + setupOptions: { path: '/moq', authority: 'relay.example:443' }, + }); + + it('a moqt-21 transport selects draft 21', async () => { + const conn = new MoqtConnection(); + const transport = quicTransport('moqt-21'); + transport.openIncomingUni().push(setupBytes()); + await conn.connect(transport); + expect(conn.draftVersion).toBe(21); + expect(conn.session.state).toBe(SessionState.ESTABLISHED); + }); + + it('a connection made for draft 21 refuses a moqt-18 transport', async () => { + const conn = new MoqtConnection(21); + const transport = quicTransport('moqt-18'); + await expect(conn.connect(transport)).rejects.toThrow(/draft 18.*draft 21/); + await conn.close(); + }); +}); + +describe('MoqtConnection(21) subscriptions sharing a Track Alias (§3.1)', () => { + const SHARED = 50n; + + async function sharedPair() { + const pair = await connectedPair21(); + pair.server.onSubscribe = (requestId) => { void pair.server.acceptSubscribe(requestId, SHARED); }; + return pair; + } + + async function publish(server: MoqtConnection, group: bigint, object: bigint, requestId?: bigint): Promise { + const sid = await server.openSubgroup(varint(SHARED), varint(group), varint(0n), { + endOfGroup: false, publisherPriority: 1, ...(requestId === undefined ? {} : { requestId }), + }); + await server.sendObject(sid, varint(object), new Uint8Array([Number(group), Number(object)])); + await server.closeSubgroup(sid); + await flushN(); + } + + const locations = (objs: MoqtObject[]) => objs.map((o) => `${o.groupId}/${o.objectId}`); + + it('both subscriptions stay bound and each gets only the objects its filter selects', async () => { + const { client, server, errors } = await sharedPair(); + const a: MoqtObject[] = []; + const b: MoqtObject[] = []; + const subA = await client.subscribeTrack(ns('live'), nm('video'), { + filter: { type: 'AbsoluteRange', startGroup: 0n, startObject: 0n, endGroup: 1n }, + onObject: (o) => a.push(o), + }); + const subB = await client.subscribeTrack(ns('live'), nm('video'), { + filter: { type: 'AbsoluteStart', startGroup: 2n, startObject: 0n }, + onObject: (o) => b.push(o), + }); + expect(subA.trackAlias).toBe(SHARED); + expect(subB.trackAlias).toBe(SHARED); + + await publish(server, 1n, 0n, subA.requestId); + await publish(server, 2n, 0n, subB.requestId); + + expect(locations(a)).toEqual(['1/0']); + expect(locations(b)).toEqual(['2/0']); + expect(client.session.state).toBe(SessionState.ESTABLISHED); + expect(errors).toEqual([]); + }); + + it('an object sent once per matching subscription reaches each of them once', async () => { + const { client, server } = await sharedPair(); + const a: MoqtObject[] = []; + const b: MoqtObject[] = []; + const subA = await client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => a.push(o) }); + const subB = await client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => b.push(o) }); + + await publish(server, 3n, 0n, subA.requestId); + await publish(server, 3n, 0n, subB.requestId); + + expect(locations(a)).toEqual(['3/0']); + expect(locations(b)).toEqual(['3/0']); + }); + + it('a PUBLISH_DONE for one subscription leaves the other receiving on the alias', async () => { + const pair = await connectedPair21(); + const ids: bigint[] = []; + pair.server.onSubscribe = (requestId) => { ids.push(requestId); void pair.server.acceptSubscribe(requestId, SHARED); }; + const { client, server, errors } = pair; + const a: MoqtObject[] = []; + const b: MoqtObject[] = []; + await client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => a.push(o) }); + await client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => b.push(o) }); + + await server.publishDone(ids[0]!, varint(0x2n), 'track ended for the first'); + await flushN(); + await publish(server, 5n, 0n); + + expect(a).toEqual([]); + expect(locations(b)).toEqual(['5/0']); + expect(client.session.state).toBe(SessionState.ESTABLISHED); + expect(errors).toEqual([]); + }); + + it('ending one subscription leaves the other receiving on the alias', async () => { + const { client, server, errors } = await sharedPair(); + const a: MoqtObject[] = []; + const b: MoqtObject[] = []; + const subA = await client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => a.push(o) }); + await client.subscribeTrack(ns('live'), nm('video'), { onObject: (o) => b.push(o) }); + + await subA.unsubscribe(); + await flushN(); + await publish(server, 4n, 0n); + + expect(a).toEqual([]); + expect(locations(b)).toEqual(['4/0']); + expect(client.session.state).toBe(SessionState.ESTABLISHED); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/webtransport/src/adapter.ts b/packages/webtransport/src/adapter.ts index 17130b57..fcad14e3 100644 --- a/packages/webtransport/src/adapter.ts +++ b/packages/webtransport/src/adapter.ts @@ -35,7 +35,12 @@ import { RequestError18, SubscriptionState, ForwardState, + windowContains, + decodeFillParameters, + decodeSubscriptionFilter, + resolveFillWindow, } from '@openmoq/transport'; +import type { FillOptions, SubscriptionWindow } from '@openmoq/transport'; import type { EndpointRoleValue, ControlMessage, @@ -82,6 +87,7 @@ import { IncomingUniRouter, type RoutedIncomingUniStream } from './topology/inco import { InboundRequestStreamContext } from './topology/inbound-request.js'; import { MoqtConnectionError } from './adapter-error.js'; import type { WebTransportLike, WebTransportBidirectionalStream, MoqtSetupRouting } from './types.js'; +import { isDraft21, isRequestStreamDraft, isWiredDraft } from '@openmoq/transport'; @@ -127,6 +133,13 @@ export interface TrackSubscribeOptions { * still legitimately in flight (§10.11). */ readonly deliveryTimeout?: SubscribeOptions['deliveryTimeout']; + /** + * draft-21 §3.4 FILL_PARAMETERS: the publisher also delivers the fill range on + * a fill fetch stream. Its objects reach the connection's `onObject` like FETCH + * objects; `onDataStream` announces the stream with `fill: true` and this + * subscription's Request ID in the FETCH_HEADER. + */ + readonly fill?: SubscribeOptions['fill']; /** Called for each object delivered on this subscription (stream-based only; datagrams excluded). */ onObject?: (obj: MoqtObject) => void; /** @@ -163,6 +176,16 @@ interface RawSubState { readonly sub: TrackSubscription; resolve: ((sub: TrackSubscription) => void) | null; reject: ((err: Error) => void) | null; + /** Objects already delivered while the alias is shared, for deduplication (bounded). */ + seen?: Set; +} + +/** Bound on the per-subscription dedup set of a shared Track Alias. */ +const SHARED_ALIAS_SEEN_LIMIT = 4096; + +/** Whether any Location of `group` falls inside `window`. */ +function windowCoversGroup(window: SubscriptionWindow, group: bigint): boolean { + return group >= window.start.group && (window.end === undefined || group <= window.end.group); } type PendingAliasEvent = { @@ -221,7 +244,7 @@ export interface IncomingPublish { * than trying to construct a codec for an unwired draft. * * @see draft-ietf-moq-transport-16 §3.1 - * - 'moqt-18' → 18, 'moqt-16' → 16 + * - 'moqt-21' → 21, 'moqt-18' → 18, 'moqt-16' → 16 * - 'moq-00' → 14 (pre-15 convention) * - '' / undefined / unsupported token → undefined (use constructor version) */ @@ -231,7 +254,7 @@ function protocolToDraftVersion(protocol: string | undefined): DraftVersion | un const match = protocol.match(/^moqt-(\d+)$/); if (!match) return undefined; const n = parseInt(match[1]!, 10); - return n === 14 || n === 16 || n === 18 ? (n as DraftVersion) : undefined; + return isWiredDraft(n) ? n : undefined; } /** @@ -304,6 +327,9 @@ function splitOwnership( return { sessionOptions: rest as T, onRequestId }; } +/** draft-21 FILL_PARAMETERS message parameter (§9.20.16). */ +const FILL_PARAMETERS = 0x23n; + export class MoqtConnection { /** The underlying sans-I/O session state machine. Set by {@link configureForVersion}. */ session!: Session; @@ -401,6 +427,12 @@ export class MoqtConnection { private rawSubscriptions = new Map(); /** Track subscriptions by trackAlias (active, alias resolved). */ private rawAliasMaps = new Map(); + /** + * draft-21 §3.1: every subscription sharing a Track Alias (two or more, same + * track), in binding order. `rawAliasMaps` keeps the first as the alias owner + * for the terminal machinery; objects go to each member whose filter matches. + */ + private readonly sharedAliasSubs = new Map(); /** Generic subscribe() requests awaiting SUBSCRIBE_OK / REQUEST_ERROR. */ private pendingGenericSubscriptions = new Set(); /** Local monotonic ownership generation for every unanswered SUBSCRIBE. */ @@ -434,7 +466,8 @@ export class MoqtConnection { /** Inbound PUBLISH (draft-18 §10.10) stream contexts, keyed by Request ID. */ private inboundRequestContexts = new Map(); /** Inbound PUBLISH tracks by Track Alias, for routing data objects. */ - private publishAliasMaps = new Map(); + private publishAliasMaps = new Map(); + private subscriberObjectHistory = new WeakMap>(); /** Requested Group Order per inbound FETCH Request ID (default ascending). */ private inboundFetchGroupOrder = new Map(); @@ -469,7 +502,20 @@ export class MoqtConnection { groupOrder: GroupOrder; prior: FetchObjectPrior18 | undefined; isFirstObject: boolean; + closing?: Promise; + /** A draft-21 fill stream: the Request ID of the subscription it fills. */ + fillOf?: bigint; + fillWindow?: SubscriptionWindow; }>(); + private static readonly MAX_PENDING_FILLS = 256; + /** Inbound draft-21 fills awaiting an application response stream. */ + private readonly inboundFillRequests = new Map(); + /** Fills with a reserved or open response stream, mapped to their subscription. */ + private readonly servedFills = new Map(); /** * Called for each application-relevant control message received after setup. @@ -648,6 +694,9 @@ export class MoqtConnection { // Receiver terminal tracker (tombstone TTL timers) + seen counts. for (const alias of [...this.terminatedAliases.keys()]) this.clearTerminatedAlias(alias); this.aliasStreamsSeen.clear(); + this.aliasTerminalTotals.clear(); + for (const retained of this.terminalSubscribers.values()) retained.cancelTimer(); + this.terminalSubscribers.clear(); this.aliasDeliveryTimeoutMs.clear(); this.aliasPublisherTimeout.clear(); this.inboundPublishTimeouts.clear(); @@ -680,7 +729,12 @@ export class MoqtConnection { this.fetchGroupOrder.clear(); this.inboundFetchGroupOrder.clear(); this.fetchServeReserved.clear(); + this.inboundFillRequests.clear(); + this.servedFills.clear(); this.fetchStreams.clear(); + this.fillStreams.clear(); + for (const pending of this.pendingFillHeaders.values()) pending.finish(false); + this.pendingFillHeaders.clear(); this.recentlyCancelledFetches.clear(); // Incoming data-stream readers — cancel each, then drop. This is a LOCAL // teardown: route it through the ownership primitive so a read parked in @@ -983,6 +1037,7 @@ export class MoqtConnection { */ private outgoingStreams = new Map; + groupId: bigint; hasExtensions: boolean; previousObjectId: bigint; isFirstObject: boolean; @@ -1007,7 +1062,7 @@ export class MoqtConnection { * On termination the entry is RETIRED (moved to {@link retiredPublisherAliases}), * so this map tracks only live subscriptions. */ - private publisherAliasRequests = new Map(); + private publisherAliasRequests = new Map>(); /** * BOUNDED LRU of recently-RETIRED publisher alias → request ID. A send on a @@ -1094,6 +1149,11 @@ export class MoqtConnection { * observed or `terminatedAliasTtlMs` expires — never retained indefinitely. */ private aliasStreamsSeen = new Map(); + private readonly aliasTerminalTotals = new Map(); + private readonly terminalSubscribers = new Map void; + }>(); /** * Per-alias tombstone. `remaining` is how many late streams are still expected * (bigint, counted down as they arrive); `null` means the count is UNKNOWN — @@ -1162,6 +1222,14 @@ export class MoqtConnection { private fetchServeReserved = new Set(); /** §9.15 sentinel: publisher could not set an exact Stream Count. */ private static readonly STREAM_COUNT_UNKNOWN = (1n << 62n) - 1n; + /** + * draft-21 §3.4 fill fetch streams, by the Request ID that asked for the fill + * (the SUBSCRIBE's, or a REQUEST_UPDATE's), with the subscription they fill. + * Kept apart from `fetchStreams`: a fill has no FETCH of its own, and ending + * it must not touch the subscription's request stream. + */ + private fillStreams = new Map(); + private readonly pendingFillHeaders = new Map void }>(); /** setTimeout's safe upper bound (2^31-1 ms); longer durations are chunked. */ private static readonly MAX_TIMER_MS = 2_147_483_647; @@ -1229,10 +1297,10 @@ export class MoqtConnection { // PATH/AUTHORITY are forbidden over WebTransport but carry native-QUIC // routing information. The sans-I/O gate enforces the selected binding. this.session = new Session(this._role, version, { webtransport: transportKind === 'webtransport' }); - if (version === 18) { - this.codec = createControlCodec(18); + if (isRequestStreamDraft(version)) { + this.codec = createControlCodec(version); this.framer = new ControlStreamFramer(this.codec); - this.dataCodec = createDataCodec(18); + this.dataCodec = createDataCodec(version); this.uniPair = createUniPairTopology(this.session); // Surface request-stream failures (unsolicited/wrong-typed response) at once, // de-duped against the response-consumer path (same Error instance). @@ -1293,13 +1361,14 @@ export class MoqtConnection { // Native QUIC support starts at draft 18. Unlike the historical // WebTransport fallback, ALPN is mandatory and must identify the exact // wire draft before any MOQT stream is opened. - if (transport.protocol !== 'moqt-18') { + const nativeDraft = transport.protocol === 'moqt-18' ? 18 : transport.protocol === 'moqt-21' ? 21 : undefined; + if (nativeDraft === undefined) { throw new ProtocolViolationError( - `native QUIC negotiated ALPN ${JSON.stringify(transport.protocol ?? '')}; expected "moqt-18"`, + `native QUIC negotiated ALPN ${JSON.stringify(transport.protocol ?? '')}; expected "moqt-18" or "moqt-21"`, ); } - if (this._requestedVersion !== undefined && this._requestedVersion !== 18) { - throw new Error(`native QUIC supports draft 18 only, not draft ${this._requestedVersion}`); + if (this._requestedVersion !== undefined && this._requestedVersion !== nativeDraft) { + throw new Error(`native QUIC negotiated draft ${nativeDraft}, but this connection was created for draft ${this._requestedVersion}`); } if (!Number.isSafeInteger(transport.maxDatagramSize) || transport.maxDatagramSize! <= 0) { throw new Error('native QUIC requires negotiated QUIC datagram support'); @@ -1314,7 +1383,7 @@ export class MoqtConnection { throw new Error('native QUIC SETUP routing conflicts with the transport URI'); } nativeRouting = routing; - selectedVersion = 18; + selectedVersion = nativeDraft; } else if (transportKind !== 'webtransport') { throw new Error(`unsupported transport kind: ${String(transportKind)}`); } else if (this._requestedVersion === undefined && transport.protocol) { @@ -1331,7 +1400,7 @@ export class MoqtConnection { // terminal coordinator, ignores a stale transport after migration. void this.watchTransportClosed(transport); - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { // draft-18: open the uni control-stream pair and exchange SETUP. Request // responses arrive on their own bidi streams (see subscribe()), so no // shared control read loop is started. SETUP, subgroup, FETCH, and @@ -1518,7 +1587,7 @@ export class MoqtConnection { await send(); } catch (err) { this.session.rollbackRequest(requestId, { retainCancellationProvenance: true }); - if (this.session.draftVersion !== 18) { + if (!isRequestStreamDraft(this.session.draftVersion)) { this.closeSessionFatal( `control-stream write failed: ${err instanceof Error ? err.message : String(err)}`, ); @@ -1555,7 +1624,7 @@ export class MoqtConnection { message: send.message, }); } catch (err) { - this.publisherAliasRequests.delete(trackAlias); + this.removePublisherAliasRequest(trackAlias, requestId); if (!this.session.rollbackUnsentPublish(requestId)) { this.closeSessionFatal( `could not preserve the request sequence after PUBLISH ${requestId} failed before emission`, @@ -1567,7 +1636,7 @@ export class MoqtConnection { try { await this.controlWriter!.write(bytes); } catch (err) { - this.publisherAliasRequests.delete(trackAlias); + this.removePublisherAliasRequest(trackAlias, requestId); this.session.rollbackRequest(requestId, { retainCancellationProvenance: true }); this.closeSessionFatal( `control-stream write failed while publishing request ${requestId}: ${err instanceof Error ? err.message : String(err)}`, @@ -1584,7 +1653,7 @@ export class MoqtConnection { * may answer (e.g. a §3.2 reserved-namespace REQUEST_ERROR) synchronously. */ private async sendSubscribeActions(actions: SessionOutboundAction[]): Promise { - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { const subscribeMsg = (actions.find((a) => a.type === 'send_control') as SendControlAction).message; const requestId = (subscribeMsg as { requestId: bigint }).requestId; // The delivery IS the topology's wire-order barrier: the read loop awaits @@ -1642,17 +1711,22 @@ export class MoqtConnection { namespace: Uint8Array[], name: Uint8Array, trackAlias: bigint, - options?: { parameters?: Parameters; trackProperties?: TrackProperties }, + options?: { + parameters?: Parameters; + trackProperties?: TrackProperties; + /** draft-21: inherit Forward from this accepted SUBSCRIBE_TRACKS. */ + subscribeTracksRequestId?: bigint; + }, ): Promise { const { requestId, actions } = this.session.publish(namespace, name, trackAlias, options); // Associate the advertised alias with this publish for §10.11 Stream Count // accounting and terminal enforcement on the data-plane APIs. - this.publisherAliasRequests.set(trackAlias, requestId); - if (this.session.draftVersion === 18) { + this.addPublisherAliasRequest(trackAlias, requestId); + if (isRequestStreamDraft(this.session.draftVersion)) { const publishMsg = (actions.find((a) => a.type === 'send_control') as SendControlAction).message; // Roll back the publisher-alias authority too (so openSubgroup can't publish // on an alias the peer never learned) if the request-stream open fails. - await this.openD18Request(requestId, publishMsg, () => this.publisherAliasRequests.delete(trackAlias)); + await this.openD18Request(requestId, publishMsg, () => this.removePublisherAliasRequest(trackAlias, requestId)); return requestId; } await this.emitLegacyPublishOrRollback(requestId, trackAlias, actions); @@ -1804,7 +1878,7 @@ export class MoqtConnection { */ private refreshAliasDeliveryTimeout(alias: bigint): void { const reqId = this.rawAliasMaps.get(alias)?.requestId - ?? [...this.publishAliasMaps].find(([a]) => a === alias)?.[1]?.requestId; + ?? this.publishAliasMaps.get(alias)?.[0]?.requestId; if (reqId === undefined || reqId === null) return; const sub = this.session.getSubscription(reqId) ?? this.session.getIncomingSubscription(reqId); if (!sub) return; @@ -1941,7 +2015,9 @@ export class MoqtConnection { if (!suppress) this.onMessage?.(stamped); const fwdTarget = this.outboundPublishForwardTarget(stamped as { type: string; requestId?: bigint }); const fwdBefore = fwdTarget === undefined ? undefined : this.getPublishForwardState(fwdTarget); - await this.executeActions(this.session.handleControlMessage(message, { requestId })); + const actions = this.session.handleControlMessage(message, { requestId }); + this.settleFillHeaders(); + await this.executeActions(actions); const fwdAfter = this.changedPublishForwardState(fwdTarget, fwdBefore); if (fwdTarget !== undefined && fwdAfter !== undefined) { this.notifyPublishForwardChange(fwdTarget, fwdAfter); @@ -2019,6 +2095,7 @@ export class MoqtConnection { this.beginPendingAliasReplay(alias, requestId, null, replay); this.onMessage?.(stampedOk); } + this.settleFillHeaders(); await this.executeActions(actions); } @@ -2047,7 +2124,7 @@ export class MoqtConnection { // session.unsubscribe: draft-14/16 → UNSUBSCRIBE control action + alias // unregister; draft-18 → no control action + alias unregister. await this.executeActions(this.session.unsubscribe(requestId)); - if (this.session.draftVersion === 18) await this.uniPair!.cancelRequest(requestId); + if (isRequestStreamDraft(this.session.draftVersion)) await this.uniPair!.cancelRequest(requestId); this.onError?.(err); } @@ -2060,6 +2137,10 @@ export class MoqtConnection { */ private aliasReuseSafe(alias: bigint): boolean { if (this.drainingAliases.has(alias)) return false; // drain window in progress + const live = this.session.getSubscriberRequestsForAlias(alias); + for (const { sub } of this.terminalSubscribers.values()) { + if (sub.trackAlias === alias && !live.some((owner) => owner.trackKey === sub.trackKey)) return false; + } const t = this.terminatedAliases.get(alias); return !t || (t.strict && t.remaining === 0n); } @@ -2114,6 +2195,7 @@ export class MoqtConnection { } this.rawSubscriptions.clear(); this.rawAliasMaps.clear(); + this.sharedAliasSubs.clear(); this.pendingGenericSubscriptions.clear(); this.pendingSubscriptionGenerations.clear(); this.clearAllPendingAliases(); @@ -2139,26 +2221,47 @@ export class MoqtConnection { // streams are actively stopped, not merely unrouted. The topology FINs our // send direction and drains to the publisher's FIN. if (message.type === 'PUBLISH_DONE') { + // A subscription sharing its alias ends alone: the alias, its streams and + // their terminal accounting stay with the other subscriptions (draft-21 §3.1). + const doneRaw = this.rawSubscriptions.get(originalRequestId); + const subscription = this.session.getSubscription(originalRequestId); + const alias = subscription?.trackAlias; + const aliasStillShared = alias !== undefined && this.aliasHasOtherSubscriber(alias, originalRequestId); + if (doneRaw) this.detachSharedAlias(doneRaw); // Capture the alias BEFORE the session reclaims the subscription. - const doneAlias = this.session.getSubscription(originalRequestId)?.trackAlias; - const streamCount = (message as { streamCount?: bigint }).streamCount ?? 0n; + const doneAlias = aliasStillShared ? undefined : alias; + const ownCount = (message as { streamCount?: bigint }).streamCount ?? 0n; + const streamCount = alias === undefined ? ownCount : this.recordSubscriberTerminal(alias, ownCount); const stampedDone = { ...message, requestId: originalRequestId } as ControlMessage; // Terminal drain: arm SYNCHRONOUSLY, BEFORE the application callback — // no callback may observe a DONE'd subscription without the drain pending. - const draining = this.drainConfigs.has(originalRequestId) && doneAlias !== undefined; - if (draining) this.applyTerminalDrain(doneAlias!, BigInt(streamCount), originalRequestId); + const draining = this.drainConfigs.has(originalRequestId) && alias !== undefined; + if (draining && subscription) this.retainTerminalSubscriber(subscription); + const cancelFills = draining ? Promise.resolve() : this.cancelFill(originalRequestId); + const aliasDraining = draining || (doneAlias !== undefined && [...this.terminalSubscribers.values()].some(({ sub }) => sub.trackAlias === doneAlias)); + if (aliasDraining && doneAlias !== undefined) this.applyTerminalDrain(doneAlias, BigInt(streamCount), originalRequestId); // Non-drained terminal application starts BEFORE the callback, like the // drain above: its arm runs before the first await, so a reentrant // close() inside the callback CLEARS the terminal state instead of having // its cleanup undone by an arm that follows it. - const discard = !draining && doneAlias !== undefined + const discard = !aliasDraining && doneAlias !== undefined ? this.applyTerminalStreamCount(doneAlias, BigInt(streamCount), originalRequestId) : Promise.resolve(); this.onMessage?.(stampedDone); await this.executeActions(this.session.handleControlMessage(stampedDone)); + await cancelFills; await discard; return; } + // draft-21 §9.10: the publisher's report on OUR subscription's request stream. + // Stamp the subscription's request ID, let the session apply it (Largest + // Location; an out-of-scope parameter closes the session), surface to the app. + if (message.type === 'PUBLISH_STATE_NOTIFY') { + const stampedNotify = { ...message, requestId: originalRequestId } as ControlMessage; + this.onMessage?.(stampedNotify); + await this.executeActions(this.session.handleControlMessage(stampedNotify)); + return; + } const updateId = (message as { requestId: bigint }).requestId; const stamped = { ...message, existingRequestId: originalRequestId } as ControlMessage; this.onMessage?.(stamped); @@ -2179,6 +2282,10 @@ export class MoqtConnection { // may send PUBLISH_DONE, which must remain behind this update response on // the request stream. Do not await yet: Forward=0 must still be reported // promptly when the transport write is blocked. + if (isDraft21(this.session.draftVersion) && send?.message.type === 'REQUEST_OK') { + if (!this.rememberInboundFill(updateId, originalRequestId, (message as { parameters: Parameters }).parameters)) return; + this.finalizeInboundFill(updateId, send.message.parameters); + } const responseWrite = send ? this.uniPair!.writeOnRequest(originalRequestId, send.message) : Promise.resolve(); @@ -2238,17 +2345,24 @@ export class MoqtConnection { // request STILL owns the alias routing: a delayed peer-close after the alias // was legitimately reused by a new subscription must not re-tombstone it. const raw = this.rawSubscriptions.get(requestId); - const teardownAlias = raw && raw.trackAlias !== null && this.rawAliasMaps.get(raw.trackAlias) === raw - ? raw.trackAlias : null; + // A subscription sharing its alias leaves it to the others (draft-21 §3.1). + if (raw) this.detachSharedAlias(raw); + const alias = isDraft21(this.session.draftVersion) + ? this.session.getSubscription(requestId)?.trackAlias ?? raw?.trackAlias + : raw?.trackAlias; + const retained = this.terminalSubscribers.has(requestId); + if (alias != null && this.session.getSubscription(requestId)) this.recordSubscriberTerminal(alias, null); + const teardownAlias = !retained && alias != null + && !this.deliverySubscribers21(alias).some((sub) => sub.requestId !== requestId) + && (!raw || this.rawAliasMaps.get(alias) === raw) ? alias : null; if (teardownAlias !== null) this.armSubscriberAliasTeardown(teardownAlias); this.cancelPendingAliasReplaysForRequest(requestId); - - await this.executeActions(this.session.handleOutboundRequestClosed(requestId)); + const cancelledFills = !retained && (alias == null || !this.drainingAliases.has(alias)) ? this.cancelFill(requestId) : Promise.resolve(); + const closeActions = this.session.handleOutboundRequestClosed(requestId); + await cancelledFills; + await this.executeActions(closeActions); + if (teardownAlias !== null) await this.discardOpenStreamsForAlias(teardownAlias); if (raw) { - if (teardownAlias !== null) { - // Tombstone armed above; now early-discard any streams still open. - await this.discardOpenStreamsForAlias(teardownAlias); - } this.removeRawSubscription(raw); // Defensive: a still-pending subscribeTrack() (no SUBSCRIBE_OK yet) must not // hang when its request stream closes before the response (peer FIN/reset, or @@ -2321,6 +2435,9 @@ export class MoqtConnection { if (options?.deliveryTimeout !== undefined) { subscribeOpts.deliveryTimeout = options.deliveryTimeout; } + if (options?.fill !== undefined) { + subscribeOpts.fill = options.fill; + } // Build the SUBSCRIBE synchronously (allocates the Request ID) so the response // resolver is registered BEFORE any bytes go out. Otherwise a zero-latency peer // (e.g. the §3.2 reserved-namespace auto-reject) could answer with REQUEST_ERROR @@ -2345,7 +2462,9 @@ export class MoqtConnection { // Register the resolver/rejecter synchronously (executor runs now), THEN send. const result = new Promise((resolve, reject) => { - this.rawSubscriptions.set(reqIdBigint, { requestId: reqIdBigint, trackAlias: null, sub, resolve, reject }); + this.rawSubscriptions.set(reqIdBigint, { + requestId: reqIdBigint, trackAlias: null, sub, resolve, reject, + }); }); try { await this.sendSubscribeActions(actions); @@ -2369,6 +2488,136 @@ export class MoqtConnection { * Returns true if the object was claimed by a track subscription. * Called from data stream handlers before this.onObject. */ + /** + * Bind a subscription's route at SUBSCRIBE_OK. On draft 21 a publisher MAY give + * concurrent subscriptions to one track the same alias (§3.1); the session has + * already verified it is the same track, so the subscription joins the alias's + * group instead of replacing the route. + */ + private bindRawAlias(alias: bigint, raw: RawSubState): void { + if (!isDraft21(this.session.draftVersion)) { + this.rawAliasMaps.set(alias, raw); + return; + } + const current = this.rawAliasMaps.get(alias); + if (current === undefined || current === raw) { + this.rawAliasMaps.set(alias, raw); + return; + } + const group = this.sharedAliasSubs.get(alias) ?? [current]; + if (!group.includes(raw)) group.push(raw); + this.sharedAliasSubs.set(alias, group); + } + + private bindPublishAlias(pub: IncomingPublish): void { + const members = isDraft21(this.session.draftVersion) ? this.publishAliasMaps.get(pub.trackAlias) ?? [] : []; + members.push(pub); + this.publishAliasMaps.set(pub.trackAlias, members); + } + + private publishAliasForRequest(requestId: bigint): bigint | undefined { + for (const [alias, members] of this.publishAliasMaps) { + if (members.some((pub) => pub.requestId === requestId)) return alias; + } + return undefined; + } + + private removePublishAliasMember(alias: bigint, requestId: bigint): void { + const members = this.publishAliasMaps.get(alias)?.filter((pub) => pub.requestId !== requestId) ?? []; + if (members.length > 0) this.publishAliasMaps.set(alias, members); + else this.publishAliasMaps.delete(alias); + } + + private aliasHasOtherSubscriber(alias: bigint, requestId: bigint): boolean { + return isDraft21(this.session.draftVersion) + && this.session.getSubscriberRequestsForAlias(alias).some((sub) => sub.requestId !== requestId); + } + + private deliverySubscribers21(alias: bigint): SubscriptionStateMachine[] { + return [ + ...this.session.getSubscriberRequestsForAlias(alias), + ...[...this.terminalSubscribers.values()].filter(({ sub }) => sub.trackAlias === alias).map(({ sub }) => sub), + ]; + } + + private recordSubscriberTerminal(alias: bigint, count: bigint | null): bigint { + if (!isDraft21(this.session.draftVersion)) return count ?? MoqtConnection.STREAM_COUNT_UNKNOWN; + const unknown = 0xffffffffffffffffn; + const prior = this.aliasTerminalTotals.get(alias); + const total = prior === null || count === null || count === unknown ? null : (prior ?? 0n) + count; + this.aliasTerminalTotals.set(alias, total); + return total === null || total >= unknown ? unknown : total; + } + + private retainTerminalSubscriber(sub: SubscriptionStateMachine): void { + if (!isDraft21(this.session.draftVersion) || this.terminalSubscribers.has(sub.requestId)) return; + const ttl = Math.max(this.terminatedAliasTtlMs, this.aliasDeliveryTimeoutMs.get(sub.trackAlias!) ?? 0); + const cancelTimer = this.armGuardTimer(ttl, () => { void this.finishTerminalSubscriber(sub.requestId); }); + this.terminalSubscribers.set(sub.requestId, { sub, cancelTimer }); + } + + private async finishTerminalSubscriber(requestId: bigint): Promise { + const retained = this.terminalSubscribers.get(requestId); + if (!retained) return; + this.terminalSubscribers.delete(requestId); + retained.cancelTimer(); + await this.cancelFill(requestId); + if (this._terminated) return; + const cfg = this.drainConfigs.get(requestId); + this.drainConfigs.delete(requestId); + try { cfg?.onDrained?.(requestId); } + catch (err) { + try { this.onError?.(err instanceof Error ? err : new Error(String(err))); } catch { /* observer failure */ } + } + } + + private hasDeliveredObject(sub: SubscriptionStateMachine, obj: MoqtObject): boolean { + const key = `${obj.groupId}/${obj.subgroupId ?? ''}/${obj.objectId}`; + let seen = this.subscriberObjectHistory.get(sub); + if (seen?.has(key)) return true; + if (!seen) this.subscriberObjectHistory.set(sub, seen = new Set()); + seen.add(key); + if (seen.size > SHARED_ALIAS_SEEN_LIMIT) seen.delete(seen.values().next().value!); + return false; + } + + /** + * draft-21 §3.1: an object on a shared alias could belong to any of the + * subscriptions; each receives it when its filter matches, at most once (the + * publisher sends one copy per matching subscription). + */ + private deliverToSharedAlias(group: readonly RawSubState[], obj: MoqtObject): void { + const groupId = BigInt(obj.groupId); + const objectId = BigInt(obj.objectId); + const key = `${groupId}/${String(obj.subgroupId ?? '')}/${objectId}`; + for (const raw of [...group]) { + if (this.rawSubscriptions.get(raw.requestId) !== raw) continue; + const window = this.session.getSubscription(raw.requestId)?.locationWindow; + if (window && !windowContains(window, groupId, objectId)) continue; + const seen = raw.seen ??= new Set(); + if (seen.has(key)) continue; + seen.add(key); + if (seen.size > SHARED_ALIAS_SEEN_LIMIT) seen.delete(seen.values().next().value!); + raw.sub.onObject?.(obj); + } + } + + /** + * Take `raw` out of its alias group. True when other subscriptions still use + * the alias, so the caller must leave the alias route and its streams alone. + */ + private detachSharedAlias(raw: RawSubState): boolean { + const alias = raw.trackAlias; + if (alias === null) return false; + const group = this.sharedAliasSubs.get(alias); + if (!group || !group.includes(raw)) return false; + const rest = group.filter((r) => r !== raw); + if (rest.length > 1) this.sharedAliasSubs.set(alias, rest); + else this.sharedAliasSubs.delete(alias); + if (this.rawAliasMaps.get(alias) === raw) this.rawAliasMaps.set(alias, rest[0]!); + return true; + } + private routeToTrackSubscription(streamId: bigint, obj: MoqtObject): boolean { const alias = BigInt(obj.trackAlias); const replay = this.pendingAliasReplays.get(alias); @@ -2377,13 +2626,40 @@ export class MoqtConnection { kind: 'object', streamId, object: obj, ownerGeneration: 0n, }); } + if (isDraft21(this.session.draftVersion)) { + const members = this.deliverySubscribers21(alias); + if (members.length > 0) { + const generic: SubscriptionStateMachine[] = []; + for (const member of members) { + if (this._terminated || !this.deliverySubscribers21(alias).includes(member)) continue; + const window = member.locationWindow; + if (window && !windowContains(window, obj.groupId, obj.objectId)) continue; + if (this.hasDeliveredObject(member, obj)) continue; + const raw = this.rawSubscriptions.get(member.requestId); + if (raw) raw.sub.onObject?.(obj); + else if (!member.isPublishInitiated) generic.push(member); + else { + this.publishAliasMaps.get(alias)?.find((pub) => pub.requestId === member.requestId)?.onObject?.(obj); + } + } + if (!this._terminated && generic.some((sub) => this.deliverySubscribers21(alias).includes(sub))) this.onObject?.(streamId, obj); + return true; + } + if (this.drainingAliases.has(alias)) return true; + } + const shared = this.sharedAliasSubs.get(alias); + if (shared) { + this.deliverToSharedAlias(shared, obj); + return true; + } const rawSub = this.rawAliasMaps.get(alias); if (rawSub) { - rawSub.sub.onObject?.(obj); + if (isDraft21(this.session.draftVersion)) this.deliverToSharedAlias([rawSub], obj); + else rawSub.sub.onObject?.(obj); return true; } // draft-18 §10.10: objects for an accepted inbound PUBLISH route by alias. - const pub = this.publishAliasMaps.get(alias); + const pub = this.publishAliasMaps.get(alias)?.[0]; if (pub) { pub.onObject?.(obj); return true; @@ -2398,7 +2674,7 @@ export class MoqtConnection { if (this.terminatedAliases.has(alias)) return true; // An established generic subscribe() intentionally uses the connection-wide // onObject callback. A pending raw subscription elsewhere must not steal it. - if (this.session.getTrackByAlias(varint(alias)) !== undefined) return false; + if (this.session.getTrackByAlias(alias) !== undefined) return false; // The publisher may deliver objects before its SUBSCRIBE_OK is processed // (control and data streams are not ordered relative to each other, §10.4); // dropping them would permanently starve the subscription of anything it @@ -2428,17 +2704,42 @@ export class MoqtConnection { }); return; } + if (isDraft21(this.session.draftVersion)) { + const members = this.session.getSubscriberRequestsForAlias(alias); + if (members.length > 0) { + for (const member of members) { + if (this._terminated || !this.session.getSubscriberRequestsForAlias(alias).includes(member)) continue; + const window = member.locationWindow; + if (window && !windowCoversGroup(window, header.groupId)) continue; + const raw = this.rawSubscriptions.get(member.requestId); + if (raw) raw.sub.onSubgroupClosed?.(header); + else this.publishAliasMaps.get(alias)?.find((pub) => pub.requestId === member.requestId)?.onSubgroupClosed?.(header); + } + return; + } + } + const shared = this.sharedAliasSubs.get(alias); + if (shared) { + const group = BigInt(header.groupId); + for (const raw of shared) { + if (this.rawSubscriptions.get(raw.requestId) !== raw) continue; + const window = this.session.getSubscription(raw.requestId)?.locationWindow; + if (window && !windowCoversGroup(window, group)) continue; + raw.sub.onSubgroupClosed?.(header); + } + return; + } const rawSub = this.rawAliasMaps.get(alias); if (rawSub) { rawSub.sub.onSubgroupClosed?.(header); return; } - const pub = this.publishAliasMaps.get(alias); - if (pub) { - pub.onSubgroupClosed?.(header); + const publications = this.publishAliasMaps.get(alias); + if (publications) { + for (const pub of [...publications]) pub.onSubgroupClosed?.(header); return; } - if (this.session.getTrackByAlias(varint(alias)) !== undefined) return; + if (this.session.getTrackByAlias(alias) !== undefined) return; this.bufferPendingAlias(alias, undefined, header); } @@ -2501,7 +2802,7 @@ export class MoqtConnection { if (!this.hasPendingRawSubscription()) return false; if (this.terminatedAliases.has(alias)) return false; if (this.publishAliasMaps.has(alias)) return false; - if (this.session.getTrackByAlias(varint(alias)) !== undefined) return false; + if (this.session.getTrackByAlias(alias) !== undefined) return false; const ownerGeneration = this.latestPendingSubscriptionGeneration(); if (ownerGeneration === undefined) return false; @@ -2685,6 +2986,7 @@ export class MoqtConnection { } private removeRawSubscription(raw: RawSubState): void { + this.detachSharedAlias(raw); if (this.rawSubscriptions.get(raw.requestId) === raw) this.rawSubscriptions.delete(raw.requestId); if (raw.trackAlias !== null && this.rawAliasMaps.get(raw.trackAlias) === raw) { this.rawAliasMaps.delete(raw.trackAlias); @@ -2730,6 +3032,7 @@ export class MoqtConnection { this.refreshAliasDeliveryTimeout(alias); // §8: reflect any accepted REQUEST_UPDATE before arming this.armTerminatedAlias(alias, null, /* strict */ false); // TTL-only; refuses reuse until it expires this.rawAliasMaps.delete(alias); + this.sharedAliasSubs.delete(alias); } /** Early-discard (STOP_SENDING) every incoming subgroup stream open on `alias`. */ @@ -2743,7 +3046,9 @@ export class MoqtConnection { private clearTerminatedAlias(alias: bigint): void { const t = this.terminatedAliases.get(alias); if (t) { t.cancelTimer(); this.terminatedAliases.delete(alias); } + if (!t && isDraft21(this.session.draftVersion) && this.deliverySubscribers21(alias).length > 1) return; this.aliasStreamsSeen.delete(alias); + this.aliasTerminalTotals.delete(alias); this.aliasDeliveryTimeoutMs.delete(alias); this.aliasPublisherTimeout.delete(alias); } @@ -2788,6 +3093,7 @@ export class MoqtConnection { // where no request-stream close tears rawAliasMaps down separately). this.publishAliasMaps.delete(alias); this.rawAliasMaps.delete(alias); + this.sharedAliasSubs.delete(alias); this.cancelPendingAliasReplay(alias); const existing = this.terminatedAliases.get(alias); // Do NOT downgrade a STRICT Stream-Count tombstone (which tracks outstanding @@ -2822,14 +3128,14 @@ export class MoqtConnection { */ private applyTerminalDrain(alias: bigint, streamCount: bigint, owningRequestId: bigint): void { const owner = this.rawAliasMaps.get(alias)?.requestId - ?? this.publishAliasMaps.get(alias)?.requestId; + ?? this.publishAliasMaps.get(alias)?.[0]?.requestId; if (owner !== undefined && owner !== owningRequestId) return; // crossed OLD DONE on a reused alias if (this.drainingAliases.has(alias)) return; // already draining — exactly-once // §8: reflect any accepted REQUEST_UPDATE in the effective timeout first. this.refreshAliasDeliveryTimeout(alias); - const UNKNOWN_COUNT = (1n << 62n) - 1n; // "could not give an exact count" sentinel + const UNKNOWN_COUNT = isDraft21(this.session.draftVersion) ? 0xffffffffffffffffn : MoqtConnection.STREAM_COUNT_UNKNOWN; const seen = this.aliasStreamsSeen.get(alias) ?? 0n; - const acceptRemaining = streamCount >= UNKNOWN_COUNT + const acceptRemaining = streamCount === UNKNOWN_COUNT ? null : (streamCount > seen ? streamCount - seen : 0n); const ttlMs = Math.max(this.terminatedAliasTtlMs, this.aliasDeliveryTimeoutMs.get(alias) ?? 0); @@ -2847,6 +3153,7 @@ export class MoqtConnection { // through the sweep's await window below. state.drained = true; state.cancelTimer(); + await this.cancelFill(state.requestId); // Cancel still-open streams and AWAIT their cleanup (fail-closed: a // cancellation that never settles keeps the drain safely blocked). await this.discardOpenStreamsForAlias(alias); @@ -2855,6 +3162,11 @@ export class MoqtConnection { // teardown race this guards. if (this.drainingAliases.get(alias) !== state) return; this.drainingAliases.delete(alias); + this.aliasStreamsSeen.delete(alias); + this.aliasTerminalTotals.delete(alias); + for (const [id, retained] of [...this.terminalSubscribers]) { + if (retained.sub.trackAlias === alias) await this.finishTerminalSubscriber(id); + } // Deliberately NO tombstone: the drain window WAS the §10.11 retention. // The alias is now free, and a same-alias stream arriving next may be the // REUSED alias's first data (racing its SUBSCRIBE_OK) — it must reach the @@ -2881,7 +3193,7 @@ export class MoqtConnection { // new route (§11.1). `undefined` owner (no live route) is the terminating // request's own already-partly-torn-down subscription — proceed. const owner = this.rawAliasMaps.get(alias)?.requestId - ?? this.publishAliasMaps.get(alias)?.requestId; + ?? this.publishAliasMaps.get(alias)?.[0]?.requestId; if (owner !== undefined && owner !== owningRequestId) return; // 1) Install terminal state synchronously (before any await). @@ -2891,7 +3203,8 @@ export class MoqtConnection { // the updated window are still valid. this.refreshAliasDeliveryTimeout(alias); const seen = this.aliasStreamsSeen.get(alias) ?? 0n; - const remaining: bigint | null = streamCount === MoqtConnection.STREAM_COUNT_UNKNOWN + const unknown = isDraft21(this.session.draftVersion) ? 0xffffffffffffffffn : MoqtConnection.STREAM_COUNT_UNKNOWN; + const remaining: bigint | null = streamCount === unknown ? null // §9.15 sentinel: exact count unknown — rely on the TTL only : (streamCount - seen > 0n ? streamCount - seen : 0n); this.armTerminatedAlias(alias, remaining, /* strict */ true); @@ -2923,7 +3236,7 @@ export class MoqtConnection { // alias, just before this call. Here we only bind routing + resolve. raw.trackAlias = alias; (raw.sub as { trackAlias: bigint }).trackAlias = alias; - this.rawAliasMaps.set(alias, raw); + this.bindRawAlias(alias, raw); // Claim only events that named this exact request while it was pending. // Callback delivery remains deferred until after raw.resolve() below. // onObject/onSubgroupClosed are documented "mutable, read live on each delivery": @@ -2993,7 +3306,7 @@ export class MoqtConnection { existingRequestId: bigint, options?: RequestUpdateOptions, ): Promise { - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { // Local REQUEST_UPDATE is only valid for an inbound PUBLISH (we are the // subscriber there). For an inbound SUBSCRIBE we are the publisher — the // peer updates that subscription, not us — so it is NOT eligible here. @@ -3018,7 +3331,7 @@ export class MoqtConnection { // ALSO drop publishAliasMaps and arm the terminal-alias guard — otherwise // the route stays live and a late/independent data stream on the alias // would keep reaching the application. - const pubAlias = [...this.publishAliasMaps].find(([, p]) => p.requestId === existingRequestId)?.[0]; + const pubAlias = this.publishAliasForRequest(existingRequestId); if (pubAlias !== undefined) { await this.rollbackFailedAcceptance(existingRequestId, pubAlias, /* isPublishInitiated */ true); } else { @@ -3107,21 +3420,29 @@ export class MoqtConnection { // direct API, the TrackSubscription wrapper, and the peer-close path all // arm at the same point and cannot diverge. const raw = this.rawSubscriptions.get(requestId); - if (raw && raw.trackAlias !== null && this.rawAliasMaps.get(raw.trackAlias) === raw) { - this.armSubscriberAliasTeardown(raw.trackAlias); - } - - const actions = this.session.unsubscribe(requestId); // draft-18 returns no send_control + const alias = isDraft21(this.session.draftVersion) + ? this.session.getSubscription(requestId)?.trackAlias ?? raw?.trackAlias + : raw?.trackAlias; + // A subscription sharing its alias leaves it to the others (draft-21 §3.1). + const aliasStillShared = alias != null && isDraft21(this.session.draftVersion) + && this.deliverySubscribers21(alias).some((sub) => sub.requestId !== requestId); + if (alias != null && this.session.getSubscription(requestId)) this.recordSubscriberTerminal(alias, null); + if (raw) this.detachSharedAlias(raw); + if (!aliasStillShared && alias != null) this.armSubscriberAliasTeardown(alias); + + // draft-21 §3.4: the fill ends with its subscription. + const cancelledFills = this.cancelFill(requestId); + const actions = this.session.unsubscribe(requestId); // Retire before any callback can resume. + await cancelledFills; await this.executeActions(actions); - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { // Reset the subscribe request stream; this is the draft-18 cancellation // signal (a LOCAL cancel — the response handler ignores the resulting // RequestCancelledError, so it surfaces no onError). await this.uniPair!.cancelRequest(requestId); - const current = this.rawSubscriptions.get(requestId) ?? raw; - if (current && current.trackAlias !== null) { + if (!aliasStillShared && alias != null) { // Tombstone already armed above; now early-discard open streams. - await this.discardOpenStreamsForAlias(current.trackAlias); + await this.discardOpenStreamsForAlias(alias); } } // Drop the raw subscription for EVERY draft: draft-14/16 has no request-stream @@ -3162,31 +3483,37 @@ export class MoqtConnection { * timeout timer — those remain application/timer concerns. A server MAY supply a * `newSessionUri`; a client MUST leave it empty. For draft-18, `requestId` (the * smallest peer Request ID that may be unprocessed) is required by the receiver - * and MUST match the peer's request-id parity. + * and MUST match the peer's request-id parity. Draft 21 omits this field. * @see draft-ietf-moq-transport-18 §10.4 + * @see draft-ietf-moq-transport-21 §9.2 */ async sendGoaway(options: { newSessionUri?: string; timeout?: bigint; requestId?: bigint } = {}): Promise { const newSessionUri = options.newSessionUri ?? ''; - if (this.session.draftVersion === 18) { - // §10.4 local guards — never emit an invalid control-stream GOAWAY. The peer - // would reject these; refuse to put them on the wire in the first place. - if (options.requestId === undefined) { - throw new MoqtConnectionError('sendGoaway (draft-18): a control-stream GOAWAY requires a Request ID', { errorSource: 'control' }); - } + if (isRequestStreamDraft(this.session.draftVersion)) { if (this._role === EndpointRole.CLIENT && newSessionUri.length > 0) { - throw new MoqtConnectionError('sendGoaway (draft-18): a client MUST send a zero-length New Session URI', { errorSource: 'control' }); + throw new MoqtConnectionError(`sendGoaway (draft-${this.session.draftVersion}): a client MUST send a zero-length New Session URI`, { errorSource: 'control' }); } - // The Request ID refers to the RECEIVER's request IDs, so it must carry the - // peer's parity: server→client is even, client→server is odd. - const expectedParity = this._role === EndpointRole.SERVER ? 0n : 1n; - if ((options.requestId & 1n) !== expectedParity) { - throw new MoqtConnectionError( - `sendGoaway (draft-18): Request ID ${options.requestId} has the wrong parity for the peer (expected ${expectedParity === 0n ? 'even' : 'odd'})`, - { errorSource: 'control' }, - ); + let requestId: bigint | undefined; + if (!isDraft21(this.session.draftVersion)) { + if (options.requestId === undefined) { + throw new MoqtConnectionError('sendGoaway (draft-18): a control-stream GOAWAY requires a Request ID', { errorSource: 'control' }); + } + // Draft 18 refers to the receiver's requests: server-to-client is even, + // client-to-server is odd. Draft 21 has no Request ID in GOAWAY. + const expectedParity = this._role === EndpointRole.SERVER ? 0n : 1n; + if ((options.requestId & 1n) !== expectedParity) { + throw new MoqtConnectionError( + `sendGoaway (draft-18): Request ID ${options.requestId} has the wrong parity for the peer (expected ${expectedParity === 0n ? 'even' : 'odd'})`, + { errorSource: 'control' }, + ); + } + requestId = options.requestId; } - const message: Goaway = { type: 'GOAWAY', newSessionUri, timeout: options.timeout ?? 0n, requestId: options.requestId }; + const message: Goaway = { + type: 'GOAWAY', newSessionUri, timeout: options.timeout ?? 0n, + ...(requestId !== undefined ? { requestId } : {}), + }; await this.uniPair!.sendControl(message); // draft-18 control stream is the uni pair return; } @@ -3220,7 +3547,7 @@ export class MoqtConnection { namespacePrefix, subscribeOptions, ); - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { // draft-18 §10.18: SUBSCRIBE_NAMESPACE opens a CONTINUING request stream. // The first response (REQUEST_OK / REQUEST_ERROR) goes through the normal // stamped pipeline; subsequent NAMESPACE / NAMESPACE_DONE are routed to @@ -3341,7 +3668,7 @@ export class MoqtConnection { return; } - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { // draft-18 §10.18: gracefully close the continuing request stream. await this.uniPair!.closeContinuingRequest(requestId); return; @@ -3369,7 +3696,7 @@ export class MoqtConnection { * @param requestId The request ID of the track subscription to cancel */ async cancelTracks(requestId: bigint): Promise { - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { await this.uniPair!.closeContinuingRequest(requestId); } } @@ -3395,7 +3722,7 @@ export class MoqtConnection { const { sessionOptions, onRequestId } = splitOwnership(options); const { requestId, actions } = this.session.fetch(namespace, name, sessionOptions as FetchOptions); this.invokeOwnershipCallback(requestId, onRequestId); - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { // draft-18: FETCH opens its own bidi request stream rather than travelling // on the control stream. Same machinery as subscribe(). Remember the // requested Group Order so the fetch-object decoder can read deltas. @@ -3427,7 +3754,7 @@ export class MoqtConnection { const { sessionOptions, onRequestId } = splitOwnership(options); const { requestId, actions } = this.session.joiningFetch(sessionOptions as JoiningFetchOptions); this.invokeOwnershipCallback(requestId, onRequestId); - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { this.fetchGroupOrder.set(requestId, options.groupOrder ?? 'ascending'); const fetchMsg = (actions.find((a) => a.type === 'send_control') as SendControlAction).message; await this.openD18Request(requestId, fetchMsg, () => this.fetchGroupOrder.delete(requestId)); @@ -3462,22 +3789,60 @@ export class MoqtConnection { } /** - * Cancel an active fetch. + * Stop a draft-21 fill (§3.4) without touching its subscription: STOP_SENDING on + * the fill fetch stream if it is open, or discard it when it arrives. The + * subscription keeps delivering live objects. * - * draft-14/16: sends FETCH_CANCEL on the control stream, and STOP_SENDING on - * the associated data stream if open. - * §9.18: "A subscriber sends a FETCH_CANCEL message to a publisher to - * indicate it is no longer interested in receiving objects for the fetch - * identified by the 'Request ID'." - * §5.2: "If the data stream is already open, it MAY send STOP_SENDING for - * the data stream along with FETCH_CANCEL, but MUST send FETCH_CANCEL." - * - * draft-18: FETCH_CANCEL was REMOVED; cancellation is STOP_SENDING / - * RESET_STREAM on the bidi request stream and the fetch data stream (§3.3.2). - * No control message is sent. - * - * @param requestId The request ID of the fetch to cancel - * @see draft-ietf-moq-transport-16 §5.2, §9.18; draft-ietf-moq-transport-18 §3.3.2 + * @param requestId the Request ID that asked for the fill (a SUBSCRIBE's or a + * REQUEST_UPDATE's); a SUBSCRIBE's Request ID stops every fill of that subscription + */ + async cancelFill(requestId: bigint): Promise { + const targets = new Set([requestId, ...this.session.expectedFillsOf(requestId)]); + for (const [fillId, fill] of this.fillStreams) { + if (fill.subscriptionId === requestId) targets.add(fillId); + } + const discards: Promise[] = []; + for (const fillId of targets) { + const open = this.fillStreams.get(fillId); + if (open === undefined) { + // One-shot marker: the FETCH_HEADER handler discards the late fill stream. + if (this.session.cancelFillRequest(fillId)) this.recentlyCancelledFetches.add(fillId); + this.pendingFillHeaders.get(fillId)?.finish(false); + continue; + } + this.fillStreams.delete(fillId); + const reader = this.dataStreamReaders.get(open.streamId); + if (reader) discards.push(this.discardLocalReader(open.streamId, reader, new Error('fill cancelled'))); + } + await Promise.all(discards); + } + + private settleFillHeaders(): void { + for (const [requestId, pending] of this.pendingFillHeaders) { + if (!this.session.isFillResponsePending(requestId)) { + pending.finish(this.session.fillSubscriptionFor(requestId) !== undefined); + } + } + } + + private async waitForFillResponse(requestId: bigint, streamId: bigint): Promise { + if (this.pendingFillHeaders.has(requestId)) throw new ProtocolViolationError(`Second fill stream for request ${requestId}`); + if (this.pendingFillHeaders.size >= MoqtConnection.MAX_PENDING_ALIASES) return false; + return new Promise((resolve) => { + const timer = setTimeout(() => finish(false), this.joiningFetchTimeoutMs); + const finish = (accepted: boolean): void => { + clearTimeout(timer); + this.pendingFillHeaders.delete(requestId); + resolve(accepted); + }; + this.pendingFillHeaders.set(requestId, { streamId, finish }); + }); + } + + /** + * Cancel an ordinary fetch. Draft-14/16 sends FETCH_CANCEL and stops its data + * stream. Draft-18/21 cancels the request and data streams without a control + * message. Use cancelFill() for a draft-21 fill, which has no FETCH request. */ async fetchCancel(requestId: bigint): Promise { // §5.2: the fetch may already have ended (its data stream FIN'd/reset → reclaimed @@ -3499,7 +3864,7 @@ export class MoqtConnection { const actions = this.session.fetchCancel(requestId); await this.executeActions(actions); - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { // §3.3.2: cancel the bidi request stream (STOP_SENDING + RESET_STREAM). // This is a LOCAL cancel, so the request-stream response handler ignores // the resulting RequestCancelledError (no onError, no unhandled rejection). @@ -3546,7 +3911,7 @@ export class MoqtConnection { name: Uint8Array, ): Promise { const { requestId, actions } = this.session.trackStatus(namespace, name); - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { const msg = (actions.find((a) => a.type === 'send_control') as SendControlAction).message; await this.openD18Request(requestId, msg); return requestId; @@ -3590,7 +3955,7 @@ export class MoqtConnection { */ async publishNamespace(namespace: Uint8Array[]): Promise { const { requestId, actions } = this.session.publishNamespace(namespace); - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { const msg = (actions.find((a) => a.type === 'send_control') as SendControlAction).message; await this.openD18Request(requestId, msg); return requestId; @@ -3613,7 +3978,7 @@ export class MoqtConnection { */ async publishNamespaceDone(requestId: bigint): Promise { const actions = this.session.publishNamespaceDone(requestId); - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { // Terminate local state (no send_control action) and cancel the request // stream — that cancellation IS the draft-18 withdrawal signal. await this.executeActions(actions); @@ -3659,6 +4024,7 @@ export class MoqtConnection { ); } const actions = this.session.acceptSubscribe(requestId, trackAlias, options); + this.finalizeInboundFill(requestId, options?.parameters); // §5.1: the acceptance may itself force a session close (e.g. the superseded-set // is at capacity, §5.1). Then there is no acceptance to report — execute the // close, settle parked joins as NOT accepted, and reject rather than resolving @@ -3676,7 +4042,7 @@ export class MoqtConnection { // §10.11: authorize publisher data-plane ops (openSubgroup / sendObject / // sendDatagram) on this alias ONLY for an inbound SUBSCRIBE we serve — never for // an inbound PUBLISH, where we are the subscriber. - if (!isPublishInitiated) this.publisherAliasRequests.set(trackAlias, requestId); + if (!isPublishInitiated) this.addPublisherAliasRequest(trackAlias, requestId); // §8: for an inbound PUBLISH (we are the subscriber), COMPUTE the effective // timeout by combining the publisher's Track-Property timeouts (captured at bind) // with OUR PUBLISH_OK Message Parameters — but DEFER committing it until the @@ -3761,14 +4127,15 @@ export class MoqtConnection { * The caller re-throws so the failed acceptance is not reported as success. */ private async rollbackFailedAcceptance(requestId: bigint, trackAlias: bigint, isPublishInitiated: boolean): Promise { - this.publisherAliasRequests.delete(trackAlias); + this.removePublisherAliasRequest(trackAlias, requestId); // For an inbound PUBLISH, tear down its route AND arm a bounded late-object // guard SYNCHRONOUSLY (before the first await), exactly like the inbound-FIN // teardown — a subgroup opened BEFORE the failed PUBLISH_OK and delivered after // would otherwise fall through to the connection-level onObject. Owner-checked // so a reused alias's newer publication is untouched. - const ownsRoute = isPublishInitiated - && this.publishAliasMaps.get(trackAlias)?.requestId === requestId; + const ownsRoute = isPublishInitiated && this.publishAliasForRequest(requestId) === trackAlias + && !this.aliasHasOtherSubscriber(trackAlias, requestId); + if (isPublishInitiated) this.removePublishAliasMember(trackAlias, requestId); if (ownsRoute) this.armTerminatedAlias(trackAlias, null, /* strict */ false); // drops route + installs guard this.inboundPublishTimeouts.delete(requestId); await this.executeActions(this.session.handleInboundRequestClosed(requestId)); @@ -3802,7 +4169,7 @@ export class MoqtConnection { actions: SessionOutboundAction[], finalize = false, ): Promise { - if (this.session.draftVersion !== 18) return false; + if (!isRequestStreamDraft(this.session.draftVersion)) return false; const ctx = this.inboundRequestContexts.get(requestId); if (!ctx) return false; const send = actions.find((a) => a.type === 'send_control') as SendControlAction | undefined; @@ -3840,7 +4207,9 @@ export class MoqtConnection { // callback. Owner-checked so a reused alias's newer publication is untouched. const incoming = this.session.getIncomingSubscription(requestId); const pubAlias = incoming?.isPublishInitiated ? incoming.trackAlias : undefined; - const ownsRoute = pubAlias !== undefined && this.publishAliasMaps.get(pubAlias)?.requestId === requestId; + const ownsRoute = pubAlias !== undefined && this.publishAliasForRequest(requestId) === pubAlias + && !this.aliasHasOtherSubscriber(pubAlias, requestId); + if (pubAlias !== undefined) this.removePublishAliasMember(pubAlias, requestId); if (ownsRoute) this.armTerminatedAlias(pubAlias, null, /* strict */ false); const actions = this.session.rejectSubscribe(requestId, errorCode, reason); if (await this.writeInboundRequestResponse(requestId, actions)) { @@ -3918,13 +4287,28 @@ export class MoqtConnection { private detachFetchStreamsForRequest(requestId: bigint): WritableStreamDefaultWriter[] { const writers: WritableStreamDefaultWriter[] = []; for (const [streamId, st] of [...this.fetchOutgoingStreams]) { - if (st.requestId !== requestId) continue; - this.fetchOutgoingStreams.delete(streamId); + // §3.4.1: ending a subscription resets its open fill streams, including + // those a REQUEST_UPDATE asked for under its own Request ID. + if (st.requestId !== requestId && st.fillOf !== requestId) continue; + this.removeOutgoingFetchStream(streamId); writers.push(st.writer); } return writers; } + private removeOutgoingFetchStream(streamId: bigint): void { + const state = this.fetchOutgoingStreams.get(streamId); + this.fetchOutgoingStreams.delete(streamId); + if (state?.fillOf !== undefined) { + this.servedFills.delete(state.requestId); + this.inboundFetchGroupOrder.delete(state.requestId); + this.fetchServeReserved.delete(state.requestId); + const open = this.openSubgroupsByRequest.get(state.fillOf); + open?.delete(streamId); + if (open?.size === 0) this.openSubgroupsByRequest.delete(state.fillOf); + } + } + /** Detach (synchronously) + abort every open FETCH response stream for `requestId`. */ private async abortFetchStreamsForRequest(requestId: bigint, reason: string): Promise { for (const writer of this.detachFetchStreamsForRequest(requestId)) { @@ -4231,7 +4615,7 @@ export class MoqtConnection { // subscription's own bidi request stream (an accepted inbound SUBSCRIBE), // then the stream is sealed and fully closed (FIN + STOP_SENDING). Not the // uni control stream. - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { const ctx = this.inboundRequestContexts.get(requestId); if (ctx) { const send = actions.find((a) => a.type === 'send_control') as SendControlAction | undefined; @@ -4261,8 +4645,16 @@ export class MoqtConnection { */ private publisherSubscriptionForAlias( trackAlias: bigint, + selectedRequestId?: bigint, ): { requestId: bigint; sub: SubscriptionStateMachine | undefined } | undefined { - const requestId = this.publisherAliasRequests.get(trackAlias); + const requests = this.publisherAliasRequests.get(trackAlias); + if (selectedRequestId === undefined && requests && requests.size > 1) { + throw new MoqtConnectionError(`Track alias ${trackAlias} is shared; specify requestId for publisher ownership`, { errorSource: 'data' }); + } + const requestId = selectedRequestId ?? requests?.values().next().value; + if (selectedRequestId !== undefined && !requests?.has(selectedRequestId)) { + return { requestId: selectedRequestId, sub: undefined }; + } if (requestId === undefined) { // A recently-retired alias: still refuse sends (sub undefined) so a // post-termination open is rejected, until it ages out of the bounded LRU. @@ -4275,6 +4667,19 @@ export class MoqtConnection { return { requestId, sub }; } + private addPublisherAliasRequest(alias: bigint, requestId: bigint): void { + const requests = this.publisherAliasRequests.get(alias) ?? new Set(); + requests.add(requestId); + this.publisherAliasRequests.set(alias, requests); + this.retiredPublisherAliases.delete(alias); + } + + private removePublisherAliasRequest(alias: bigint, requestId: bigint): void { + const requests = this.publisherAliasRequests.get(alias); + requests?.delete(requestId); + if (requests?.size === 0) this.publisherAliasRequests.delete(alias); + } + /** Publisher-side subscription state for an adapter-owned request ID. */ private publisherSubscriptionForRequest(requestId: bigint): SubscriptionStateMachine | undefined { return this.session.getIncomingSubscription(requestId) @@ -4288,9 +4693,11 @@ export class MoqtConnection { * subscription churn stays bounded. */ private retirePublisherRequest(requestId: bigint): void { - for (const [alias, rid] of this.publisherAliasRequests) { - if (rid !== requestId) continue; - this.publisherAliasRequests.delete(alias); + this.revokePublisherFills(requestId); + for (const [alias, requests] of this.publisherAliasRequests) { + if (!requests.has(requestId)) continue; + this.removePublisherAliasRequest(alias, requestId); + if (requests.size > 0) continue; if (this.retiredPublisherAliases.size >= MoqtConnection.MAX_RETIRED_PUBLISHER_ALIASES) { const oldest = this.retiredPublisherAliases.keys().next().value; if (oldest !== undefined) this.retiredPublisherAliases.delete(oldest); @@ -4304,6 +4711,18 @@ export class MoqtConnection { // pending op so an in-flight generation is never lost. } + private revokePublisherFills(requestId: bigint): void { + for (const [fillId, fill] of this.inboundFillRequests) { + if (fill.subscriptionId === requestId) this.inboundFillRequests.delete(fillId); + } + for (const [fillId, subscriptionId] of this.servedFills) { + if (subscriptionId !== requestId) continue; + this.servedFills.delete(fillId); + this.inboundFetchGroupOrder.delete(fillId); + this.fetchServeReserved.delete(fillId); + } + } + /** Cap publisherGeneration: evict the oldest entry that has no pending op. */ private boundPublisherGeneration(): void { if (this.publisherGeneration.size <= MoqtConnection.MAX_RETIRED_PUBLISHER_ALIASES) return; @@ -4322,8 +4741,9 @@ export class MoqtConnection { private beginPublishOp( trackAlias: bigint, what: string, + requestId?: bigint, ): { requestId: bigint; sub: SubscriptionStateMachine; generation: number } { - const assoc = this.publisherSubscriptionForAlias(trackAlias); + const assoc = this.publisherSubscriptionForAlias(trackAlias, requestId); // The public API documents trackAlias as coming from acceptSubscribe() / // publish(). An UNKNOWN alias (never associated, or aged out of the retired // LRU) is rejected — never silently published with no accounting. @@ -4452,9 +4872,10 @@ export class MoqtConnection { const aborts: Array> = []; if (open) { for (const sid of [...open]) { - const st = this.outgoingStreams.get(sid); + const st = this.outgoingStreams.get(sid) ?? this.fetchOutgoingStreams.get(sid); if (st) { this.outgoingStreams.delete(sid); + this.removeOutgoingFetchStream(sid); // INITIATE every cancellation before awaiting any individual one. // The FULFILMENT of each abort is what proves the stream was reset // (§5.1.1), so a rejection is recorded, not swallowed. @@ -4497,6 +4918,8 @@ export class MoqtConnection { groupId: bigint, subgroupId: bigint, opts?: { + /** Required when concurrent subscriptions share this alias (draft 21). */ + requestId?: bigint; publisherPriority?: number; hasExtensions?: boolean; endOfGroup?: boolean; @@ -4515,7 +4938,7 @@ export class MoqtConnection { ): Promise { // §10.4.2 bit 6: FIRST_OBJECT (0x40) is a draft-18-only header bit — reject the // option on draft-14/16 BEFORE opening a stream. - if (opts?.firstObject && this.session.draftVersion !== 18) { + if (opts?.firstObject && !isRequestStreamDraft(this.session.draftVersion)) { throw new MoqtConnectionError('openSubgroup: firstObject is a draft-18-only option', { errorSource: 'data' }); } if (!this.transport?.createUnidirectionalStream) { @@ -4529,7 +4952,7 @@ export class MoqtConnection { // sees it in flight and refuses — the terminated check and the reservation // are one atomic step, not a pre-await check that a deferred stream // creation could slip past. - const assoc = this.beginPublishOp(trackAlias, 'openSubgroup'); + const assoc = this.beginPublishOp(trackAlias, 'openSubgroup', opts?.requestId); try { let writable: WritableStream; try { @@ -4573,6 +4996,7 @@ export class MoqtConnection { const streamId = this.nextOutgoingStreamId++; this.outgoingStreams.set(streamId, { writer, + groupId, hasExtensions: opts?.hasExtensions ?? false, previousObjectId: 0n, isFirstObject: true, @@ -4605,7 +5029,7 @@ export class MoqtConnection { // bits 1-2 are the subgroup-ID mode. draft-18 renamed EXTENSIONS→PROPERTIES // (bit 0x01) vs draft-14/16 (0x20), so the flag bit differs by version. const mode = opts?.subgroupIdMode ?? SubgroupIdMode.EXPLICIT; - const d18 = this.session.draftVersion === 18; + const d18 = isRequestStreamDraft(this.session.draftVersion); let typeByte = SubgroupFlags.SUBGROUP_MARKER | (mode << 1); if (opts?.hasExtensions) typeByte |= d18 ? SubgroupFlags18.PROPERTIES : SubgroupFlags.EXTENSIONS; if (opts?.endOfGroup) typeByte |= d18 ? SubgroupFlags18.END_OF_GROUP : SubgroupFlags.END_OF_GROUP; @@ -4714,10 +5138,11 @@ export class MoqtConnection { { errorSource: 'data' }, ); } + this.assertPublisherLocation(sub, state.groupId, objectId); } const obj = { objectId, extensions, payload, status: undefined }; - const objectBytes = this.session.draftVersion === 18 + const objectBytes = isRequestStreamDraft(this.session.draftVersion) ? encodeSubgroupObject18(obj, state.hasExtensions, state.previousObjectId, state.isFirstObject) : encodeSubgroupObject(obj, state.hasExtensions, varint(state.previousObjectId), state.isFirstObject); @@ -4744,9 +5169,9 @@ export class MoqtConnection { groupId: bigint, objectId: bigint, payload: Uint8Array, - opts?: { publisherPriority?: number }, + opts?: { publisherPriority?: number; requestId?: bigint }, ): Promise { - if (this.session.draftVersion !== 18) { + if (!isRequestStreamDraft(this.session.draftVersion)) { throw new MoqtConnectionError('sendDatagram is draft-18 only', { errorSource: 'data' }); } if (!this.transport?.datagrams) { @@ -4754,8 +5179,9 @@ export class MoqtConnection { } // §10.11: no datagrams for a terminated subscription; reserve the operation // synchronously so a concurrent publishDone refuses (see openSubgroup). - const assoc = this.beginPublishOp(trackAlias, 'sendDatagram'); + const assoc = this.beginPublishOp(trackAlias, 'sendDatagram', opts?.requestId); try { + if (assoc?.sub) this.assertPublisherLocation(assoc.sub, groupId, objectId); const bytes = encodeObjectDatagram18({ typeByte: 0x00, // plain OBJECT_DATAGRAM: priority present, no flags trackAlias, @@ -4781,6 +5207,12 @@ export class MoqtConnection { } } + private assertPublisherLocation(sub: SubscriptionStateMachine, group: bigint, object: bigint): void { + if (isDraft21(this.session.draftVersion) && sub.locationWindow && !windowContains(sub.locationWindow, group, object)) { + throw new MoqtConnectionError(`Object ${group}/${object} is outside the subscription Location Filter`, { errorSource: 'data' }); + } + } + /** * Close an outgoing subgroup stream (sends FIN). * @@ -4843,7 +5275,7 @@ export class MoqtConnection { * @see draft-ietf-moq-transport-18 §11.4.4 */ async openFetchStream(requestId: bigint): Promise { - if (this.session.draftVersion !== 18) { + if (!isRequestStreamDraft(this.session.draftVersion)) { throw new MoqtConnectionError('openFetchStream is draft-18 only', { errorSource: 'data' }); } const groupOrder = this.inboundFetchGroupOrder.get(requestId); @@ -4862,7 +5294,17 @@ export class MoqtConnection { throw new MoqtConnectionError(`FETCH ${requestId} already has its one response stream (§11.4.4)`, { errorSource: 'data' }); } this.fetchServeReserved.add(requestId); - const writer = (await this.transport.createUnidirectionalStream()).getWriter(); + const fillOf = this.servedFills.get(requestId); + let stream: WritableStream; + try { + stream = await this.transport.createUnidirectionalStream(); + } catch (err) { + // Allocation did not create a response stream, so the one-stream allowance + // remains available. Cancellation may already have revoked admission. + this.fetchServeReserved.delete(requestId); + throw err; + } + const writer = stream.getWriter(); // §10.13: authorization was validated BEFORE the createUnidirectionalStream // await. A rejectFetch() or a request-stream close during that await // deauthorizes the FETCH SYNCHRONOUSLY (its group-order entry is dropped before @@ -4871,16 +5313,28 @@ export class MoqtConnection { // RESURRECT itself. Abort the freshly-opened stream and refuse. if (!this.inboundFetchGroupOrder.has(requestId)) { this.fetchServeReserved.delete(requestId); // no stream established — release the reservation - try { await writer.abort(new Error('inbound FETCH deauthorized during stream open')); } catch { /* already down */ } + await this.abortFetchWriter(writer, new Error('inbound FETCH deauthorized during stream open'), fillOf); throw new MoqtConnectionError(`No admitted inbound FETCH for request ${requestId}`, { errorSource: 'data' }); } + const streamId = this.nextOutgoingStreamId++; + this.fetchOutgoingStreams.set(streamId, { + requestId, writer, groupOrder, prior: undefined, isFirstObject: true, + ...(fillOf === undefined ? {} : { fillOf }), + }); + if (fillOf !== undefined) { + this.publisherSubscriptionForRequest(fillOf)!.incrementStreamCount(); + const open = this.openSubgroupsByRequest.get(fillOf) ?? new Set(); + open.add(streamId); + this.openSubgroupsByRequest.set(fillOf, open); + } try { await writer.write(encodeFetchHeader18(requestId)); } catch (err) { // The FETCH_HEADER write failed: the stream is unusable — abort it so it is // not left half-open, and surface the failure (do not register a dead writer). this.fetchServeReserved.delete(requestId); // no stream established — release the reservation - try { await writer.abort(err instanceof Error ? err : new Error(String(err))); } catch { /* already down */ } + await this.abortFetchWriter(writer, err instanceof Error ? err : new Error(String(err)), fillOf); + this.removeOutgoingFetchStream(streamId); throw err instanceof Error ? err : new Error(String(err)); } // Re-check once more after the header write (single-threaded: no await between @@ -4888,18 +5342,110 @@ export class MoqtConnection { // landed during the header write itself. if (!this.inboundFetchGroupOrder.has(requestId)) { this.fetchServeReserved.delete(requestId); - try { await writer.abort(new Error('inbound FETCH deauthorized during stream open')); } catch { /* already down */ } + await this.abortFetchWriter(writer, new Error('inbound FETCH deauthorized during stream open'), fillOf); + this.removeOutgoingFetchStream(streamId); throw new MoqtConnectionError(`No admitted inbound FETCH for request ${requestId}`, { errorSource: 'data' }); } - const streamId = this.nextOutgoingStreamId++; - this.fetchOutgoingStreams.set(streamId, { requestId, writer, groupOrder, prior: undefined, isFirstObject: true }); return streamId; } + private async abortFetchWriter(writer: WritableStreamDefaultWriter, reason: Error, fillOf: bigint | undefined): Promise { + if (fillOf === undefined) { + try { await writer.abort(reason); } catch { /* already down */ } + return; + } + // A fill counts toward its subscription's terminal stream count. If its + // reset cannot be proved, closing the session prevents a false clean end. + const reset = Promise.resolve().then(() => writer.abort(reason)).then(() => true, () => false); + if (!await this.allFulfilledWithin([reset], MoqtConnection.PUBLISHER_ABORT_DEADLINE_MS)) { + this.closeSessionInternalError(`Fill for subscription ${fillOf} could not be reset`); + } + } + + /** + * draft-21 §3.4: open the fill stream for an inbound SUBSCRIBE, or a + * REQUEST_UPDATE on one, that carried FILL_PARAMETERS. It is a fetch stream + * whose FETCH_HEADER carries that message's Request ID, written with {@link sendFetchObject} / + * {@link sendFetchEndOfRange} in the fill's effective group order; {@link closeFetchStream} + * FINs it (the fill is complete). There is no FETCH_OK. One per request. + */ + async openFillStream(requestId: bigint): Promise { + const fill = this.inboundFillRequests.get(requestId); + if (fill === undefined) { + throw new MoqtConnectionError(`No pending fill for request ${requestId}`, { errorSource: 'data' }); + } + const { subscriptionId, options } = fill; + const sub = this.publisherSubscriptionForRequest(subscriptionId); + if (!sub || sub.state !== SubscriptionState.ESTABLISHED || sub.forwardState === ForwardState.PAUSED) { + throw new MoqtConnectionError(`Fill ${requestId} has no active subscription`, { errorSource: 'data' }); + } + if (fill.window === undefined) throw new MoqtConnectionError(`Fill ${requestId} has no response snapshot`, { errorSource: 'data' }); + const assoc = this.beginPublishOp(sub.trackAlias!, 'openFillStream', subscriptionId); + this.inboundFillRequests.delete(requestId); + this.servedFills.set(requestId, subscriptionId); + this.inboundFetchGroupOrder.set(requestId, options.groupOrder ?? sub.groupOrder); + try { + const stream = await this.openFetchStream(requestId); + const state = this.fetchOutgoingStreams.get(stream); + if (!state) throw new MoqtConnectionError(`Fill ${requestId} was cancelled during stream open`, { errorSource: 'data' }); + state.fillWindow = fill.window; + return stream; + } catch (err) { + // Only restore a fill that never acquired its stream. Header failures and + // cancellation reclaim servedFills themselves; neither may be retried. + if (this.servedFills.get(requestId) === subscriptionId + && this.inboundFetchGroupOrder.has(requestId) + && !this.fetchServeReserved.has(requestId)) { + this.servedFills.delete(requestId); + this.inboundFetchGroupOrder.delete(requestId); + this.inboundFillRequests.set(requestId, fill); + } + throw err; + } finally { + this.endPublishOp(assoc.requestId); + } + } + + private rememberInboundFill(requestId: bigint, subscriptionId: bigint, parameters: Parameters): boolean { + const bytes = parameters.get(FILL_PARAMETERS)?.[0]; + const sub = this.publisherSubscriptionForRequest(subscriptionId); + if (!(bytes instanceof Uint8Array) || !sub || sub.forwardState !== ForwardState.ACTIVE) return true; + if (this.inboundFillRequests.size + this.servedFills.size >= MoqtConnection.MAX_PENDING_FILLS) { + this.closeSessionInternalError('Too many outstanding fill streams'); + return false; + } + const options = decodeFillParameters(bytes); + const filter = options.filter ?? (sub.currentFilter === undefined ? undefined : decodeSubscriptionFilter(sub.currentFilter, 21)); + const groupOrder = options.groupOrder ?? sub.requestedGroupOrder; + this.inboundFillRequests.set(requestId, { + subscriptionId, + options: { + ...(filter === undefined ? {} : { filter }), + ...(groupOrder === undefined ? {} : { groupOrder }), + }, + }); + return true; + } + + private finalizeInboundFill(requestId: bigint, parameters?: Parameters): void { + const fill = this.inboundFillRequests.get(requestId); + if (!fill) return; + const value = parameters?.get(0x09n)?.[0]; + const largest = value !== null && typeof value === 'object' && 'group' in value && 'object' in value + ? value as { group: bigint; object: bigint } : undefined; + const window = resolveFillWindow(fill.options.filter, largest); + if (window === null) this.inboundFillRequests.delete(requestId); + else fill.window = window; + } + /** Send a normal fetch object on a FETCH response stream (§11.4.4). */ async sendFetchObject(streamId: bigint, fields: FetchObjectFields): Promise { const state = this.fetchOutgoingStreams.get(streamId); if (!state) throw new MoqtConnectionError(`Unknown fetch stream ${streamId}`, { errorSource: 'data' }); + if (state.closing) throw new MoqtConnectionError(`Fetch stream ${streamId} is closing`, { errorSource: 'data' }); + if (state.fillWindow && !windowContains(state.fillWindow, fields.groupId, fields.objectId)) { + throw new MoqtConnectionError(`Object ${fields.groupId}/${fields.objectId} is outside the fill range`, { errorSource: 'data' }); + } const { bytes, nextPrior } = encodeFetchObject18(fields, state.prior, state.isFirstObject, state.groupOrder); await state.writer.write(bytes); state.prior = nextPrior; @@ -4910,7 +5456,12 @@ export class MoqtConnection { async sendFetchEndOfRange(streamId: bigint, nonExistent: boolean, groupId: bigint, objectId: bigint): Promise { const state = this.fetchOutgoingStreams.get(streamId); if (!state) throw new MoqtConnectionError(`Unknown fetch stream ${streamId}`, { errorSource: 'data' }); - const { bytes, nextPrior } = encodeFetchEndOfRange18(nonExistent, groupId, objectId, state.prior); + if (state.closing) throw new MoqtConnectionError(`Fetch stream ${streamId} is closing`, { errorSource: 'data' }); + if (state.fillWindow && !windowContains(state.fillWindow, groupId, objectId)) { + throw new MoqtConnectionError(`Range end ${groupId}/${objectId} is outside the fill range`, { errorSource: 'data' }); + } + const { bytes, nextPrior } = encodeFetchEndOfRange18(nonExistent, groupId, objectId, state.prior, + isDraft21(this.session.draftVersion) ? 21 : 18); await state.writer.write(bytes); state.prior = nextPrior; state.isFirstObject = false; @@ -4920,6 +5471,21 @@ export class MoqtConnection { async closeFetchStream(streamId: bigint): Promise { const state = this.fetchOutgoingStreams.get(streamId); if (!state) return; + if (state.closing) return state.closing; + if (state.fillOf !== undefined) { + const fin = (async () => { + try { await state.writer.close(); } + catch (err) { + this.closeSessionInternalError(`Fill ${state.requestId} could not finish: ${String(err)}`); + throw err; + } finally { + this.removeOutgoingFetchStream(streamId); + this.inboundFetchGroupOrder.delete(state.requestId); + } + })(); + state.closing = fin; + return fin; + } try { await state.writer.close(); } catch { /* already closed */ } this.fetchOutgoingStreams.delete(streamId); // §10.13: the publisher finished serving — RECLAIM the accepted incoming fetch in @@ -5015,7 +5581,7 @@ export class MoqtConnection { } this.pendingGenericSubscriptions.delete(action.requestId); this.settlePendingAliasOwnership(action.requestId); - if (this.session.draftVersion === 18) await this.uniPair?.cancelRequest(action.requestId); + if (isRequestStreamDraft(this.session.draftVersion)) await this.uniPair?.cancelRequest(action.requestId); break; } case 'close_stream': @@ -5185,7 +5751,7 @@ export class MoqtConnection { onObject: null, onSubgroupClosed: null, }; - this.publishAliasMaps.set(incoming.trackAlias, incoming); + this.bindPublishAlias(incoming); this.onPublish(incoming); } } @@ -5480,9 +6046,14 @@ export class MoqtConnection { // — but only for the route THIS request still owns (found by requestId), so // a reused alias's newer publication is untouched. Captured before the // session teardown below (which unregisters the session alias). - const pubAlias = ctx.openerKind === 'publish' - ? [...this.publishAliasMaps].find(([, p]) => p.requestId === requestId)?.[0] - : undefined; + const pubAlias = ctx.openerKind === 'publish' ? this.publishAliasForRequest(requestId) : undefined; + const endsAlias = pubAlias !== undefined && !this.aliasHasOtherSubscriber(pubAlias, requestId) + && ![...this.terminalSubscribers.values()].some(({ sub }) => sub.trackAlias === pubAlias); + if (pubAlias !== undefined) { + this.recordSubscriberTerminal(pubAlias, null); + this.removePublishAliasMember(pubAlias, requestId); + } + const cancelledFills = pubAlias === undefined ? Promise.resolve() : this.cancelFill(requestId); // SYNCHRONOUS route removal + tombstone install + FETCH deauthorization // (before the FIRST await) so nothing can slip through the teardown window: // - a late object can't route (armTerminatedAlias drops publishAliasMaps); @@ -5490,18 +6061,20 @@ export class MoqtConnection { // cancelled FETCH — its group-order authorization is gone before we await, // and its post-await re-check now fails (§10.13). Deauthorizing only AFTER // the session-teardown await left a window in which a new stream opened. - if (pubAlias !== undefined) this.armTerminatedAlias(pubAlias, null, /* strict */ false); + if (endsAlias) this.armTerminatedAlias(pubAlias!, null, /* strict */ false); this.inboundFetchGroupOrder.delete(requestId); this.fetchServeReserved.delete(requestId); // the one-stream reservation is released on teardown + this.revokePublisherFills(requestId); this.inboundPublishTimeouts.delete(requestId); // never-accepted inbound PUBLISH // §10.13: DETACH every open FETCH response stream SYNCHRONOUSLY (before the // first await), so sendFetchObject()/closeFetchStream() for this cancelled // fetch fail immediately rather than continuing to write during the teardown // awaits below. The writers are aborted afterward. - const fetchWriters = this.detachFetchStreamsForRequest(requestId); + const fetchWriters = wasSubscribe ? [] : this.detachFetchStreamsForRequest(requestId); await this.executeActions(this.session.handleInboundRequestClosed(requestId)); + await cancelledFills; this.inboundRequestContexts.delete(requestId); - if (pubAlias !== undefined) await this.discardOpenStreamsForAlias(pubAlias); + if (endsAlias) await this.discardOpenStreamsForAlias(pubAlias!); // §5.1.1: the subscriber cancelled — RESET every publisher data stream // still open for this subscription and drop its accounting, so no more // objects can be written for a subscription the peer abandoned. @@ -5597,6 +6170,15 @@ export class MoqtConnection { } const originalId = ctx.requestId; + if (message.type === 'PUBLISH_STATE_NOTIFY') { + if (ctx.openerKind !== 'publish' || this.session.getIncomingSubscription(originalId)?.state !== SubscriptionState.ESTABLISHED) { + throw new ProtocolViolationError('PUBLISH_STATE_NOTIFY requires an established inbound PUBLISH (9.10)'); + } + const stamped = { ...message, requestId: originalId } as ControlMessage; + await this.executeActions(this.session.handleControlMessage(stamped)); + if (!this._terminated) this.onMessage?.(stamped); + return; + } if (message.type === 'PUBLISH_DONE') { // §10.11: terminate the subscription. Only an inbound PUBLISH carries a // PUBLISH_DONE; on any other opener it is a protocol violation. @@ -5607,22 +6189,29 @@ export class MoqtConnection { // then apply the Stream Count so open/late streams are STOP_SENDINGed and // the alias tombstone is cleared once the count is met (or the TTL fires), // rather than retaining the publication's routing indefinitely. - const doneAlias = [...this.publishAliasMaps].find(([, p]) => p.requestId === originalId)?.[0]; - const streamCount = (message as { streamCount?: bigint }).streamCount ?? 0n; + const alias = this.publishAliasForRequest(originalId); + const aliasStillShared = alias !== undefined && this.aliasHasOtherSubscriber(alias, originalId); + const doneAlias = aliasStillShared ? undefined : alias; + if (aliasStillShared) this.removePublishAliasMember(alias!, originalId); + const ownCount = (message as { streamCount?: bigint }).streamCount ?? 0n; + const streamCount = alias === undefined ? ownCount : this.recordSubscriberTerminal(alias, ownCount); const stamped = { ...message, requestId: originalId } as ControlMessage; const pubDraining = this.drainConfigs.has(originalId) && doneAlias !== undefined; - if (pubDraining) this.applyTerminalDrain(doneAlias!, BigInt(streamCount), originalId); + const aliasDraining = pubDraining || (doneAlias !== undefined && [...this.terminalSubscribers.values()].some(({ sub }) => sub.trackAlias === doneAlias)); + if (aliasDraining && doneAlias !== undefined) this.applyTerminalDrain(doneAlias, BigInt(streamCount), originalId); + const cancelledFills = pubDraining ? Promise.resolve() : this.cancelFill(originalId); // Non-drained terminal application starts BEFORE the callback, like the // drain above: its arm runs before the first await, so a reentrant // close() inside the callback CLEARS the terminal state instead of having // its cleanup undone by an arm that follows it. - const discard = !pubDraining && doneAlias !== undefined + const discard = !aliasDraining && doneAlias !== undefined ? this.applyTerminalStreamCount(doneAlias, BigInt(streamCount), originalId) : Promise.resolve(); this.onMessage?.(stamped); await this.executeActions(this.session.handleInboundPublishDone(originalId)); ctx.seal(); this.inboundRequestContexts.delete(originalId); + await cancelledFills; await discard; return; } @@ -5637,6 +6226,18 @@ export class MoqtConnection { // session routes it by the opener — a PUBLISH subscription update, or a // §10.9.2 SUBSCRIBE_NAMESPACE / SUBSCRIBE_TRACKS prefix update. const updateId = (message as { requestId: bigint }).requestId; + // draft-21 §9.1.7: updates are answered at once except on a still-pending + // SUBSCRIBE, where the answers queue behind SUBSCRIBE_OK. A peer that sends + // one more than our MAX_REQUEST_UPDATES while they queue closes the session. + const ownMaxUpdates = this.session.ownMaxRequestUpdates; + if (isDraft21(this.session.draftVersion) && ownMaxUpdates > 0n + && BigInt(this.deferredUpdateResponses.get(originalId)?.length ?? 0) >= ownMaxUpdates) { + const reason = `more than MAX_REQUEST_UPDATES (${ownMaxUpdates}) unanswered REQUEST_UPDATEs on request ${originalId}`; + const closeActions = this.session.close(SessionError.TOO_MANY_REQUEST_UPDATES, reason); + await this.executeActions(closeActions); + this.notifyClose(closeActions[0] as CloseConnectionAction, reason); + return; + } const stamped = { ...message, existingRequestId: originalId } as ControlMessage; this.onMessage?.(stamped); const actions = this.session.handleControlMessage(stamped, { @@ -5653,6 +6254,14 @@ export class MoqtConnection { return; } const send = actions.find((a) => a.type === 'send_control') as SendControlAction | undefined; + // draft-21 §3.4: an accepted update carrying FILL_PARAMETERS asks for a fill + // under the update's own Request ID (served with openFillStream). + if (isDraft21(this.session.draftVersion) && ctx.openerKind === 'subscribe' + && send?.message.type !== 'REQUEST_ERROR' + && (message as { parameters?: Map }).parameters?.has(FILL_PARAMETERS)) { + if (!this.rememberInboundFill(updateId, originalId, (message as { parameters: Parameters }).parameters)) return; + if (send?.message.type === 'REQUEST_OK') this.finalizeInboundFill(updateId, send.message.parameters); + } // §10.8: SUBSCRIBE_OK is the FIRST response on a successful subscribe // stream. An update racing a still-PENDING subscription is APPLIED // immediately (§10.12.2), but its REQUEST_OK is deferred and flushed — @@ -5775,7 +6384,7 @@ export class MoqtConnection { onObject: null, onSubgroupClosed: null, }; - this.publishAliasMaps.set(incoming.trackAlias, incoming); + this.bindPublishAlias(incoming); // §8 applies to a PUBLISH-initiated subscription too. Capture the publisher's raw // OBJECT/SUBGROUP_DELIVERY_TIMEOUT Track Properties so acceptSubscribe can COMBINE // them with OUR PUBLISH_OK Message Parameters; set a provisional (publisher-only) @@ -5833,6 +6442,9 @@ export class MoqtConnection { await this.executeActions(actions); ctx.bind(requestId, 'subscribe'); this.inboundRequestContexts.set(requestId, ctx); + if (isDraft21(this.session.draftVersion) && sub.parameters.has(FILL_PARAMETERS)) { + if (!this.rememberInboundFill(requestId, requestId, sub.parameters)) return; + } this.onSubscribe?.(requestId, sub.trackNamespace, sub.trackName, sub.parameters as Map); } @@ -5923,7 +6535,7 @@ export class MoqtConnection { // forward-state rule for joins: §9.16.2 permits association with a // Pending subscription and requires only the Largest Object filter, so // a legal pending d16 join must not be rejected here. - if (this.session.draftVersion === 18) { + if (isRequestStreamDraft(this.session.draftVersion)) { const sub = this.session.getIncomingSubscription(subRequestId); if (!sub || sub.forwardState !== ForwardState.ACTIVE) { try { @@ -6093,6 +6705,28 @@ export class MoqtConnection { await this.processDataStreamReader(stream.reader, streamId, stream.prefix); } + /** Both subgroup and fill streams count toward the alias's terminal budget. */ + private admitSubscriberStream(alias: bigint): string | undefined { + const drain = this.drainingAliases.get(alias); + if (drain) { + if (drain.drained) return 'stream during terminal-drain completion'; + if (drain.acceptRemaining !== null) { + if (drain.acceptRemaining <= 0n) return 'stream beyond the announced Stream Count'; + drain.acceptRemaining -= 1n; + } + } + const tombstone = this.terminatedAliases.get(alias); + if (tombstone) { + if (tombstone.remaining !== null) { + tombstone.remaining -= 1n; + if (tombstone.remaining <= 0n) this.clearTerminatedAlias(alias); + } + return 'late stream on a terminated alias'; + } + this.aliasStreamsSeen.set(alias, (this.aliasStreamsSeen.get(alias) ?? 0n) + 1n); + return undefined; + } + private async processDataStreamReader( reader: ReadableStreamDefaultReader, streamId: bigint, @@ -6139,50 +6773,12 @@ export class MoqtConnection { if (headerResult.type === 'subgroup') { const header = headerResult.header as SubgroupHeader; const subgroupAlias = header.trackAlias as bigint; - // §10.11 receiver terminal tracker: a stream for an alias whose - // PUBLISH_DONE already arrived is a LATE expected stream — early-discard - // it (STOP_SENDING), count it against the announced total, and clear the - // tombstone once the whole count is observed. Never delivered. - // Terminal drain: a draining alias still DELIVERS late streams, capped - // by the announced Stream Count. Beyond the cap, discard like a - // tombstoned stream (but without the tombstone countdown). - const drainState = this.drainingAliases.get(subgroupAlias); - if (drainState) { - // A completing drain (sweep in progress) no longer delivers: discard, - // closing the race between `drained` flipping and the tombstone - // being installed after the sweep's awaits. - if (drainState.drained) { - await this.discardLocalReader(streamId, reader, new Error('stream during terminal-drain completion — discarded')); - this.onStreamClosed?.(streamId, undefined, 'local-discard'); - return; - } - if (drainState.acceptRemaining !== null) { - if (drainState.acceptRemaining <= 0n) { - await this.discardLocalReader(streamId, reader, new Error('stream beyond the announced Stream Count during terminal drain — discarded')); - this.onStreamClosed?.(streamId, undefined, 'local-discard'); - return; - } - drainState.acceptRemaining -= 1n; - } - // fall through: deliver normally (routing is still alive) - } - const tombstone = this.terminatedAliases.get(subgroupAlias); - if (tombstone) { - // Count down only a KNOWN remaining (bigint); a null remaining is the - // 2^62-1 sentinel — unknown count, cleared by TTL alone. Clear as soon - // as the known count is met so a legitimately reused alias isn't held. - if (tombstone.remaining !== null) { - tombstone.remaining -= 1n; - if (tombstone.remaining <= 0n) this.clearTerminatedAlias(subgroupAlias); - } - await this.discardLocalReader(streamId, reader, new Error('late stream on a terminated alias — early discard (§10.11)')); + const discardReason = this.admitSubscriberStream(subgroupAlias); + if (discardReason) { + await this.discardLocalReader(streamId, reader, new Error(discardReason)); this.onStreamClosed?.(streamId, undefined, 'local-discard'); return; } - // Count the stream toward this alias's lifetime total (so a later - // PUBLISH_DONE knows how many streams are still outstanding) and track - // it as open so the terminal message can STOP_SENDING it. - this.aliasStreamsSeen.set(subgroupAlias, (this.aliasStreamsSeen.get(subgroupAlias) ?? 0n) + 1n); this.incomingSubgroupAliases.set(streamId, subgroupAlias); // Header decoded — route objects below this.onQlogEvent?.({ @@ -6218,6 +6814,43 @@ export class MoqtConnection { } else { const header = headerResult.header as FetchHeader; const fetchReqId = header.requestId as bigint; + if (this.session.isFillResponsePending(fetchReqId)) { + if (!await this.waitForFillResponse(fetchReqId, streamId)) { + this.session.cancelFillRequest(fetchReqId); + this.recentlyCancelledFetches.delete(fetchReqId); + await this.discardLocalReader(streamId, reader, new Error('fill response unavailable')); + this.onStreamClosed?.(streamId, undefined, 'local-discard'); + return; + } + } + // draft-21 §3.4: a fill fetch stream carries the Request ID of the SUBSCRIBE + // or REQUEST_UPDATE that asked for it. One stream per fill; the session + // stops expecting it once this one is accepted. + const fillOrder = this.session.fillGroupOrderFor(fetchReqId); + const fillSub = isDraft21(this.session.draftVersion) && !this.recentlyCancelledFetches.has(fetchReqId) + ? this.session.takeFill(fetchReqId) + : undefined; + if (fillSub !== undefined) { + if (fillSub.trackAlias !== undefined) { + const discardReason = this.admitSubscriberStream(fillSub.trackAlias); + if (discardReason) { + await this.discardLocalReader(streamId, reader, new Error(discardReason)); + this.onStreamClosed?.(streamId, undefined, 'local-discard'); + return; + } + } + this.fillStreams.set(fetchReqId, { streamId, subscriptionId: fillSub.requestId as bigint, groupOrder: fillOrder! }); + this.onQlogEvent?.({ type: 'stream_type_set', owner: 'remote', stream_id: streamId, stream_type: 'fetch_header' }); + this.onQlogEvent?.({ type: 'fetch_header_parsed', stream_id: streamId, request_id: fetchReqId }); + if (this.locallyDiscardedReaders.has(reader)) { + this.onStreamClosed?.(streamId, undefined, 'local-discard'); + return; + } + this.onDataStream?.(streamId, { type: 'fetch', header, fill: true }); + readTerminal = await this.readFetchObjects18(reader, buf, streamId, header); + this.onStreamClosed?.(streamId, undefined, terminalKind(readTerminal)); + return; + } // §10.13: a data stream for a fetch WE cancelled may still be in flight. It is // NOT a new fetch nor a §11.4.4 duplicate — DISCARD it (STOP_SENDING via the // finally's reader cleanup) and CONSUME the one-shot marker (a cancellation @@ -6269,7 +6902,7 @@ export class MoqtConnection { // Phase 2: Decode fetch objects. draft-18 has its own object format // (group-order-aware deltas, End-of-Range prior rules) and correlates by // FETCH_HEADER.requestId — not by a (fabricated) track alias. - readTerminal = this.dataCodec!.version === 18 + readTerminal = isRequestStreamDraft(this.dataCodec!.version) ? await this.readFetchObjects18(reader, buf, streamId, header) : await this.readFetchObjects(reader, buf, streamId); } @@ -6329,6 +6962,12 @@ export class MoqtConnection { // response stream per fetch) — a cancelled fetch is instead recognized by its // lossless cancellation marker and its late stream discarded; a completed one // has no marker, so a second stream is correctly rejected. + for (const [reqId, fill] of this.fillStreams) { + if (fill.streamId === streamId) { + this.fillStreams.delete(reqId); + break; + } + } for (const [reqId, sid] of this.fetchStreams) { if (sid === streamId) { this.fetchStreams.delete(reqId); @@ -6697,7 +7336,10 @@ export class MoqtConnection { // group-order map is recorded for every fetch() (Ascending by default), so a // missing entry means an unknown/unrequested fetch — a PROTOCOL_VIOLATION, // never a silently-defaulted decode. - const groupOrder = this.fetchGroupOrder.get(header.requestId); + // A draft-21 fill is delivered in ascending order (§3.4); a FETCH records its own. + const groupOrder = this.fetchGroupOrder.get(header.requestId) + ?? (this.fillStreams.get(header.requestId as bigint)?.streamId === streamId + ? this.fillStreams.get(header.requestId as bigint)!.groupOrder : undefined); if (groupOrder === undefined) { // No group-order entry AND no cancellation marker (the marker is checked — and // consumed — at the FETCH_HEADER handler and the loop-top guard BEFORE we reach diff --git a/packages/webtransport/src/testkit/loopback.ts b/packages/webtransport/src/testkit/loopback.ts index bcd69792..e0b5ccdc 100644 --- a/packages/webtransport/src/testkit/loopback.ts +++ b/packages/webtransport/src/testkit/loopback.ts @@ -223,7 +223,7 @@ export class LoopPipe { /** One endpoint of a loopback pair. Streams it opens surface on the peer. */ export class LoopbackTransport implements WebTransportLike { /** No negotiated protocol — callers pass the draft version explicitly. */ - readonly protocol: string | undefined = undefined; + readonly protocol?: string; /** Outbound uni streams WE opened (uniOut[0] is our control stream). */ readonly uniOut: LoopPipe[] = []; diff --git a/packages/webtransport/src/testkit/pair.ts b/packages/webtransport/src/testkit/pair.ts index f2cd5653..5972d8df 100644 --- a/packages/webtransport/src/testkit/pair.ts +++ b/packages/webtransport/src/testkit/pair.ts @@ -9,7 +9,7 @@ import { MoqtConnection } from '../adapter.js'; import { createLoopback, type LoopbackTransport, type PipeFaults } from './loopback.js'; import type { WebTransportLike } from '../types.js'; -import { varint, type DraftVersion, type SetupOptions } from '@openmoq/transport'; +import { varint, isRequestStreamDraft, type DraftVersion, type SetupOptions } from '@openmoq/transport'; /** Build a single-field Track Namespace tuple from a string. */ export const ns = (s: string): Uint8Array[] => [new TextEncoder().encode(s)]; @@ -61,6 +61,8 @@ export async function connectedPair( faults?: { a?: PipeFaults; b?: PipeFaults }; serverOptions?: { joiningFetchTimeoutMs?: number; terminatedAliasTtlMs?: number }; clientOptions?: { joiningFetchTimeoutMs?: number; terminatedAliasTtlMs?: number }; + /** Extra Setup Options the server advertises (e.g. draft-21 maxRequestUpdates). */ + serverSetup?: SetupOptions; } = {}, ): Promise { const { a, b } = createLoopback(opts.faults ?? {}); @@ -78,7 +80,10 @@ export async function connectedPair( // which never happen here. draft-18 has no request credit (the option is // ignored there), so both sides get an empty setup. const clientSetup: SetupOptions = {}; - const serverSetup: SetupOptions = version === 18 ? {} : { maxRequestId: varint(1_000_000n) }; + const serverSetup: SetupOptions = { + ...(isRequestStreamDraft(version) ? {} : { maxRequestId: varint(1_000_000n) }), + ...(opts.serverSetup ?? {}), + }; // Both endpoints establish concurrently — neither can complete before the other // starts (each must read the peer's SETUP). await Promise.all([client.connect(a, clientSetup), server.connect(b, serverSetup)]); diff --git a/packages/webtransport/src/testkit/scenario.ts b/packages/webtransport/src/testkit/scenario.ts index 06da6c3d..a34d75b2 100644 --- a/packages/webtransport/src/testkit/scenario.ts +++ b/packages/webtransport/src/testkit/scenario.ts @@ -29,6 +29,7 @@ import { flush, type PipeFaults } from './loopback.js'; import { makePrng } from './prng.js'; import { fnv1a64, type TraceRecord } from './trace-hash.js'; import type { TrackSubscription } from '../adapter.js'; +import { isRequestStreamDraft } from '@openmoq/transport'; /** Stable op codes (folded into the trace hash). */ export const Op = { @@ -219,7 +220,7 @@ export async function runScenario(opts: ScenarioOptions): Promise void | Promise; - constructor(private readonly session: Session) {} + constructor(private readonly session: Session) { + this.version = session.draftVersion === 21 ? 21 : 18; + this.codec = createControlCodec(this.version); + } /** * Establish the draft-18 control-stream pair: open our outbound uni control @@ -970,6 +974,22 @@ class RequestStreamContext { await this.onPeerRequest?.(message); continue; } + // draft-21 §9.10: PUBLISH_STATE_NOTIFY is a publisher's report on a + // subscription's request stream, after SUBSCRIBE_OK. Not a response to + // any local operation — never FIFO-matched. Anywhere else it is a + // protocol violation. + if (message.type === 'PUBLISH_STATE_NOTIFY') { + if (this.openerType !== 'SUBSCRIBE') { + throw new ProtocolViolationError( + `PUBLISH_STATE_NOTIFY not accepted on a ${this.openerType} request stream`, + ); + } + if (!this.subscribeEstablished) { + throw new ProtocolViolationError('PUBLISH_STATE_NOTIFY without a preceding SUBSCRIBE_OK'); + } + await this.onPeerRequest?.(message); + continue; + } // §10.11: PUBLISH_DONE is the publisher's TERMINAL message on a // subscription's request stream — accepted exactly once, only after // a successful SUBSCRIBE_OK (never after REQUEST_ERROR), and only diff --git a/tools/moq-interop-client/src/main.ts b/tools/moq-interop-client/src/main.ts index 2c2be562..6e1bdcce 100644 --- a/tools/moq-interop-client/src/main.ts +++ b/tools/moq-interop-client/src/main.ts @@ -51,7 +51,7 @@ export function __setBoundScale(v: number) { BOUND_SCALE = v; } /** MOQT_DRAFT is THE draft selector. The old scaffold read DRAFT_VERSION while * compose sets MOQT_DRAFT, so a planned draft-16 row silently ran the default. * A non-empty unsupported value fails closed rather than defaulting. */ -const SUPPORTED_DRAFTS = [18, 16] as const; +const SUPPORTED_DRAFTS = [18, 16, 21] as const; function selectDraft(): number { const raw = (process.env.MOQT_DRAFT ?? "").trim(); if (raw === "") return 18; @@ -63,6 +63,7 @@ function selectDraft(): number { } /** Over WebTransport these are WT-Available-Protocols values, not QUIC ALPNs. */ function protocolsFor(draft: number): string[] | undefined { + if (draft === 21) return ["moqt-21"]; if (draft === 18) return ["moqt-18"]; if (draft === 16) return ["moqt-16"]; return undefined; // draft 14: plain h3 + in-band CLIENT_SETUP diff --git a/tsconfig.test.json b/tsconfig.test.json index 95c84c1b..ce3fbf07 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -12,12 +12,15 @@ "rootDir": ".", "types": ["node"] }, - // Scoped to simulation suites, test-support helpers, and repository scripts. The wider + // Scoped to simulation suites, test-support helpers, repository scripts, + // and draft-21 conformance tests. The wider // `*.test.ts` corpus predates any typecheck gate and does not compile clean // under these options; widening this include is a separate cleanup. "include": [ "packages/*/src/**/*.sim.test.ts", "packages/*/test-support/**/*.ts", + "packages/transport/src/session/session-d21-conformance.test.ts", + "packages/webtransport/src/adapter-d21-conformance.test.ts", "scripts/**/*.test.ts", "_migration/**/*.test.ts" ]