Files
Toju/e2e/tests/voice/roster-loss-preserves-voice.spec.ts
myxelium 92c2f578e2 fix(voice): route media on evidence and switch devices without dropping the call
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.
2026-08-14 03:19:29 +02:00

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 }
);
}