@blockcast/mmt-container 0.5.0-main.ed11aec88d91 → 0.5.0

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 (43) hide show
  1. package/dist/browser-runtime.js +150 -21
  2. package/dist/browser-runtime.js.map +2 -2
  3. package/dist/fec-manager.d.ts.map +1 -1
  4. package/dist/fec-manager.js.map +2 -2
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js.map +2 -2
  8. package/dist/mmtp-container-parser.d.ts +10 -0
  9. package/dist/mmtp-container-parser.d.ts.map +1 -1
  10. package/dist/mmtp-container-parser.js +31 -1
  11. package/dist/mmtp-container-parser.js.map +2 -2
  12. package/dist/mmtp-container-parser.no-modules.js +31 -1
  13. package/dist/mmtp-container-parser.no-modules.js.map +2 -2
  14. package/dist/mmtp-decode-pipeline.js +31 -1
  15. package/dist/mmtp-decode-pipeline.js.map +2 -2
  16. package/dist/mmtp-router.d.ts +11 -0
  17. package/dist/mmtp-router.d.ts.map +1 -1
  18. package/dist/mmtp-router.js +54 -1
  19. package/dist/mmtp-router.js.map +2 -2
  20. package/dist/multicast-manager.d.ts +25 -1
  21. package/dist/multicast-manager.d.ts.map +1 -1
  22. package/dist/multicast-manager.js +92 -2
  23. package/dist/multicast-manager.js.map +2 -2
  24. package/dist/no-modules.js +31 -1
  25. package/dist/no-modules.js.map +2 -2
  26. package/dist/repair-fetch.d.ts +41 -10
  27. package/dist/repair-fetch.d.ts.map +1 -1
  28. package/dist/repair-fetch.js +58 -19
  29. package/dist/repair-fetch.js.map +2 -2
  30. package/dist/wasm-mmtp-container-parser.js +31 -1
  31. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  32. package/package.json +7 -6
  33. package/src/fec-manager.ts +3 -0
  34. package/src/index.ts +1 -0
  35. package/src/mmtp-container-parser.ts +48 -0
  36. package/src/mmtp-router.ts +27 -0
  37. package/src/multicast-manager.ts +68 -0
  38. package/src/repair-fetch.ts +131 -31
  39. package/test-vectors/catalog/negative/multicast-auth-padded-key.json +0 -1
  40. package/test-vectors/catalog/negative/multicast-auth-reused-track-format.json +0 -1
  41. package/test-vectors/catalog/negative/multicast-auth-root-dotted-key.json +0 -1
  42. package/test-vectors/catalog/negative/multicast-auth-trailing-pad-bits.json +0 -1
  43. package/test-vectors/repair-policy-v1.json +113 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockcast/mmt-container",
3
- "version": "0.5.0-main.ed11aec88d91",
3
+ "version": "0.5.0",
4
4
  "description": "MMTP/LOC container parsing for MMT over MoQ and SSM",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -141,7 +141,8 @@
141
141
  "./catalog.schema.json": "./schemas/catalog.schema.json",
142
142
  "./test-vectors/catalog-validation.json": "./test-vectors/catalog-validation.json",
143
143
  "./test-vectors/align.json": "./test-vectors/align.json",
144
- "./test-vectors/catalog-timing.json": "./test-vectors/catalog-timing.json"
144
+ "./test-vectors/catalog-timing.json": "./test-vectors/catalog-timing.json",
145
+ "./test-vectors/repair-policy-v1.json": "./test-vectors/repair-policy-v1.json"
145
146
  },
146
147
  "files": [
147
148
  "dist",
@@ -164,10 +165,10 @@
164
165
  "test:watch": "vitest"
165
166
  },
