@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.
- package/dist/browser-runtime.js +150 -21
- package/dist/browser-runtime.js.map +2 -2
- package/dist/fec-manager.d.ts.map +1 -1
- package/dist/fec-manager.js.map +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +2 -2
- package/dist/mmtp-container-parser.d.ts +10 -0
- package/dist/mmtp-container-parser.d.ts.map +1 -1
- package/dist/mmtp-container-parser.js +31 -1
- package/dist/mmtp-container-parser.js.map +2 -2
- package/dist/mmtp-container-parser.no-modules.js +31 -1
- package/dist/mmtp-container-parser.no-modules.js.map +2 -2
- package/dist/mmtp-decode-pipeline.js +31 -1
- package/dist/mmtp-decode-pipeline.js.map +2 -2
- package/dist/mmtp-router.d.ts +11 -0
- package/dist/mmtp-router.d.ts.map +1 -1
- package/dist/mmtp-router.js +54 -1
- package/dist/mmtp-router.js.map +2 -2
- package/dist/multicast-manager.d.ts +25 -1
- package/dist/multicast-manager.d.ts.map +1 -1
- package/dist/multicast-manager.js +92 -2
- package/dist/multicast-manager.js.map +2 -2
- package/dist/no-modules.js +31 -1
- package/dist/no-modules.js.map +2 -2
- package/dist/repair-fetch.d.ts +41 -10
- package/dist/repair-fetch.d.ts.map +1 -1
- package/dist/repair-fetch.js +58 -19
- package/dist/repair-fetch.js.map +2 -2
- package/dist/wasm-mmtp-container-parser.js +31 -1
- package/dist/wasm-mmtp-container-parser.js.map +2 -2
- package/package.json +7 -6
- package/src/fec-manager.ts +3 -0
- package/src/index.ts +1 -0
- package/src/mmtp-container-parser.ts +48 -0
- package/src/mmtp-router.ts +27 -0
- package/src/multicast-manager.ts +68 -0
- package/src/repair-fetch.ts +131 -31
- package/test-vectors/catalog/negative/multicast-auth-padded-key.json +0 -1
- package/test-vectors/catalog/negative/multicast-auth-reused-track-format.json +0 -1
- package/test-vectors/catalog/negative/multicast-auth-root-dotted-key.json +0 -1
- package/test-vectors/catalog/negative/multicast-auth-trailing-pad-bits.json +0 -1
- 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
|
|
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
|
|
168
|
-
"@blockcast/mmt-base": "0.5.0
|
|
169
|
-
"@blockcast/mmt-fec": "0.5.0
|
|
170
|
-
"@blockcast/mmt-manifest-verify": "0.5.0
|
|
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",
|
package/src/fec-manager.ts
CHANGED
|
@@ -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
|
@@ -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
|
package/src/mmtp-router.ts
CHANGED
|
@@ -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;
|
package/src/multicast-manager.ts
CHANGED
|
@@ -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
|
}
|
package/src/repair-fetch.ts
CHANGED
|
@@ -22,15 +22,23 @@
|
|
|
22
22
|
* everything.
|
|
23
23
|
*
|
|
24
24
|
* Priority (from the block's class, see `repairPriorityForClass`):
|
|
25
|
-
* - P0 (keyframe / unknown)
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 <
|
|
777
|
+
if (entry.priority < priority) return true
|
|
707
778
|
}
|
|
708
779
|
return false
|
|
709
780
|
}
|
|
710
781
|
|
|
711
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
878
|
+
if (entry.priority === 0) start(entry)
|
|
798
879
|
}
|
|
799
|
-
if (
|
|
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)
|