@blockcast/mmt-container 2.1.0 → 2.2.0-main.0f6aad7bb803

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/browser-runtime.js +458 -77
  2. package/dist/browser-runtime.js.map +4 -4
  3. package/dist/carp-catalog.js.map +1 -1
  4. package/dist/catalog-fec-params.js.map +1 -1
  5. package/dist/catalog-network-source.js.map +1 -1
  6. package/dist/cmcd-producer.d.ts +5 -0
  7. package/dist/cmcd-producer.d.ts.map +1 -1
  8. package/dist/cmcd-producer.js.map +2 -2
  9. package/dist/delivery-catalog.js.map +1 -1
  10. package/dist/delta-fallback-config.js.map +1 -1
  11. package/dist/fec-manager.js.map +2 -2
  12. package/dist/fec-state.js.map +1 -1
  13. package/dist/fec-worker-setup.js.map +1 -1
  14. package/dist/held-layer-admission.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +2 -2
  18. package/dist/iso-block-locator.js.map +1 -1
  19. package/dist/mfu-reassembler.js +12 -19
  20. package/dist/mfu-reassembler.js.map +2 -2
  21. package/dist/mmtp-container-parser.js +12 -19
  22. package/dist/mmtp-container-parser.js.map +2 -2
  23. package/dist/mmtp-container-parser.no-modules.js +133 -34
  24. package/dist/mmtp-container-parser.no-modules.js.map +3 -3
  25. package/dist/mmtp-decode-pipeline.js +12 -19
  26. package/dist/mmtp-decode-pipeline.js.map +2 -2
  27. package/dist/mmtp-fec-session.d.ts +41 -3
  28. package/dist/mmtp-fec-session.d.ts.map +1 -1
  29. package/dist/mmtp-fec-session.js +118 -13
  30. package/dist/mmtp-fec-session.js.map +3 -3
  31. package/dist/mmtp-router.d.ts +9 -1
  32. package/dist/mmtp-router.d.ts.map +1 -1
  33. package/dist/mmtp-router.js +1468 -33
  34. package/dist/mmtp-router.js.map +4 -4
  35. package/dist/multicast-auth-catalog.js.map +1 -1
  36. package/dist/multicast-input.js.map +1 -1
  37. package/dist/multicast-manager.d.ts +15 -2
  38. package/dist/multicast-manager.d.ts.map +1 -1
  39. package/dist/multicast-manager.js +15246 -14969
  40. package/dist/multicast-manager.js.map +4 -4
  41. package/dist/multicast-transport.d.ts +11 -3
  42. package/dist/multicast-transport.d.ts.map +1 -1
  43. package/dist/multicast-transport.js +255 -19
  44. package/dist/multicast-transport.js.map +4 -4
  45. package/dist/multicast-unwrap.js +12 -19
  46. package/dist/multicast-unwrap.js.map +2 -2
  47. package/dist/no-modules.js +130 -33
  48. package/dist/no-modules.js.map +3 -3
  49. package/dist/repair-fetch.js.map +1 -1
  50. package/dist/signaling.js.map +2 -2
  51. package/dist/timestamp-codec.js +12 -19
  52. package/dist/timestamp-codec.js.map +2 -2
  53. package/dist/wasm-mfu-reassembler.js.map +1 -1
  54. package/dist/wasm-mmtp-container-parser.js +12 -19
  55. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  56. package/package.json +5 -5
  57. package/src/cmcd-producer.ts +5 -0
  58. package/src/index.ts +2 -0
  59. package/src/mmtp-fec-session.ts +115 -15
  60. package/src/mmtp-router.ts +81 -16
  61. package/src/multicast-manager.ts +26 -5
  62. package/src/multicast-transport.ts +44 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockcast/mmt-container",
3
- "version": "2.1.0",
3
+ "version": "2.2.0-main.0f6aad7bb803",
4
4
  "description": "MMTP/LOC container parsing for MMT over MoQ and SSM",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -169,10 +169,10 @@
169
169
  "test:watch": "vitest"
170
170
  },
