@blockcast/mmt-container 2.1.0 → 2.2.0-main.050b52fb9b00

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 (105) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +1949 -374
  3. package/dist/browser-runtime.js.map +4 -4
  4. package/dist/carp-catalog.js.map +1 -1
  5. package/dist/catalog-fec-params.d.ts +17 -1
  6. package/dist/catalog-fec-params.d.ts.map +1 -1
  7. package/dist/catalog-fec-params.js +15 -0
  8. package/dist/catalog-fec-params.js.map +2 -2
  9. package/dist/catalog-network-source.js.map +1 -1
  10. package/dist/cmcd-producer.d.ts +65 -47
  11. package/dist/cmcd-producer.d.ts.map +1 -1
  12. package/dist/cmcd-producer.js +138 -109
  13. package/dist/cmcd-producer.js.map +2 -2
  14. package/dist/delivery-catalog.js +26 -16
  15. package/dist/delivery-catalog.js.map +2 -2
  16. package/dist/delta-fallback-config.js.map +1 -1
  17. package/dist/fec-manager.d.ts +70 -12
  18. package/dist/fec-manager.d.ts.map +1 -1
  19. package/dist/fec-manager.js +403 -58
  20. package/dist/fec-manager.js.map +2 -2
  21. package/dist/fec-state.js.map +1 -1
  22. package/dist/fec-worker-setup.d.ts +1 -1
  23. package/dist/fec-worker-setup.js +1 -1
  24. package/dist/fec-worker-setup.js.map +1 -1
  25. package/dist/held-layer-admission.js.map +1 -1
  26. package/dist/index.d.ts +6 -5
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +9 -1
  29. package/dist/index.js.map +2 -2
  30. package/dist/iso-block-locator.d.ts +48 -13
  31. package/dist/iso-block-locator.d.ts.map +1 -1
  32. package/dist/iso-block-locator.js +97 -25
  33. package/dist/iso-block-locator.js.map +2 -2
  34. package/dist/iso-bmff-timing.d.ts +10 -1
  35. package/dist/iso-bmff-timing.d.ts.map +1 -1
  36. package/dist/iso-bmff-timing.js +36 -2
  37. package/dist/iso-bmff-timing.js.map +2 -2
  38. package/dist/media-router.js.map +1 -1
  39. package/dist/mfu-reassembler.js +12 -19
  40. package/dist/mfu-reassembler.js.map +2 -2
  41. package/dist/mmtp-container-parser.d.ts +79 -10
  42. package/dist/mmtp-container-parser.d.ts.map +1 -1
  43. package/dist/mmtp-container-parser.js +219 -45
  44. package/dist/mmtp-container-parser.js.map +2 -2
  45. package/dist/mmtp-container-parser.no-modules.js +342 -63
  46. package/dist/mmtp-container-parser.no-modules.js.map +3 -3
  47. package/dist/mmtp-decode-pipeline.js +218 -45
  48. package/dist/mmtp-decode-pipeline.js.map +2 -2
  49. package/dist/mmtp-fec-session.d.ts +41 -3
  50. package/dist/mmtp-fec-session.d.ts.map +1 -1
  51. package/dist/mmtp-fec-session.js +118 -13
  52. package/dist/mmtp-fec-session.js.map +3 -3
  53. package/dist/mmtp-router.d.ts +43 -9
  54. package/dist/mmtp-router.d.ts.map +1 -1
  55. package/dist/mmtp-router.js +2006 -95
  56. package/dist/mmtp-router.js.map +4 -4
  57. package/dist/multicast-auth-catalog.js.map +1 -1
  58. package/dist/multicast-input.js.map +1 -1
  59. package/dist/multicast-manager.d.ts +28 -5
  60. package/dist/multicast-manager.d.ts.map +1 -1
  61. package/dist/multicast-manager.js +16116 -15005
  62. package/dist/multicast-manager.js.map +4 -4
  63. package/dist/multicast-transport.d.ts +11 -3
  64. package/dist/multicast-transport.d.ts.map +1 -1
  65. package/dist/multicast-transport.js +281 -35
  66. package/dist/multicast-transport.js.map +4 -4
  67. package/dist/multicast-unwrap.js +12 -19
  68. package/dist/multicast-unwrap.js.map +2 -2
  69. package/dist/no-modules.js +336 -59
  70. package/dist/no-modules.js.map +3 -3
  71. package/dist/repair-fetch.d.ts +36 -8
  72. package/dist/repair-fetch.d.ts.map +1 -1
  73. package/dist/repair-fetch.js +285 -26
  74. package/dist/repair-fetch.js.map +3 -3
  75. package/dist/repair-layer-subscriptions.d.ts +82 -0
  76. package/dist/repair-layer-subscriptions.d.ts.map +1 -0
  77. package/dist/repair-layer-subscriptions.js +192 -0
  78. package/dist/repair-layer-subscriptions.js.map +7 -0
  79. package/dist/signaling.js.map +2 -2
  80. package/dist/timestamp-codec.js +12 -19
  81. package/dist/timestamp-codec.js.map +2 -2
  82. package/dist/track-session.d.ts +1 -1
  83. package/dist/track-session.d.ts.map +1 -1
  84. package/dist/track-session.js +1 -0
  85. package/dist/track-session.js.map +2 -2
  86. package/dist/wasm-mfu-reassembler.js.map +1 -1
  87. package/dist/wasm-mmtp-container-parser.js +218 -45
  88. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  89. package/package.json +8 -5
  90. package/schemas/catalog.schema.json +4 -0
  91. package/src/catalog-fec-params.ts +36 -1
  92. package/src/cmcd-producer.ts +194 -179
  93. package/src/fec-manager.ts +433 -32
  94. package/src/fec-worker-setup.ts +2 -2
  95. package/src/index.ts +20 -2
  96. package/src/iso-block-locator.ts +133 -31
  97. package/src/iso-bmff-timing.ts +40 -1
  98. package/src/mmtp-container-parser.ts +306 -29
  99. package/src/mmtp-fec-session.ts +115 -15
  100. package/src/mmtp-router.ts +629 -54
  101. package/src/multicast-manager.ts +70 -11
  102. package/src/multicast-transport.ts +44 -20
  103. package/src/repair-fetch.ts +363 -49
  104. package/src/repair-layer-subscriptions.ts +261 -0
  105. package/src/track-session.ts +4 -1