166
167
  "dependencies": {
167
- "@blockcast/fec-worker": "0.5.0-main.ed11aec88d91",
168
- "@blockcast/mmt-base": "0.5.0-main.ed11aec88d91",
169
- "@blockcast/mmt-fec": "0.5.0-main.ed11aec88d91",
170
- "@blockcast/mmt-manifest-verify": "0.5.0-main.ed11aec88d91",
168
+ "@blockcast/fec-worker": "^0.5.0",
169
+ "@blockcast/mmt-base": "^0.5.0",
170
+ "@blockcast/mmt-fec": "^0.5.0",
171
+ "@blockcast/mmt-manifest-verify": "^0.5.0",
171
172
  "@noble/curves": "^1.9.7",
172
173
  "@noble/hashes": "^1.8.0",
173
174
  "ajv": "^8.17.1",
@@ -3706,6 +3706,9 @@ export class FECManager {
3706
3706
  if (this.#refusedRecoveries.size > 1000) dropOldest(this.#refusedRecoveries, 500)
3707
3707
  }
3708
3708
  if (esi >= meta.ssbLength! - 1) {
3709
+ // Safe to forget the block here: the Worker never re-emits a
3710
+ // terminal block (packages/fec-worker/src/fec-worker.ts guards
3711
+ // source and repair feeds with isTerminalBlock).
3709
3712
  this.#workerRecoveredNextEsi.delete(blockKey)
3710
3713
  } else {
3711
3714
  this.#workerRecoveredNextEsi.set(blockKey, esi + 1)
package/src/index.ts CHANGED
@@ -649,6 +649,7 @@ export {
649
649
  type RepairPriorityReason,
650
650
  type RepairSendMeasurement,
651
651
  type RepairSkipReason,
652
+ type RepairStopReason,
652
653
  type RepairSubscribeCallback,
653
654
  type WireRepairFetchOptions,
654
655
  } from './repair-fetch.js'
@@ -234,6 +234,14 @@ export interface FecReleaseGate {
234
234
  ): FrameGapDecision
235
235
  /** Every frame released in order from a gated stream. */
236
236
  onReleased?(data: Uint8Array, meta: FrameMeta): void
237
+ /**
238
+ * Whether a complete frame is a RAP (draft-ramadan-moq-fec 11.4.2). Asked
239
+ * for each video frame with a Source FEC Payload ID that enters the gate,
240
+ * held or released; absent, no frame counts as a RAP.
241
+ */
242
+ isRap?(data: Uint8Array, meta: FrameMeta): boolean
243
+ /** A complete RAP entered the gate at SS_ID `ssId` (see `isRap`). */
244
+ onRap?(packetId: number, catalogTrackName: string | undefined, ssId: number): void
237
245
  }
238
246
 
239
247
  /** Typed ISO-BMFF timing-sample budget counters. See `timingBudgetStats`. */
@@ -692,6 +700,15 @@ function authoredGap(before: FrameMeta, after: FrameMeta): boolean {
692
700
  return b.decodeTime > a.decodeTime + BigInt(a.durationTicks)
693
701
  }
694
702
 
703
+ /**
704
+ * Serial-number order on the uint32 SS_ID (RFC 1982 style): `a` follows `b`
705
+ * within half the space, so a 2^32 wrap does not read a post-wrap block as
706
+ * below a pre-wrap RAP.
707
+ */
708
+ function ssIdAfter(a: number, b: number): boolean {
709
+ return ((a - b) | 0) > 0
710
+ }
711
+
695
712
  interface GatedFrame extends PendingFrame {
696
713
  /** performance.now() at push; measures the hold against the gate's limit. */
697
714
  arrivedAtMs: number
@@ -704,6 +721,8 @@ interface GatedStream {
704
721
  last: PendingFrame | null
705
722
  lastSbn: number | undefined
706
723
  discontinuity: boolean
724
+ /** Latest (serial order) SS_ID of a complete RAP that entered the gate since the last reset. */
725
+ rapSsId: number | undefined
707
726
  }
708
727
 
709
728
  /**
@@ -797,11 +816,17 @@ class FecReleaseSequencer {
797
816
  stream.last = null
798
817
  stream.lastSbn = undefined
799
818
  stream.discontinuity = false
819
+ stream.rapSsId = undefined
800
820
  }
801
821
  if (stream.last !== null && compareFrameOrder(frame, stream.last) <= 0) {
802
822
  this.#recordStat('droppedStale')
803
823
  return
804
824
  }
825
+ const ssId = meta.fecSbn
826
+ if (meta.trackType === 'video' && ssId !== undefined && this.#gate.isRap?.(data, meta) === true) {
827
+ if (stream.rapSsId === undefined || ssIdAfter(ssId, stream.rapSsId)) stream.rapSsId = ssId
828
+ this.#gate.onRap?.(stream.packetId, stream.catalogTrackName, ssId)
829
+ }
805
830
  if (stream.last === null) {
806
831
  // The first release establishes decoder sync: video must start on a
807
832
  // RAP; earlier deltas are undecodable and are dropped.
@@ -841,6 +866,23 @@ class FecReleaseSequencer {
841
866
  return earliest
842
867
  }
843
868
 
869
+ /**
870
+ * Whether a complete RAP above SS_ID `ssId` entered the gate of a stream of
871
+ * `packetId` (draft-ramadan-moq-fec 11.4.2: repair of a block below it is
872
+ * moot). Its SS_ID is the one its frame completed in; a frame's packets
873
+ * hold consecutive SS_IDs in authored order, so a complete RAP with a
874
+ * packet above `ssId` has every packet of it at or above the first SS_ID
875
+ * any loss at or below `ssId` could have taken.
876
+ */
877
+ rapAbove(packetId: number, catalogTrackName: string | undefined, ssId: number): boolean {
878
+ for (const stream of this.#streams.values()) {
879
+ if (stream.packetId !== packetId) continue
880
+ if (catalogTrackName !== undefined && stream.catalogTrackName !== catalogTrackName) continue
881
+ if (stream.rapSsId !== undefined && ssIdAfter(stream.rapSsId, ssId)) return true
882
+ }
883
+ return false
884
+ }
885
+
844
886
  /** Re-evaluate held gaps after FEC state changed. */
845
887
  poke(streamKey?: string): void {
846
888
  if (streamKey !== undefined) {
@@ -946,6 +988,7 @@ class FecReleaseSequencer {
946
988
  last: null,
947
989
  lastSbn: undefined,
948
990
  discontinuity: false,
991
+ rapSsId: undefined,
949
992
  }
950
993
  this.#streams.set(streamKey, stream)
951
994
  }
@@ -1346,6 +1389,11 @@ export class MmtpContainerParser {
1346
1389
  return this.#fecSequencer?.earliestHeldArrivalMs(packetId, catalogTrackName)
1347
1390
  }
1348
1391
 
1392
+ /** See FecReleaseSequencer.rapAbove; false when no stream is FEC-gated. */
1393
+ fecReleaseRapAbove(packetId: number, catalogTrackName: string | undefined, ssId: number): boolean {
1394
+ return this.#fecSequencer?.rapAbove(packetId, catalogTrackName, ssId) ?? false
1395
+ }
1396
+
1349
1397
  /** Re-evaluate held gaps after FEC block state changed. */
1350
1398
  pokeFecRelease(packetId?: number, catalogTrackName?: string): void {
1351
1399
  if (!this.#fecSequencer) return
@@ -1011,6 +1011,7 @@ export class MmtpRouter {
1011
1011
  | null = null;
1012
1012
  /** Typed observer of every gap decision the release gate makes. */
1013
1013
  readonly #onFrameGap: ((decision: FrameGapDecision) => void) | null;
1014
+ readonly #fecRapObservers = new Set<(packetId: number, catalogTrackName: string | undefined, ssId: number) => void>();
1014
1015
  // Pokes requested while a packet is being processed are deferred to its
1015
1016
  // end: a synchronous recovery completes its block BEFORE routing the
1016
1017
  // recovered frames, and releasing a held gap in between would strand them.
@@ -3165,6 +3166,28 @@ export class MmtpRouter {
3165
3166
  return this.#parser.fecReleaseEarliestHeldAtMs(packetId, catalogTrackName);
3166
3167
  }
3167
3168
 
3169
+ /**
3170
+ * Whether a complete RAP above SS_ID `ssId` entered this stream's FEC
3171
+ * release gate (see FecReleaseSequencer.rapAbove).
3172
+ */
3173
+ fecReleaseRapAbove(packetId: number, catalogTrackName: string | undefined, ssId: number): boolean {
3174
+ return this.#parser.fecReleaseRapAbove(packetId, catalogTrackName, ssId);
3175
+ }
3176
+
3177
+ /**
3178
+ * Observe each complete RAP entering an FEC release gate, with the SS_ID
3179
+ * its frame completed in. Called synchronously from the frame push: an
3180
+ * observer that drives FEC state must defer that work.
3181
+ */
3182
+ addFecRapObserver(
3183
+ observer: (packetId: number, catalogTrackName: string | undefined, ssId: number) => void,
3184
+ ): () => void {
3185
+ this.#fecRapObservers.add(observer);
3186
+ return () => {
3187
+ this.#fecRapObservers.delete(observer);
3188
+ };
3189
+ }
3190
+
3168
3191
  #requestFecPoke(packetId: number, catalogTrackName: string | undefined): void {
3169
3192
  if (this.#disposed) return;
3170
3193
  if (this.#fecPokeDepth > 0) {
@@ -3215,6 +3238,10 @@ export class MmtpRouter {
3215
3238
  this.#onFrameGap?.(decision);
3216
3239
  return decision;
3217
3240
  },
3241
+ isRap: (data, meta) => this.#isRapForGap(data, meta),
3242
+ onRap: (packetId, catalogTrackName, ssId) => {
3243
+ for (const observer of this.#fecRapObservers) observer(packetId, catalogTrackName, ssId);
3244
+ },
3218
3245
  onReleased: (data, meta) => {
3219
3246
  if (meta.trackType !== "video" || meta.mpuSequenceNumber === undefined) return;
3220
3247
  const timing = meta.authoredTiming;
@@ -38,6 +38,7 @@ import type {
38
38
  RepairPriorityReason,
39
39
  RepairSendMeasurement,
40
40
  RepairSkipReason,
41
+ RepairStopReason,
41
42
  RepairSubscribeCallback,
42
43
  } from "./repair-fetch.js";
43
44
  import {
@@ -358,6 +359,9 @@ interface RepairSymbolCounts {
358
359
  * the deadline) so a harness reads the budget instead of inferring it.
359
360
  * - `repair-preempted` / `repair-deferred`: the scheduler aborted a delta
360
361
  * repair for keyframe work, or never ran it before its block resolved.
362
+ * - `repair-stopped`: the scheduler ended a running repair FETCH (its
363
+ * transport cancels it on the wire) and why; `moot` is a RAP above the
364
+ * block that arrived complete first (draft-ramadan-moq-fec 11.4.2).
361
365
  * - `repair-priority`: the catalog could not supply the SUBSCRIBER_PRIORITY a
362
366
  * repair FETCH must carry (draft-ramadan-moq-fec §11.4.5), so the FETCH was
363
367
  * sent without the parameter (`omitted`, keyframe/unknown blocks) or not
@@ -435,6 +439,12 @@ export type RepairLifecycleEvent =
435
439
  readonly rttMs: number | null;
436
440
  readonly budgetMs: number;
437
441
  }
442
+ | {
443
+ readonly version: 2;
444
+ readonly type: "repair-stopped";
445
+ readonly block: RepairBlockIdentity;
446
+ readonly reason: RepairStopReason;
447
+ }
438
448
  | {
439
449
  readonly version: 2;
440
450
  readonly type: "repair-priority";
@@ -510,6 +520,13 @@ export interface BlockPolicyStats {
510
520
  * it (sources + RSB_length < K'), so no FETCH was sent.
511
521
  */
512
522
  repairsSkippedGroupCannotComplete: number;
523
+ /**
524
+ * `repairsSkipped` by reason: a keyframe-bearing block lay below a RAP
525
+ * that had already arrived complete (draft-ramadan-moq-fec 11.4.2).
526
+ */
527
+ repairsSkippedMootRap: number;
528
+ /** Running repair FETCHes the scheduler stopped, by reason (`repair-stopped`). */
529
+ repairStops: Record<RepairStopReason, number>;
513
530
  /**
514
531
  * The most recent skip: its reason, the measured RTT (null when
515
532
  * unmeasured), the time that was left to the deadline, and the request's
@@ -572,6 +589,8 @@ function newBlockPolicyStats(
572
589
  repairsSkippedPresentationDelayUnavailable: 0,
573
590
  repairsSkippedRangeNoAnchor: 0,
574
591
  repairsSkippedGroupCannotComplete: 0,
592
+ repairsSkippedMootRap: 0,
593
+ repairStops: { preempted: 0, reprioritized: 0, expired: 0, resolved: 0, reset: 0, disposed: 0, moot: 0 },
575
594
  lastRepairSkip: null,
576
595
  keyframeRepairFetchesSent: 0,
577
596
  rangeRepairFetchesSent: 0,
@@ -726,6 +745,16 @@ const REPAIR_SKIP_REASONS: ReadonlySet<unknown> = new Set([
726
745
  "presentation-delay-unavailable",
727
746
  "range-no-anchor",
728
747
  "group-cannot-complete",
748
+ "moot-rap",
749
+ ]);
750
+ const REPAIR_STOP_REASONS: ReadonlySet<unknown> = new Set([
751
+ "preempted",
752
+ "reprioritized",
753
+ "expired",
754
+ "resolved",
755
+ "reset",
756
+ "disposed",
757
+ "moot",
729
758
  ]);
730
759
  const REPAIR_DEADLINE_BOUNDS: ReadonlySet<unknown> = new Set(["wait", "presentation", "keyframe", "retention"]);
731
760
  const GAP_REASONS: Readonly<Record<string, "stall" | "conceal">> = {
@@ -840,6 +869,12 @@ export function isRepairLifecycleEvent(
840
869
  isNullableNonNegative(value["rttMs"]) &&
841
870
  isFiniteNumber(value["budgetMs"])
842
871
  );
872
+ case "repair-stopped":
873
+ return (
874
+ hasExactKeys(value, ["version", "type", "block", "reason"]) &&
875
+ isRepairBlockIdentity(block) &&
876
+ REPAIR_STOP_REASONS.has(value["reason"])
877
+ );
843
878
  case "repair-priority": {
844
879
  const blockClass = value["blockClass"];
845
880
  const action = value["action"];
@@ -3097,6 +3132,7 @@ export class MulticastManager {
3097
3132
  else if (reason === "rtt-exceeds-budget") stats.repairsSkippedRttExceedsBudget++;
3098
3133
  else if (reason === "range-no-anchor") stats.repairsSkippedRangeNoAnchor++;
3099
3134
  else if (reason === "group-cannot-complete") stats.repairsSkippedGroupCannotComplete++;
3135
+ else if (reason === "moot-rap") stats.repairsSkippedMootRap++;
3100
3136
  else stats.repairsSkippedPresentationDelayUnavailable++;
3101
3137
  stats.lastRepairSkip = {
3102
3138
  reason,
@@ -3178,6 +3214,37 @@ export class MulticastManager {
3178
3214
  });
3179
3215
  }
3180
3216
 
3217
+ /** Record a running repair FETCH the scheduler stopped, and why. */
3218
+ noteRepairStopped(
3219
+ reason: RepairStopReason,
3220
+ sbn: number,
3221
+ packetId: number,
3222
+ catalogTrackName?: string,
3223
+ ): void {
3224
+ this.#statsFor(packetId, catalogTrackName).repairStops[reason]++;
3225
+ this.#emitRepairLifecycle({
3226
+ version: 2,
3227
+ type: "repair-stopped",
3228
+ block: repairBlockIdentity(packetId, sbn, catalogTrackName),
3229
+ reason,
3230
+ });
3231
+ }
3232
+
3233
+ /**
3234
+ * Whether a complete RAP above SS_ID `ssId` reached the stream's in-order
3235
+ * release gate (draft-ramadan-moq-fec 11.4.2: repair below it is moot).
3236
+ */
3237
+ rapCompleteAbove(packetId: number, catalogTrackName: string | undefined, ssId: number): boolean {
3238
+ return this.#router.fecReleaseRapAbove(packetId, catalogTrackName, ssId);
3239
+ }
3240
+
3241
+ /** Observe each complete RAP reaching a release gate (synchronous: defer FEC work). */
3242
+ addRapCompleteObserver(
3243
+ observer: (packetId: number, catalogTrackName: string | undefined, ssId: number) => void,
3244
+ ): () => void {
3245
+ return this.#router.addFecRapObserver(observer);
3246
+ }
3247
+
3181
3248
  /** Catalog/reset epoch of a packet's streams; gap decisions carry it. */
3182
3249
  epochFor(packetId: number): number {
3183
3250
  return this.#fec.epochFor(packetId);
@@ -3194,6 +3261,7 @@ export class MulticastManager {
3194
3261
  if (stats.epoch !== this.#fec.epochFor(stats.packetId)) continue;
3195
3262
  out[key] = {
3196
3263
  ...stats,
3264
+ repairStops: { ...stats.repairStops },
3197
3265
  lastRepairSkip: stats.lastRepairSkip === null ? null : { ...stats.lastRepairSkip },
3198
3266
  };
3199
3267
  }
@@ -22,15 +22,23 @@
22
22
  * everything.
23
23
  *
24
24
  * Priority (from the block's class, see `repairPriorityForClass`):
25
- * - P0 (keyframe / unknown) and P1 (audio) dispatch as soon as they are
26
- * enqueued, with no cap. Each has its own fail deadline, and a cap would
27
- * turn a recoverable protected block into a stall; their number is
28
- * already bounded by the blocks one fail window can hold.
29
- * - P2 (delta) dispatches only while no P0/P1 is queued or in flight,
30
- * earliest absolute fail deadline first. Enqueuing a P0/P1 aborts every
31
- * in-flight P2 (`repair-preempted`), which returns to the queue while its
32
- * block is unresolved. A P2 that never ran before its block resolved is
33
- * reported as `repair-deferred`.
25
+ * - P0 (keyframe / unknown) dispatches as soon as it is enqueued, with no
26
+ * cap. Each has its own fail deadline, and a cap would turn a
27
+ * recoverable protected block into a stall; their number is already
28
+ * bounded by the blocks one fail window can hold. A P0 block that lies
29
+ * below a RAP the receiver already holds complete is not requested at
30
+ * all (`moot-rap`), and a P0 FETCH still running when such a RAP
31
+ * arrives is cancelled (`repair-stopped`, `moot`): the receiver resumes
32
+ * from that RAP (draft-ramadan-moq-fec §11.4.2).
33
+ * - P1 (audio) dispatches, uncapped, while no P0 is queued or in flight;
34
+ * P2 (delta) only while no P0/P1 is, earliest absolute fail deadline
35
+ * first (§11.4.5 item 3).
36
+ * - Only a P0 FETCH the scheduler actually sends aborts the in-flight P1
37
+ * and P2 FETCHes (`repair-preempted`), which return to the queue while
38
+ * their blocks are unresolved: a keyframe request that is refused, moot
39
+ * or otherwise never sent cancels nothing (§11.4.5 item 3), and an audio
40
+ * request never preempts. A P2 that never ran before its block resolved
41
+ * is reported as `repair-deferred`.
34
42
  * - A block reclassified to a higher class while its P2 fetch is in flight
35
43
  * is re-issued at once under the new class's wire priority: the running
36
44
  * FETCH is aborted (FETCH_CANCEL) and a new one sent.
@@ -401,6 +409,11 @@ export type RepairSkipReason =
401
409
  * only duplicates (BLO-50082).
402
410
  */
403
411
  | 'group-cannot-complete'
412
+ /**
413
+ * The keyframe-bearing block lies below a RAP the receiver already holds
414
+ * complete, so its repair would be moot (draft-ramadan-moq-fec §11.4.2).
415
+ */
416
+ | 'moot-rap'
404
417
 
405
418
  /**
406
419
  * Which term set a FETCH's deadline (BLO-33597): the multicast wait, the
@@ -513,6 +526,23 @@ export interface RepairFetchHost {
513
526
  packetId: number,
514
527
  catalogTrackName?: string,
515
528
  ): void
529
+ /** A running FETCH the scheduler stopped, and why (optional). */
530
+ noteRepairStopped?(
531
+ reason: RepairStopReason,
532
+ sbn: number,
533
+ packetId: number,
534
+ catalogTrackName?: string,
535
+ ): void
536
+ /**
537
+ * Whether a complete RAP above SS_ID `ssId` already reached the stream
538
+ * (optional; absent, none ever has). Under the bc-provenance profile only
539
+ * an admitted RAP counts (multicast-01 §7.2.7).
540
+ */
541
+ rapCompleteAbove?(packetId: number, catalogTrackName: string | undefined, ssId: number): boolean
542
+ /** Observe each complete RAP reaching a stream (optional; called synchronously). */
543
+ addRapCompleteObserver?(
544
+ observer: (packetId: number, catalogTrackName: string | undefined, ssId: number) => void,
545
+ ): () => void
516
546
  /**
517
547
  * A FETCH the scheduler admitted and handed to the transport (optional):
518
548
  * every attempt, a retry or a reprioritised restart included.
@@ -526,7 +556,22 @@ export interface RepairFetchHost {
526
556
  ): void
527
557
  }
528
558
 
529
- type StopReason = 'preempted' | 'reprioritized' | 'expired' | 'resolved' | 'reset' | 'disposed'
559
+ /**
560
+ * Why the scheduler ended a running FETCH (the transport cancels it on the
561
+ * wire): preempted by a sent keyframe FETCH, reissued at a new class's
562
+ * priority, past its deadline, its block resolved, track reset or a newer
563
+ * request for the block, dispose, or `moot` (a RAP above the block arrived
564
+ * complete first, draft-ramadan-moq-fec §11.4.2).
565
+ */
566
+ export type RepairStopReason =
567
+ | 'preempted'
568
+ | 'reprioritized'
569
+ | 'expired'
570
+ | 'resolved'
571
+ | 'reset'
572
+ | 'disposed'
573
+ | 'moot'
574
+ type StopReason = RepairStopReason
530
575
 
531
576
  interface FetchRun {
532
577
  readonly controller: AbortController
@@ -639,9 +684,43 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
639
684
  entry.running = null
640
685
  run.stopped = reason
641
686
  run.controller.abort()
642
- // A preempted delta returns to the queue; until it runs again nothing is
687
+ // A preempted request returns to the queue; until it runs again nothing is
643
688
  // in flight that could land, so the block goes back to its multicast wait.
644
689
  if (reason === 'preempted') releaseHold(entry)
690
+ mc.noteRepairStopped?.(reason, entry.sbn, entry.packetId, entry.catalogTrackName)
691
+ }
692
+
693
+ /** The last SS_ID the entry covers, or undefined while its block is undelimited. */
694
+ const lastSsId = (entry: RepairEntry): number | undefined => {
695
+ if (entry.range) return entry.sbn + entry.range.holeLength - 1
696
+ const length = symbolCounts(entry.sbn, entry.packetId, entry.catalogTrackName)?.authoredLength
697
+ return length === undefined ? undefined : entry.sbn + length - 1
698
+ }
699
+
700
+ /** A keyframe request whose whole block lies below a RAP already held complete. */
701
+ const moot = (entry: RepairEntry): boolean => {
702
+ if (entry.priority !== 0) return false
703
+ const last = lastSsId(entry)
704
+ if (last === undefined) return false
705
+ try {
706
+ return mc.rapCompleteAbove?.(entry.packetId, entry.catalogTrackName, last) === true
707
+ } catch {
708
+ return false
709
+ }
710
+ }
711
+
712
+ /** Drop a request no FETCH will be sent for, and say why. */
713
+ const skip = (entry: RepairEntry, reason: RepairSkipReason, rttMs: number | null, budgetMs: number): void => {
714
+ retire(entry)
715
+ releaseHold(entry)
716
+ clearEntry(entry)
717
+ mc.noteRepairSkipped(
718
+ reason,
719
+ { rttMs, budgetMs, priority: entry.priority },
720
+ entry.sbn,
721
+ entry.packetId,
722
+ entry.catalogTrackName,
723
+ )
645
724
  }
646
725
 
647
726
  /** The keyframe budget for a P0 block, or null: absent, throwing or not positive. */
@@ -683,16 +762,7 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
683
762
  ? null
684
763
  : 'rtt-exceeds-budget'
685
764
  if (reason === null) return rttMs
686
- retire(entry)
687
- releaseHold(entry)
688
- clearEntry(entry)
689
- mc.noteRepairSkipped(
690
- reason,
691
- { rttMs, budgetMs, priority: entry.priority },
692
- entry.sbn,
693
- entry.packetId,
694
- entry.catalogTrackName,
695
- )
765
+ skip(entry, reason, rttMs, budgetMs)
696
766
  return null
697
767
  }
698
768
 
@@ -701,16 +771,21 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
701
771
  if (entry.expiry !== undefined) clearTimeout(entry.expiry)
702
772
  }
703
773
 
704
- const urgentOutstanding = (): boolean => {
774
+ /** Whether any entry of priority below `priority` is queued or in flight. */
775
+ const outstandingBelow = (priority: RepairPriority): boolean => {
705
776
  for (const entry of entries.values()) {
706
- if (entry.priority < 2) return true
777
+ if (entry.priority < priority) return true
707
778
  }
708
779
  return false
709
780
  }
710
781
 
711
- const preemptDeltas = (): void => {
782
+ /**
783
+ * A keyframe FETCH is being sent: cancel every in-flight audio and delta
784
+ * FETCH (§11.4.5 item 3). Called only for a FETCH actually sent.
785
+ */
786
+ const preemptBestEffort = (): void => {
712
787
  for (const entry of entries.values()) {
713
- if (entry.priority !== 2 || !entry.running) continue
788
+ if (entry.priority === 0 || !entry.running) continue
714
789
  stop(entry, 'preempted')
715
790
  note('repair-preempted', entry)
716
791
  }
@@ -740,8 +815,14 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
740
815
  /** `counted` is false for a reprioritisation restart, which is no retry. */
741
816
  const start = (entry: RepairEntry, counted = true): void => {
742
817
  const now = performance.now()
818
+ if (moot(entry)) {
819
+ skip(entry, 'moot-rap', readRtt(), entry.deadlineAt - now)
820
+ return
821
+ }
743
822
  const rttMs = admit(entry, now)
744
823
  if (rttMs === null) return
824
+ // Before the transport sees this request, so the cancels go out first.
825
+ if (entry.priority === 0) preemptBestEffort()
745
826
  if (counted) entry.attempts++
746
827
  mc.noteRepairFetchSent?.(entry.priority, entry.sbn, entry.packetId, entry.catalogTrackName, {
747
828
  rttMs,
@@ -775,7 +856,7 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
775
856
  // A fetch still running at its block's fail bound can no longer land
776
857
  // in time. Cancel it here rather than trust the transport or the FEC
777
858
  // layer's resolution to end it: a hung P0/P1 left running would keep
778
- // urgentOutstanding() true and gate every P2 for the session.
859
+ // outstandingBelow() true and gate every P1/P2 for the session.
779
860
  if (entry.deadlineAt <= now) {
780
861
  stop(entry, 'expired')
781
862
  retire(entry)
@@ -794,9 +875,13 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
794
875
  queued.push(entry)
795
876
  }
796
877
  for (const entry of queued) {
797
- if (entry.priority < 2) start(entry)
878
+ if (entry.priority === 0) start(entry)
798
879
  }
799
- if (urgentOutstanding()) return
880
+ if (outstandingBelow(1)) return
881
+ for (const entry of queued) {
882
+ if (entry.priority === 1) start(entry)
883
+ }
884
+ if (outstandingBelow(2)) return
800
885
  queued
801
886
  .filter((entry) => entry.priority === 2)
802
887
  .sort((a, b) => a.deadlineAt - b.deadlineAt || a.order - b.order)
@@ -980,7 +1065,6 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
980
1065
  stop(entry, 'reprioritized')
981
1066
  start(entry, false)
982
1067
  }
983
- preemptDeltas()
984
1068
  dispatch()
985
1069
  return
986
1070
  }
@@ -1156,7 +1240,6 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
1156
1240
  }
1157
1241
  entries.set(key, entry)
1158
1242
  noteBudget(entry, deadline.extensionMs)
1159
- if (entry.priority < 2) preemptDeltas()
1160
1243
  dispatch()
1161
1244
  })
1162
1245
 
@@ -1245,16 +1328,33 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
1245
1328
  }
1246
1329
  entries.set(entry.key, entry)
1247
1330
  entry.expiry = setTimeout(dispatch, Math.max(0, entry.deadlineAt - performance.now()) + 1)
1248
- preemptDeltas()
1249
1331
  dispatch()
1250
1332
  })
1251
1333
  }
1252
1334
 
1335
+ // A RAP arrived complete: a keyframe FETCH below it is moot (§11.4.2). The
1336
+ // observer runs inside the frame push, so the cancel waits for a microtask
1337
+ // rather than re-entering the FEC layer through the hold release.
1338
+ const removeRapObserver = mc.addRapCompleteObserver?.((packetId) => {
1339
+ queueMicrotask(() => {
1340
+ if (disposed) return
1341
+ for (const entry of [...entries.values()]) {
1342
+ if (entry.packetId !== packetId || !entry.running || !moot(entry)) continue
1343
+ stop(entry, 'moot')
1344
+ retire(entry)
1345
+ releaseHold(entry)
1346
+ clearEntry(entry)
1347
+ }
1348
+ dispatch()
1349
+ })
1350
+ })
1351
+
1253
1352
  return () => {
1254
1353
  disposed = true
1255
1354
  mc.setUnicastRepair?.(false)
1256
1355
  removeResetObserver()
1257
1356
  removeLifecycleObserver()
1357
+ removeRapObserver?.()
1258
1358
  for (const entry of [...entries.values()]) {
1259
1359
  clearEntry(entry)
1260
1360
  if (entry.running) releaseHold(entry)
@@ -20,7 +20,6 @@
20
20
  ],
21
21
  "multicast.auth": {
22
22
  "scheme": "bc-provenance",
23
- "hash": "blake3",
24
23
  "sig": "ed25519",
25
24
  "publicKey": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=",
26
25
  "keyId": "2026-08",
@@ -30,7 +30,6 @@
30
30
  ],
31
31
  "multicast.auth": {
32
32
  "scheme": "bc-provenance",
33
- "hash": "blake3",
34
33
  "sig": "ed25519",
35
34
  "publicKey": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg",
36
35
  "keyId": "2026-08",
@@ -30,7 +30,6 @@
30
30
  ],
31
31
  "multicast.auth": {
32
32
  "scheme": "bc-provenance",
33
- "hash": "blake3",
34
33
  "sig": "ed25519",
35
34
  "publicKey": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg",
36
35
  "keyId": "2026-08",
@@ -20,7 +20,6 @@
20
20
  ],
21
21
  "multicast.auth": {
22
22
  "scheme": "bc-provenance",
23
- "hash": "blake3",
24
23
  "sig": "ed25519",
25
24
  "publicKey": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbh",
26
25
  "keyId": "2026-08",