171
171
  "dependencies": {
172
- "@blockcast/fec-worker": "^2.1.0",
173
- "@blockcast/mmt-base": "^2.1.0",
174
- "@blockcast/mmt-fec": "^2.1.0",
175
- "@blockcast/mmt-manifest-verify": "^2.1.0",
172
+ "@blockcast/fec-worker": "2.2.0-main.0f6aad7bb803",
173
+ "@blockcast/mmt-base": "2.2.0-main.0f6aad7bb803",
174
+ "@blockcast/mmt-fec": "2.2.0-main.0f6aad7bb803",
175
+ "@blockcast/mmt-manifest-verify": "2.2.0-main.0f6aad7bb803",
176
176
  "@noble/curves": "^1.9.7",
177
177
  "@noble/hashes": "^1.8.0",
178
178
  "ajv": "^8.17.1",
@@ -28,6 +28,11 @@ export interface ManifestVerificationStats {
28
28
 
29
29
  /** Transport state snapshot, built by the player wiring layer from SocketPool. */
30
30
  export interface TransportSnapshot {
31
+ /**
32
+ * CMCD has no user-agent value: report a `'user-agent'` join
33
+ * (`ResolvedMulticastTransport`, the browser's own MulticastReceiver) as
34
+ * `'native'`, since the browser does not say which leg it chose.
35
+ */
31
36
  transport: 'native' | 'amt' | 'moq'
32
37
  /** IPv4/IPv6 multicast group address */
33
38
  multicastGroup?: string
package/src/index.ts CHANGED
@@ -602,6 +602,8 @@ export {
602
602
  type MmtpFecSessionConfig,
603
603
  type MmtpFecSessionDecoder,
604
604
  type MmtpFecSessionDecoderConstructor,
605
+ type MmtpFecSessionMessageConfig,
606
+ type MmtpFecSessionMessageUpdate,
605
607
  type MmtpFecSessionMixedPathStats,
606
608
  type MmtpFecSessionPath,
607
609
  type MmtpFecSessionPathCounts,
@@ -23,6 +23,15 @@
23
23
  * uses. A packet below B_0 at or after the anchor fails the session closed:
24
24
  * `stats.failClosed` reports why, and every later repair is refused.
25
25
  *
26
+ * Catalog-less (draft-ramadan-moq-mmt-02 8.3, 13.2): a receiver holding
27
+ * layer 0 under the AL-FEC message alone builds the session from the message's
28
+ * fec_flow entry (`alFecFlow`) instead. K, T, the code point and the repair
29
+ * packet_id come from that entry, and layer 0 is held at
30
+ * maximum_p_for_repair_flow, standing for O_0 + P_0, by the anchored clause of
31
+ * draft-ramadan-moq-fec-core-00 7.3.2 alone. A later version that raises the
32
+ * field raises the held total (updateAlFecFlow()); the message carries no
33
+ * millisecond budget, so the caller states the retention (fec-core-00 9.4).
34
+ *
26
35
  * Every repair packet that cannot be used is counted under the reason it was
27
36
  * refused, and every block ends under exactly one outcome, so a stream whose
28
37
  * FEC is doing nothing can be told apart from one with nothing to repair.
@@ -30,6 +39,8 @@
30
39
 
31
40
  import {
32
41
  admitMmtpRepairObject,
42
+ alFecRaptorQFlowConfig,
43
+ FEC_CODE_RAPTORQ,
33
44
  isoBlockContains,
34
45
  isoBlocksBehindLowerBound,
35
46
  parseFecBlockProfile,
@@ -39,6 +50,8 @@ import {
39
50
  serial32Precedes,
40
51
  ssbg1FitsSymbol,
41
52
  trimSsbg1SourceSymbol,
53
+ type AlFecFlow,
54
+ type AlFecRaptorQFlowConfig,
42
55
  type FecBlockProfile,
43
56
  } from '@blockcast/mmt-base'
44
57
  import { deriveFecRetentionBlocks, fecBlockBudgetMs } from '@blockcast/mmt-fec'
@@ -105,6 +118,25 @@ export interface MmtpFecSessionConfig {
105
118
  heldLayers?: readonly number[]
106
119
  }
107
120
 
121
+ /**
122
+ * A catalog-less session: one fec_flow entry of the AL-FEC message
123
+ * (`parseAlFecMessage(...).flows[i]`), draft-ramadan-moq-mmt-02 8.3.
124
+ */
125
+ export interface MmtpFecSessionMessageConfig {
126
+ /** The source track's entry; it must pass `alFecRaptorQFlowConfig`. */
127
+ alFecFlow: AlFecFlow
128
+ /**
129
+ * The decoder retention in blocks. The message carries no elapsed-time
130
+ * budget (mmt-02 7.5), so open-block state is bounded by local policy
131
+ * (fec-core-00 9.4); required, never defaulted.
132
+ */
133
+ retentionBlocks: number
134
+ Decoder: MmtpFecSessionDecoderConstructor
135
+ }
136
+
137
+ /** What updateAlFecFlow() did with a later version of the message. */
138
+ export type MmtpFecSessionMessageUpdate = 'raised' | 'unchanged' | 'refused'
139
+
108
140
  /**
109
141
  * The path a symbol arrived on: its MoQT subscription, or the multicast
110
142
  * group (native SSM or AMT) carrying the same asset. The caller states it;
@@ -197,12 +229,17 @@ export class MmtpFecSession {
197
229
  readonly algorithm: 'raptorq' | 'reed-solomon'
198
230
  readonly sourceSymbols: number
199
231
  /** P_0 = B_0, the held total every repair packet is checked against. */
200
- readonly repairSymbols: number
232
+ get repairSymbols(): number {
233
+ return this.#repairSymbols
234
+ }
201
235
  readonly symbolSize: number
202
236
  /** The decoder's retention, in blocks, derived from the catalog budget. */
203
237
  readonly retentionBlocks: number
204
238
 
205
239
  #decoder: MmtpFecSessionDecoder | null
240
+ #repairSymbols: number
241
+ /** The message entry a catalog-less session was built from; undefined under a catalog. */
242
+ #message: AlFecRaptorQFlowConfig | undefined
206
243
  /** Blocks a repair has defined and that are not settled yet, by SS_Start. */
207
244
  #blocks = new Map<number, BlockState>()
208
245
  /** SS_Starts of settled blocks, so a later repair for one is refused. */
@@ -240,7 +277,26 @@ export class MmtpFecSession {
240
277
  * the scheme, the block profile, K, P_0 (`repairSymbols`), T,
241
278
  * `interleaveDepthMs` or `reorderToleranceMs`. None is defaulted.
242
279
  */
243
- constructor(config: MmtpFecSessionConfig) {
280
+ constructor(config: MmtpFecSessionConfig | MmtpFecSessionMessageConfig) {
281
+ if (config && 'alFecFlow' in config) {
282
+ const message = alFecRaptorQFlowConfig(config.alFecFlow)
283
+ if (!message) {
284
+ throw new Error('MmtpFecSession: the AL-FEC entry is outside draft-ramadan-moq-mmt-02 8.3 (iso-ssbg1-v1)')
285
+ }
286
+ this.#message = message
287
+ // Coding byte 0x14 is the iso-ssbg1-v1 profile (mmt-02 8.3).
288
+ this.blockProfile = parseFecBlockProfile('iso-ssbg1-v1', 'alFecFlow')
289
+ this.algorithm = message.fecCodeId === FEC_CODE_RAPTORQ ? 'raptorq' : 'reed-solomon'
290
+ this.sourceSymbols = message.maximumK
291
+ this.#repairSymbols = message.maximumP
292
+ this.symbolSize = message.repairSymbolSize
293
+ this.retentionBlocks = requirePositiveInteger(config.retentionBlocks, 'retentionBlocks')
294
+ this.#repairPacketId = message.repairFlowId
295
+ this.#heldLayers = layerZero(message.maximumP)
296
+ this.#anchorHorizon = heldLayerAnchorHorizon(this.retentionBlocks, this.sourceSymbols)
297
+ this.#decoder = buildDecoder(config.Decoder, this)
298
+ return
299
+ }
244
300
  const fec = config?.fec
245
301
  if (!fec || typeof fec !== 'object') {
246
302
  throw new Error('MmtpFecSession: the catalog fec instance is required')
@@ -251,7 +307,7 @@ export class MmtpFecSession {
251
307
  }
252
308
  this.algorithm = fec.scheme
253
309
  this.sourceSymbols = requirePositiveInteger(fec.sourceSymbols, 'fec.sourceSymbols')
254
- this.repairSymbols = requirePositiveInteger(fec.repairSymbols, 'fec.repairSymbols')
310
+ this.#repairSymbols = requirePositiveInteger(fec.repairSymbols, 'fec.repairSymbols')
255
311
  this.symbolSize = requirePositiveInteger(fec.symbolSize, 'fec.symbolSize')
256
312
  const interleaveDepthMs = requireNumber(fec.interleaveDepthMs, 'fec.interleaveDepthMs')
257
313
  const reorderToleranceMs = requireNumber(fec.reorderToleranceMs, 'fec.reorderToleranceMs')
@@ -271,17 +327,38 @@ export class MmtpFecSession {
271
327
  config.heldLayers,
272
328
  )
273
329
  this.#anchorHorizon = heldLayerAnchorHorizon(this.retentionBlocks, this.sourceSymbols)
274
- if (typeof config.Decoder !== 'function') {
275
- throw new Error('MmtpFecSession: a decoder constructor (wasm_bindgen.MmtFecDecoder) is required')
276
- }
277
- const decoder = new config.Decoder(this.retentionBlocks, this.blockProfile, this.algorithm)
278
- try {
279
- decoder.configure_params(this.sourceSymbols, this.symbolSize)
280
- } catch (error) {
281
- decoder.free()
282
- throw error
330
+ this.#decoder = buildDecoder(config.Decoder, this)
331
+ }
332
+
333
+ /**
334
+ * A later version of the AL-FEC message, catalog-less sessions only. A
335
+ * maximum_p_for_repair_flow above the held total raises it (`raised`): the
336
+ * anchored check restarts at the new total, so a block generated under the
337
+ * previous one stays valid before the first block carrying the new total
338
+ * (fec-core-00 7.3.2, mmt-02 8.3). A field at or below it changes nothing
339
+ * (`unchanged`): no publisher lowers it, and the check catches a wire that
340
+ * does. An entry with another asset, repair flow, code point, K or T, or
341
+ * one outside 8.3, is `refused`: that is a new source encoding, which needs
342
+ * a new session. A latched fail-closed stays latched either way.
343
+ */
344
+ updateAlFecFlow(flow: AlFecFlow): MmtpFecSessionMessageUpdate {
345
+ const held = this.#message
346
+ if (!held) throw new Error('MmtpFecSession: updateAlFecFlow() needs a session built from an AL-FEC entry')
347
+ const next = alFecRaptorQFlowConfig(flow)
348
+ if (
349
+ !next ||
350
+ next.sourcePacketIds[0] !== held.sourcePacketIds[0] ||
351
+ next.repairFlowId !== held.repairFlowId ||
352
+ next.fecCodeId !== held.fecCodeId ||
353
+ next.maximumK !== held.maximumK ||
354
+ next.repairSymbolSize !== held.repairSymbolSize
355
+ ) {
356
+ return 'refused'
283
357
  }
284
- this.#decoder = decoder
358
+ if (next.maximumP <= this.#repairSymbols) return 'unchanged'
359
+ this.#repairSymbols = next.maximumP
360
+ this.#heldLayers = layerZero(next.maximumP)
361
+ return 'raised'
285
362
  }
286
363
 
287
364
  /** True once release() ran. */
@@ -386,7 +463,7 @@ export class MmtpFecSession {
386
463
  // An RSB_length below a held layer's total goes to the 7.3.2 stop, not
387
464
  // to the discard; the block state is unchanged either way.
388
465
  const verdict = this.#heldLayers.failsClosed(
389
- repairTrackName, frame.ssStart, frame.rsbLength, this.#anchorHorizon, this.#newestSource,
466
+ this.#trackFor(repairTrackName), frame.ssStart, frame.rsbLength, this.#anchorHorizon, this.#newestSource,
390
467
  )
391
468
  if (verdict) {
392
469
  this.#stats.failClosed = verdict
@@ -399,7 +476,7 @@ export class MmtpFecSession {
399
476
  // fec-01 6.3.9, after the 4.5 discards and the size check, before a block
400
477
  // is placed. Without a track name only the anchored rule (b) applies.
401
478
  const verdict = this.#heldLayers.admit(
402
- repairTrackName,
479
+ this.#trackFor(repairTrackName),
403
480
  frame.ssStart,
404
481
  frame.rsbLength,
405
482
  this.#anchorHorizon,
@@ -464,6 +541,11 @@ export class MmtpFecSession {
464
541
  return true
465
542
  }
466
543
 
544
+ /** Catalog-less: layer 0 has no own-track clause (mmt-02 8.3), so no track name is judged. */
545
+ #trackFor(repairTrackName: string | undefined): string | undefined {
546
+ return this.#message ? undefined : repairTrackName
547
+ }
548
+
467
549
  #drop(reason: string): Uint8Array[] {
468
550
  this.#stats.dropped[reason] = (this.#stats.dropped[reason] ?? 0) + 1
469
551
  return []
@@ -600,6 +682,24 @@ export class MmtpFecSession {
600
682
  }
601
683
  }
602
684
 
685
+ function layerZero(total: number): HeldLayerAdmission {
686
+ return new HeldLayerAdmission([{ layer: 0, repairTrackName: '', rsIdOffset: 0, repairSymbols: total }])
687
+ }
688
+
689
+ function buildDecoder(Decoder: MmtpFecSessionDecoderConstructor, session: MmtpFecSession): MmtpFecSessionDecoder {
690
+ if (typeof Decoder !== 'function') {
691
+ throw new Error('MmtpFecSession: a decoder constructor (wasm_bindgen.MmtFecDecoder) is required')
692
+ }
693
+ const decoder = new Decoder(session.retentionBlocks, session.blockProfile, session.algorithm)
694
+ try {
695
+ decoder.configure_params(session.sourceSymbols, session.symbolSize)
696
+ } catch (error) {
697
+ decoder.free()
698
+ throw error
699
+ }
700
+ return decoder
701
+ }
702
+
603
703
  function requirePositiveInteger(value: unknown, path: string): number {
604
704
  if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
605
705
  throw new Error(`MmtpFecSession: ${path} must be a positive integer (got ${String(value)})`)
@@ -23,6 +23,7 @@ import {
23
23
  type MMTPHeader,
24
24
  type MoqRepairFrame,
25
25
  } from "@blockcast/mmt-base";
26
+ import { USER_AGENT_VERIFIED } from "@blockcast/mmt-transport";
26
27
  import type { FECManager, IsoHeldSource, WasmFecDecoder } from "./fec-manager.js";
27
28
  import type { IsoPlacedSource, IsoRepairPlacement } from "./iso-block-locator.js";
28
29
  import { isMfuReassemblyError } from "./mfu-reassembler.js";
@@ -245,6 +246,14 @@ export interface MmtpIngressContext {
245
246
  * the same ingress and `feedPacket` splits them off.
246
247
  */
247
248
  flow?: string;
249
+ /**
250
+ * `USER_AGENT_VERIFIED` when the datagram came from the browser's own
251
+ * MulticastReceiver, which verified the flow's records itself and delivers
252
+ * only admitted data (`userAgentVerifiedIngress` in
253
+ * `@blockcast/mmt-transport`). A protected multicast datagram carrying it
254
+ * is admitted without a record. Any other value means nothing.
255
+ */
256
+ admittedBy?: symbol;
248
257
  }
249
258
 
250
259
  /** One physical stream: an MMTP packet ID and the catalog track it carries. */
@@ -946,6 +955,15 @@ function sourceSymbolEvidence(
946
955
  };
947
956
  }
948
957
 
958
+ /** One protected flow: its 7.2.7 receiver, and the ingress its datagrams arrive with. */
959
+ interface ProvenanceEntry {
960
+ receiver: BcProvenanceV2Receiver;
961
+ ingress: MmtpIngressContext;
962
+ protected?: boolean;
963
+ /** A datagram the user agent verified admitted the flow (`admittedBy`). */
964
+ userAgent?: boolean;
965
+ }
966
+
949
967
  export class MmtpRouter {
950
968
  readonly #parser: MmtpContainerParser;
951
969
  // Source AND FEC-recovered MFU fragments share ONE reassembler — the parser's
@@ -955,10 +973,7 @@ export class MmtpRouter {
955
973
  #defaultFecMapper: FecBlockMapper | null;
956
974
  #fecMappersByPacketId = new Map<number, FecBlockMapper>();
957
975
  /** Protected flows: one 7.2.7 receiver each, and the ingress its datagrams arrive with. */
958
- readonly #provenance = new Map<
959
- string,
960
- { receiver: BcProvenanceV2Receiver; ingress: MmtpIngressContext; protected?: boolean }
961
- >();
976
+ readonly #provenance = new Map<string, ProvenanceEntry>();
962
977
  #onFlowProtected: ((flow: string) => void) | null = null;
963
978
  #unadmittedListedDrops = 0;
964
979
  #unlistedRepairSetAside = 0;
@@ -2329,12 +2344,13 @@ export class MmtpRouter {
2329
2344
  if (!entry) return;
2330
2345
  entry.receiver.release();
2331
2346
  entry.protected = false;
2347
+ entry.userAgent = false;
2332
2348
  // ponytail: drops every governed block's record, not only this flow's; per-flow if joins multiply.
2333
2349
  this.#assistBlocks.clear();
2334
2350
  this.#assistFailures.clear();
2335
2351
  }
2336
2352
 
2337
- /** MoQ packets of a track a protected flow lists, dropped on its unicast path: duplicate, overflow, or listed by more than one flow. */
2353
+ /** Packets of a track a protected flow lists, dropped unread: a MoQ copy (duplicate, overflow, or listed by more than one flow) or a multicast source datagram that names no flow, of a track a user-agent-admitted flow lists. */
2338
2354
  get unadmittedListedDropCount(): number {
2339
2355
  return this.#unadmittedListedDrops;
2340
2356
  }
@@ -2384,6 +2400,14 @@ export class MmtpRouter {
2384
2400
  return false;
2385
2401
  }
2386
2402
 
2403
+ /** Whether a protected flow the user agent admitted lists `packetId`. */
2404
+ #userAgentGoverns(packetId: number): boolean {
2405
+ for (const entry of this.#provenance.values()) {
2406
+ if (entry.protected && entry.userAgent && ingressHasPacketId(entry.ingress, packetId)) return true;
2407
+ }
2408
+ return false;
2409
+ }
2410
+
2387
2411
  /**
2388
2412
  * A MoQ repair symbol no protected flow lists, owned by a source track one
2389
2413
  * does: its FEC instance is governed (7.2.7 step 2) and takes only admitted
@@ -2503,7 +2527,7 @@ export class MmtpRouter {
2503
2527
  fec.cancelBlockTimeout(blockStart, ownerPacketId, catalogTrackName);
2504
2528
  fec.countRecovery(ownerPacketId, blockStart, catalogTrackName);
2505
2529
  fec.markBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2506
- })) {
2530
+ }, true)) {
2507
2531
  this.#unlistedAssistedRecoveries++;
2508
2532
  this.#assistBlocks.delete(key);
2509
2533
  return;
@@ -3959,6 +3983,15 @@ export class MmtpRouter {
3959
3983
  debugRouter(`[MmtpRouter] Dropping multicast packet pid=${info.packetId}: flow ${ingress.flow} has no receiver`);
3960
3984
  return;
3961
3985
  }
3986
+ // The user agent verified this datagram against the flow's records
3987
+ // itself, and the records never reach the page: admitted as is. The
3988
+ // flow stays registered, so its MoQ copies are still checked.
3989
+ if (ingress.admittedBy === USER_AGENT_VERIFIED) {
3990
+ this.#noteFlowAdmitted(ingress.flow);
3991
+ protectedFlow.userAgent = true;
3992
+ this.#processMMTP(packet, source, true, ingress, overlapCopy);
3993
+ return;
3994
+ }
3962
3995
  // Held: released by a record (feedProvenanceRecord) or expired. Duplicate / overflow: dropped.
3963
3996
  const { verdict } = protectedFlow.receiver.onDatagram(packet, "multicast");
3964
3997
  if (verdict === "admitted") {
@@ -3983,6 +4016,18 @@ export class MmtpRouter {
3983
4016
  }
3984
4017
  }
3985
4018
 
4019
+ // A multicast datagram that names no flow is unverified. Of a track a
4020
+ // user-agent-admitted flow lists it would feed that flow's live decoder,
4021
+ // whose recoveries are kept without a record (#routeVerifiedFecSymbols):
4022
+ // its source is dropped here and its repair set aside below, as for
4023
+ // unlisted MoQ. Under a record-only flow it is read as before: those
4024
+ // recoveries still need a record.
4025
+ const flowless = source === "multicast" && !manifestAdmitted && ingress?.flow === undefined;
4026
+ if (flowless && !info.isRepair && this.#userAgentGoverns(info.packetId)) {
4027
+ this.#unadmittedListedDrops++;
4028
+ return;
4029
+ }
4030
+
3986
4031
  if (info.isRepair) {
3987
4032
  // Repair packet — per ISO 23008-1 §C.5.3, carries (SS_Start, SSB_length, RS_ID).
3988
4033
  // Read from the header parsed above rather than parsing it again
@@ -4003,9 +4048,10 @@ export class MmtpRouter {
4003
4048
  );
4004
4049
  if (!repair) return;
4005
4050
  if (ownerPacketId != null && this.#fecManager) {
4006
- // An unadmitted MoQ symbol reaching here is unlisted (#admitUnicast).
4051
+ // An unadmitted MoQ symbol reaching here is unlisted (#admitUnicast);
4052
+ // a flow-less multicast one is unverified.
4007
4053
  if (
4008
- source === "moq" && !manifestAdmitted &&
4054
+ ((flowless && this.#userAgentGoverns(ownerPacketId)) || (source === "moq" && !manifestAdmitted)) &&
4009
4055
  this.#setAsideUnlistedRepair(ownerPacketId, catalogTrackNameForPacket(ingress, ownerPacketId), repair)
4010
4056
  ) return;
4011
4057
  this.#repairPidCounts[info.packetId] = (this.#repairPidCounts[info.packetId] || 0) + 1;
@@ -4950,6 +4996,7 @@ export class MmtpRouter {
4950
4996
  ownerPacketId?: number,
4951
4997
  catalogTrackName?: string,
4952
4998
  complete?: () => void,
4999
+ assisted = false,
4953
5000
  ): boolean {
4954
5001
  // MMTP path: use processFecBlock for MMTP-wrapped recovery.
4955
5002
  // Recovered symbols are individual MMTP packets that may be fragments
@@ -4974,7 +5021,7 @@ export class MmtpRouter {
4974
5021
  ownerPacketId,
4975
5022
  blockProfile,
4976
5023
  });
4977
- return this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete);
5024
+ return this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete, assisted);
4978
5025
  }
4979
5026
 
4980
5027
  /**
@@ -4987,6 +5034,19 @@ export class MmtpRouter {
4987
5034
  * recovered packets is refused is unrecovered (7.2.7): its caller leaves it
4988
5035
  * open, so it reaches its FEC deadline and fails by class. Always true
4989
5036
  * outside a protected flow.
5037
+ *
5038
+ * On a flow the user agent admitted, the live decoder's recoveries are kept
5039
+ * without a record: while the flow is protected that decoder takes only
5040
+ * symbols the user agent or a record verified (a flow-less multicast
5041
+ * datagram of a listed track is dropped or set aside in #processMMTP), so
5042
+ * what it recovers is as verified as they are. An `assisted` (scratch-decoder) recovery mixes in
5043
+ * unlisted MoQ repair and still needs a record.
5044
+ * ponytail: per-flow flag; per-block provenance only if a protected flow
5045
+ * ever mixes verified and unverified live inputs.
5046
+ * ponytail: a worker frame computed before the protection reset can still
5047
+ * arrive after it (frames carry no epoch); its inputs are MoQ copies read
5048
+ * and played before protection, so it admits nothing new. Drop pre-reset
5049
+ * frames by epoch only if pre-protection input ever matters.
4990
5050
  */
4991
5051
  #routeVerifiedFecSymbols(
4992
5052
  blockId: number,
@@ -4994,8 +5054,11 @@ export class MmtpRouter {
4994
5054
  ownerPacketId?: number,
4995
5055
  catalogTrackName?: string,
4996
5056
  complete?: () => void,
5057
+ assisted = false,
4997
5058
  ): boolean {
4998
- const receiver = ownerPacketId == null ? undefined : this.#provenanceFor(ownerPacketId, catalogTrackName);
5059
+ const entry = ownerPacketId == null ? undefined : this.#provenanceFor(ownerPacketId, catalogTrackName);
5060
+ const receiver = entry === undefined || entry === "ambiguous" ? entry : entry.receiver;
5061
+ const userAgent = !assisted && entry !== undefined && entry !== "ambiguous" && entry.protected === true && entry.userAgent === true;
4999
5062
  let recovered = true;
5000
5063
  if (receiver) {
5001
5064
  const parsed = this.#stream(ownerPacketId!, catalogTrackName).duplicates.parsedSymbols;
@@ -5008,6 +5071,8 @@ export class MmtpRouter {
5008
5071
  let admitted = false;
5009
5072
  if (receiver === "ambiguous") {
5010
5073
  this.#ambiguousRecoveredDrops++;
5074
+ } else if (userAgent) {
5075
+ admitted = true;
5011
5076
  } else {
5012
5077
  const wire = new Uint8Array(sym.rawSymbol.byteLength + 4);
5013
5078
  wire.set(sym.rawSymbol);
@@ -5033,7 +5098,7 @@ export class MmtpRouter {
5033
5098
  #provenanceFor(
5034
5099
  packetId: number,
5035
5100
  catalogTrackName: string | undefined,
5036
- ): BcProvenanceV2Receiver | "ambiguous" | undefined {
5101
+ ): ProvenanceEntry | "ambiguous" | undefined {
5037
5102
  // A caller that names no track matches every name the packet id was admitted under.
5038
5103
  const byName = this.#provenanceStreamFlow.get(packetId);
5039
5104
  const flows = new Set(
@@ -5042,12 +5107,12 @@ export class MmtpRouter {
5042
5107
  : byName.has(catalogTrackName) ? [byName.get(catalogTrackName)!] : [],
5043
5108
  );
5044
5109
  if (flows.size > 1) return "ambiguous";
5045
- for (const bound of flows) return this.#provenance.get(bound)?.receiver ?? "ambiguous";
5046
- let match: BcProvenanceV2Receiver | undefined;
5047
- for (const { receiver, ingress } of this.#provenance.values()) {
5048
- if (ingress.tracks.some((t) => t.packetId === packetId && (catalogTrackName === undefined || t.name === catalogTrackName))) {
5110
+ for (const bound of flows) return this.#provenance.get(bound) ?? "ambiguous";
5111
+ let match: ProvenanceEntry | undefined;
5112
+ for (const entry of this.#provenance.values()) {
5113
+ if (entry.ingress.tracks.some((t) => t.packetId === packetId && (catalogTrackName === undefined || t.name === catalogTrackName))) {
5049
5114
  if (match) return "ambiguous";
5050
- match = receiver;
5115
+ match = entry;
5051
5116
  }
5052
5117
  }
5053
5118
  return match;
@@ -50,7 +50,11 @@ import {
50
50
  type StreamSwitchCommit,
51
51
  type TrackPacketCallback,
52
52
  } from "./mmtp-router.js";
53
- import { isMulticastTrackPacketId, type WindowMulticastGateway } from "@blockcast/mmt-transport";
53
+ import {
54
+ isMulticastTrackPacketId,
55
+ userAgentVerifiedIngress,
56
+ type WindowMulticastGateway,
57
+ } from "@blockcast/mmt-transport";
54
58
  import {
55
59
  MulticastTransport,
56
60
  type MulticastFailbackReason,
@@ -94,6 +98,8 @@ export interface MulticastRepairEndpoint {
94
98
  mode?: string;
95
99
  amtRelay?: string;
96
100
  amtRelayPort?: number;
101
+ /** The media join's `'driad'` discovery (no relay pinned), carried so the repair group rides the same relay. */
102
+ amtDiscovery?: 'driad';
97
103
  /** The repair packet IDs this group carries. */
98
104
  packetIds: number[];
99
105
  /** Each repair packet ID bound to the media packet ID it repairs. */
@@ -2353,10 +2359,20 @@ export class MulticastManager {
2353
2359
  * repair roles and owners are registered here; in-band owners stay the
2354
2360
  * caller's. Joining is best effort: a group that fails to join is logged
2355
2361
  * and leaves the media join as it was.
2362
+ *
2363
+ * `options.gateway` names the gateway a subscribe callback reads through.
2364
+ * When it is the browser's own MulticastReceiver provider, the callback's
2365
+ * packets are fed as admitted by the user agent
2366
+ * (`userAgentVerifiedIngress`); nothing the callback passes can mark them.
2367
+ * The mark rests on this statement, not on the data path: every packet the
2368
+ * callback delivers must come from a subscription opened on
2369
+ * `options.gateway`. A callback that also merges another source (a MoQ or
2370
+ * WebSocket repair feed) must not name a native provider here.
2356
2371
  */
2357
2372
  joinRepairEndpoints(
2358
2373
  endpoints: readonly MulticastRepairEndpoint[],
2359
2374
  provider?: WindowMulticastGateway | MulticastRepairSubscribe,
2375
+ options?: { gateway?: WindowMulticastGateway },
2360
2376
  ): () => void {
2361
2377
  const releases: Array<() => void> = [];
2362
2378
  let released = false;
@@ -2382,22 +2398,26 @@ export class MulticastManager {
2382
2398
  ...(endpoint.flow === undefined ? {} : { flow: endpoint.flow }),
2383
2399
  };
2384
2400
  let sender: string | undefined;
2385
- const onPacket = (packet: Uint8Array, sourceAddress?: string) => {
2401
+ const feed = (packet: Uint8Array, sourceAddress: string | undefined, ingressToFeed: MmtpIngressContext) => {
2386
2402
  if (released) return;
2387
2403
  // The media join's sender owns the repair too; this group's own first
2388
2404
  // sender stands in only until the media join has one.
2389
2405
  if (sourceAddress !== undefined && !sameDatagramSource(sourceAddress, this.#transport.sender ?? (sender ??= sourceAddress))) return;
2390
- this.#router.feedPacket(packet, "multicast", ingress);
2406
+ this.#router.feedPacket(packet, "multicast", ingressToFeed);
2391
2407
  };
2392
2408
  const fail = (error: unknown) =>
2393
2409
  console.warn(`[MulticastManager] repair group ${endpoint.group}:${endpoint.port} not joined`, error);
2394
2410
  if (typeof provider === "function") {
2411
+ const marked = userAgentVerifiedIngress(ingress, options?.gateway);
2412
+ const onPacket = (packet: Uint8Array, sourceAddress?: string) => feed(packet, sourceAddress, marked);
2395
2413
  Promise.resolve()
2396
2414
  .then(() => provider(endpoint, onPacket))
2397
2415
  .then((release) => (released ? safely(release) : releases.push(release)), fail);
2398
2416
  continue;
2399
2417
  }
2400
- const transport = new MulticastTransport((packet) => onPacket(packet), {
2418
+ // The transport marks what it delivers when its gateway is the user
2419
+ // agent's MulticastReceiver provider; feedPacket would not.
2420
+ const transport = new MulticastTransport<MmtpIngressContext>((packet, delivered) => feed(packet, undefined, delivered ?? ingress), {
2401
2421
  gateway: provider,
2402
2422
  lockedTo: () => this.#transport.sender,
2403
2423
  });
@@ -2409,11 +2429,12 @@ export class MulticastManager {
2409
2429
  mode: endpoint.mode as MulticastEndpointConfig["mode"],
2410
2430
  amtRelay: endpoint.amtRelay,
2411
2431
  amtRelayPort: endpoint.amtRelayPort ?? (endpoint.amtRelay?.trim() ? DEFAULT_AMT_RELAY_PORT : undefined),
2432
+ amtDiscovery: endpoint.amtDiscovery,
2412
2433
  packetIds: endpoint.packetIds,
2413
2434
  tracks: endpoint.tracks.map(({ name, packetId }) => ({ name, packetId })),
2414
2435
  // A protected repair group's records ride in band on its port (7.2.3).
2415
2436
  ...(endpoint.flow === undefined ? {} : { protected: true }),
2416
- }).then((joined) => {
2437
+ }, ingress).then((joined) => {
2417
2438
  if (!joined && !released) fail(transport.lastJoinOutcome);
2418
2439
  });
2419
2440
  }