2147 lines
70 KiB
TypeScript
2147 lines
70 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type {
|
|
ClientMessage,
|
|
ParticipantRouteAssignment,
|
|
} from "../src/shared/protocol.ts";
|
|
import {
|
|
MediaRouteTransition,
|
|
reportActivePeerRouteFailure,
|
|
} from "../src/client/media/route-transition.ts";
|
|
import { HostSfuRoute } from "../src/client/media/host-sfu-route.ts";
|
|
import {
|
|
plannedHostProvisionalChild,
|
|
resolveHostPreparedChildActivation,
|
|
} from "../src/client/media/host-provisional-child.ts";
|
|
import { ViewerSfuRoute } from "../src/client/media/viewer-sfu-route.ts";
|
|
import { QUALITY_PROFILES } from "../src/client/media/quality.ts";
|
|
import {
|
|
EMPTY_METRICS,
|
|
type ConnectionMetrics,
|
|
} from "../src/client/types.ts";
|
|
|
|
const peerAssignment = (
|
|
parentPeerId: string,
|
|
childPeerIds: string[] = [],
|
|
): ParticipantRouteAssignment => ({
|
|
upstream: { kind: "peer", peerId: parentPeerId },
|
|
childPeerIds,
|
|
sfuPublicationGeneration: null,
|
|
});
|
|
|
|
const sfuAssignment = (childPeerIds: string[] = []): ParticipantRouteAssignment => ({
|
|
upstream: { kind: "sfu" },
|
|
childPeerIds,
|
|
sfuPublicationGeneration: null,
|
|
});
|
|
|
|
const hostAssignment = (
|
|
publicationGeneration: string | null,
|
|
childPeerIds: string[] = [],
|
|
): ParticipantRouteAssignment => ({
|
|
upstream: { kind: "none" },
|
|
childPeerIds,
|
|
sfuPublicationGeneration: publicationGeneration,
|
|
});
|
|
|
|
function sfuConfig(revision: number) {
|
|
return {
|
|
type: "sfu-config" as const,
|
|
revision,
|
|
url: "wss://sfu.example.test",
|
|
token: `token-${revision}`,
|
|
};
|
|
}
|
|
|
|
function hostSfuIngressGrant(
|
|
newConnectionId = "selected-connection-new",
|
|
revision = 1,
|
|
) {
|
|
return {
|
|
type: "selected-edge-turn" as const,
|
|
edgeKind: "host-sfu-ingress" as const,
|
|
revision,
|
|
hostPeerId: "host_12345678",
|
|
publicationGeneration: "generation-a",
|
|
oldConnectionId: "generation-a",
|
|
newConnectionId,
|
|
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
iceServer: {
|
|
urls: ["turn:turn.example.test:3478?transport=udp"] as [string],
|
|
username: "1787230000:opaque_identity_12345678",
|
|
credential: "short-lived-credential",
|
|
},
|
|
};
|
|
}
|
|
|
|
function createFakePublisher(log: string[], label: string) {
|
|
return {
|
|
connect: vi.fn(async () => {
|
|
log.push(`${label}:connect`);
|
|
return true;
|
|
}),
|
|
activate: vi.fn(async () => {
|
|
log.push(`${label}:activate`);
|
|
return true;
|
|
}),
|
|
deactivate: vi.fn(async () => {
|
|
log.push(`${label}:deactivate`);
|
|
return true;
|
|
}),
|
|
replaceStream: vi.fn(async () => true),
|
|
updateProfile: vi.fn(async () => true),
|
|
disconnect: vi.fn(async () => {
|
|
log.push(`${label}:disconnect`);
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe("Host provisional child ownership", () => {
|
|
it("admits only one strict child within the physical Host edge budget", () => {
|
|
expect(
|
|
plannedHostProvisionalChild({
|
|
revision: 7,
|
|
assignment: hostAssignment(null, ["active-child", "probe-child"]),
|
|
activeChildPeerIds: ["active-child"],
|
|
selectedPeerId: null,
|
|
maxMediaEdges: 2,
|
|
}),
|
|
).toBe("probe-child");
|
|
expect(
|
|
plannedHostProvisionalChild({
|
|
revision: 7,
|
|
assignment: hostAssignment("publication", ["probe-child"]),
|
|
activeChildPeerIds: [],
|
|
selectedPeerId: null,
|
|
maxMediaEdges: 2,
|
|
}),
|
|
).toBe("probe-child");
|
|
expect(
|
|
plannedHostProvisionalChild({
|
|
revision: 7,
|
|
assignment: hostAssignment(null, ["probe-child"]),
|
|
activeChildPeerIds: [],
|
|
selectedPeerId: "selected-overlay",
|
|
maxMediaEdges: 2,
|
|
}),
|
|
).toBe("probe-child");
|
|
expect(
|
|
plannedHostProvisionalChild({
|
|
revision: 7,
|
|
assignment: hostAssignment("publication", [
|
|
"active-child",
|
|
"probe-child",
|
|
]),
|
|
activeChildPeerIds: ["active-child"],
|
|
selectedPeerId: null,
|
|
maxMediaEdges: 2,
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
plannedHostProvisionalChild({
|
|
revision: 7,
|
|
assignment: hostAssignment(null, ["active-child", "probe-child"]),
|
|
activeChildPeerIds: ["active-child"],
|
|
selectedPeerId: "selected-overlay",
|
|
maxMediaEdges: 2,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("promotes the exact identity and preserves a failed connection tombstone", () => {
|
|
const input = {
|
|
revision: 7,
|
|
assignment: hostAssignment(null, ["active-child", "probe-child"]),
|
|
activeChildPeerIds: ["active-child"],
|
|
selectedPeerId: null,
|
|
maxMediaEdges: 2,
|
|
};
|
|
expect(
|
|
resolveHostPreparedChildActivation({
|
|
...input,
|
|
prepared: {
|
|
revision: 7,
|
|
peerId: "probe-child",
|
|
failedConnectionId: null,
|
|
},
|
|
}),
|
|
).toEqual({ kind: "promote", peerId: "probe-child" });
|
|
expect(
|
|
resolveHostPreparedChildActivation({
|
|
...input,
|
|
prepared: {
|
|
revision: 7,
|
|
peerId: "probe-child",
|
|
failedConnectionId: "failed-probe-connection",
|
|
},
|
|
}),
|
|
).toEqual({
|
|
kind: "failed",
|
|
peerId: "probe-child",
|
|
connectionId: "failed-probe-connection",
|
|
});
|
|
expect(
|
|
resolveHostPreparedChildActivation({ ...input, prepared: null }),
|
|
).toEqual({ kind: "ordinary" });
|
|
expect(
|
|
resolveHostPreparedChildActivation({
|
|
...input,
|
|
prepared: {
|
|
revision: 6,
|
|
peerId: "probe-child",
|
|
failedConnectionId: null,
|
|
},
|
|
}),
|
|
).toEqual({ kind: "ordinary" });
|
|
expect(
|
|
resolveHostPreparedChildActivation({
|
|
...input,
|
|
prepared: {
|
|
revision: 7,
|
|
peerId: "other-child",
|
|
failedConnectionId: null,
|
|
},
|
|
}),
|
|
).toEqual({ kind: "ordinary" });
|
|
});
|
|
});
|
|
|
|
interface FakeSubscriberEvents {
|
|
onStream: (stream: MediaStream | null) => void;
|
|
onVideoAvailability?: (available: boolean) => void;
|
|
onStats?: (metrics: ConnectionMetrics) => void;
|
|
onState?: (state: "connected" | "reconnecting") => void;
|
|
onDisconnected: () => void;
|
|
}
|
|
|
|
function createFakeSubscriber(
|
|
events: FakeSubscriberEvents,
|
|
log: string[],
|
|
label: string,
|
|
) {
|
|
return {
|
|
events,
|
|
connect: vi.fn(async () => {
|
|
log.push(`${label}:connect`);
|
|
return true;
|
|
}),
|
|
activate: vi.fn(() => {
|
|
log.push(`${label}:activate`);
|
|
return true;
|
|
}),
|
|
deactivate: vi.fn(() => {
|
|
log.push(`${label}:deactivate`);
|
|
return true;
|
|
}),
|
|
disconnect: vi.fn(async () => {
|
|
log.push(`${label}:disconnect`);
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function activateViewerSfuRoute(
|
|
events: ConstructorParameters<typeof ViewerSfuRoute>[0],
|
|
log: string[] = [],
|
|
) {
|
|
let subscriber!: ReturnType<typeof createFakeSubscriber>;
|
|
const route = new ViewerSfuRoute({
|
|
...events,
|
|
createSubscriber: (subscriberEvents) => {
|
|
subscriber = createFakeSubscriber(subscriberEvents, log, "subscriber");
|
|
return subscriber;
|
|
},
|
|
});
|
|
route.accept({ revision: 1, phase: "prepare", assignment: sfuAssignment() });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
route.accept({ revision: 1, phase: "active", assignment: sfuAssignment() });
|
|
await vi.waitFor(() => expect(subscriber.activate).toHaveBeenCalledOnce());
|
|
subscriber.events.onStream({} as MediaStream);
|
|
return { route, subscriber };
|
|
}
|
|
|
|
describe("MediaRouteTransition", () => {
|
|
it("keeps current media through prepare and until an active SFU video track", () => {
|
|
const route = new MediaRouteTransition();
|
|
const direct = peerAssignment("host_12345678", ["child_a_12345678"]);
|
|
const sfu = sfuAssignment(["child_a_12345678"]);
|
|
|
|
expect(route.accept({ revision: 1, phase: "active", assignment: direct })).toBe(
|
|
"accepted",
|
|
);
|
|
const directToken = route.token()!;
|
|
expect(route.markMediaActive(directToken)).toBe(true);
|
|
|
|
expect(route.accept({ revision: 2, phase: "prepare", assignment: sfu })).toBe(
|
|
"accepted",
|
|
);
|
|
expect(route.getActiveAssignment()).toEqual(direct);
|
|
expect(route.getMediaAssignment()).toEqual(direct);
|
|
|
|
expect(route.accept({ revision: 2, phase: "active", assignment: sfu })).toBe(
|
|
"accepted",
|
|
);
|
|
const sfuToken = route.token()!;
|
|
expect(route.getActiveAssignment()).toEqual(sfu);
|
|
expect(route.getMediaAssignment()).toEqual(direct);
|
|
|
|
expect(route.markMediaActive(sfuToken)).toBe(true);
|
|
expect(route.getMediaAssignment()).toEqual(sfu);
|
|
});
|
|
|
|
it("rejects stale config and async work after a rollback revision", () => {
|
|
const route = new MediaRouteTransition();
|
|
const sfu = sfuAssignment();
|
|
const direct = peerAssignment("host_12345678");
|
|
|
|
route.accept({ revision: 4, phase: "prepare", assignment: sfu });
|
|
const staleToken = route.token()!;
|
|
expect(route.acceptsConfig(4)).toBe(true);
|
|
|
|
route.accept({ revision: 5, phase: "active", assignment: direct });
|
|
|
|
expect(
|
|
route.accept({ revision: 4, phase: "active", assignment: sfu }),
|
|
).toBe("stale");
|
|
expect(route.acceptsConfig(4)).toBe(false);
|
|
expect(route.owns(staleToken)).toBe(false);
|
|
expect(route.markMediaActive(staleToken)).toBe(false);
|
|
});
|
|
|
|
it("is idempotent for duplicates and rejects conflicting same revisions", () => {
|
|
const route = new MediaRouteTransition();
|
|
const direct = peerAssignment("host_12345678");
|
|
|
|
expect(route.accept({ revision: 7, phase: "prepare", assignment: direct })).toBe(
|
|
"accepted",
|
|
);
|
|
const token = route.token();
|
|
expect(route.accept({ revision: 7, phase: "prepare", assignment: direct })).toBe(
|
|
"duplicate",
|
|
);
|
|
expect(route.token()).toEqual(token);
|
|
expect(
|
|
route.accept({
|
|
revision: 7,
|
|
phase: "prepare",
|
|
assignment: peerAssignment("other_12345678"),
|
|
}),
|
|
).toBe("stale");
|
|
});
|
|
|
|
it("returns signaling failure so ViewerPeer can retry its route report", () => {
|
|
const route = new MediaRouteTransition();
|
|
const assignment = peerAssignment("host_12345678");
|
|
route.accept({ revision: 9, phase: "active", assignment });
|
|
const send = vi.fn(() => false);
|
|
|
|
expect(
|
|
reportActivePeerRouteFailure(
|
|
route,
|
|
"host_12345678",
|
|
"connection_12345678",
|
|
send,
|
|
),
|
|
).toBe(false);
|
|
expect(send).toHaveBeenCalledWith({
|
|
type: "route-failed",
|
|
revision: 9,
|
|
phase: "active",
|
|
connectionId: "connection_12345678",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("HostSfuRoute", () => {
|
|
it("exposes only current active publication stats and clears retired evidence", async () => {
|
|
const updates: Array<
|
|
Parameters<NonNullable<ConstructorParameters<typeof HostSfuRoute>[0]["onPublisherUpdate"]>>[0]
|
|
> = [];
|
|
const statsCallbacks: Array<(metrics: ConnectionMetrics | null) => void> = [];
|
|
const publishers: ReturnType<typeof createFakePublisher>[] = [];
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: () => true,
|
|
onPublisherUpdate: (snapshot) => updates.push(snapshot),
|
|
createPublisher: (_onDisconnected, onStats) => {
|
|
const publisher = createFakePublisher(
|
|
[],
|
|
`publisher-${publishers.length + 1}`,
|
|
);
|
|
publishers.push(publisher);
|
|
statsCallbacks.push(onStats);
|
|
return publisher;
|
|
},
|
|
});
|
|
const metrics = {
|
|
...EMPTY_METRICS,
|
|
trackIdentifier: "screen-a",
|
|
framesPerSecond: 60,
|
|
};
|
|
const generationA = hostAssignment("generation-a");
|
|
route.accept({ revision: 1, phase: "prepare", assignment: generationA });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
await route.acceptAndWait({
|
|
revision: 1,
|
|
phase: "active",
|
|
assignment: generationA,
|
|
});
|
|
|
|
statsCallbacks[0]?.(metrics);
|
|
expect(updates.at(-1)).toMatchObject({
|
|
metrics: { trackIdentifier: "screen-a", framesPerSecond: 60 },
|
|
});
|
|
|
|
const generationB = hostAssignment("generation-b");
|
|
route.accept({ revision: 2, phase: "prepare", assignment: generationB });
|
|
await route.acceptConfig(sfuConfig(2));
|
|
await route.acceptAndWait({
|
|
revision: 2,
|
|
phase: "active",
|
|
assignment: generationB,
|
|
});
|
|
expect(updates.at(-1)).toBeNull();
|
|
|
|
statsCallbacks[0]?.({ ...metrics, framesPerSecond: 1 });
|
|
expect(updates.at(-1)).toBeNull();
|
|
statsCallbacks[1]?.({
|
|
...metrics,
|
|
trackIdentifier: "screen-b",
|
|
framesPerSecond: 30,
|
|
});
|
|
expect(updates.at(-1)).toMatchObject({
|
|
metrics: { trackIdentifier: "screen-b", framesPerSecond: 30 },
|
|
});
|
|
|
|
await route.resyncAuthoritative({
|
|
revision: 1,
|
|
phase: "active",
|
|
assignment: hostAssignment(null),
|
|
});
|
|
expect(updates.at(-1)).toBeNull();
|
|
});
|
|
|
|
it("settles authoritative activation before exposing its quality warning", async () => {
|
|
const publisher = {
|
|
...createFakePublisher([], "publisher"),
|
|
getQualityWarning: vi.fn(() => "SFU sender parameters were rewritten"),
|
|
};
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: () => true,
|
|
createPublisher: () => publisher,
|
|
});
|
|
const assignment = hostAssignment("generation-a");
|
|
|
|
route.accept({ revision: 1, phase: "prepare", assignment });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
await expect(
|
|
route.resyncAuthoritative({ revision: 1, phase: "active", assignment }),
|
|
).resolves.toBe("accepted");
|
|
|
|
expect(publisher.activate).toHaveBeenCalledOnce();
|
|
expect(route.getQualityWarning()).toBe(
|
|
"SFU sender parameters were rewritten",
|
|
);
|
|
});
|
|
|
|
it("reports a publisher prepare failure without requesting refresh", async () => {
|
|
const messages: ClientMessage[] = [];
|
|
const publisher = createFakePublisher([], "publisher");
|
|
publisher.connect.mockResolvedValue(false);
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createPublisher: () => publisher,
|
|
});
|
|
|
|
route.accept({
|
|
revision: 1,
|
|
phase: "prepare",
|
|
assignment: hostAssignment("generation-a"),
|
|
});
|
|
await route.acceptConfig(sfuConfig(1));
|
|
|
|
expect(messages).toEqual([
|
|
{
|
|
type: "route-failed",
|
|
revision: 1,
|
|
phase: "prepare",
|
|
connectionId: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("reports the pending peer revision when the active publisher fails", async () => {
|
|
const messages: ClientMessage[] = [];
|
|
const publisher = createFakePublisher([], "publisher");
|
|
let disconnectActive!: () => void;
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createPublisher: (onDisconnected) => {
|
|
disconnectActive = onDisconnected;
|
|
return publisher;
|
|
},
|
|
});
|
|
const active = hostAssignment("generation-a");
|
|
route.accept({ revision: 1, phase: "prepare", assignment: active });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
route.accept({ revision: 1, phase: "active", assignment: active });
|
|
await vi.waitFor(() => expect(publisher.activate).toHaveBeenCalledOnce());
|
|
|
|
messages.length = 0;
|
|
route.accept({
|
|
revision: 2,
|
|
phase: "prepare",
|
|
assignment: hostAssignment(null, ["viewer-root"]),
|
|
});
|
|
disconnectActive();
|
|
|
|
expect(messages).toEqual([{
|
|
type: "route-failed",
|
|
revision: 2,
|
|
phase: "prepare",
|
|
connectionId: null,
|
|
}]);
|
|
});
|
|
|
|
it("accepts a forward-revision selected ingress before its config arrives", async () => {
|
|
const messages: ClientMessage[] = [];
|
|
const publishers: ReturnType<typeof createFakePublisher>[] = [];
|
|
const failures: Array<() => void> = [];
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createPublisher: (onDisconnected) => {
|
|
const publisher = createFakePublisher(
|
|
[],
|
|
`publisher-${publishers.length + 1}`,
|
|
);
|
|
publishers.push(publisher);
|
|
failures.push(onDisconnected);
|
|
return publisher;
|
|
},
|
|
});
|
|
const active = hostAssignment("generation-a");
|
|
route.accept({ revision: 1, phase: "prepare", assignment: active });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
await route.acceptAndWait({
|
|
revision: 1,
|
|
phase: "active",
|
|
assignment: active,
|
|
});
|
|
|
|
messages.length = 0;
|
|
route.accept({
|
|
revision: 2,
|
|
phase: "prepare",
|
|
assignment: hostAssignment("generation-a", ["viewer-root"]),
|
|
});
|
|
failures[0]();
|
|
expect(messages).toContainEqual({
|
|
type: "route-failed",
|
|
revision: 2,
|
|
phase: "prepare",
|
|
connectionId: null,
|
|
});
|
|
|
|
await route.acceptAndWait({
|
|
revision: 3,
|
|
phase: "active",
|
|
assignment: active,
|
|
});
|
|
expect(messages).toContainEqual({ type: "refresh-sfu", revision: 3 });
|
|
expect(
|
|
route.startSelectedEdgeTurn(
|
|
hostSfuIngressGrant("selected-forward-connection", 3),
|
|
),
|
|
).toBe(true);
|
|
await route.acceptConfig(sfuConfig(3));
|
|
|
|
await vi.waitFor(() => expect(publishers).toHaveLength(2));
|
|
expect(publishers[1]?.connect).toHaveBeenCalledWith({
|
|
url: "wss://sfu.example.test",
|
|
token: "token-3",
|
|
rtcConfig: {
|
|
iceServers: [hostSfuIngressGrant().iceServer],
|
|
iceTransportPolicy: "relay",
|
|
},
|
|
});
|
|
await vi.waitFor(() => expect(messages).toContainEqual({
|
|
type: "route-ready",
|
|
revision: 3,
|
|
phase: "active",
|
|
}));
|
|
});
|
|
|
|
it("reports the selected ingress connection when it fails during peer prepare", async () => {
|
|
const messages: ClientMessage[] = [];
|
|
const publishers: ReturnType<typeof createFakePublisher>[] = [];
|
|
const failures: Array<() => void> = [];
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createPublisher: (onDisconnected) => {
|
|
const publisher = createFakePublisher(
|
|
[],
|
|
`publisher-${publishers.length + 1}`,
|
|
);
|
|
publishers.push(publisher);
|
|
failures.push(onDisconnected);
|
|
return publisher;
|
|
},
|
|
});
|
|
const active = hostAssignment("generation-a");
|
|
route.accept({ revision: 1, phase: "prepare", assignment: active });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
route.accept({ revision: 1, phase: "active", assignment: active });
|
|
await vi.waitFor(() => expect(publishers[0]?.activate).toHaveBeenCalledOnce());
|
|
failures[0]();
|
|
messages.length = 0;
|
|
|
|
expect(route.startSelectedEdgeTurn(hostSfuIngressGrant())).toBe(true);
|
|
await vi.waitFor(() =>
|
|
expect(messages).toContainEqual({
|
|
type: "route-ready",
|
|
revision: 1,
|
|
phase: "active",
|
|
}),
|
|
);
|
|
messages.length = 0;
|
|
route.accept({
|
|
revision: 2,
|
|
phase: "prepare",
|
|
assignment: hostAssignment(null, ["viewer-root"]),
|
|
});
|
|
failures[1]();
|
|
|
|
expect(messages).toEqual([{
|
|
type: "route-failed",
|
|
revision: 2,
|
|
phase: "prepare",
|
|
connectionId: "selected-connection-new",
|
|
}]);
|
|
});
|
|
|
|
it("applies a host-SFU selected grant only to a relay-only publisher", async () => {
|
|
const messages: ClientMessage[] = [];
|
|
const publishers: ReturnType<typeof createFakePublisher>[] = [];
|
|
const failures: Array<() => void> = [];
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createPublisher: (onDisconnected) => {
|
|
const publisher = createFakePublisher([], `publisher-${publishers.length + 1}`);
|
|
publishers.push(publisher);
|
|
failures.push(onDisconnected);
|
|
if (publishers.length === 1) {
|
|
publisher.connect.mockResolvedValue(false);
|
|
}
|
|
return publisher;
|
|
},
|
|
});
|
|
const assignment = hostAssignment("generation-a");
|
|
route.accept({ revision: 1, phase: "prepare", assignment });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
expect(messages).toContainEqual({
|
|
type: "route-failed",
|
|
revision: 1,
|
|
phase: "prepare",
|
|
connectionId: null,
|
|
});
|
|
|
|
expect(
|
|
route.startSelectedEdgeTurn(hostSfuIngressGrant()),
|
|
).toBe(true);
|
|
await vi.waitFor(() => expect(publishers).toHaveLength(2));
|
|
expect(publishers[1].connect).toHaveBeenCalledWith({
|
|
url: "wss://sfu.example.test",
|
|
token: "token-1",
|
|
rtcConfig: {
|
|
iceServers: [
|
|
{
|
|
urls: ["turn:turn.example.test:3478?transport=udp"],
|
|
username: "1787230000:opaque_identity_12345678",
|
|
credential: "short-lived-credential",
|
|
},
|
|
],
|
|
iceTransportPolicy: "relay",
|
|
},
|
|
});
|
|
expect(failures).toHaveLength(2);
|
|
expect(messages).toContainEqual({
|
|
type: "route-ready",
|
|
revision: 1,
|
|
phase: "prepare",
|
|
});
|
|
});
|
|
|
|
it("identifies a failed selected ingress retry by its new connection", async () => {
|
|
const messages: ClientMessage[] = [];
|
|
const publishers: ReturnType<typeof createFakePublisher>[] = [];
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createPublisher: () => {
|
|
const publisher = createFakePublisher([], `publisher-${publishers.length + 1}`);
|
|
publisher.connect.mockResolvedValue(false);
|
|
publishers.push(publisher);
|
|
return publisher;
|
|
},
|
|
});
|
|
|
|
route.accept({
|
|
revision: 1,
|
|
phase: "prepare",
|
|
assignment: hostAssignment("generation-a"),
|
|
});
|
|
await route.acceptConfig(sfuConfig(1));
|
|
expect(route.startSelectedEdgeTurn(hostSfuIngressGrant())).toBe(true);
|
|
|
|
await vi.waitFor(() => expect(publishers).toHaveLength(2));
|
|
await vi.waitFor(() =>
|
|
expect(messages).toContainEqual({
|
|
type: "route-failed",
|
|
revision: 1,
|
|
phase: "prepare",
|
|
connectionId: "selected-connection-new",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("surfaces a bounded publisher stage after active fallback fails", async () => {
|
|
const publisher = {
|
|
...createFakePublisher([], "publisher"),
|
|
getFailureStage: vi.fn(() => "video-publish" as const),
|
|
};
|
|
publisher.activate.mockResolvedValue(false);
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: () => true,
|
|
createPublisher: () => publisher,
|
|
});
|
|
const assignment = hostAssignment("generation-a");
|
|
|
|
route.accept({ revision: 1, phase: "prepare", assignment });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
await route.acceptAndWait({ revision: 1, phase: "active", assignment });
|
|
|
|
expect(route.getQualityWarning()).toBe(
|
|
"SFU 视频发布失败,已启动自动恢复",
|
|
);
|
|
|
|
route.accept({
|
|
revision: 2,
|
|
phase: "active",
|
|
assignment: hostAssignment(null),
|
|
});
|
|
expect(route.getQualityWarning()).toBeNull();
|
|
});
|
|
|
|
it("clears a prior failure when authoritative state replaces the revision", async () => {
|
|
const publisher = {
|
|
...createFakePublisher([], "publisher"),
|
|
getFailureStage: vi.fn(() => "transport" as const),
|
|
};
|
|
publisher.activate.mockResolvedValue(false);
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: () => true,
|
|
createPublisher: () => publisher,
|
|
});
|
|
const failedAssignment = hostAssignment("generation-a");
|
|
|
|
route.accept({ revision: 1, phase: "prepare", assignment: failedAssignment });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
await route.acceptAndWait({
|
|
revision: 1,
|
|
phase: "active",
|
|
assignment: failedAssignment,
|
|
});
|
|
expect(route.getQualityWarning()).toBe(
|
|
"SFU 传输失败,已启动自动恢复",
|
|
);
|
|
|
|
await route.resyncAuthoritative({
|
|
revision: 1,
|
|
phase: "active",
|
|
assignment: hostAssignment(null),
|
|
});
|
|
expect(route.getQualityWarning()).toBeNull();
|
|
});
|
|
|
|
it("never overlaps more than two host media edges while changing route kinds", async () => {
|
|
const log: string[] = [];
|
|
const edgeCounts: number[] = [];
|
|
const publishers: Array<{
|
|
connect: ReturnType<typeof vi.fn>;
|
|
activate: ReturnType<typeof vi.fn>;
|
|
deactivate: ReturnType<typeof vi.fn>;
|
|
replaceStream: ReturnType<typeof vi.fn>;
|
|
updateProfile: ReturnType<typeof vi.fn>;
|
|
disconnect: ReturnType<typeof vi.fn>;
|
|
}> = [];
|
|
let directEdges = 0;
|
|
let sfuEdges = 0;
|
|
const record = (event: string) => {
|
|
log.push(event);
|
|
edgeCounts.push(directEdges + sfuEdges);
|
|
};
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: (children) => {
|
|
directEdges = children.length;
|
|
record(`children:${children.join(",")}`);
|
|
},
|
|
send: () => true,
|
|
createPublisher: () => {
|
|
const label = `publisher-${publishers.length + 1}`;
|
|
const publisher = {
|
|
connect: vi.fn(async () => true),
|
|
activate: vi.fn(async () => {
|
|
sfuEdges = 1;
|
|
record(`${label}:activate`);
|
|
return true;
|
|
}),
|
|
deactivate: vi.fn(async () => {
|
|
sfuEdges = 0;
|
|
record(`${label}:deactivate`);
|
|
return true;
|
|
}),
|
|
replaceStream: vi.fn(async () => true),
|
|
updateProfile: vi.fn(async () => true),
|
|
disconnect: vi.fn(async () => record(`${label}:disconnect`)),
|
|
};
|
|
publishers.push(publisher);
|
|
return publisher;
|
|
},
|
|
});
|
|
|
|
route.accept({
|
|
revision: 1,
|
|
phase: "active",
|
|
assignment: hostAssignment(null, ["direct-a", "direct-b"]),
|
|
});
|
|
await vi.waitFor(() => expect(log).toContain("children:direct-a,direct-b"));
|
|
|
|
const generationA = hostAssignment("generation-a", ["direct-a"]);
|
|
route.accept({ revision: 2, phase: "prepare", assignment: generationA });
|
|
await route.acceptConfig(sfuConfig(2));
|
|
log.length = 0;
|
|
route.accept({ revision: 2, phase: "active", assignment: generationA });
|
|
await vi.waitFor(() => expect(publishers[0]?.activate).toHaveBeenCalledOnce());
|
|
expect(log.slice(0, 2)).toEqual([
|
|
"children:direct-a",
|
|
"publisher-1:activate",
|
|
]);
|
|
|
|
const generationB = hostAssignment("generation-b", ["direct-b"]);
|
|
route.accept({ revision: 3, phase: "prepare", assignment: generationB });
|
|
await route.acceptConfig(sfuConfig(3));
|
|
log.length = 0;
|
|
route.accept({ revision: 3, phase: "active", assignment: generationB });
|
|
await vi.waitFor(() => expect(publishers[1]?.activate).toHaveBeenCalledOnce());
|
|
expect(log.slice(0, 4)).toEqual([
|
|
"children:direct-b",
|
|
"publisher-1:deactivate",
|
|
"publisher-1:disconnect",
|
|
"publisher-2:activate",
|
|
]);
|
|
|
|
log.length = 0;
|
|
route.accept({
|
|
revision: 4,
|
|
phase: "active",
|
|
assignment: hostAssignment(null, ["direct-a", "direct-b"]),
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(log).toContain("children:direct-a,direct-b"),
|
|
);
|
|
expect(log.slice(0, 3)).toEqual([
|
|
"publisher-2:deactivate",
|
|
"publisher-2:disconnect",
|
|
"children:direct-a,direct-b",
|
|
]);
|
|
expect(Math.max(...edgeCounts)).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
it("serializes continuous active revisions while the old publisher retires", async () => {
|
|
const publishers: ReturnType<typeof createFakePublisher>[] = [];
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: () => true,
|
|
createPublisher: () => {
|
|
const publisher = createFakePublisher(
|
|
[],
|
|
`publisher-${publishers.length + 1}`,
|
|
);
|
|
publishers.push(publisher);
|
|
return publisher;
|
|
},
|
|
});
|
|
|
|
const generationA = hostAssignment("generation-a", ["direct-a"]);
|
|
route.accept({ revision: 1, phase: "prepare", assignment: generationA });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
route.accept({ revision: 1, phase: "active", assignment: generationA });
|
|
await vi.waitFor(() => expect(publishers[0]?.activate).toHaveBeenCalledOnce());
|
|
|
|
let finishOldDeactivate!: () => void;
|
|
let oldDeactivateFinished = false;
|
|
const oldDeactivateGate = new Promise<void>((resolve) => {
|
|
finishOldDeactivate = resolve;
|
|
});
|
|
publishers[0]?.deactivate.mockImplementation(async () => {
|
|
await oldDeactivateGate;
|
|
oldDeactivateFinished = true;
|
|
return true;
|
|
});
|
|
|
|
const generationB = hostAssignment("generation-b", ["direct-b"]);
|
|
route.accept({ revision: 2, phase: "prepare", assignment: generationB });
|
|
await route.acceptConfig(sfuConfig(2));
|
|
route.accept({ revision: 2, phase: "active", assignment: generationB });
|
|
await vi.waitFor(() => expect(publishers[0]?.deactivate).toHaveBeenCalled());
|
|
|
|
const generationC = hostAssignment("generation-c", ["direct-c"]);
|
|
route.accept({ revision: 3, phase: "prepare", assignment: generationC });
|
|
await route.acceptConfig(sfuConfig(3));
|
|
route.accept({ revision: 3, phase: "active", assignment: generationC });
|
|
|
|
expect(publishers[2]?.disconnect).not.toHaveBeenCalled();
|
|
expect(publishers[1]?.activate).not.toHaveBeenCalled();
|
|
expect(publishers[2]?.activate).not.toHaveBeenCalled();
|
|
|
|
finishOldDeactivate();
|
|
await vi.waitFor(() => expect(oldDeactivateFinished).toBe(true));
|
|
expect(publishers[2]?.disconnect).not.toHaveBeenCalled();
|
|
await vi.waitFor(() => expect(publishers[2]?.activate).toHaveBeenCalledOnce());
|
|
expect(publishers[1]?.activate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("releases active ownership when a published activation becomes stale", async () => {
|
|
const streamA = {} as MediaStream;
|
|
const streamB = {} as MediaStream;
|
|
let currentStream = streamA;
|
|
const publishers: ReturnType<typeof createFakePublisher>[] = [];
|
|
const route = new HostSfuRoute({
|
|
getStream: () => currentStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: () => true,
|
|
createPublisher: () => {
|
|
const publisher = createFakePublisher(
|
|
[],
|
|
`publisher-${publishers.length + 1}`,
|
|
);
|
|
publishers.push(publisher);
|
|
return publisher;
|
|
},
|
|
});
|
|
|
|
const generationA = hostAssignment("generation-a");
|
|
route.accept({ revision: 1, phase: "prepare", assignment: generationA });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
|
|
let finishReplace!: () => void;
|
|
const replaceGate = new Promise<void>((resolve) => {
|
|
finishReplace = resolve;
|
|
});
|
|
publishers[0]?.activate.mockImplementation(async () => {
|
|
currentStream = streamB;
|
|
return true;
|
|
});
|
|
publishers[0]?.replaceStream.mockImplementation(async () => {
|
|
await replaceGate;
|
|
return true;
|
|
});
|
|
|
|
route.accept({ revision: 1, phase: "active", assignment: generationA });
|
|
await vi.waitFor(() => expect(publishers[0]?.replaceStream).toHaveBeenCalled());
|
|
|
|
const generationB = hostAssignment("generation-b");
|
|
route.accept({ revision: 2, phase: "prepare", assignment: generationB });
|
|
await route.acceptConfig(sfuConfig(2));
|
|
finishReplace();
|
|
await vi.waitFor(() => expect(publishers[0]?.disconnect).toHaveBeenCalled());
|
|
|
|
await expect(
|
|
route.updateProfile(QUALITY_PROFILES["1080p30"]),
|
|
).resolves.toBe(true);
|
|
expect(publishers[0]?.updateProfile).not.toHaveBeenCalled();
|
|
expect(publishers[1]?.disconnect).not.toHaveBeenCalled();
|
|
|
|
route.accept({ revision: 2, phase: "active", assignment: generationB });
|
|
await vi.waitFor(() => expect(publishers[1]?.activate).toHaveBeenCalledOnce());
|
|
});
|
|
|
|
it("keeps an activating publisher on a duplicate active update", async () => {
|
|
const messages: ClientMessage[] = [];
|
|
const publisher = createFakePublisher([], "publisher");
|
|
let finishActivate!: () => void;
|
|
const activateGate = new Promise<void>((resolve) => {
|
|
finishActivate = resolve;
|
|
});
|
|
publisher.activate.mockImplementation(async () => {
|
|
await activateGate;
|
|
return true;
|
|
});
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: () => undefined,
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createPublisher: () => publisher,
|
|
});
|
|
const assignment = hostAssignment("generation-a");
|
|
|
|
route.accept({ revision: 1, phase: "prepare", assignment });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
route.accept({ revision: 1, phase: "active", assignment });
|
|
await vi.waitFor(() => expect(publisher.activate).toHaveBeenCalledOnce());
|
|
|
|
expect(
|
|
route.accept({ revision: 1, phase: "active", assignment }),
|
|
).toBe("duplicate");
|
|
expect(publisher.activate).toHaveBeenCalledOnce();
|
|
expect(publisher.disconnect).not.toHaveBeenCalled();
|
|
|
|
finishActivate();
|
|
await vi.waitFor(() =>
|
|
expect(messages).toContainEqual({
|
|
type: "route-ready",
|
|
revision: 1,
|
|
phase: "active",
|
|
}),
|
|
);
|
|
expect(publisher.disconnect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prepares without media, commits in order, and reuses a publication generation", async () => {
|
|
const log: string[] = [];
|
|
const messages: ClientMessage[] = [];
|
|
const publishers: ReturnType<typeof createFakePublisher>[] = [];
|
|
const stream = {} as MediaStream;
|
|
const route = new HostSfuRoute({
|
|
getStream: () => stream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: (children) => log.push(`children:${children.join(",")}`),
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createPublisher: () => {
|
|
const publisher = createFakePublisher(
|
|
log,
|
|
`publisher-${publishers.length + 1}`,
|
|
);
|
|
publishers.push(publisher);
|
|
return publisher;
|
|
},
|
|
});
|
|
|
|
const generationA = hostAssignment("generation-a", ["direct-a"]);
|
|
route.accept({ revision: 1, phase: "prepare", assignment: generationA });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
|
|
expect(publishers[0]?.activate).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "route-ready",
|
|
revision: 1,
|
|
phase: "prepare",
|
|
});
|
|
|
|
route.accept({ revision: 1, phase: "active", assignment: generationA });
|
|
await vi.waitFor(() => expect(publishers[0]?.activate).toHaveBeenCalledOnce());
|
|
|
|
const generationB = hostAssignment("generation-b", ["direct-b"]);
|
|
route.accept({ revision: 2, phase: "prepare", assignment: generationB });
|
|
await route.acceptConfig(sfuConfig(2));
|
|
expect(publishers[0]?.deactivate).not.toHaveBeenCalled();
|
|
expect(publishers[1]?.activate).not.toHaveBeenCalled();
|
|
|
|
log.length = 0;
|
|
route.accept({ revision: 2, phase: "active", assignment: generationB });
|
|
await vi.waitFor(() => expect(publishers[1]?.activate).toHaveBeenCalledOnce());
|
|
expect(log.slice(0, 4)).toEqual([
|
|
"children:direct-b",
|
|
"publisher-1:deactivate",
|
|
"publisher-1:disconnect",
|
|
"publisher-2:activate",
|
|
]);
|
|
|
|
route.accept({
|
|
revision: 3,
|
|
phase: "active",
|
|
assignment: hostAssignment("generation-b", ["direct-c"]),
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(messages).toContainEqual({
|
|
type: "route-ready",
|
|
revision: 3,
|
|
phase: "active",
|
|
}),
|
|
);
|
|
expect(publishers).toHaveLength(2);
|
|
expect(publishers[1]?.deactivate).not.toHaveBeenCalled();
|
|
|
|
route.accept({
|
|
revision: 4,
|
|
phase: "prepare",
|
|
assignment: hostAssignment("generation-c"),
|
|
});
|
|
await route.acceptConfig(sfuConfig(4));
|
|
route.accept({
|
|
revision: 5,
|
|
phase: "active",
|
|
assignment: hostAssignment("generation-b", ["direct-c"]),
|
|
});
|
|
await vi.waitFor(() => expect(publishers[2]?.disconnect).toHaveBeenCalled());
|
|
expect(publishers[1]?.deactivate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retires old media before applying a lower authoritative host revision", async () => {
|
|
const log: string[] = [];
|
|
const publisher = createFakePublisher(log, "publisher");
|
|
const route = new HostSfuRoute({
|
|
getStream: () => ({}) as MediaStream,
|
|
getProfile: () => QUALITY_PROFILES["720p30"],
|
|
reconcileChildren: (children) =>
|
|
log.push(`children:${children.join(",")}`),
|
|
send: () => true,
|
|
createPublisher: () => publisher,
|
|
});
|
|
|
|
const oldAssignment = hostAssignment("generation-old");
|
|
route.accept({ revision: 10, phase: "prepare", assignment: oldAssignment });
|
|
await route.acceptConfig(sfuConfig(10));
|
|
route.accept({ revision: 10, phase: "active", assignment: oldAssignment });
|
|
await vi.waitFor(() => expect(publisher.activate).toHaveBeenCalledOnce());
|
|
|
|
let finishDeactivate!: () => void;
|
|
const deactivateGate = new Promise<void>((resolve) => {
|
|
finishDeactivate = resolve;
|
|
});
|
|
publisher.deactivate.mockImplementation(async () => {
|
|
log.push("publisher:deactivate");
|
|
await deactivateGate;
|
|
return true;
|
|
});
|
|
log.length = 0;
|
|
|
|
const authoritative = hostAssignment(null, ["new-child-a", "new-child-b"]);
|
|
const resync = route.resyncAuthoritative({
|
|
revision: 1,
|
|
phase: "active",
|
|
assignment: authoritative,
|
|
});
|
|
await vi.waitFor(() => expect(publisher.deactivate).toHaveBeenCalledOnce());
|
|
expect(log).toEqual(["children:", "publisher:deactivate"]);
|
|
|
|
finishDeactivate();
|
|
await expect(resync).resolves.toBe("accepted");
|
|
expect(log).toEqual([
|
|
"children:",
|
|
"publisher:deactivate",
|
|
"publisher:disconnect",
|
|
"children:new-child-a,new-child-b",
|
|
]);
|
|
|
|
await expect(
|
|
route.resyncAuthoritative({
|
|
revision: 1,
|
|
phase: "active",
|
|
assignment: authoritative,
|
|
}),
|
|
).resolves.toBe("duplicate");
|
|
expect(publisher.disconnect).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("ViewerSfuRoute", () => {
|
|
it("retains an active SFU subscriber when the root has zero peer children", async () => {
|
|
const reconcileSfuChildren = vi.fn();
|
|
const { route, subscriber } = await activateViewerSfuRoute({
|
|
activatePeer: () => undefined,
|
|
reconcileSfuChildren,
|
|
onSfuStream: () => undefined,
|
|
send: () => true,
|
|
});
|
|
reconcileSfuChildren.mockClear();
|
|
|
|
route.accept({
|
|
revision: 2,
|
|
phase: "active",
|
|
assignment: sfuAssignment(),
|
|
});
|
|
|
|
await vi.waitFor(() =>
|
|
expect(reconcileSfuChildren).toHaveBeenCalledWith([]),
|
|
);
|
|
expect(subscriber.activate).toHaveBeenCalledOnce();
|
|
expect(subscriber.deactivate).not.toHaveBeenCalled();
|
|
expect(subscriber.disconnect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports a subscriber prepare failure without requesting refresh", async () => {
|
|
const messages: ClientMessage[] = [];
|
|
const subscriber = createFakeSubscriber(
|
|
{ onStream: () => undefined, onDisconnected: () => undefined },
|
|
[],
|
|
"subscriber",
|
|
);
|
|
subscriber.connect.mockResolvedValue(false);
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer: () => undefined,
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createSubscriber: () => subscriber,
|
|
});
|
|
|
|
route.accept({
|
|
revision: 1,
|
|
phase: "prepare",
|
|
assignment: sfuAssignment(),
|
|
});
|
|
await route.acceptConfig(sfuConfig(1));
|
|
|
|
expect(messages).toEqual([
|
|
{
|
|
type: "route-failed",
|
|
revision: 1,
|
|
phase: "prepare",
|
|
connectionId: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("reports the pending peer revision when the active subscriber fails", async () => {
|
|
const messages: ClientMessage[] = [];
|
|
const { route, subscriber } = await activateViewerSfuRoute({
|
|
activatePeer: () => undefined,
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
});
|
|
|
|
messages.length = 0;
|
|
route.accept({
|
|
revision: 2,
|
|
phase: "prepare",
|
|
assignment: peerAssignment("host-peer"),
|
|
});
|
|
subscriber.events.onDisconnected();
|
|
|
|
expect(messages).toEqual([{
|
|
type: "route-failed",
|
|
revision: 2,
|
|
phase: "prepare",
|
|
connectionId: null,
|
|
}]);
|
|
});
|
|
|
|
it("buffers an assigned parent offer until SFU fallback retirement completes", async () => {
|
|
const log: string[] = [];
|
|
const bufferedSignals: string[] = [];
|
|
const deliveredSignals: string[] = [];
|
|
let preparedParentPeerId: string | null = null;
|
|
const subscribers: ReturnType<typeof createFakeSubscriber>[] = [];
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer: (assignment) => {
|
|
const peerId =
|
|
assignment.upstream.kind === "peer"
|
|
? assignment.upstream.peerId
|
|
: "none";
|
|
log.push(`peer:${peerId}`);
|
|
if (peerId === preparedParentPeerId) {
|
|
deliveredSignals.push(...bufferedSignals.splice(0));
|
|
preparedParentPeerId = null;
|
|
}
|
|
},
|
|
preparePeer: (assignment) => {
|
|
preparedParentPeerId =
|
|
assignment?.upstream.kind === "peer"
|
|
? assignment.upstream.peerId
|
|
: null;
|
|
},
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: () => true,
|
|
createSubscriber: (events) => {
|
|
const subscriber = createFakeSubscriber(
|
|
events,
|
|
log,
|
|
`subscriber-${subscribers.length + 1}`,
|
|
);
|
|
subscribers.push(subscriber);
|
|
return subscriber;
|
|
},
|
|
});
|
|
const sfu = sfuAssignment();
|
|
|
|
route.accept({ revision: 1, phase: "prepare", assignment: sfu });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
route.accept({ revision: 1, phase: "active", assignment: sfu });
|
|
await vi.waitFor(() =>
|
|
expect(subscribers[0]?.activate).toHaveBeenCalledOnce(),
|
|
);
|
|
subscribers[0]?.events.onStream({} as MediaStream);
|
|
|
|
let finishDisconnect!: () => void;
|
|
const disconnectGate = new Promise<void>((resolve) => {
|
|
finishDisconnect = resolve;
|
|
});
|
|
subscribers[0]?.disconnect.mockImplementation(async () => {
|
|
log.push("subscriber-1:disconnect");
|
|
await disconnectGate;
|
|
});
|
|
|
|
log.length = 0;
|
|
route.accept({
|
|
revision: 2,
|
|
phase: "active",
|
|
assignment: peerAssignment("host-peer"),
|
|
});
|
|
expect(preparedParentPeerId).toBe("host-peer");
|
|
bufferedSignals.push("early-offer");
|
|
expect(deliveredSignals).toEqual([]);
|
|
await vi.waitFor(() =>
|
|
expect(subscribers[0]?.disconnect).toHaveBeenCalledOnce(),
|
|
);
|
|
expect(log).toEqual([
|
|
"subscriber-1:deactivate",
|
|
"subscriber-1:disconnect",
|
|
]);
|
|
|
|
finishDisconnect();
|
|
await vi.waitFor(() => expect(log).toContain("peer:host-peer"));
|
|
expect(log.slice(0, 3)).toEqual([
|
|
"subscriber-1:deactivate",
|
|
"subscriber-1:disconnect",
|
|
"peer:host-peer",
|
|
]);
|
|
expect(deliveredSignals).toEqual(["early-offer"]);
|
|
});
|
|
|
|
it("promotes a proven peer probe before retiring active SFU media", async () => {
|
|
const log: string[] = [];
|
|
let probeReady = false;
|
|
const prepared: Array<{ parent: string | null; revision?: number }> = [];
|
|
const activatePeer = vi.fn(
|
|
async (assignment: ParticipantRouteAssignment, revision?: number) => {
|
|
const parent = assignment.upstream.kind === "peer"
|
|
? assignment.upstream.peerId
|
|
: assignment.upstream.kind;
|
|
log.push(`peer:${parent}:${revision ?? "active"}`);
|
|
return revision === undefined ? undefined : probeReady;
|
|
},
|
|
);
|
|
const { route, subscriber } = await activateViewerSfuRoute({
|
|
activatePeer,
|
|
preparePeer: (assignment, revision) => prepared.push({
|
|
parent: assignment?.upstream.kind === "peer"
|
|
? assignment.upstream.peerId
|
|
: null,
|
|
...(revision === undefined ? {} : { revision }),
|
|
}),
|
|
reconcileSfuChildren: (children) => log.push(`children:${children.join(",")}`),
|
|
onSfuStream: () => undefined,
|
|
send: () => true,
|
|
}, log);
|
|
log.length = 0;
|
|
|
|
const peer = peerAssignment("host-peer", ["relay-child"]);
|
|
route.accept({ revision: 2, phase: "prepare", assignment: peer });
|
|
expect(prepared).toContainEqual({ parent: "host-peer", revision: 2 });
|
|
expect(log).not.toContain("children:relay-child");
|
|
expect(subscriber.deactivate).not.toHaveBeenCalled();
|
|
|
|
route.accept({ revision: 2, phase: "active", assignment: peer });
|
|
await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(peer, 2));
|
|
expect(subscriber.deactivate).not.toHaveBeenCalled();
|
|
expect(subscriber.disconnect).not.toHaveBeenCalled();
|
|
|
|
probeReady = true;
|
|
route.accept({ revision: 2, phase: "active", assignment: peer });
|
|
await vi.waitFor(() => expect(subscriber.disconnect).toHaveBeenCalledOnce());
|
|
expect(log.indexOf("peer:host-peer:2")).toBeLessThan(
|
|
log.indexOf("subscriber:deactivate"),
|
|
);
|
|
expect(log).toContain("children:relay-child");
|
|
expect(activatePeer).not.toHaveBeenCalledWith(peer);
|
|
});
|
|
|
|
it.each([
|
|
["peer Viewer", peerAssignment("current-parent", ["provisional-child"])],
|
|
["SFU root Viewer", sfuAssignment(["provisional-child"])],
|
|
])("uses the prepare/active child lifecycle for a %s", async (_label, assignment) => {
|
|
const prepared: Array<{
|
|
revision?: number;
|
|
children: readonly string[] | null;
|
|
}> = [];
|
|
const activated: Array<{
|
|
revision: number;
|
|
children: readonly string[];
|
|
}> = [];
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer: () => undefined,
|
|
prepareChild: (children, revision) => prepared.push({
|
|
children,
|
|
...(revision === undefined ? {} : { revision }),
|
|
}),
|
|
activateChildren: (children, revision) =>
|
|
activated.push({ children, revision }),
|
|
onSfuStream: () => undefined,
|
|
send: () => true,
|
|
});
|
|
|
|
route.accept({
|
|
revision: 1,
|
|
phase: "active",
|
|
assignment: { ...assignment, childPeerIds: [] },
|
|
});
|
|
await vi.waitFor(() => expect(activated).toEqual([{
|
|
revision: 1,
|
|
children: [],
|
|
}]));
|
|
prepared.length = 0;
|
|
activated.length = 0;
|
|
route.accept({ revision: 2, phase: "prepare", assignment });
|
|
expect(prepared).toEqual([{
|
|
revision: 2,
|
|
children: ["provisional-child"],
|
|
}]);
|
|
route.accept({ revision: 2, phase: "active", assignment });
|
|
await vi.waitFor(() => expect(activated).toEqual([{
|
|
revision: 2,
|
|
children: ["provisional-child"],
|
|
}]));
|
|
});
|
|
|
|
it("prepares a second peer without replacing the active peer before commit", async () => {
|
|
const prepared: Array<{ parent: string | null; revision?: number }> = [];
|
|
const activatePeer = vi.fn();
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer,
|
|
preparePeer: (assignment, revision) => prepared.push({
|
|
parent: assignment?.upstream.kind === "peer"
|
|
? assignment.upstream.peerId
|
|
: null,
|
|
...(revision === undefined ? {} : { revision }),
|
|
}),
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: () => true,
|
|
});
|
|
const oldPeer = peerAssignment("old-parent");
|
|
const newPeer = peerAssignment("new-parent");
|
|
|
|
route.accept({ revision: 1, phase: "active", assignment: oldPeer });
|
|
await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(oldPeer));
|
|
activatePeer.mockClear();
|
|
route.accept({ revision: 2, phase: "prepare", assignment: newPeer });
|
|
expect(prepared.at(-1)).toEqual({ parent: "new-parent", revision: 2 });
|
|
expect(activatePeer).not.toHaveBeenCalled();
|
|
|
|
route.accept({ revision: 3, phase: "active", assignment: oldPeer });
|
|
await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(oldPeer));
|
|
expect(activatePeer).not.toHaveBeenCalledWith(newPeer, 2);
|
|
});
|
|
|
|
it("reports an active failure instead of promoting a failed ready peer probe", async () => {
|
|
const sent: ClientMessage[] = [];
|
|
const activatePeer = vi.fn(() => true);
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer,
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: (message) => { sent.push(message); return true; },
|
|
});
|
|
const oldPeer = peerAssignment("old-parent");
|
|
const newPeer = peerAssignment("new-parent");
|
|
|
|
route.accept({ revision: 1, phase: "active", assignment: oldPeer });
|
|
await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(oldPeer));
|
|
activatePeer.mockClear();
|
|
route.accept({ revision: 2, phase: "prepare", assignment: newPeer });
|
|
expect(route.reportPeerProbeFailure("new-parent", "failed-probe", true)).toBe(true);
|
|
expect(sent).toContainEqual({ type: "route-failed", revision: 2,
|
|
phase: "prepare", connectionId: "failed-probe" });
|
|
|
|
route.accept({ revision: 2, phase: "active", assignment: newPeer });
|
|
await vi.waitFor(() => expect(sent).toContainEqual({ type: "route-failed",
|
|
revision: 2, phase: "active", connectionId: "failed-probe" }));
|
|
expect(activatePeer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks promotion when a ready probe fails after active is accepted", async () => {
|
|
const sent: ClientMessage[] = [];
|
|
const activatePeer = vi.fn(() => true);
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer,
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: (message) => { sent.push(message); return true; },
|
|
});
|
|
const oldPeer = peerAssignment("old-parent");
|
|
const newPeer = peerAssignment("new-parent");
|
|
|
|
route.accept({ revision: 1, phase: "active", assignment: oldPeer });
|
|
await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(oldPeer));
|
|
activatePeer.mockClear();
|
|
route.accept({ revision: 2, phase: "prepare", assignment: newPeer });
|
|
route.accept({ revision: 2, phase: "active", assignment: newPeer });
|
|
expect(route.reportPeerProbeFailure("new-parent", "accepted-failed-probe", true)).toBe(true);
|
|
await vi.waitFor(() => expect(sent).toContainEqual({ type: "route-failed",
|
|
revision: 2, phase: "active", connectionId: "accepted-failed-probe" }));
|
|
expect(activatePeer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears a failed peer probe tombstone on rollback", async () => {
|
|
const sent: ClientMessage[] = [];
|
|
const prepared: Array<{ parent: string | null; revision?: number }> = [];
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer: () => true,
|
|
preparePeer: (assignment, revision) => prepared.push({
|
|
parent: assignment?.upstream.kind === "peer"
|
|
? assignment.upstream.peerId
|
|
: null,
|
|
...(revision === undefined ? {} : { revision }),
|
|
}),
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: (message) => { sent.push(message); return true; },
|
|
});
|
|
const oldPeer = peerAssignment("old-parent");
|
|
|
|
route.accept({ revision: 1, phase: "active", assignment: oldPeer });
|
|
route.accept({ revision: 2, phase: "prepare", assignment: peerAssignment("new-parent") });
|
|
route.reportPeerProbeFailure("new-parent", "rolled-back-probe", true);
|
|
route.accept({ revision: 3, phase: "active", assignment: oldPeer });
|
|
await vi.waitFor(() => expect(prepared.at(-1)).toEqual({ parent: "old-parent" }));
|
|
expect(sent).not.toContainEqual(expect.objectContaining({
|
|
phase: "active", connectionId: "rolled-back-probe",
|
|
}));
|
|
});
|
|
|
|
it("clears a failed peer probe tombstone on authoritative resync", async () => {
|
|
const sent: ClientMessage[] = [];
|
|
const activatePeer = vi.fn(() => true);
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer,
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: (message) => { sent.push(message); return true; },
|
|
});
|
|
const oldPeer = peerAssignment("old-parent");
|
|
const newPeer = peerAssignment("new-parent");
|
|
|
|
route.accept({ revision: 1, phase: "active", assignment: oldPeer });
|
|
await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(oldPeer));
|
|
route.accept({ revision: 2, phase: "prepare", assignment: newPeer });
|
|
route.reportPeerProbeFailure("new-parent", "resynced-probe", true);
|
|
activatePeer.mockClear();
|
|
await route.resyncAuthoritative({ revision: 2, phase: "active", assignment: newPeer });
|
|
await vi.waitFor(() => expect(activatePeer).toHaveBeenCalledWith(newPeer));
|
|
expect(sent).not.toContainEqual(expect.objectContaining({
|
|
phase: "active", connectionId: "resynced-probe",
|
|
}));
|
|
});
|
|
|
|
it("requests provisional peer teardown on a newer SFU rollback", async () => {
|
|
const prepared: Array<{ parent: string | null; revision?: number }> = [];
|
|
const { route, subscriber } = await activateViewerSfuRoute({
|
|
activatePeer: () => undefined,
|
|
preparePeer: (assignment, revision) => prepared.push({
|
|
parent: assignment?.upstream.kind === "peer"
|
|
? assignment.upstream.peerId
|
|
: null,
|
|
...(revision === undefined ? {} : { revision }),
|
|
}),
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: () => true,
|
|
});
|
|
|
|
route.accept({
|
|
revision: 2,
|
|
phase: "prepare",
|
|
assignment: peerAssignment("host-peer"),
|
|
});
|
|
expect(prepared.at(-1)).toEqual({ parent: "host-peer", revision: 2 });
|
|
route.accept({ revision: 3, phase: "active", assignment: sfuAssignment() });
|
|
expect(prepared.at(-1)).toEqual({ parent: null });
|
|
expect(subscriber.deactivate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("re-proves a sticky SFU route after a cooldown-bound authority reassertion", async () => {
|
|
const healthy: number[] = [];
|
|
const messages: ClientMessage[] = [];
|
|
let subscriber!: ReturnType<typeof createFakeSubscriber>;
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer: () => undefined,
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
onHealthySfu: (revision) => healthy.push(revision),
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createSubscriber: (events) => {
|
|
subscriber = createFakeSubscriber(events, [], "subscriber");
|
|
return subscriber;
|
|
},
|
|
});
|
|
|
|
await route.resyncAuthoritative({
|
|
revision: 7,
|
|
phase: "active",
|
|
assignment: sfuAssignment(),
|
|
});
|
|
route.armHealthySfuReselection(7);
|
|
await route.acceptConfig(sfuConfig(7));
|
|
await vi.waitFor(() => expect(subscriber.activate).toHaveBeenCalledOnce());
|
|
expect(messages).not.toContainEqual({
|
|
type: "route-ready",
|
|
revision: 7,
|
|
phase: "active",
|
|
});
|
|
subscriber.events.onStream({} as MediaStream);
|
|
expect(messages).toContainEqual({
|
|
type: "route-ready",
|
|
revision: 7,
|
|
phase: "active",
|
|
});
|
|
subscriber.events.onStats?.({
|
|
intervalPacketsReceived: 4,
|
|
intervalFramesDecoded: 0,
|
|
} as ConnectionMetrics);
|
|
subscriber.events.onStats?.({
|
|
intervalPacketsReceived: 4,
|
|
intervalFramesDecoded: 3,
|
|
} as ConnectionMetrics);
|
|
expect(healthy).toEqual([]);
|
|
|
|
route.armHealthySfuReselection(7);
|
|
subscriber.events.onStats?.({
|
|
intervalPacketsReceived: 4,
|
|
intervalFramesDecoded: 3,
|
|
} as ConnectionMetrics);
|
|
subscriber.events.onStats?.({
|
|
intervalPacketsReceived: 2,
|
|
intervalFramesDecoded: 1,
|
|
} as ConnectionMetrics);
|
|
expect(healthy).toEqual([7]);
|
|
|
|
expect(route.accept({
|
|
revision: 7,
|
|
phase: "active",
|
|
assignment: sfuAssignment(),
|
|
})).toBe("duplicate");
|
|
subscriber.events.onStats?.({
|
|
intervalPacketsReceived: 3,
|
|
intervalFramesDecoded: 2,
|
|
} as ConnectionMetrics);
|
|
subscriber.events.onStats?.({
|
|
intervalPacketsReceived: 5,
|
|
intervalFramesDecoded: 4,
|
|
} as ConnectionMetrics);
|
|
expect(healthy).toEqual([7, 7]);
|
|
});
|
|
|
|
it("switches on first SFU video and forwards loss across same-publication revision reuse", async () => {
|
|
const log: string[] = [];
|
|
const messages: ClientMessage[] = [];
|
|
const peerAssignments: ParticipantRouteAssignment[] = [];
|
|
const streams: Array<{
|
|
stream: MediaStream;
|
|
initialVideoStream: boolean;
|
|
assignment: ParticipantRouteAssignment;
|
|
}> = [];
|
|
const sfuUpdates: Array<ConnectionMetrics | null> = [];
|
|
const sfuStates: string[] = [];
|
|
const videoAvailability: boolean[] = [];
|
|
const metrics = {} as ConnectionMetrics;
|
|
const subscribers: ReturnType<typeof createFakeSubscriber>[] = [];
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer: (assignment) => {
|
|
peerAssignments.push(assignment);
|
|
},
|
|
reconcileSfuChildren: (children) => log.push(`children:${children.join(",")}`),
|
|
onSfuStream: (stream, assignment, initialVideoStream) => {
|
|
streams.push({ stream, assignment, initialVideoStream });
|
|
},
|
|
onSfuUpdate: (update) => sfuUpdates.push(update),
|
|
onSfuState: (state) => sfuStates.push(state),
|
|
onSfuVideoAvailability: (available) =>
|
|
videoAvailability.push(available),
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createSubscriber: (events) => {
|
|
const subscriber = createFakeSubscriber(
|
|
events,
|
|
log,
|
|
`subscriber-${subscribers.length + 1}`,
|
|
);
|
|
subscribers.push(subscriber);
|
|
return subscriber;
|
|
},
|
|
});
|
|
|
|
route.accept({
|
|
revision: 1,
|
|
phase: "active",
|
|
assignment: peerAssignment("host-peer"),
|
|
});
|
|
await vi.waitFor(() => expect(peerAssignments).toHaveLength(1));
|
|
const nextAssignment = sfuAssignment(["relay-child"]);
|
|
route.accept({ revision: 2, phase: "prepare", assignment: nextAssignment });
|
|
await route.acceptConfig(sfuConfig(2));
|
|
|
|
expect(peerAssignments).toHaveLength(1);
|
|
expect(streams).toHaveLength(0);
|
|
expect(subscribers[0]?.activate).not.toHaveBeenCalled();
|
|
expect(messages).toContainEqual({
|
|
type: "route-ready",
|
|
revision: 2,
|
|
phase: "prepare",
|
|
});
|
|
|
|
route.accept({ revision: 2, phase: "active", assignment: nextAssignment });
|
|
await vi.waitFor(() =>
|
|
expect(subscribers[0]?.activate).toHaveBeenCalledOnce(),
|
|
);
|
|
expect(streams).toHaveLength(0);
|
|
expect(messages).not.toContainEqual({
|
|
type: "route-ready",
|
|
revision: 2,
|
|
phase: "active",
|
|
});
|
|
|
|
subscribers[0]?.events.onVideoAvailability?.(false);
|
|
subscribers[0]?.events.onVideoAvailability?.(true);
|
|
expect(videoAvailability).toEqual([]);
|
|
subscribers[0]?.events.onStats?.(metrics);
|
|
subscribers[0]?.events.onState?.("reconnecting");
|
|
expect(sfuUpdates).toEqual([]);
|
|
expect(sfuStates).toEqual([]);
|
|
|
|
const stream = {} as MediaStream;
|
|
subscribers[0]?.events.onStream(stream);
|
|
subscribers[0]?.events.onStats?.(metrics);
|
|
subscribers[0]?.events.onState?.("reconnecting");
|
|
expect(sfuUpdates).toEqual([metrics]);
|
|
expect(sfuStates).toEqual(["reconnecting"]);
|
|
expect(streams).toEqual([
|
|
{ stream, assignment: nextAssignment, initialVideoStream: true },
|
|
]);
|
|
expect(messages).toContainEqual({
|
|
type: "route-ready",
|
|
revision: 2,
|
|
phase: "active",
|
|
});
|
|
|
|
subscribers[0]?.events.onVideoAvailability?.(false);
|
|
subscribers[0]?.events.onVideoAvailability?.(true);
|
|
expect(videoAvailability).toEqual([false, true]);
|
|
expect(messages).toContainEqual({
|
|
type: "route-media-unavailable",
|
|
revision: 2,
|
|
});
|
|
expect(
|
|
messages.filter(
|
|
(message) =>
|
|
message.type === "route-ready" &&
|
|
message.revision === 2 &&
|
|
message.phase === "active",
|
|
),
|
|
).toHaveLength(2);
|
|
|
|
const revisionThreeAssignment = sfuAssignment(["other-child"]);
|
|
route.accept({
|
|
revision: 3,
|
|
phase: "active",
|
|
assignment: revisionThreeAssignment,
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(messages).toContainEqual({
|
|
type: "route-ready",
|
|
revision: 3,
|
|
phase: "active",
|
|
}),
|
|
);
|
|
expect(subscribers).toHaveLength(1);
|
|
expect(subscribers[0]?.deactivate).not.toHaveBeenCalled();
|
|
subscribers[0]?.events.onVideoAvailability?.(false);
|
|
expect(videoAvailability).toEqual([false, true, false]);
|
|
expect(messages).toContainEqual({
|
|
type: "route-media-unavailable",
|
|
revision: 3,
|
|
});
|
|
|
|
route.accept({
|
|
revision: 4,
|
|
phase: "prepare",
|
|
assignment: sfuAssignment(),
|
|
});
|
|
await route.acceptConfig(sfuConfig(4));
|
|
route.accept({
|
|
revision: 5,
|
|
phase: "active",
|
|
assignment: sfuAssignment(["other-child"]),
|
|
});
|
|
await vi.waitFor(() => expect(subscribers[1]?.disconnect).toHaveBeenCalled());
|
|
expect(subscribers[0]?.deactivate).not.toHaveBeenCalled();
|
|
expect(streams).toHaveLength(1);
|
|
expect(messages).not.toContainEqual({
|
|
type: "route-ready",
|
|
revision: 5,
|
|
phase: "active",
|
|
});
|
|
|
|
await route.disconnect();
|
|
expect(sfuUpdates).toEqual([metrics, null]);
|
|
subscribers[0]?.events.onStats?.(metrics);
|
|
subscribers[0]?.events.onState?.("connected");
|
|
subscribers[0]?.events.onVideoAvailability?.(false);
|
|
expect(sfuUpdates).toEqual([metrics, null]);
|
|
expect(sfuStates).toEqual(["reconnecting"]);
|
|
expect(videoAvailability).toEqual([false, true, false]);
|
|
});
|
|
|
|
it("rebuilds an authoritative SFU route before proving a replaced publication", async () => {
|
|
const log: string[] = [];
|
|
const messages: ClientMessage[] = [];
|
|
const resetMedia = vi.fn();
|
|
const subscribers: ReturnType<typeof createFakeSubscriber>[] = [];
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer: () => undefined,
|
|
resetMedia,
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createSubscriber: (events) => {
|
|
const subscriber = createFakeSubscriber(
|
|
events,
|
|
log,
|
|
`subscriber-${subscribers.length + 1}`,
|
|
);
|
|
subscribers.push(subscriber);
|
|
return subscriber;
|
|
},
|
|
});
|
|
const assignment = sfuAssignment();
|
|
|
|
route.accept({ revision: 1, phase: "prepare", assignment });
|
|
await route.acceptConfig(sfuConfig(1));
|
|
route.accept({ revision: 1, phase: "active", assignment });
|
|
await vi.waitFor(() =>
|
|
expect(subscribers[0]?.activate).toHaveBeenCalledOnce(),
|
|
);
|
|
subscribers[0]?.events.onStream({} as MediaStream);
|
|
messages.length = 0;
|
|
log.length = 0;
|
|
|
|
await expect(
|
|
route.resyncAuthoritative({
|
|
revision: 2,
|
|
phase: "active",
|
|
assignment,
|
|
}),
|
|
).resolves.toBe("accepted");
|
|
|
|
expect(resetMedia).toHaveBeenCalledOnce();
|
|
expect(log).toEqual([
|
|
"subscriber-1:deactivate",
|
|
"subscriber-1:disconnect",
|
|
]);
|
|
expect(messages).not.toContainEqual({
|
|
type: "route-ready",
|
|
revision: 2,
|
|
phase: "active",
|
|
});
|
|
|
|
await route.acceptConfig(sfuConfig(2));
|
|
await vi.waitFor(() =>
|
|
expect(subscribers[1]?.activate).toHaveBeenCalledOnce(),
|
|
);
|
|
expect(subscribers).toHaveLength(2);
|
|
expect(messages).not.toContainEqual({
|
|
type: "route-ready",
|
|
revision: 2,
|
|
phase: "active",
|
|
});
|
|
|
|
subscribers[1]?.events.onStream({} as MediaStream);
|
|
expect(messages).toContainEqual({
|
|
type: "route-ready",
|
|
revision: 2,
|
|
phase: "active",
|
|
});
|
|
|
|
messages.length = 0;
|
|
route.accept({ revision: 3, phase: "active", assignment });
|
|
await vi.waitFor(() =>
|
|
expect(messages).toContainEqual({
|
|
type: "route-ready",
|
|
revision: 3,
|
|
phase: "active",
|
|
}),
|
|
);
|
|
expect(subscribers).toHaveLength(2);
|
|
expect(subscribers[1]?.deactivate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("disconnects a subscriber whose connect completes after rollback", async () => {
|
|
let finishConnect!: () => void;
|
|
const connectGate = new Promise<void>((resolve) => {
|
|
finishConnect = resolve;
|
|
});
|
|
const messages: ClientMessage[] = [];
|
|
const subscriber = {
|
|
connect: vi.fn(async () => {
|
|
await connectGate;
|
|
return true;
|
|
}),
|
|
activate: vi.fn(() => true),
|
|
deactivate: vi.fn(() => true),
|
|
disconnect: vi.fn(async () => undefined),
|
|
};
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer: () => undefined,
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createSubscriber: () => subscriber,
|
|
});
|
|
|
|
route.accept({
|
|
revision: 10,
|
|
phase: "prepare",
|
|
assignment: sfuAssignment(),
|
|
});
|
|
const connecting = route.acceptConfig(sfuConfig(10));
|
|
route.accept({
|
|
revision: 11,
|
|
phase: "active",
|
|
assignment: peerAssignment("host-peer"),
|
|
});
|
|
finishConnect();
|
|
await connecting;
|
|
|
|
expect(subscriber.activate).not.toHaveBeenCalled();
|
|
expect(subscriber.disconnect).toHaveBeenCalled();
|
|
expect(messages).not.toContainEqual({
|
|
type: "route-ready",
|
|
revision: 10,
|
|
phase: "prepare",
|
|
});
|
|
});
|
|
|
|
it("refreshes once after terminal loss and reports a failed rebuild", async () => {
|
|
const messages: ClientMessage[] = [];
|
|
const subscribers: ReturnType<typeof createFakeSubscriber>[] = [];
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer: () => undefined,
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: (message) => {
|
|
messages.push(message);
|
|
return true;
|
|
},
|
|
createSubscriber: (events) => {
|
|
const subscriber = createFakeSubscriber(
|
|
events,
|
|
[],
|
|
`subscriber-${subscribers.length + 1}`,
|
|
);
|
|
if (subscribers.length === 1) {
|
|
subscriber.connect.mockResolvedValue(false);
|
|
}
|
|
subscribers.push(subscriber);
|
|
return subscriber;
|
|
},
|
|
});
|
|
const assignment = sfuAssignment();
|
|
|
|
route.accept({ revision: 20, phase: "prepare", assignment });
|
|
await route.acceptConfig(sfuConfig(20));
|
|
route.accept({ revision: 20, phase: "active", assignment });
|
|
await vi.waitFor(() =>
|
|
expect(subscribers[0]?.activate).toHaveBeenCalledOnce(),
|
|
);
|
|
subscribers[0]?.events.onStream({} as MediaStream);
|
|
messages.length = 0;
|
|
|
|
subscribers[0]?.events.onDisconnected();
|
|
expect(messages).toEqual([{ type: "refresh-sfu", revision: 20 }]);
|
|
|
|
await route.acceptConfig(sfuConfig(20));
|
|
expect(messages).toEqual([
|
|
{ type: "refresh-sfu", revision: 20 },
|
|
{
|
|
type: "route-failed",
|
|
revision: 20,
|
|
phase: "active",
|
|
connectionId: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("retires old media before applying a lower authoritative viewer revision", async () => {
|
|
const log: string[] = [];
|
|
const bufferedSignals: string[] = [];
|
|
const deliveredSignals: string[] = [];
|
|
let preparedParentPeerId: string | null = null;
|
|
const subscribers: ReturnType<typeof createFakeSubscriber>[] = [];
|
|
const route = new ViewerSfuRoute({
|
|
activatePeer: (assignment) => {
|
|
const upstream = assignment.upstream;
|
|
log.push(
|
|
`peer:${upstream.kind === "peer" ? upstream.peerId : upstream.kind}`,
|
|
);
|
|
if (
|
|
upstream.kind === "peer" &&
|
|
upstream.peerId === preparedParentPeerId
|
|
) {
|
|
deliveredSignals.push(...bufferedSignals.splice(0));
|
|
preparedParentPeerId = null;
|
|
}
|
|
},
|
|
preparePeer: (assignment) => {
|
|
preparedParentPeerId =
|
|
assignment?.upstream.kind === "peer"
|
|
? assignment.upstream.peerId
|
|
: null;
|
|
},
|
|
resetMedia: () => log.push("reset-media"),
|
|
reconcileSfuChildren: () => undefined,
|
|
onSfuStream: () => undefined,
|
|
send: () => true,
|
|
createSubscriber: (events) => {
|
|
const subscriber = createFakeSubscriber(
|
|
events,
|
|
log,
|
|
`subscriber-${subscribers.length + 1}`,
|
|
);
|
|
subscribers.push(subscriber);
|
|
return subscriber;
|
|
},
|
|
});
|
|
|
|
route.accept({ revision: 10, phase: "prepare", assignment: sfuAssignment() });
|
|
await route.acceptConfig(sfuConfig(10));
|
|
route.accept({ revision: 10, phase: "active", assignment: sfuAssignment() });
|
|
await vi.waitFor(() =>
|
|
expect(subscribers[0]?.activate).toHaveBeenCalledOnce(),
|
|
);
|
|
subscribers[0]?.events.onStream({} as MediaStream);
|
|
log.length = 0;
|
|
|
|
let finishDisconnect!: () => void;
|
|
const disconnectGate = new Promise<void>((resolve) => {
|
|
finishDisconnect = resolve;
|
|
});
|
|
subscribers[0]?.disconnect.mockImplementation(async () => {
|
|
log.push("subscriber-1:disconnect");
|
|
await disconnectGate;
|
|
});
|
|
|
|
const authoritative = peerAssignment("new-host-peer");
|
|
const resync = route.resyncAuthoritative({
|
|
revision: 1,
|
|
phase: "active",
|
|
assignment: authoritative,
|
|
});
|
|
expect(preparedParentPeerId).toBe("new-host-peer");
|
|
bufferedSignals.push("early-resync-offer");
|
|
expect(deliveredSignals).toEqual([]);
|
|
await vi.waitFor(() =>
|
|
expect(subscribers[0]?.disconnect).toHaveBeenCalledOnce(),
|
|
);
|
|
expect(log).toEqual([
|
|
"reset-media",
|
|
"subscriber-1:deactivate",
|
|
"subscriber-1:disconnect",
|
|
]);
|
|
|
|
finishDisconnect();
|
|
await expect(resync).resolves.toBe("accepted");
|
|
expect(log).toEqual([
|
|
"reset-media",
|
|
"subscriber-1:deactivate",
|
|
"subscriber-1:disconnect",
|
|
"peer:new-host-peer",
|
|
]);
|
|
expect(deliveredSignals).toEqual(["early-resync-offer"]);
|
|
|
|
await expect(
|
|
route.resyncAuthoritative({
|
|
revision: 1,
|
|
phase: "active",
|
|
assignment: authoritative,
|
|
}),
|
|
).resolves.toBe("duplicate");
|
|
expect(subscribers[0]?.disconnect).toHaveBeenCalledOnce();
|
|
});
|
|
});
|