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.
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import { countCreatedPeerConnections } from '../../helpers/peer-role';
|
||||
import { openSettingsDetailPage } from '../../helpers/settings-modal';
|
||||
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||
import {
|
||||
dumpRtcDiagnostics,
|
||||
installAutoResumeAudioContext,
|
||||
installWebRTCTracking,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForOpenDataChannelCount
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
|
||||
interface VoiceClient extends Client {
|
||||
displayName: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
test.describe('Live audio device change', () => {
|
||||
test('switching the microphone mid-call keeps both directions of audio alive', async ({ createClient }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const clients = await createVoicePair(createClient, `Mic Swap ${Date.now()}`);
|
||||
const [alice, bob] = clients;
|
||||
|
||||
await assertMeshAudio(clients, 'initial two-user voice');
|
||||
|
||||
const connectionsBefore = {
|
||||
alice: await countCreatedPeerConnections(alice.page),
|
||||
bob: await countCreatedPeerConnections(bob.page)
|
||||
};
|
||||
const sentTracksBefore = await readOutboundAudioTrackIds(alice.page);
|
||||
|
||||
expect(sentTracksBefore, 'Alice should be sending audio before the switch').toHaveLength(1);
|
||||
|
||||
await test.step('Alice picks a different microphone from voice settings', async () => {
|
||||
await openVoiceSettings(alice.page);
|
||||
|
||||
const alternateDeviceId = await readAlternateInputDeviceId(alice.page);
|
||||
|
||||
await startVoiceStateWatch(alice.page);
|
||||
await alice.page.getByTestId('voice-settings-input-device').selectOption(alternateDeviceId);
|
||||
|
||||
// The swap re-captures the microphone; give it a moment before reading senders.
|
||||
await expect
|
||||
.poll(async () => (await readOutboundAudioTrackIds(alice.page))[0], { timeout: 20_000 })
|
||||
.not.toBe(sentTracksBefore[0]);
|
||||
});
|
||||
|
||||
await test.step('The session was never interrupted', async () => {
|
||||
const drops = await stopVoiceStateWatch(alice.page);
|
||||
|
||||
expect(drops, 'Alice left and rejoined voice instead of swapping the track').toBe(0);
|
||||
|
||||
expect(
|
||||
await countCreatedPeerConnections(alice.page),
|
||||
'Alice rebuilt her peer connection to change microphone'
|
||||
).toBe(connectionsBefore.alice);
|
||||
|
||||
expect(
|
||||
await countCreatedPeerConnections(bob.page),
|
||||
'Bob rebuilt his peer connection because Alice changed microphone'
|
||||
).toBe(connectionsBefore.bob);
|
||||
});
|
||||
|
||||
await test.step('Audio still flows both ways on the new microphone', async () => {
|
||||
await waitForConnectedPeerCount(alice.page, 1, 30_000);
|
||||
await waitForConnectedPeerCount(bob.page, 1, 30_000);
|
||||
await assertMeshAudio(clients, 'after microphone switch');
|
||||
});
|
||||
});
|
||||
|
||||
test('switching the speaker mid-call keeps remote audio playing', async ({ createClient }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const clients = await createVoicePair(createClient, `Speaker Swap ${Date.now()}`);
|
||||
const [alice] = clients;
|
||||
|
||||
await assertMeshAudio(clients, 'initial two-user voice');
|
||||
|
||||
await openVoiceSettings(alice.page);
|
||||
|
||||
const alternateDeviceId = await readAlternateOutputDeviceId(alice.page);
|
||||
|
||||
test.skip(alternateDeviceId === null, 'This browser exposes no audio output devices');
|
||||
|
||||
await startVoiceStateWatch(alice.page);
|
||||
await alice.page.getByTestId('voice-settings-output-device').selectOption(alternateDeviceId as string);
|
||||
|
||||
await expect
|
||||
.poll(async () => readPreferredOutputDeviceId(alice.page), { timeout: 20_000 })
|
||||
.toBe(alternateDeviceId === '' ? 'default' : alternateDeviceId);
|
||||
|
||||
expect(await stopVoiceStateWatch(alice.page), 'Changing the speaker dropped Alice out of voice').toBe(0);
|
||||
|
||||
await assertMeshAudio(clients, 'after speaker switch');
|
||||
});
|
||||
});
|
||||
|
||||
async function openVoiceSettings(page: Page): Promise<void> {
|
||||
await openSettingsDetailPage(page, 'voice');
|
||||
await expect(page.getByTestId('voice-settings-input-device')).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** The picker value to switch to: any real device, else the system-default entry. */
|
||||
async function readAlternateInputDeviceId(page: Page): Promise<string> {
|
||||
const select = page.getByTestId('voice-settings-input-device');
|
||||
const currentValue = await select.inputValue();
|
||||
const values = await select.locator('option').evaluateAll(
|
||||
(options) => options.map((option) => (option as HTMLOptionElement).value)
|
||||
);
|
||||
const alternate = values.find((value) => value !== currentValue);
|
||||
|
||||
if (alternate === undefined) {
|
||||
throw new Error(`The microphone picker only offers "${currentValue}", so no switch can be made`);
|
||||
}
|
||||
|
||||
return alternate;
|
||||
}
|
||||
|
||||
async function readAlternateOutputDeviceId(page: Page): Promise<string | null> {
|
||||
const select = page.getByTestId('voice-settings-output-device');
|
||||
const currentValue = await select.inputValue();
|
||||
const values = await select.locator('option').evaluateAll(
|
||||
(options) => options.map((option) => (option as HTMLOptionElement).value)
|
||||
);
|
||||
|
||||
return values.find((value) => value !== currentValue) ?? null;
|
||||
}
|
||||
|
||||
/** The audio track ids this page is currently sending, one per peer connection. */
|
||||
async function readOutboundAudioTrackIds(page: Page): Promise<(string | null)[]> {
|
||||
return await page.evaluate(() => {
|
||||
const connections = (window as unknown as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? [];
|
||||
|
||||
return connections
|
||||
.filter((connection) => connection.connectionState === 'connected')
|
||||
.map((connection) => {
|
||||
const audioSender = connection
|
||||
.getSenders()
|
||||
.find((sender) => sender.track?.kind === 'audio');
|
||||
|
||||
return audioSender?.track?.id ?? null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readPreferredOutputDeviceId(page: Page): Promise<string | null> {
|
||||
return await page.evaluate(() => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
interface PlaybackShape { preferredOutputDeviceId?: string }
|
||||
|
||||
const host = document.querySelector('app-voice-settings');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const playback = debugApi.getComponent(host)['voicePlayback'] as PlaybackShape | undefined;
|
||||
|
||||
return playback?.preferredOutputDeviceId ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start counting moments where this client considered itself out of voice.
|
||||
* A device change that tears the session down and rebuilds it registers here,
|
||||
* even when the end state looks healthy again.
|
||||
*/
|
||||
async function startVoiceStateWatch(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
interface AngularDebugApi {
|
||||
getComponent: (element: Element) => Record<string, unknown>;
|
||||
}
|
||||
interface VoiceStateShape { isConnected?: boolean }
|
||||
interface UserShape { voiceState?: VoiceStateShape }
|
||||
|
||||
const watchWindow = window as unknown as { __voiceDrops?: number; __voiceWatch?: number };
|
||||
|
||||
watchWindow.__voiceDrops = 0;
|
||||
watchWindow.__voiceWatch = window.setInterval(() => {
|
||||
const host = document.querySelector('app-rooms-side-panel');
|
||||
const debugApi = (window as { ng?: AngularDebugApi }).ng;
|
||||
|
||||
if (!host || !debugApi?.getComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
|
||||
|
||||
if (currentUser?.voiceState?.isConnected === false) {
|
||||
watchWindow.__voiceDrops = (watchWindow.__voiceDrops ?? 0) + 1;
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopVoiceStateWatch(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => {
|
||||
const watchWindow = window as unknown as { __voiceDrops?: number; __voiceWatch?: number };
|
||||
|
||||
if (watchWindow.__voiceWatch !== undefined) {
|
||||
window.clearInterval(watchWindow.__voiceWatch);
|
||||
watchWindow.__voiceWatch = undefined;
|
||||
}
|
||||
|
||||
return watchWindow.__voiceDrops ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function createVoicePair(
|
||||
createClient: () => Promise<Client>,
|
||||
serverName: string
|
||||
): Promise<VoiceClient[]> {
|
||||
const clients: VoiceClient[] = [];
|
||||
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const client = await createClient();
|
||||
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
clients.push({
|
||||
...client,
|
||||
displayName: `Device Voice ${index + 1}`,
|
||||
username: `device_voice_${Date.now()}_${index + 1}`
|
||||
});
|
||||
}
|
||||
|
||||
await test.step('Register both clients', async () => {
|
||||
for (const client of clients) {
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(client.username, client.displayName, USER_PASSWORD);
|
||||
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Create and join the server', async () => {
|
||||
await new ServerSearchPage(clients[0].page).createServer(serverName, {
|
||||
description: 'Live audio device change test'
|
||||
});
|
||||
|
||||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
|
||||
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Join both clients to voice', async () => {
|
||||
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
await waitForConnectedPeerCount(client.page, 1, 90_000);
|
||||
await waitForOpenDataChannelCount(client.page, 1, 90_000);
|
||||
await waitForAudioStatsPresent(client.page, 30_000);
|
||||
}
|
||||
});
|
||||
|
||||
return clients;
|
||||
}
|
||||
|
||||
async function assertMeshAudio(clients: readonly VoiceClient[], label: string): Promise<void> {
|
||||
for (const client of clients) {
|
||||
try {
|
||||
await waitForAllPeerAudioFlow(client.page, 1, 60_000);
|
||||
} catch (error) {
|
||||
console.log(`[${client.displayName} ${label} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { test } from '../../fixtures/multi-client';
|
||||
import { countCreatedPeerConnections } from '../../helpers/peer-role';
|
||||
import { createVoicePairInNewServer, type VoicePairClient } from '../../helpers/voice-session';
|
||||
import {
|
||||
dumpRtcDiagnostics,
|
||||
getOpenDataChannelCount,
|
||||
getPerPeerAudioStats
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
|
||||
type PeerAudioStats = Awaited<ReturnType<typeof getPerPeerAudioStats>>;
|
||||
|
||||
/** Override for a quick check or a longer leak hunt: `SOAK_MINUTES=2 npx playwright test ...`. */
|
||||
const SOAK_MINUTES = Number(process.env['SOAK_MINUTES'] ?? 30);
|
||||
const SAMPLE_INTERVAL_MS = 30_000;
|
||||
|
||||
interface ResourceSnapshot {
|
||||
audioElements: number;
|
||||
heapMb: number;
|
||||
remoteTracks: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A long call must not accumulate anything. Structural counters are the honest leak
|
||||
* signal here - a churning recovery loop shows up as extra remote tracks or audio
|
||||
* elements long before heap bytes say anything conclusive.
|
||||
*/
|
||||
async function readResources(page: Page): Promise<ResourceSnapshot> {
|
||||
return page.evaluate(() => {
|
||||
interface HeapCapablePerformance extends Performance {
|
||||
memory?: { usedJSHeapSize: number };
|
||||
}
|
||||
|
||||
const usedHeap = (performance as HeapCapablePerformance).memory?.usedJSHeapSize ?? 0;
|
||||
const remoteTracks = (window as unknown as { __rtcRemoteTracks?: unknown[] }).__rtcRemoteTracks ?? [];
|
||||
|
||||
return {
|
||||
audioElements: document.querySelectorAll('audio').length,
|
||||
heapMb: Math.round(usedHeap / (1_024 * 1_024)),
|
||||
remoteTracks: remoteTracks.length
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function describeStats(stats: PeerAudioStats): string {
|
||||
return stats
|
||||
.map((stat) => `${stat.connectionState} in=${stat.inboundPackets} out=${stat.outboundPackets}`)
|
||||
.join(' | ') || 'no peers';
|
||||
}
|
||||
|
||||
test.describe('Long voice session', () => {
|
||||
test(`carries audio for ${SOAK_MINUTES} minutes without stalling, rebuilding, or accumulating`, async ({
|
||||
createClient
|
||||
}) => {
|
||||
const soakMs = SOAK_MINUTES * 60_000;
|
||||
|
||||
test.setTimeout(soakMs + 300_000);
|
||||
|
||||
const clients = await createVoicePairInNewServer(createClient, `Voice Soak ${Date.now()}`, {
|
||||
namePrefix: 'Soak Voice'
|
||||
});
|
||||
const baselineConnections = await Promise.all(clients.map((client) => countCreatedPeerConnections(client.page)));
|
||||
|
||||
expect(baselineConnections, 'each client should start with exactly one peer connection').toEqual([1, 1]);
|
||||
|
||||
const baselineResources = await Promise.all(clients.map((client) => readResources(client.page)));
|
||||
const previousStats: PeerAudioStats[] = await Promise.all(
|
||||
clients.map((client) => getPerPeerAudioStats(client.page))
|
||||
);
|
||||
const deadline = Date.now() + soakMs;
|
||||
const startedAt = Date.now();
|
||||
|
||||
let sampleIndex = 0;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await clients[0].page.waitForTimeout(SAMPLE_INTERVAL_MS);
|
||||
sampleIndex++;
|
||||
|
||||
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1_000);
|
||||
|
||||
for (let index = 0; index < clients.length; index++) {
|
||||
const client = clients[index];
|
||||
|
||||
await assertClientStillHealthy(client, previousStats[index], elapsedSeconds);
|
||||
previousStats[index] = await getPerPeerAudioStats(client.page);
|
||||
}
|
||||
|
||||
const resources = await Promise.all(clients.map((current) => readResources(current.page)));
|
||||
|
||||
console.log(
|
||||
`[soak] sample ${sampleIndex} at +${elapsedSeconds}s: `
|
||||
+ clients
|
||||
.map((client, index) => `${client.displayName} heap=${resources[index].heapMb}MB`
|
||||
+ ` audio=${resources[index].audioElements} tracks=${resources[index].remoteTracks}`)
|
||||
.join(', ')
|
||||
);
|
||||
}
|
||||
|
||||
await test.step('Nothing accumulated over the session', async () => {
|
||||
const finalResources = await Promise.all(clients.map((client) => readResources(client.page)));
|
||||
|
||||
for (let index = 0; index < clients.length; index++) {
|
||||
const baseline = baselineResources[index];
|
||||
const final = finalResources[index];
|
||||
const label = clients[index].displayName;
|
||||
|
||||
// A stable call fires `track` once per remote track; repeats mean the media path
|
||||
// was torn down and rebuilt behind the assertions above.
|
||||
expect(final.remoteTracks, `${label} gained remote tracks during the soak`).toBe(baseline.remoteTracks);
|
||||
expect(final.audioElements, `${label} accumulated audio elements`).toBeLessThanOrEqual(baseline.audioElements + 1);
|
||||
expect(
|
||||
final.heapMb,
|
||||
`${label} heap grew from ${baseline.heapMb}MB to ${final.heapMb}MB`
|
||||
).toBeLessThan(baseline.heapMb * 3 + 200);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function assertClientStillHealthy(
|
||||
client: VoicePairClient,
|
||||
previous: PeerAudioStats,
|
||||
elapsedSeconds: number
|
||||
): Promise<void> {
|
||||
const label = `${client.displayName} at +${elapsedSeconds}s`;
|
||||
|
||||
try {
|
||||
const current = await getPerPeerAudioStats(client.page);
|
||||
const connected = current.filter((stat) => stat.connectionState === 'connected');
|
||||
|
||||
expect(connected, `${label}: expected exactly one connected peer, saw ${describeStats(current)}`).toHaveLength(1);
|
||||
|
||||
const before = previous[0];
|
||||
const now = current[0];
|
||||
|
||||
expect(now.inboundPackets, `${label}: inbound audio stalled`).toBeGreaterThan(before.inboundPackets);
|
||||
expect(now.outboundPackets, `${label}: outbound audio stalled`).toBeGreaterThan(before.outboundPackets);
|
||||
|
||||
// A rebuild would restore audio within a sample or two, so the flow assertions above
|
||||
// cannot see it. Only the creation count can.
|
||||
expect(
|
||||
await countCreatedPeerConnections(client.page),
|
||||
`${label}: the peer connection was rebuilt mid-call`
|
||||
).toBe(1);
|
||||
|
||||
expect(await getOpenDataChannelCount(client.page), `${label}: the control channel is not open`).toBe(1);
|
||||
} catch (error) {
|
||||
console.log(`[soak] ${label} diagnostics:\n${await dumpRtcDiagnostics(client.page)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test, type Client } from '../../fixtures/multi-client';
|
||||
import {
|
||||
forceRelayOnlyIce,
|
||||
getRelayIceConfigs,
|
||||
seedTurnOnlyIceServers,
|
||||
waitForRelayedCandidatePairs,
|
||||
type TurnCredentials
|
||||
} from '../../helpers/turn-relay';
|
||||
import {
|
||||
isDockerAvailable,
|
||||
startTurnServer,
|
||||
type TurnServerHandle
|
||||
} from '../../helpers/turn-server';
|
||||
import { installDeterministicVoiceSettings } from '../../helpers/voice-session';
|
||||
import {
|
||||
installAutoResumeAudioContext,
|
||||
installWebRTCTracking,
|
||||
waitForAllPeerAudioFlow,
|
||||
waitForAudioStatsPresent,
|
||||
waitForConnectedPeerCount,
|
||||
waitForOpenDataChannelCount
|
||||
} from '../../helpers/webrtc-helpers';
|
||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
|
||||
const USER_PASSWORD = 'TestPass123!';
|
||||
const VOICE_CHANNEL = 'General';
|
||||
|
||||
/**
|
||||
* Symmetric NAT gives a browser no usable direct path, so the whole call has to
|
||||
* ride a TURN relay. `iceTransportPolicy: 'relay'` reproduces that without any
|
||||
* network trickery: host and server-reflexive candidates are thrown away, and
|
||||
* only the TURN server the app was configured with is left.
|
||||
*/
|
||||
test.describe('Relay-only voice', () => {
|
||||
let turnServer: TurnServerHandle | null = null;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (!await isDockerAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
turnServer = await startTurnServer();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await turnServer?.stop();
|
||||
turnServer = null;
|
||||
});
|
||||
|
||||
test('two users hear each other with every direct path removed', async ({ createClient }) => {
|
||||
test.skip(!turnServer, 'Relay-only voice needs Docker to run a local coturn.');
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const turn = turnServer as TurnServerHandle;
|
||||
const clients = await createRelayOnlyVoicePair(createClient, turn, `Relay Only Voice ${Date.now()}`);
|
||||
|
||||
await test.step('Both ends settled on a TURN relay, not a direct path', async () => {
|
||||
for (const client of clients) {
|
||||
const pairs = await waitForRelayedCandidatePairs(client.page, 1, 60_000);
|
||||
|
||||
// A direct pair here would mean the policy leaked and the test proved nothing.
|
||||
for (const pair of pairs) {
|
||||
expect(pair.localCandidateType, 'a peer connection escaped the relay-only policy').toBe('relay');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Audio flows both ways through the relay', async () => {
|
||||
for (const client of clients) {
|
||||
await waitForAllPeerAudioFlow(client.page, 1, 60_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createRelayOnlyVoicePair(
|
||||
createClient: () => Promise<Client>,
|
||||
turn: TurnCredentials,
|
||||
serverName: string
|
||||
): Promise<Client[]> {
|
||||
const clients: Client[] = [];
|
||||
const credentials: { username: string; displayName: string }[] = [];
|
||||
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const client = await createClient();
|
||||
|
||||
await installDeterministicVoiceSettings(client.page);
|
||||
await installWebRTCTracking(client.page);
|
||||
await forceRelayOnlyIce(client.page);
|
||||
await seedTurnOnlyIceServers(client.page, turn);
|
||||
await installAutoResumeAudioContext(client.page);
|
||||
|
||||
clients.push(client);
|
||||
credentials.push({
|
||||
displayName: `Relay Voice ${index + 1}`,
|
||||
username: `relay_voice_${Date.now()}_${index + 1}`
|
||||
});
|
||||
}
|
||||
|
||||
await test.step('Register both clients', async () => {
|
||||
for (const [index, client] of clients.entries()) {
|
||||
const registerPage = new RegisterPage(client.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(
|
||||
credentials[index].username,
|
||||
credentials[index].displayName,
|
||||
USER_PASSWORD
|
||||
);
|
||||
|
||||
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
|
||||
}
|
||||
});
|
||||
|
||||
await test.step('Create and join the server', async () => {
|
||||
await new ServerSearchPage(clients[0].page).createServer(serverName, {
|
||||
description: 'Relay-only voice test'
|
||||
});
|
||||
|
||||
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
|
||||
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('Join both clients to voice', async () => {
|
||||
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||
|
||||
for (const client of clients) {
|
||||
const room = new ChatRoomPage(client.page);
|
||||
|
||||
await room.joinVoiceChannel(VOICE_CHANNEL);
|
||||
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
for (const [index, client] of clients.entries()) {
|
||||
try {
|
||||
await waitForConnectedPeerCount(client.page, 1, 90_000);
|
||||
await waitForOpenDataChannelCount(client.page, 1, 90_000);
|
||||
await waitForAudioStatsPresent(client.page, 30_000);
|
||||
} catch (error) {
|
||||
// No TURN server in the config looks exactly like a failed relay from the
|
||||
// outside, so show what the app actually handed to WebRTC.
|
||||
const configs = await getRelayIceConfigs(client.page);
|
||||
|
||||
console.log(`[relay client ${index + 1} ice configs] ${JSON.stringify(configs.slice(0, 3))}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return clients;
|
||||
}
|
||||
Reference in New Issue
Block a user