Outgoing voice was gated on the observer's roster copy of the remote user's voice state, which is signaling gossip. The signal server broadcasts `user_left` for any socket it declares dead, so a suspended laptop or a flaky hop wiped that copy and the observer detached its microphone from a peer that never left the channel - a silent member with no way back through the UI. `decideVoicePathRouting` now closes a path only on positive evidence: we left voice, the peer itself reported another channel or none, or the connection is gone. Missing gossip holds an established path instead. Opening still needs confirmation, so a guess never starts sending; the same rule gates playback, camera video, and the microphone a new connection puts in its first offer. Peers are also asked for their voice state when a connection or data channel comes up, so a rebuilt path re-confirms itself. Alongside it, the microphone can be switched mid-call: capture moves to a device service and rules, the live track is swapped with `replaceTrack` so the session is never renegotiated, and the speaking indicator follows the new stream.
253 lines
9.0 KiB
TypeScript
253 lines
9.0 KiB
TypeScript
import { type Page } from '@playwright/test';
|
|
import { test } from '../../fixtures/multi-client';
|
|
import { createVoicePairInNewServer, type VoicePairClient } from '../../helpers/voice-session';
|
|
import {
|
|
dumpRtcDiagnostics,
|
|
getAudioStatsDelta,
|
|
waitForConnectedPeerCount,
|
|
waitForOpenDataChannelCount
|
|
} from '../../helpers/webrtc-helpers';
|
|
|
|
/**
|
|
* The signal server pings every 30s and gives up on a socket 45s after the last pong,
|
|
* so it needs up to 75s to declare a client dead and broadcast `user_left`.
|
|
*/
|
|
const DEAD_SOCKET_HOLD_MS = 95_000;
|
|
|
|
/**
|
|
* Outgoing voice used to be gated on the observer's roster copy of the remote user's
|
|
* voice state, which is signaling gossip. The signal server broadcasts `user_left` for
|
|
* any socket it declares dead, so a sleeping laptop, a flaky wifi hop, or a dropped
|
|
* socket wiped that copy - and the observer cut its microphone to a peer that never
|
|
* left the channel.
|
|
*/
|
|
test.describe('Losing a peer from the roster must not silence the call', () => {
|
|
// The roster wipe is injected directly, because reproducing it through a real outage
|
|
// depends on whether the observer notices the dead transport before `user_left`
|
|
// arrives - the reducer keeps the voice state while a live peer transport exists.
|
|
test('keeps sending to a peer the roster forgot', async ({ createClient }) => {
|
|
test.setTimeout(300_000);
|
|
|
|
const clients = await createVoicePairInNewServer(
|
|
createClient,
|
|
`Roster Wipe Voice ${Date.now()}`,
|
|
{ namePrefix: 'Roster Wipe' }
|
|
);
|
|
const [peer, observer] = clients;
|
|
|
|
for (const client of clients) {
|
|
await assertTwoWayAudio(client, 'before the roster wipe');
|
|
}
|
|
|
|
await test.step('The observer is told the peer left the server', async () => {
|
|
const wipedUserId = await wipeRemoteVoiceMembersFromRoster(observer.page);
|
|
|
|
test.info().annotations.push({ type: 'wiped user', description: wipedUserId });
|
|
await waitForNoRemoteVoiceMembersInRoster(observer.page, 15_000);
|
|
});
|
|
|
|
// Nothing about the media plane changed, so the peer must not lose a single second of
|
|
// audio. Checking only the end state would hide the cut: the peer keeps sending voice
|
|
// heartbeats, so the roster heals itself moments later.
|
|
await test.step('The peer never stops receiving the observer microphone', async () => {
|
|
await assertUninterruptedInboundAudio(peer, 10);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The sleep/wake shape without a suspend: one client loses its signal socket long
|
|
* enough for the server to declare it dead, and its peer connections die with it. When
|
|
* everything returns the peer re-identifies with no voice state attached, so asking the
|
|
* peer over the rebuilt data channel is the only thing that can confirm it is still in
|
|
* our channel.
|
|
*
|
|
* `recovery-preserves-media.spec.ts` cannot reach this: killing the server leaves
|
|
* nobody to broadcast `user_left`.
|
|
*/
|
|
test('restores two-way voice after the server declares one client dead', async ({ createClient }) => {
|
|
test.setTimeout(600_000);
|
|
|
|
const clients = await createVoicePairInNewServer(
|
|
createClient,
|
|
`Roster Loss Voice ${Date.now()}`,
|
|
{ namePrefix: 'Roster Loss' }
|
|
);
|
|
const [droppedClient, observer] = clients;
|
|
|
|
for (const client of clients) {
|
|
await assertTwoWayAudio(client, 'before the outage');
|
|
}
|
|
|
|
await test.step('One client loses its signal socket and its peer connections', async () => {
|
|
await droppedClient.context.setOffline(true);
|
|
await closeTrackedPeerConnections(droppedClient.page);
|
|
await observer.page.waitForTimeout(DEAD_SOCKET_HOLD_MS);
|
|
});
|
|
|
|
await test.step('Both clients are two-way again once the socket returns', async () => {
|
|
await droppedClient.context.setOffline(false);
|
|
|
|
for (const client of clients) {
|
|
await waitForConnectedPeerCount(client.page, 1, 180_000);
|
|
await waitForOpenDataChannelCount(client.page, 1, 180_000);
|
|
}
|
|
|
|
for (const client of clients) {
|
|
await assertTwoWayAudio(client, 'after the socket returned', 90_000);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
/** Fail unless the client both sends and receives voice packets within the timeout. */
|
|
async function assertTwoWayAudio(
|
|
client: VoicePairClient,
|
|
label: string,
|
|
timeoutMs = 60_000
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
let outboundPacketsDelta = 0;
|
|
let inboundPacketsDelta = 0;
|
|
|
|
while (Date.now() < deadline) {
|
|
({ outboundPacketsDelta, inboundPacketsDelta } = await getAudioStatsDelta(client.page, 3_000));
|
|
|
|
if (outboundPacketsDelta > 0 && inboundPacketsDelta > 0) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`${client.displayName} is not two-way ${label}: sent ${outboundPacketsDelta}, `
|
|
+ `received ${inboundPacketsDelta} packets in the last sample.\n`
|
|
+ await dumpRtcDiagnostics(client.page)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fail if the client goes even one second without receiving voice packets. Peers gossip
|
|
* their voice state every 5s, so a torn-down microphone comes back on its own - only a
|
|
* continuous sample can tell that the audio never stopped.
|
|
*/
|
|
async function assertUninterruptedInboundAudio(
|
|
client: VoicePairClient,
|
|
seconds: number
|
|
): Promise<void> {
|
|
for (let sample = 1; sample <= seconds; sample++) {
|
|
const { inboundPacketsDelta } = await getAudioStatsDelta(client.page, 1_000);
|
|
|
|
if (inboundPacketsDelta === 0) {
|
|
throw new Error(
|
|
`${client.displayName} stopped receiving voice ${sample}s after the roster wipe.\n`
|
|
+ await dumpRtcDiagnostics(client.page)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Kill the media plane the way a suspend does, leaving the peer to notice on its own. */
|
|
async function closeTrackedPeerConnections(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
const connections = (window as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? [];
|
|
|
|
for (const connection of connections) {
|
|
connection.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Replay what the signal server does when it declares a socket dead: tell this client the
|
|
* remote user left the server, with no live transport recorded. Returns the wiped user id.
|
|
*/
|
|
async function wipeRemoteVoiceMembersFromRoster(page: Page): Promise<string> {
|
|
return page.evaluate(() => {
|
|
interface RosterUser {
|
|
id?: string;
|
|
oderId?: string;
|
|
peerId?: string;
|
|
voiceState?: { isConnected?: boolean };
|
|
}
|
|
interface StoreLike {
|
|
dispatch: (action: { type: string } & Record<string, unknown>) => void;
|
|
}
|
|
interface AngularDebugApi {
|
|
getComponent: (element: Element) => Record<string, unknown>;
|
|
}
|
|
|
|
const host = document.querySelector('app-rooms-side-panel');
|
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
|
|
if (!host || !debugApi?.getComponent) {
|
|
throw new Error('Angular debug API is unavailable, cannot reach the store');
|
|
}
|
|
|
|
const component = debugApi.getComponent(host);
|
|
const store = component['store'] as StoreLike | undefined;
|
|
const users = (component['onlineUsers'] as (() => RosterUser[]) | undefined)?.() ?? [];
|
|
const currentUser = (component['currentUser'] as (() => RosterUser | null) | undefined)?.() ?? null;
|
|
const currentRoom = (component['currentRoom'] as (() => { id?: string } | null) | undefined)?.() ?? null;
|
|
const remoteVoiceUser = users.find((user) =>
|
|
user.voiceState?.isConnected === true
|
|
&& user.id !== currentUser?.id
|
|
&& user.oderId !== currentUser?.oderId);
|
|
|
|
if (!store || !remoteVoiceUser?.id || !currentRoom?.id) {
|
|
throw new Error('No remote voice member to wipe from the roster');
|
|
}
|
|
|
|
store.dispatch({
|
|
type: '[Users] User Left',
|
|
userId: remoteVoiceUser.id,
|
|
serverId: currentRoom.id,
|
|
connectedPeerIds: []
|
|
});
|
|
|
|
return remoteVoiceUser.id;
|
|
});
|
|
}
|
|
|
|
/** Wait until no remote user in the client's roster claims to be in voice. */
|
|
async function waitForNoRemoteVoiceMembersInRoster(page: Page, timeout: number): Promise<void> {
|
|
await page.waitForFunction(
|
|
() => {
|
|
interface RosterUser {
|
|
id?: string;
|
|
oderId?: string;
|
|
peerId?: string;
|
|
voiceState?: { isConnected?: boolean };
|
|
}
|
|
interface AngularDebugApi {
|
|
getComponent: (element: Element) => Record<string, unknown>;
|
|
}
|
|
|
|
const host = document.querySelector('app-rooms-side-panel');
|
|
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
|
|
|
if (!host || !debugApi?.getComponent) {
|
|
return false;
|
|
}
|
|
|
|
const component = debugApi.getComponent(host);
|
|
const users = (component['onlineUsers'] as (() => RosterUser[]) | undefined)?.() ?? [];
|
|
const currentUser = (component['currentUser'] as (() => RosterUser | null) | undefined)?.() ?? null;
|
|
const selfIds = new Set([
|
|
currentUser?.id,
|
|
currentUser?.oderId,
|
|
currentUser?.peerId
|
|
].filter(Boolean));
|
|
|
|
return users
|
|
.filter((user) => ![
|
|
user.id,
|
|
user.oderId,
|
|
user.peerId
|
|
].some((id) => !!id && selfIds.has(id)))
|
|
.every((user) => user.voiceState?.isConnected !== true);
|
|
},
|
|
undefined,
|
|
{ timeout }
|
|
);
|
|
}
|