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.
152 lines
5.8 KiB
TypeScript
152 lines
5.8 KiB
TypeScript
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;
|
|
}
|
|
}
|