@@ -41,6 +41,18 @@
41
41
  * or otherwise never sent cancels nothing (§11.4.5 item 3), and an audio
42
42
  * request never preempts. A P2 that never ran before its block resolved
43
43
  * is reported as `repair-deferred`.
44
+ * - A hole walked over the one-Group transport (no `rangeTransport`) is
45
+ * repair the run gets before its FEC deadline (`walkHole`). On a video
46
+ * track it is keyframe repair (class `unknown`): P0, and no audio or
47
+ * delta FETCH is issued while one of its Group FETCHes is outstanding
48
+ * (fec-multicast-00 rule 3). It reaches no FEC deadline, so it preempts
49
+ * nothing and holds back no audio or delta request: one that comes due
50
+ * cancels the walk's Group FETCH and goes out at once, and the walk asks
51
+ * for that Group again after it (`yieldVideoWalks`). On an audio track
52
+ * it is audio repair: P1 at the audio wire priority, one Group per FETCH,
53
+ * and like any audio request it waits while keyframe repair is
54
+ * outstanding, stopping before its next Group, and is preempted by a
55
+ * keyframe FETCH sent.
44
56
  * - A block reclassified to a higher class while its P2 fetch is in flight
45
57
  * is re-issued at once under the new class's wire priority: the running
46
58
  * FETCH is aborted (FETCH_CANCEL) and a new one sent.
@@ -100,21 +112,35 @@
100
112
  * gate behind it) past that deadline.
101
113
  */
102
114
 
103
- import { parseFecBlockProfile, type FecBlockProfile } from '@blockcast/mmt-base'
115
+ import {
116
+ extractRepairFecPayloadId,
117
+ parseFecBlockProfile,
118
+ serial32Add,
119
+ serial32Distance,
120
+ type FecBlockProfile,
121
+ } from '@blockcast/mmt-base'
104
122
  import { extendSsId, repairFetchRange, type RepairFetchRange } from '@blockcast/mmt-fec'
105
123
  import {
106
124
  repairPriorityForClass,
107
125
  type BlockClass,
108
126
  type ClassifierMode,
109
127
  type RepairPriority,
128
+ type TrackKind,
110
129
  } from './block-policy.js'
111
- import type { UndelimitedIsoLoss } from './fec-manager.js'
130
+ import type { ExpiredIsoHole, UndelimitedIsoLoss } from './fec-manager.js'
112
131
  import type { RepairLifecycleObserver } from './multicast-manager.js'
113
132
  import { REPAIR_PRIORITY_MIN } from './repair-layers.js'
114
133
 
115
134
  /** The MoQ Transport priority scale: 0..255, a lower value is delivered first. */
116
135
  const MOQT_PRIORITY_MAX = 255
117
136
 
