@blockcast/mmt-transport 0.2.0 → 0.2.2
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/README.md +264 -0
- package/dist/.build-stamp +0 -0
- package/dist/abr-controller.d.ts +3 -0
- package/dist/abr-controller.d.ts.map +1 -1
- package/dist/abr-controller.js +5 -0
- package/dist/abr-controller.js.map +1 -1
- package/dist/amt-gateway.d.ts.map +1 -1
- package/dist/amt-gateway.js +2 -1
- package/dist/amt-gateway.js.map +1 -1
- package/dist/endpoint-config.d.ts +20 -0
- package/dist/endpoint-config.d.ts.map +1 -0
- package/dist/endpoint-config.js +103 -0
- package/dist/endpoint-config.js.map +1 -0
- package/dist/endpoint-config.test.d.ts +2 -0
- package/dist/endpoint-config.test.d.ts.map +1 -0
- package/dist/endpoint-config.test.js +39 -0
- package/dist/endpoint-config.test.js.map +1 -0
- package/dist/fec-client.d.ts +2 -2
- package/dist/fec-client.d.ts.map +1 -1
- package/dist/fec-client.js +10 -5
- package/dist/fec-client.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/mmt-transport.no-modules.js +1154 -0
- package/dist/mmt-transport.no-modules.js.map +7 -0
- package/dist/multicast-orchestrator.d.ts +56 -0
- package/dist/multicast-orchestrator.d.ts.map +1 -0
- package/dist/multicast-orchestrator.js +123 -0
- package/dist/multicast-orchestrator.js.map +1 -0
- package/dist/multicast-subscription-pool.d.ts +18 -0
- package/dist/multicast-subscription-pool.d.ts.map +1 -0
- package/dist/multicast-subscription-pool.js +130 -0
- package/dist/multicast-subscription-pool.js.map +1 -0
- package/dist/multicast-subscription-pool.test.d.ts +2 -0
- package/dist/multicast-subscription-pool.test.d.ts.map +1 -0
- package/dist/multicast-subscription-pool.test.js +108 -0
- package/dist/multicast-subscription-pool.test.js.map +1 -0
- package/dist/multicast-tier.d.ts +14 -0
- package/dist/multicast-tier.d.ts.map +1 -0
- package/dist/multicast-tier.js +22 -0
- package/dist/multicast-tier.js.map +1 -0
- package/dist/multicast-tier.test.d.ts +2 -0
- package/dist/multicast-tier.test.d.ts.map +1 -0
- package/dist/multicast-tier.test.js +75 -0
- package/dist/multicast-tier.test.js.map +1 -0
- package/dist/no-modules.d.ts +29 -0
- package/dist/no-modules.d.ts.map +1 -0
- package/dist/no-modules.js +35 -0
- package/dist/no-modules.js.map +1 -0
- package/dist/stall-detector.d.ts +22 -0
- package/dist/stall-detector.d.ts.map +1 -0
- package/dist/stall-detector.js +46 -0
- package/dist/stall-detector.js.map +1 -0
- package/dist/stall-detector.test.d.ts +2 -0
- package/dist/stall-detector.test.d.ts.map +1 -0
- package/dist/stall-detector.test.js +33 -0
- package/dist/stall-detector.test.js.map +1 -0
- package/dist/takeover.d.ts +49 -0
- package/dist/takeover.d.ts.map +1 -0
- package/dist/takeover.js +43 -0
- package/dist/takeover.js.map +1 -0
- package/dist/transport-manager.d.ts +28 -18
- package/dist/transport-manager.d.ts.map +1 -1
- package/dist/transport-manager.js +192 -138
- package/dist/transport-manager.js.map +1 -1
- package/dist/transport-manager.test.d.ts +2 -0
- package/dist/transport-manager.test.d.ts.map +1 -0
- package/dist/transport-manager.test.js +155 -0
- package/dist/transport-manager.test.js.map +1 -0
- package/dist/types.d.ts +36 -36
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/unicast-fallback.d.ts +36 -0
- package/dist/unicast-fallback.d.ts.map +1 -0
- package/dist/unicast-fallback.js +53 -0
- package/dist/unicast-fallback.js.map +1 -0
- package/package.json +6 -6
- package/src/abr-controller.ts +9 -0
- package/src/amt-gateway.ts +2 -1
- package/src/endpoint-config.test.ts +54 -0
- package/src/endpoint-config.ts +121 -0
- package/src/fec-client.ts +11 -5
- package/src/index.ts +72 -2
- package/src/multicast-orchestrator.ts +150 -0
- package/src/multicast-subscription-pool.test.ts +115 -0
- package/src/multicast-subscription-pool.ts +164 -0
- package/src/multicast-tier.test.ts +80 -0
- package/src/multicast-tier.ts +32 -0
- package/src/no-modules.ts +44 -0
- package/src/stall-detector.test.ts +34 -0
- package/src/stall-detector.ts +63 -0
- package/src/takeover.ts +71 -0
- package/src/transport-manager.test.ts +192 -0
- package/src/transport-manager.ts +203 -153
- package/src/types.ts +50 -31
- package/src/unicast-fallback.ts +57 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { MulticastOrchestrator } from "./multicast-orchestrator.js";
|
|
3
|
+
import { endpointKey, selectMulticastTier, type MulticastTierEndpoint } from "./multicast-tier.js";
|
|
4
|
+
|
|
5
|
+
const low: MulticastTierEndpoint = {
|
|
6
|
+
groupAddress: "232.1.1.1", port: 5000, tracks: [{ name: "video/720p" }], bandwidth: 2_000_000,
|
|
7
|
+
};
|
|
8
|
+
const high: MulticastTierEndpoint = {
|
|
9
|
+
groupAddress: "232.1.1.2", port: 5000, tracks: [{ name: "video/4k" }], bandwidth: 15_000_000,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("multicast tier selection", () => {
|
|
13
|
+
it("starts conservatively and applies headroom", () => {
|
|
14
|
+
expect(selectMulticastTier([high, low], undefined)).toBe(low);
|
|
15
|
+
expect(selectMulticastTier([low, high], 16_000_000)).toBe(low);
|
|
16
|
+
expect(selectMulticastTier([low, high], 20_000_000)).toBe(high);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("switches make-before-break only on a valid target RAP", () => {
|
|
20
|
+
let now = 0;
|
|
21
|
+
const orchestrator = new MulticastOrchestrator({
|
|
22
|
+
endpoints: [low, high], now: () => now, dwellMs: 1000, joinDeadlineMs: 5000,
|
|
23
|
+
});
|
|
24
|
+
expect(orchestrator.start()).toBe(low);
|
|
25
|
+
expect(orchestrator.onGoodput({ bitrate: 20_000_000 })).toBeUndefined();
|
|
26
|
+
now = 1000;
|
|
27
|
+
expect(orchestrator.onGoodput({ bitrate: 20_000_000 })?.kind).toBe("join-target");
|
|
28
|
+
expect(orchestrator.onTierPacket({
|
|
29
|
+
endpointKey: endpointKey(high), keyframe: false, timestamp: 10, hasValidCodecConfig: true,
|
|
30
|
+
})).toBeUndefined();
|
|
31
|
+
expect(orchestrator.onTierPacket({
|
|
32
|
+
endpointKey: endpointKey(high), keyframe: true, timestamp: 11, hasValidCodecConfig: true,
|
|
33
|
+
})?.kind).toBe("commit-swap");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("preserves a pinned initial tier", () => {
|
|
37
|
+
let now = 0;
|
|
38
|
+
const orchestrator = new MulticastOrchestrator({
|
|
39
|
+
endpoints: [low, high], initialEndpoint: high, adaptive: false,
|
|
40
|
+
now: () => now, dwellMs: 1000, joinDeadlineMs: 5000,
|
|
41
|
+
});
|
|
42
|
+
expect(orchestrator.start()).toBe(high);
|
|
43
|
+
expect(orchestrator.onGoodput({ bitrate: 1_000_000 })).toBeUndefined();
|
|
44
|
+
now = 10_000;
|
|
45
|
+
expect(orchestrator.onGoodput({ bitrate: 1_000_000 })).toBeUndefined();
|
|
46
|
+
expect(orchestrator.current).toBe(high);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("abandons a silent target when polled after the join deadline", () => {
|
|
50
|
+
let now = 0;
|
|
51
|
+
const orchestrator = new MulticastOrchestrator({
|
|
52
|
+
endpoints: [low, high], now: () => now, dwellMs: 1000, joinDeadlineMs: 5000,
|
|
53
|
+
});
|
|
54
|
+
orchestrator.start();
|
|
55
|
+
orchestrator.onGoodput({ bitrate: 20_000_000 });
|
|
56
|
+
now = 1000;
|
|
57
|
+
orchestrator.onGoodput({ bitrate: 20_000_000 });
|
|
58
|
+
now = 6000;
|
|
59
|
+
expect(orchestrator.poll()?.kind).toBe("abandon-join");
|
|
60
|
+
expect(orchestrator.state).toBe("stable");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects a target RAP older than the active tier high-water mark", () => {
|
|
64
|
+
let now = 0;
|
|
65
|
+
const orchestrator = new MulticastOrchestrator({
|
|
66
|
+
endpoints: [low, high], now: () => now, dwellMs: 1000, joinDeadlineMs: 5000,
|
|
67
|
+
});
|
|
68
|
+
orchestrator.start();
|
|
69
|
+
orchestrator.onTierPacket({
|
|
70
|
+
endpointKey: endpointKey(low), keyframe: false, timestamp: 10_000, hasValidCodecConfig: true,
|
|
71
|
+
});
|
|
72
|
+
orchestrator.onGoodput({ bitrate: 20_000_000 });
|
|
73
|
+
now = 1000;
|
|
74
|
+
orchestrator.onGoodput({ bitrate: 20_000_000 });
|
|
75
|
+
expect(orchestrator.onTierPacket({
|
|
76
|
+
endpointKey: endpointKey(high), keyframe: true, timestamp: 9_000, hasValidCodecConfig: true,
|
|
77
|
+
})).toBeUndefined();
|
|
78
|
+
expect(orchestrator.state).toBe("joining");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface MulticastTierEndpoint {
|
|
2
|
+
sourceAddress?: string;
|
|
3
|
+
groupAddress: string;
|
|
4
|
+
port: number;
|
|
5
|
+
tracks: { name: string; packetId?: number }[];
|
|
6
|
+
bandwidth?: number;
|
|
7
|
+
protocol?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function endpointKey(endpoint: MulticastTierEndpoint): string {
|
|
11
|
+
return `${endpoint.sourceAddress ?? ""}|${endpoint.groupAddress}|${endpoint.port}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function selectMulticastTier(
|
|
15
|
+
endpoints: readonly MulticastTierEndpoint[],
|
|
16
|
+
estimate: number | undefined,
|
|
17
|
+
): MulticastTierEndpoint | undefined {
|
|
18
|
+
if (endpoints.length <= 1) return endpoints[0];
|
|
19
|
+
|
|
20
|
+
const ranked = endpoints
|
|
21
|
+
.filter((endpoint) => typeof endpoint.bandwidth === "number" && endpoint.bandwidth > 0)
|
|
22
|
+
.sort((a, b) => a.bandwidth! - b.bandwidth!);
|
|
23
|
+
if (ranked.length < 2) return endpoints[0];
|
|
24
|
+
if (estimate === undefined) return ranked[0];
|
|
25
|
+
|
|
26
|
+
const budget = estimate * 0.8;
|
|
27
|
+
let selected: MulticastTierEndpoint | undefined;
|
|
28
|
+
for (const endpoint of ranked) {
|
|
29
|
+
if (endpoint.bandwidth! <= budget) selected = endpoint;
|
|
30
|
+
}
|
|
31
|
+
return selected ?? ranked[0];
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { AdaptiveBitrateController } from "./abr-controller.js";
|
|
2
|
+
import {
|
|
3
|
+
isMulticastExplicitlyDisabled,
|
|
4
|
+
isUsableMulticastEndpoint,
|
|
5
|
+
isValidMulticastGroupAddress,
|
|
6
|
+
multicastEndpointKey,
|
|
7
|
+
normalizeMulticastEndpoint,
|
|
8
|
+
readUrlMulticastConfig,
|
|
9
|
+
} from "./endpoint-config.js";
|
|
10
|
+
import { MulticastOrchestrator } from "./multicast-orchestrator.js";
|
|
11
|
+
import { MulticastSubscriptionPool } from "./multicast-subscription-pool.js";
|
|
12
|
+
import { endpointKey, selectMulticastTier } from "./multicast-tier.js";
|
|
13
|
+
import { TransportStallDetector } from "./stall-detector.js";
|
|
14
|
+
import { makeSourceSwitchHandler, wireStarvationDetector } from "./takeover.js";
|
|
15
|
+
import { TransportManager } from "./transport-manager.js";
|
|
16
|
+
import { runUnicastFallback } from "./unicast-fallback.js";
|
|
17
|
+
|
|
18
|
+
const api = {
|
|
19
|
+
AdaptiveBitrateController,
|
|
20
|
+
MulticastOrchestrator,
|
|
21
|
+
MulticastSubscriptionPool,
|
|
22
|
+
TransportManager,
|
|
23
|
+
TransportStallDetector,
|
|
24
|
+
endpointKey,
|
|
25
|
+
isMulticastExplicitlyDisabled,
|
|
26
|
+
isUsableMulticastEndpoint,
|
|
27
|
+
isValidMulticastGroupAddress,
|
|
28
|
+
makeSourceSwitchHandler,
|
|
29
|
+
multicastEndpointKey,
|
|
30
|
+
normalizeMulticastEndpoint,
|
|
31
|
+
readUrlMulticastConfig,
|
|
32
|
+
runUnicastFallback,
|
|
33
|
+
selectMulticastTier,
|
|
34
|
+
wireStarvationDetector,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
Object.defineProperty(globalThis, "__MmtTransport", {
|
|
38
|
+
configurable: true,
|
|
39
|
+
enumerable: false,
|
|
40
|
+
writable: false,
|
|
41
|
+
value: Object.freeze(api),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export default api;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { TransportStallDetector } from "./stall-detector.js";
|
|
3
|
+
|
|
4
|
+
describe("TransportStallDetector", () => {
|
|
5
|
+
it("detects repair-only starvation and recovery", () => {
|
|
6
|
+
let now = 0;
|
|
7
|
+
const onStarvation = vi.fn();
|
|
8
|
+
const onRecovery = vi.fn();
|
|
9
|
+
const detector = new TransportStallDetector({ windowMs: 1000, now: () => now, onStarvation, onRecovery });
|
|
10
|
+
detector.update({ sourceSymbols: 0, repairSymbols: 0, blocksComplete: 0, blocksRecovered: 0 });
|
|
11
|
+
now = 1000;
|
|
12
|
+
detector.update({ sourceSymbols: 0, repairSymbols: 10, blocksComplete: 0, blocksRecovered: 0 });
|
|
13
|
+
expect(onStarvation).toHaveBeenCalledOnce();
|
|
14
|
+
now = 1100;
|
|
15
|
+
detector.update({ sourceSymbols: 1, repairSymbols: 10, blocksComplete: 1, blocksRecovered: 0 });
|
|
16
|
+
expect(onRecovery).toHaveBeenCalledOnce();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("detects starvation when all packet counters stop", () => {
|
|
20
|
+
let now = 0;
|
|
21
|
+
const onStarvation = vi.fn();
|
|
22
|
+
const detector = new TransportStallDetector({
|
|
23
|
+
windowMs: 1000,
|
|
24
|
+
now: () => now,
|
|
25
|
+
onStarvation,
|
|
26
|
+
onRecovery: vi.fn(),
|
|
27
|
+
});
|
|
28
|
+
const snapshot = { sourceSymbols: 10, repairSymbols: 2, blocksComplete: 1, blocksRecovered: 0 };
|
|
29
|
+
detector.update(snapshot);
|
|
30
|
+
now = 1000;
|
|
31
|
+
detector.update(snapshot);
|
|
32
|
+
expect(onStarvation).toHaveBeenCalledOnce();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface FecProgressSnapshot {
|
|
2
|
+
sourceSymbols: number;
|
|
3
|
+
repairSymbols: number;
|
|
4
|
+
blocksComplete: number;
|
|
5
|
+
blocksRecovered: number;
|
|
6
|
+
mediaFrames?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StallDetectorConfig {
|
|
10
|
+
windowMs: number;
|
|
11
|
+
onStarvation(): void;
|
|
12
|
+
onRecovery(): void;
|
|
13
|
+
now?: () => number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class TransportStallDetector {
|
|
17
|
+
#last: FecProgressSnapshot | null = null;
|
|
18
|
+
#lastSampleAt = 0;
|
|
19
|
+
#starvedSince: number | null = null;
|
|
20
|
+
#latched = false;
|
|
21
|
+
readonly #now: () => number;
|
|
22
|
+
|
|
23
|
+
constructor(private readonly config: StallDetectorConfig) {
|
|
24
|
+
if (config.windowMs <= 0) throw new Error("Stall window must be positive and catalog-derived");
|
|
25
|
+
this.#now = config.now ?? Date.now;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get isStarved(): boolean { return this.#latched; }
|
|
29
|
+
|
|
30
|
+
update(snapshot: FecProgressSnapshot): void {
|
|
31
|
+
const previous = this.#last;
|
|
32
|
+
const previousAt = this.#lastSampleAt;
|
|
33
|
+
const now = this.#now();
|
|
34
|
+
this.#last = { ...snapshot };
|
|
35
|
+
this.#lastSampleAt = now;
|
|
36
|
+
if (!previous) return;
|
|
37
|
+
|
|
38
|
+
const blockDelta = snapshot.blocksComplete - previous.blocksComplete +
|
|
39
|
+
snapshot.blocksRecovered - previous.blocksRecovered;
|
|
40
|
+
const mediaDelta = (snapshot.mediaFrames ?? 0) - (previous.mediaFrames ?? 0);
|
|
41
|
+
|
|
42
|
+
if (blockDelta <= 0 && mediaDelta <= 0) {
|
|
43
|
+
this.#starvedSince ??= previousAt;
|
|
44
|
+
if (!this.#latched && now - this.#starvedSince >= this.config.windowMs) {
|
|
45
|
+
this.#latched = true;
|
|
46
|
+
this.config.onStarvation();
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.#starvedSince = null;
|
|
52
|
+
if (this.#latched) {
|
|
53
|
+
this.#latched = false;
|
|
54
|
+
this.config.onRecovery();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
reset(): void {
|
|
59
|
+
this.#last = null;
|
|
60
|
+
this.#starvedSince = null;
|
|
61
|
+
this.#latched = false;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/takeover.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export interface ActivatableSessions { activateMulticast(): void; }
|
|
2
|
+
|
|
3
|
+
export function makeSourceSwitchHandler(options: {
|
|
4
|
+
multicastBridgeAvailable: boolean;
|
|
5
|
+
sessionsReady: Promise<ActivatableSessions | null>;
|
|
6
|
+
onMulticastConfirmed?(): void;
|
|
7
|
+
onAbortTakeover?(): void;
|
|
8
|
+
log?(message: string): void;
|
|
9
|
+
warn?(message: string): void;
|
|
10
|
+
}): (source: string) => Promise<void> {
|
|
11
|
+
const log = options.log ?? console.log;
|
|
12
|
+
const warn = options.warn ?? console.warn;
|
|
13
|
+
return async (source) => {
|
|
14
|
+
if (source !== "multicast") return;
|
|
15
|
+
if (!options.multicastBridgeAvailable) {
|
|
16
|
+
warn("[MoqWatch] MediaRouter requested multicast without bridge; keeping MoQ active");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const sessions = await options.sessionsReady;
|
|
20
|
+
if (!sessions) {
|
|
21
|
+
warn("[MoqWatch] SessionManager unavailable; aborting multicast takeover, staying on MoQ");
|
|
22
|
+
options.onAbortTakeover?.();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
log("[MoqWatch] MediaRouter -> multicast; activating via SessionManager");
|
|
26
|
+
sessions.activateMulticast();
|
|
27
|
+
options.onMulticastConfirmed?.();
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LossArcSession { lossDetected(): void; lossRecovered(): void; }
|
|
32
|
+
export interface FecStarvationSnapshot {
|
|
33
|
+
sourceSymbols: number;
|
|
34
|
+
repairSymbols: number;
|
|
35
|
+
blocksComplete: number;
|
|
36
|
+
blocksRecovered: number;
|
|
37
|
+
mediaFrames?: number;
|
|
38
|
+
}
|
|
39
|
+
export interface StarvationDetectorLike { update(snapshot: FecStarvationSnapshot): void; reset(): void; }
|
|
40
|
+
export type StallDetectorLike = StarvationDetectorLike;
|
|
41
|
+
|
|
42
|
+
export function wireStallDetector(args: {
|
|
43
|
+
session: LossArcSession | undefined;
|
|
44
|
+
windowMs: number;
|
|
45
|
+
makeDetector(config: { windowMs: number; onStarvation(): void; onRecovery(): void }): StallDetectorLike;
|
|
46
|
+
}): StallDetectorLike | null {
|
|
47
|
+
if (!args.session || args.windowMs <= 0) return null;
|
|
48
|
+
return args.makeDetector({
|
|
49
|
+
windowMs: args.windowMs,
|
|
50
|
+
onStarvation: () => args.session!.lossDetected(),
|
|
51
|
+
onRecovery: () => args.session!.lossRecovered(),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function wireStarvationDetector(args: {
|
|
56
|
+
session: LossArcSession | undefined;
|
|
57
|
+
trackName: string;
|
|
58
|
+
fecInterleaveMs: number;
|
|
59
|
+
fecK: number;
|
|
60
|
+
makeDetector(config: { windowMs: number; onStarvation(): void; onRecovery(): void }): StarvationDetectorLike;
|
|
61
|
+
warn?(message: string): void;
|
|
62
|
+
}): Promise<StarvationDetectorLike | null> {
|
|
63
|
+
if (!args.session || args.fecInterleaveMs <= 0 || args.fecK <= 0) return null;
|
|
64
|
+
const detector = wireStallDetector({
|
|
65
|
+
session: args.session,
|
|
66
|
+
windowMs: Math.ceil(args.fecInterleaveMs * 3),
|
|
67
|
+
makeDetector: args.makeDetector,
|
|
68
|
+
});
|
|
69
|
+
if (!detector) args.warn?.(`Unable to create starvation detector for ${args.trackName}`);
|
|
70
|
+
return detector;
|
|
71
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { TransportManager } from "./transport-manager.js";
|
|
4
|
+
import type {
|
|
5
|
+
TransportConfig,
|
|
6
|
+
WindowMulticastSubscriptionEventMap,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
|
|
9
|
+
const CONFIG: TransportConfig = {
|
|
10
|
+
group: "232.1.1.50",
|
|
11
|
+
source: "192.168.1.181",
|
|
12
|
+
port: 8000,
|
|
13
|
+
streamType: "video",
|
|
14
|
+
driadEnabled: false,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("TransportManager window.multicast integration", () => {
|
|
18
|
+
let hadWindow = false;
|
|
19
|
+
let previousWindow: unknown;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
hadWindow = Object.hasOwn(globalThis, "window");
|
|
23
|
+
previousWindow = (globalThis as unknown as { window?: unknown })
|
|
24
|
+
.window;
|
|
25
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
26
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
27
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
if (hadWindow) {
|
|
32
|
+
(globalThis as unknown as { window?: unknown }).window =
|
|
33
|
+
previousWindow;
|
|
34
|
+
} else {
|
|
35
|
+
delete (globalThis as unknown as { window?: unknown }).window;
|
|
36
|
+
}
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does not treat legacy window.Multicast as a usable transport", async () => {
|
|
41
|
+
const subscribeSSM = vi.fn();
|
|
42
|
+
(globalThis as unknown as { window?: unknown }).window = {
|
|
43
|
+
Multicast: { subscribeSSM },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const manager = new TransportManager();
|
|
47
|
+
const connected = await manager.connect(CONFIG);
|
|
48
|
+
|
|
49
|
+
expect(connected).toBe(false);
|
|
50
|
+
expect(subscribeSSM).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("subscribes through lowercase window.multicast and forwards bytes", async () => {
|
|
54
|
+
const listeners = new Map<
|
|
55
|
+
keyof WindowMulticastSubscriptionEventMap,
|
|
56
|
+
(event: CustomEvent<unknown>) => void
|
|
57
|
+
>();
|
|
58
|
+
const subscription = {
|
|
59
|
+
id: "sub-1",
|
|
60
|
+
addEventListener: vi.fn((type, listener) => {
|
|
61
|
+
listeners.set(
|
|
62
|
+
type as keyof WindowMulticastSubscriptionEventMap,
|
|
63
|
+
listener as (event: CustomEvent<unknown>) => void,
|
|
64
|
+
);
|
|
65
|
+
}),
|
|
66
|
+
removeEventListener: vi.fn(),
|
|
67
|
+
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
68
|
+
};
|
|
69
|
+
const subscribe = vi.fn().mockResolvedValue(subscription);
|
|
70
|
+
(globalThis as unknown as { window?: unknown }).window = {
|
|
71
|
+
multicast: {
|
|
72
|
+
capabilities: { consumeModes: ["bytes"] },
|
|
73
|
+
subscribe,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const manager = new TransportManager();
|
|
78
|
+
const onPacket = vi.fn();
|
|
79
|
+
manager.setPacketCallback(onPacket);
|
|
80
|
+
|
|
81
|
+
const connected = await manager.connect(CONFIG);
|
|
82
|
+
expect(connected).toBe(true);
|
|
83
|
+
expect(subscribe).toHaveBeenCalledWith({
|
|
84
|
+
group: CONFIG.group,
|
|
85
|
+
source: CONFIG.source,
|
|
86
|
+
port: CONFIG.port,
|
|
87
|
+
consume: "bytes",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const payload = new Uint8Array([1, 2, 3]).buffer;
|
|
91
|
+
listeners.get("bytes")?.({ detail: payload } as CustomEvent<unknown>);
|
|
92
|
+
|
|
93
|
+
expect(onPacket).toHaveBeenCalledWith(payload);
|
|
94
|
+
expect(manager.getState().packetsReceived).toBe(1);
|
|
95
|
+
expect(manager.getState().bytesReceived).toBe(3);
|
|
96
|
+
manager.disconnect();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("releases the previous lease before reconnecting", async () => {
|
|
100
|
+
const unsubscribe = vi.fn();
|
|
101
|
+
const subscription = {
|
|
102
|
+
addEventListener: vi.fn(), removeEventListener: vi.fn(), unsubscribe,
|
|
103
|
+
};
|
|
104
|
+
const subscribe = vi.fn().mockResolvedValue(subscription);
|
|
105
|
+
(globalThis as unknown as { window?: unknown }).window = {
|
|
106
|
+
multicast: { capabilities: { consumeModes: ["bytes"] }, subscribe },
|
|
107
|
+
};
|
|
108
|
+
const manager = new TransportManager();
|
|
109
|
+
expect(await manager.connect(CONFIG)).toBe(true);
|
|
110
|
+
expect(await manager.connect(CONFIG)).toBe(true);
|
|
111
|
+
expect(unsubscribe).toHaveBeenCalledOnce();
|
|
112
|
+
manager.disconnect();
|
|
113
|
+
expect(unsubscribe).toHaveBeenCalledTimes(2);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("releases a stale lease when connect calls overlap", async () => {
|
|
117
|
+
let resolveSubscription!: (subscription: {
|
|
118
|
+
addEventListener: ReturnType<typeof vi.fn>;
|
|
119
|
+
removeEventListener: ReturnType<typeof vi.fn>;
|
|
120
|
+
unsubscribe: ReturnType<typeof vi.fn>;
|
|
121
|
+
}) => void;
|
|
122
|
+
const unsubscribe = vi.fn();
|
|
123
|
+
const subscribe = vi.fn().mockReturnValue(new Promise((resolve) => {
|
|
124
|
+
resolveSubscription = resolve;
|
|
125
|
+
}));
|
|
126
|
+
(globalThis as unknown as { window?: unknown }).window = {
|
|
127
|
+
multicast: { capabilities: { consumeModes: ["bytes"] }, subscribe },
|
|
128
|
+
};
|
|
129
|
+
const manager = new TransportManager();
|
|
130
|
+
const first = manager.connect(CONFIG);
|
|
131
|
+
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce());
|
|
132
|
+
const second = manager.connect(CONFIG);
|
|
133
|
+
resolveSubscription({ addEventListener: vi.fn(), removeEventListener: vi.fn(), unsubscribe });
|
|
134
|
+
|
|
135
|
+
expect(await Promise.all([first, second])).toEqual([false, true]);
|
|
136
|
+
expect(subscribe).toHaveBeenCalledOnce();
|
|
137
|
+
manager.disconnect();
|
|
138
|
+
expect(unsubscribe).toHaveBeenCalledOnce();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("does not publish a connection completed after disconnect", async () => {
|
|
142
|
+
let resolveSubscription!: (subscription: {
|
|
143
|
+
addEventListener: ReturnType<typeof vi.fn>;
|
|
144
|
+
removeEventListener: ReturnType<typeof vi.fn>;
|
|
145
|
+
unsubscribe: ReturnType<typeof vi.fn>;
|
|
146
|
+
}) => void;
|
|
147
|
+
const unsubscribe = vi.fn();
|
|
148
|
+
const subscribe = vi.fn().mockReturnValue(new Promise((resolve) => {
|
|
149
|
+
resolveSubscription = resolve;
|
|
150
|
+
}));
|
|
151
|
+
(globalThis as unknown as { window?: unknown }).window = {
|
|
152
|
+
multicast: { capabilities: { consumeModes: ["bytes"] }, subscribe },
|
|
153
|
+
};
|
|
154
|
+
const manager = new TransportManager();
|
|
155
|
+
const connecting = manager.connect(CONFIG);
|
|
156
|
+
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce());
|
|
157
|
+
manager.disconnect();
|
|
158
|
+
resolveSubscription({ addEventListener: vi.fn(), removeEventListener: vi.fn(), unsubscribe });
|
|
159
|
+
|
|
160
|
+
expect(await connecting).toBe(false);
|
|
161
|
+
expect(manager.getState().connected).toBe(false);
|
|
162
|
+
expect(unsubscribe).toHaveBeenCalledOnce();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rejects invalid endpoints before AMT fallback", async () => {
|
|
166
|
+
(globalThis as unknown as { window?: unknown }).window = {};
|
|
167
|
+
const manager = new TransportManager();
|
|
168
|
+
const onError = vi.fn();
|
|
169
|
+
manager.setErrorCallback(onError);
|
|
170
|
+
expect(await manager.connect({ ...CONFIG, group: "127.0.0.1", amtRelay: "relay.example" })).toBe(false);
|
|
171
|
+
expect(onError).toHaveBeenCalledOnce();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("reports disconnected after an invalid reconnect", async () => {
|
|
175
|
+
const unsubscribe = vi.fn();
|
|
176
|
+
const subscribe = vi.fn().mockResolvedValue({
|
|
177
|
+
addEventListener: vi.fn(), removeEventListener: vi.fn(), unsubscribe,
|
|
178
|
+
});
|
|
179
|
+
(globalThis as unknown as { window?: unknown }).window = {
|
|
180
|
+
multicast: { capabilities: { consumeModes: ["bytes"] }, subscribe },
|
|
181
|
+
};
|
|
182
|
+
const manager = new TransportManager();
|
|
183
|
+
expect(await manager.connect(CONFIG)).toBe(true);
|
|
184
|
+
|
|
185
|
+
expect(await manager.connect({ ...CONFIG, group: "127.0.0.1" })).toBe(false);
|
|
186
|
+
expect(unsubscribe).toHaveBeenCalledOnce();
|
|
187
|
+
expect(manager.getState()).toMatchObject({
|
|
188
|
+
mode: "disconnected",
|
|
189
|
+
connected: false,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|