137
+ /**
138
+ * How long before the span a walked hole leaves asks for its repair (D-28)
139
+ * the walk ends: past the deadline timer's 1 ms slack and one timer tick, so
140
+ * the span's request timer is armed before it is due (walkDeadlineAt).
141
+ */
142
+ const WALK_LEAD_MS = 3
143
+
118
144
  /**
119
145
  * The highest value in the Source Media priority range of
120
146
  * draft-ramadan-moq-fec §10 (64..191): one below the repair range, which
@@ -370,10 +396,14 @@ export interface WireRepairFetchOptions {
370
396
  keyframeRepairBudgetMs?: (packetId: number, catalogTrackName?: string) => number
371
397
  /**
372
398
  * Range repair for a lost SS_ID whose block no repair delimited
373
- * (draft-ramadan-moq-fec §7.5), optional. Given, the host keeps such a gap
374
- * as a pending hole and the scheduler FETCHes `repairFetchRange(x, K,
375
- * anchor)` for it under the same admission, priority and abort rules as a
376
- * block request (class `unknown`, P0). Requires `repairEpochReference`.
399
+ * (draft-ramadan-moq-fec §7.5), optional. A host that can keep such a gap as
400
+ * a pending hole gets range repair either way, under the same admission,
401
+ * priority and abort rules as a block request (class `unknown`, P0). Given,
402
+ * the scheduler FETCHes `repairFetchRange(x, K, anchor)` over it, and
403
+ * requires `repairEpochReference`. Absent, it walks the hole over
404
+ * `transport` one Group at a time from the hole's start, whose Group is a
405
+ * block's, each repair stating where the next block starts (BLO-52085),
406
+ * under the class of the hole's track (see the module note).
377
407
  */
378
408
  rangeTransport?: RepairRangeFetchTransport
379
409
  /**
@@ -475,6 +505,12 @@ export interface RepairFetchHost {
475
505
  ): boolean
476
506
  /** The admitted FETCH ended without delivering: back to the multicast wait. */
477
507
  releaseRepairHold(sbn: number, packetId: number, catalogTrackName?: string): void
508
+ /** Run `fn` with `receivedAtMs` as the receipt time of what it feeds (optional). */
509
+ withReceiptTime?(receivedAtMs: number, fn: () => void): void
510
+ /** The configured media kind of a packetId (optional; absent reads as video). */
511
+ trackKindFor?(packetId: number): TrackKind | undefined
512
+ /** The stream's D-28 repair deadline: how long after a block's sources it asks for repair (optional). */
513
+ repairDeadlineMsForPacket?(packetId: number): number | undefined
478
514
  }
479
515
  onRepairSubscribe(cb: RepairSubscribeCallback): void
480
516
  /**
@@ -493,8 +529,17 @@ export interface RepairFetchHost {
493
529
  ): void
494
530
  addRepairLifecycleObserver(observer: RepairLifecycleObserver): () => void
495
531
  addTrackResetObserver(observer: (packetId: number, catalogTrackName: string) => void): () => void
496
- /** Register for lost SS_IDs in undelimited spans (required with `rangeTransport`). */
497
- onUndelimitedLoss?(cb: (loss: UndelimitedIsoLoss) => void): void
532
+ /**
533
+ * Register for lost SS_IDs in undelimited spans (required with
534
+ * `rangeTransport`). A host that offers it and `closeIsoHoles` keeps such a
535
+ * gap as a pending hole for the scheduler to repair. `onExpired` is told of
536
+ * a pending hole the stream ran past the locator's horizon; returning true,
537
+ * the scheduler closes it (`closeIsoHoles`), otherwise the host discards it.
538
+ */
539
+ onUndelimitedLoss?(
540
+ cb: (loss: UndelimitedIsoLoss) => void,
541
+ onExpired?: (hole: ExpiredIsoHole) => boolean,
542
+ ): void
498
543
  /** A hole's range repair ended, or none is sent: feed what was not stated. */
499
544
  closeIsoHoles?(packetId: number, catalogTrackName: string | undefined, start: number, length: number): void
500
545
  clearRepairForBlock(sbn: number, packetId: number, catalogTrackName?: string): void
@@ -624,9 +669,26 @@ interface RepairEntry {
624
669
  attempts: number
625
670
  running: FetchRun | null
626
671
  /** A range request for a pending hole `[sbn, sbn + holeLength)`. */
627
- readonly range?: RepairFetchRange & { readonly lostSsId: number; readonly holeLength: number }
672
+ readonly range?: {
673
+ readonly lostSsId: number
674
+ readonly holeLength: number
675
+ /** The §7.7 Group range for `rangeTransport`; null walks the hole over `transport`. */
676
+ readonly groups: RepairFetchRange | null
677
+ /** When the hole was reported: what it states keeps the deadline a span opened then would have. */
678
+ readonly reportedAt: number
679
+ }
628
680
  /** A range request's deadline timer (see the module note). */
629
681
  expiry?: ReturnType<typeof setTimeout>
682
+ /**
683
+ * A hole walked over `transport` (walkHole): the Group its next FETCH asks
684
+ * for, and the Group FETCH it has outstanding, if any.
685
+ */
686
+ walk?: {
687
+ next: number
688
+ group: { readonly sbn: number; readonly controller: AbortController } | null
689
+ /** Its Group FETCH was cancelled for best-effort repair: ask for that Group again. */
690
+ yielded: boolean
691
+ }
630
692
  }
631
693
 
632
694
  interface ResolvedDeadline {
@@ -686,8 +748,26 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
686
748
 
687
749
  /** Whatever ends an entry: a block's repair slot, or a hole's pending state. */
688
750
  const clearEntry = (entry: RepairEntry): void => {
689
- if (entry.range) mc.closeIsoHoles!(entry.packetId, entry.catalogTrackName, entry.sbn, entry.range.holeLength)
690
- else clearRepair(entry.sbn, entry.packetId, entry.catalogTrackName)
751
+ const { range } = entry
752
+ if (range) {
753
+ atReport(range, () => mc.closeIsoHoles!(entry.packetId, entry.catalogTrackName, entry.sbn, range.holeLength))
754
+ } else {
755
+ clearRepair(entry.sbn, entry.packetId, entry.catalogTrackName)
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Run what a hole's walk states, or the rest of the hole it leaves as
761
+ * spans, at the time the hole was reported: the blocks start their
762
+ * deadlines from there, as the one repair-less span a host without holes
763
+ * opened then would have, so the walk never holds a frame later than that
764
+ * span did. A §7.7 range FETCH feeds at its own receipt time, as a host
765
+ * with `rangeTransport` always has.
766
+ */
767
+ const atReport = (range: NonNullable<RepairEntry['range']>, work: () => void): void => {
768
+ const fec = mc.fecManager
769
+ if (range.groups !== null || fec.withReceiptTime === undefined) work()
770
+ else fec.withReceiptTime(range.reportedAt, work)
691
771
  }
692
772
 
693
773
  const releaseHold = (entry: RepairEntry): void => {
@@ -781,32 +861,78 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
781
861
  const admit = (entry: RepairEntry, now: number): number | null => {
782
862
  const budgetMs = entry.deadlineAt - now
783
863
  const rttMs = readRtt()
784
- const reason: RepairSkipReason | null =
785
- !entry.range && !groupCanComplete(entry.sbn, entry.packetId, entry.catalogTrackName)
786
- ? 'group-cannot-complete'
787
- : rttMs === null
788
- ? 'rtt-unmeasured'
789
- : rttMs < budgetMs
790
- ? null
791
- : 'rtt-exceeds-budget'
864
+ const reason = refusal(entry, now, rttMs)
792
865
  if (reason === null) return rttMs
793
866
  skip(entry, reason, rttMs, budgetMs)
794
867
  return null
795
868
  }
796
869
 
870
+ /** Why admission refuses `entry` at `now` with round trip `rttMs`, or null when it admits it. */
871
+ const refusal = (entry: RepairEntry, now: number, rttMs: number | null): RepairSkipReason | null =>
872
+ !entry.range && !groupCanComplete(entry.sbn, entry.packetId, entry.catalogTrackName)
873
+ ? 'group-cannot-complete'
874
+ : rttMs === null
875
+ ? 'rtt-unmeasured'
876
+ : rttMs < entry.deadlineAt - now
877
+ ? null
878
+ : 'rtt-exceeds-budget'
879
+
797
880
  const retire = (entry: RepairEntry): void => {
798
881
  if (entries.get(entry.key) === entry) entries.delete(entry.key)
799
882
  if (entry.expiry !== undefined) clearTimeout(entry.expiry)
800
883
  }
801
884
 
802
- /** Whether any entry of priority below `priority` is queued or in flight. */
803
- const outstandingBelow = (priority: RepairPriority): boolean => {
885
+ /**
886
+ * Whether `entry` holds back a request of `priority`: no audio or delta
887
+ * repair FETCH is issued while a keyframe repair FETCH is outstanding
888
+ * (fec-multicast-00 rule 3), and no delta one while audio repair is. A
889
+ * walk (walkHole) is repair a run gets before it reaches its FEC deadline:
890
+ * on a video track it is keyframe repair while one of its Group FETCHes is
891
+ * outstanding and between two Groups holds nothing back; on an audio track
892
+ * it holds nothing back (BLO-52085). `videoWalks` false leaves video walks
893
+ * out: they yield to a request that is no walk (`yieldVideoWalks`).
894
+ */
895
+ const holdsBack = (entry: RepairEntry, priority: RepairPriority, videoWalks = true): boolean => {
896
+ if (entry.priority >= priority) return false
897
+ const { walk } = entry
898
+ if (walk === undefined) return true
899
+ if (entry.priority !== 0 || !videoWalks) return false
900
+ return entry.running === null || (walk.group !== null && !walk.group.controller.signal.aborted)
901
+ }
902
+
903
+ /** Whether any entry holds back a request of `priority` (see `holdsBack`). */
904
+ const outstandingBelow = (priority: RepairPriority, videoWalks = true): boolean => {
804
905
  for (const entry of entries.values()) {
805
- if (entry.priority < priority) return true
906
+ if (holdsBack(entry, priority, videoWalks)) return true
806
907
  }
807
908
  return false
808
909
  }
809
910
 
911
+ /**
912
+ * A video walk reaches no FEC deadline, so it holds back no audio or delta
913
+ * request that came due (one of `queued` that is no walk), that nothing
914
+ * else holds back and that admission takes (`admit`): it cancels its outstanding Group FETCH first, so no
915
+ * keyframe repair FETCH is outstanding when that request is issued
916
+ * (fec-multicast-00 rule 3), and asks for that Group again after it
917
+ * (walkHole). Audio and delta repair so goes out when it would with no
918
+ * walk at all (BLO-52085).
919
+ */
920
+ const yieldVideoWalks = (queued: readonly RepairEntry[]): void => {
921
+ const now = performance.now()
922
+ const rttMs = readRtt()
923
+ const due = queued.some((entry) =>
924
+ entry.priority !== 0 && entry.walk === undefined && !outstandingBelow(entry.priority, false) &&
925
+ refusal(entry, now, rttMs) === null)
926
+ if (!due) return
927
+ for (const entry of entries.values()) {
928
+ const walk = entry.walk
929
+ if (entry.priority !== 0 || walk === undefined || walk.group === null) continue
930
+ if (walk.group.controller.signal.aborted) continue
931
+ walk.yielded = true
932
+ walk.group.controller.abort()
933
+ }
934
+ }
935
+
810
936
  /**
811
937
  * A keyframe FETCH is being sent: cancel every in-flight audio and delta
812
938
  * FETCH (§11.4.5 item 3). Called only for a FETCH actually sent.
@@ -849,8 +975,12 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
849
975
  }
850
976
  const rttMs = admit(entry, now)
851
977
  if (rttMs === null) return
852
- // Before the transport sees this request, so the cancels go out first.
853
- if (entry.priority === 0) preemptBestEffort()
978
+ // Before the transport sees this request, so the cancels go out first. A
979
+ // walk reaches no FEC deadline, so it cancels nothing (rule 3).
980
+ if (entry.priority === 0 && entry.walk === undefined) preemptBestEffort()
981
+ // fec-multicast-00 rule 1: one FETCH per block and repair layer. A walk
982
+ // reading this block's Group leaves it to this request.
983
+ if (entry.range === undefined) leaveWalkedGroup(entry)
854
984
  if (counted) entry.attempts++
855
985
  mc.noteRepairFetchSent?.(entry.priority, entry.sbn, entry.packetId, entry.catalogTrackName, {
856
986
  rttMs,
@@ -875,6 +1005,20 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
875
1005
  void execute(entry, run)
876
1006
  }
877
1007
 
1008
+ /** End any walk's FETCH of `block`'s Group, cancelling it, before the block's own goes out. */
1009
+ const leaveWalkedGroup = (block: RepairEntry): void => {
1010
+ for (const entry of entries.values()) {
1011
+ const group = entry.walk?.group
1012
+ if (
1013
+ group?.sbn === block.sbn &&
1014
+ entry.packetId === block.packetId &&
1015
+ entry.catalogTrackName === block.catalogTrackName
1016
+ ) {
1017
+ group.controller.abort()
1018
+ }
1019
+ }
1020
+ }
1021
+
878
1022
  const dispatch = (): void => {
879
1023
  if (disposed) return
880
1024
  const now = performance.now()
@@ -905,6 +1049,7 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
905
1049
  for (const entry of queued) {
906
1050
  if (entry.priority === 0) start(entry)
907
1051
  }
1052
+ yieldVideoWalks(queued)
908
1053
  if (outstandingBelow(1)) return
909
1054
  for (const entry of queued) {
910
1055
  if (entry.priority === 1) start(entry)
@@ -954,6 +1099,25 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
954
1099
  }
955
1100
  return counts.sources + counts.repairGroupLength >= counts.authoredLength
956
1101
  }
1102
+ /**
1103
+ * Whether the block a walked Group states can use none of the Group's other
1104
+ * repair symbols: the host holds as many of its symbols as its K' (complete,
1105
+ * or decoding), its group cannot complete it, or it no longer tracks it
1106
+ * (resolved). A relay may hold only part of a Group, or keep its FETCH open,
1107
+ * so the walk does not wait for the rest. False when the host reports no
1108
+ * counts: the walk then takes the whole Group.
1109
+ */
1110
+ const groupServed = (sbn: number, packetId: number, catalogTrackName: string | undefined): boolean => {
1111
+ if (mc.fecManager.blockSymbolCounts === undefined) return false
1112
+ const counts = symbolCounts(sbn, packetId, catalogTrackName)
1113
+ if (counts === undefined || counts.authoredLength === undefined) return true
1114
+ return counts.sources + counts.repairs >= counts.authoredLength || !groupCanComplete(sbn, packetId, catalogTrackName)
1115
+ }
1116
+ /** Where the next block starts after the open block `sbn`, when a repair stated it. */
1117
+ const statedEnd = (sbn: number, packetId: number, catalogTrackName: string | undefined): number | undefined => {
1118
+ const length = symbolCounts(sbn, packetId, catalogTrackName)?.authoredLength
1119
+ return length === undefined ? undefined : serial32Add(sbn, length)
1120
+ }
957
1121
 
958
1122
  const finish = (entry: RepairEntry, run: FetchRun, fed: number, error: unknown): void => {
959
1123
  if (run.stopped !== null || entry.running !== run || entries.get(entry.key) !== entry) {
@@ -1019,23 +1183,30 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
1019
1183
  signal: run.controller.signal,
1020
1184
  subscriberPriority: entry.subscriberPriority,
1021
1185
  }
1186
+ const { range } = entry
1187
+ const feed = (packet: Uint8Array): void => {
1188
+ if (entry.catalogTrackName === undefined) {
1189
+ mc.processRepairFromMoQ(packet, entry.packetId)
1190
+ } else {
1191
+ mc.processRepairFromMoQ(packet, entry.packetId, entry.catalogTrackName)
1192
+ }
1193
+ }
1022
1194
  try {
1023
- const packets = entry.range
1024
- ? opts.rangeTransport!({
1025
- ...common,
1026
- startGroup: entry.range.startGroup,
1027
- endGroup: entry.range.endGroup,
1028
- lostSsId: entry.range.lostSsId,
1029
- })
1030
- : opts.transport({ ...common, sbn: entry.sbn })
1195
+ const packets = range === undefined
1196
+ ? opts.transport({ ...common, sbn: entry.sbn })
1197
+ : entry.walk !== undefined
1198
+ ? walkHole(entry, entry.walk, range, common)
1199
+ : opts.rangeTransport!({
1200
+ ...common,
1201
+ startGroup: range.groups!.startGroup,
1202
+ endGroup: range.groups!.endGroup,
1203
+ lostSsId: range.lostSsId,
1204
+ })
1031
1205
  for await (const packet of packets) {
1032
1206
  if (run.stopped !== null || entry.running !== run) break
1033
1207
  fed++
1034
- if (entry.catalogTrackName === undefined) {
1035
- mc.processRepairFromMoQ(packet, entry.packetId)
1036
- } else {
1037
- mc.processRepairFromMoQ(packet, entry.packetId, entry.catalogTrackName)
1038
- }
1208
+ if (range === undefined) feed(packet)
1209
+ else atReport(range, () => feed(packet))
1039
1210
  }
1040
1211
  finish(entry, run, fed, undefined)
1041
1212
  } catch (error) {
@@ -1043,6 +1214,86 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
1043
1214
  }
1044
1215
  }
1045
1216
 
1217
+ /**
1218
+ * The §7.7 range FETCH of a hole over a transport that FETCHes one Group.
1219
+ * A hole starts where a stated block ends, so ext(hole start) is a block's
1220
+ * Group; the repair of each Group states its block, and that block's end is
1221
+ * the next block's Group. Walks to the block holding the hole's highest
1222
+ * lost SS_ID (everything after it arrived) and stops early when a Group
1223
+ * names no block and none is stated there. A Group is done when it ends,
1224
+ * when it held its RSB_length repair symbols, or once the block it states
1225
+ * can use none of the rest (`groupServed`): a relay may keep the FETCH open
1226
+ * past its last object (BLO-52085 D2 80601), or hold only part of the
1227
+ * Group. Each Group is its own FETCH with its own signal, aborted once the
1228
+ * walk leaves it, so the transport cancels it on the wire, and never one
1229
+ * the block's own FETCH reads (fec-multicast-00 rule 1: `leaveWalkedGroup`
1230
+ * takes it over). Between two Groups the walk honours rule 3 (see
1231
+ * `holdsBack`): an audio walk that finds keyframe repair outstanding goes
1232
+ * back to the queue and resumes at `walk.next`; a video walk first
1233
+ * dispatches what came due, and asks again for a Group whose FETCH it
1234
+ * cancelled for audio or delta repair (`yieldVideoWalks`). Every packet is
1235
+ * fed before the walk reads the host's counts: the consumer feeds what it
1236
+ * yields before asking for more.
1237
+ */
1238
+ async function* walkHole(
1239
+ entry: RepairEntry,
1240
+ walk: NonNullable<RepairEntry['walk']>,
1241
+ hole: NonNullable<RepairEntry['range']>,
1242
+ common: Omit<RepairFetchRequest, 'sbn'>,
1243
+ ): AsyncGenerator<Uint8Array> {
1244
+ const lostAt = serial32Distance(entry.sbn, hole.lostSsId)
1245
+ for (let first = true; !common.signal.aborted; first = false) {
1246
+ if (!first && entry.priority !== 0 && outstandingBelow(1)) {
1247
+ stop(entry, 'preempted')
1248
+ note('repair-preempted', entry)
1249
+ return
1250
+ }
1251
+ if (!first && entry.priority === 0) {
1252
+ dispatch()
1253
+ if (common.signal.aborted) return
1254
+ }
1255
+ const sbn = walk.next
1256
+ let next: number | undefined
1257
+ if (entries.get(requestKey(sbn, entry.packetId, entry.catalogTrackName))?.running == null) {
1258
+ const rsIds = new Set<number>()
1259
+ const group = new AbortController()
1260
+ const cancel = (): void => group.abort()
1261
+ common.signal.addEventListener('abort', cancel, { once: true })
1262
+ walk.group = { sbn, controller: group }
1263
+ try {
1264
+ for await (const packet of opts.transport({ ...common, sbn, signal: group.signal })) {
1265
+ yield packet
1266
+ const id = extractRepairFecPayloadId(packet)
1267
+ if (id === null || id.ssStart !== sbn || id.ssbLength === 0) continue
1268
+ next ??= serial32Add(sbn, id.ssbLength)
1269
+ rsIds.add(id.rsId)
1270
+ // Aborted before the loop lets go of the transport, whose own
1271
+ // cleanup may stop listening for the abort.
1272
+ if (rsIds.size >= id.rsbLength || groupServed(sbn, entry.packetId, entry.catalogTrackName)) {
1273
+ group.abort()
1274
+ break
1275
+ }
1276
+ }
1277
+ } catch (error) {
1278
+ // The Group was taken over or yielded: the walk goes on.
1279
+ if (!group.signal.aborted || common.signal.aborted) throw error
1280
+ } finally {
1281
+ common.signal.removeEventListener('abort', cancel)
1282
+ walk.group = null
1283
+ }
1284
+ if (walk.yielded) {
1285
+ walk.yielded = false
1286
+ continue
1287
+ }
1288
+ }
1289
+ next ??= statedEnd(sbn, entry.packetId, entry.catalogTrackName)
1290
+ if (next === undefined) return
1291
+ // Past the highest lost SS_ID, which lies in the hole: done.
1292
+ if (serial32Distance(entry.sbn, next) > lostAt) return
1293
+ walk.next = next
1294
+ }
1295
+ }
1296
+
1046
1297
  const removeResetObserver = mc.addTrackResetObserver((packetId, catalogTrackName) => {
1047
1298
  for (const entry of [...entries.values()]) {
1048
1299
  if (entry.packetId !== packetId || entry.catalogTrackName !== catalogTrackName) continue
@@ -1300,17 +1551,75 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
1300
1551
  }
1301
1552
  }
1302
1553
 
1303
- if (opts.rangeTransport !== undefined) {
1304
- mc.onUndelimitedLoss!((loss) => {
1554
+ const trackKind = (packetId: number): TrackKind | undefined => {
1555
+ try {
1556
+ return mc.fecManager.trackKindFor?.(packetId)
1557
+ } catch {
1558
+ return undefined
1559
+ }
1560
+ }
1561
+
1562
+ /**
1563
+ * Where a walk of a hole reported `now` ends: WALK_LEAD_MS before the span
1564
+ * it leaves asks for its repair (D-28 from the report, as the span a host
1565
+ * without holes opened then did), so that span's request is armed before
1566
+ * it is due and goes out at the same time; never later than `deadlineAt`.
1567
+ */
1568
+ const walkDeadlineAt = (packetId: number, now: number, deadlineAt: number): number => {
1569
+ let repairDeadlineMs: number | undefined
1570
+ try {
1571
+ repairDeadlineMs = mc.fecManager.repairDeadlineMsForPacket?.(packetId)
1572
+ } catch {
1573
+ repairDeadlineMs = undefined
1574
+ }
1575
+ if (repairDeadlineMs === undefined || !Number.isFinite(repairDeadlineMs)) return deadlineAt
1576
+ return Math.min(deadlineAt, now + repairDeadlineMs - WALK_LEAD_MS)
1577
+ }
1578
+
1579
+ /**
1580
+ * The stream ran past the locator's horizon behind a hole: no repair can
1581
+ * state its blocks any more. A walking hole's walk ends and the hole closes
1582
+ * as at its deadline, so the span it leaves fails, and is reported, as the
1583
+ * span opened at its report would have (BLO-52085); the host discards any
1584
+ * other. Called from inside the stream's packet handling, so the walk ends
1585
+ * once that returns, as for a RAP (below).
1586
+ */
1587
+ const onHoleExpired = (hole: ExpiredIsoHole): boolean => {
1588
+ if (disposed) return false
1589
+ const entry = [...entries.values()].find((candidate) =>
1590
+ candidate.walk !== undefined &&
1591
+ candidate.packetId === hole.packetId &&
1592
+ candidate.catalogTrackName === hole.catalogTrackName &&
1593
+ serial32Distance(candidate.sbn, hole.holeStart) < candidate.range!.holeLength)
1594
+ if (entry === undefined) return false
1595
+ queueMicrotask(() => {
1596
+ if (entries.get(entry.key) !== entry) return
1597
+ retire(entry)
1598
+ stop(entry, 'expired')
1599
+ clearEntry(entry)
1600
+ dispatch()
1601
+ })
1602
+ return true
1603
+ }
1604
+
1605
+ // A host that can keep a hole gets range repair: the §7.7 range over
1606
+ // `rangeTransport`, or else a walk of the hole's Groups over `transport`.
1607
+ if (mc.onUndelimitedLoss !== undefined && mc.closeIsoHoles !== undefined) {
1608
+ mc.onUndelimitedLoss((loss) => {
1305
1609
  const { packetId, catalogTrackName, holeStart, holeLength } = loss
1306
1610
  const close = (): void => mc.closeIsoHoles!(packetId, catalogTrackName, holeStart, holeLength)
1307
1611
  if (disposed) return close()
1308
1612
  const blockProfile = blockProfileOf(packetId)
1309
1613
  const target = opts.resolveTarget(packetId, catalogTrackName)
1310
1614
  if (!target) return close()
1311
- const priority = repairPriorityForClass('unknown')
1312
- const range = rangeFor(loss, target.repairTrack)
1313
- if (range === null) {
1615
+ // The run's class (fec-multicast-00 Block Classification, Audio
1616
+ // Blocks): walked, a hole on an audio track is audio repair, one Group
1617
+ // per FETCH; any other is of unknown class, so keyframe repair.
1618
+ const walked = opts.rangeTransport === undefined
1619
+ const holeClass: BlockClass = walked && trackKind(packetId) === 'audio' ? 'audio' : 'unknown'
1620
+ const priority = repairPriorityForClass(holeClass)
1621
+ const groups = walked ? null : rangeFor(loss, target.repairTrack)
1622
+ if (!walked && groups === null) {
1314
1623
  close()
1315
1624
  mc.noteRepairSkipped('range-no-anchor', { rttMs: readRtt(), budgetMs: 0, priority }, holeStart, packetId, catalogTrackName)
1316
1625
  return
@@ -1321,12 +1630,15 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
1321
1630
  packetId,
1322
1631
  ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
1323
1632
  }
1324
- // `unknown` is P0, whose request is never declined (omitted at worst).
1325
- const wire = wirePriority(identity, 'unknown')!
1633
+ // `unknown` is P0, whose request is never declined (omitted at worst);
1634
+ // an audio FETCH the catalog gives no priority for is not sent.
1635
+ const wire = wirePriority(identity, holeClass)
1636
+ if (wire === null) return close()
1326
1637
  // The hole is no tracked block: no anchor, so this is the multicast
1327
1638
  // wait (`bound: 'wait'`), by intent. A hole holds the release gate, so it
1328
1639
  // must not wait out a presentation delay on top.
1329
- const deadline = presentationDeadlineAt(holeStart, packetId, catalogTrackName, performance.now(), 'unknown')
1640
+ const now = performance.now()
1641
+ const deadline = presentationDeadlineAt(holeStart, packetId, catalogTrackName, now, holeClass)
1330
1642
  if (deadline === null) {
1331
1643
  close()
1332
1644
  mc.noteRepairSkipped(
@@ -1338,6 +1650,7 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
1338
1650
  )
1339
1651
  return
1340
1652
  }
1653
+ const deadlineAt = walked ? walkDeadlineAt(packetId, now, deadline.deadlineAt) : deadline.deadlineAt
1341
1654
  const entry: RepairEntry = {
1342
1655
  key: JSON.stringify(['range', packetId, catalogTrackName ?? null, holeStart]),
1343
1656
  ...identity,
@@ -1345,15 +1658,16 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
1345
1658
  blockProfile,
1346
1659
  priority,
1347
1660
  subscriberPriority: wire.subscriberPriority,
1348
- deadlineAt: deadline.deadlineAt,
1349
- playoutDeadlineAt: deadline.deadlineAt - deadline.extensionMs,
1661
+ deadlineAt,
1662
+ playoutDeadlineAt: deadlineAt - deadline.extensionMs,
1350
1663
  anchorAt: deadline.anchorAt,
1351
1664
  delayMs: deadline.delayMs,
1352
1665
  bound: deadline.bound,
1353
1666
  order: enqueueOrder++,
1354
1667
  attempts: 0,
1355
1668
  running: null,
1356
- range: { ...range, lostSsId: loss.lostSsId, holeLength },
1669
+ range: { lostSsId: loss.lostSsId, holeLength, groups, reportedAt: now },
1670
+ ...(walked ? { walk: { next: holeStart, group: null, yielded: false } } : {}),
1357
1671
  }
1358
1672
  const previous = entries.get(entry.key)
1359
1673
  if (previous) {
@@ -1363,7 +1677,7 @@ export function wireRepairFetch(mc: RepairFetchHost, opts: WireRepairFetchOption
1363
1677
  entries.set(entry.key, entry)
1364
1678
  entry.expiry = setTimeout(dispatch, Math.max(0, entry.deadlineAt - performance.now()) + 1)
1365
1679
  dispatch()
1366
- })
1680
+ }, onHoleExpired)
1367
1681
  }
1368
1682
 
1369
1683
  // A RAP arrived complete: a keyframe FETCH below it is moot (§11.4.2). The