Repair connectivity correctly v1
This commit is contained in:
@@ -11,6 +11,7 @@ import { type Page } from '@playwright/test';
|
|||||||
export async function installWebRTCTracking(page: Page): Promise<void> {
|
export async function installWebRTCTracking(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const connections: RTCPeerConnection[] = [];
|
const connections: RTCPeerConnection[] = [];
|
||||||
|
const dataChannels: RTCDataChannel[] = [];
|
||||||
const syntheticMediaResources: {
|
const syntheticMediaResources: {
|
||||||
audioCtx: AudioContext;
|
audioCtx: AudioContext;
|
||||||
source?: AudioScheduledSourceNode;
|
source?: AudioScheduledSourceNode;
|
||||||
@@ -18,20 +19,40 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
|
|||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
(window as any).__rtcConnections = connections;
|
(window as any).__rtcConnections = connections;
|
||||||
|
(window as any).__rtcDataChannels = dataChannels;
|
||||||
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
||||||
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
|
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
|
||||||
|
|
||||||
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||||
|
const trackDataChannel = (channel: RTCDataChannel) => {
|
||||||
|
if (dataChannels.includes(channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dataChannels.push(channel);
|
||||||
|
};
|
||||||
|
|
||||||
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) {
|
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) {
|
||||||
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
|
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
|
||||||
|
const originalCreateDataChannel = pc.createDataChannel.bind(pc);
|
||||||
|
|
||||||
connections.push(pc);
|
connections.push(pc);
|
||||||
|
|
||||||
|
pc.createDataChannel = ((label: string, options?: RTCDataChannelInit) => {
|
||||||
|
const channel = originalCreateDataChannel(label, options);
|
||||||
|
|
||||||
|
trackDataChannel(channel);
|
||||||
|
return channel;
|
||||||
|
}) as RTCPeerConnection['createDataChannel'];
|
||||||
|
|
||||||
pc.addEventListener('connectionstatechange', () => {
|
pc.addEventListener('connectionstatechange', () => {
|
||||||
(window as any).__lastRtcState = pc.connectionState;
|
(window as any).__lastRtcState = pc.connectionState;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => {
|
||||||
|
trackDataChannel(event.channel);
|
||||||
|
});
|
||||||
|
|
||||||
pc.addEventListener('track', (event: RTCTrackEvent) => {
|
pc.addEventListener('track', (event: RTCTrackEvent) => {
|
||||||
(window as any).__rtcRemoteTracks.push({
|
(window as any).__rtcRemoteTracks.push({
|
||||||
kind: event.track.kind,
|
kind: event.track.kind,
|
||||||
@@ -211,6 +232,66 @@ export async function waitForConnectedPeerCount(page: Page, expectedCount: numbe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the number of tracked RTCDataChannels in the open state. */
|
||||||
|
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
||||||
|
return page.evaluate(
|
||||||
|
() => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||||
|
(channel) => channel.readyState === 'open'
|
||||||
|
).length ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
||||||
|
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(count) => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||||
|
(channel) => channel.readyState === 'open'
|
||||||
|
).length === count,
|
||||||
|
expectedCount,
|
||||||
|
{ timeout }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
||||||
|
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||||
|
|
||||||
|
let closed = 0;
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
if (channel.readyState !== 'open') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.close();
|
||||||
|
closed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return closed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch a synthetic data-channel error event on each open channel. */
|
||||||
|
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||||
|
|
||||||
|
let dispatched = 0;
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
if (channel.readyState !== 'open') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.dispatchEvent(new Event('error'));
|
||||||
|
dispatched++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dispatched;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resume all suspended AudioContext instances created by the synthetic
|
* Resume all suspended AudioContext instances created by the synthetic
|
||||||
* media patch. Uses CDP `Runtime.evaluate` with `userGesture: true` so
|
* media patch. Uses CDP `Runtime.evaluate` with `userGesture: true` so
|
||||||
|
|||||||
181
e2e/tests/voice/data-channel-recovery.spec.ts
Normal file
181
e2e/tests/voice/data-channel-recovery.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test, type Client } from '../../fixtures/multi-client';
|
||||||
|
import {
|
||||||
|
closeOpenDataChannels,
|
||||||
|
dispatchDataChannelErrors,
|
||||||
|
dumpRtcDiagnostics,
|
||||||
|
getOpenDataChannelCount,
|
||||||
|
installAutoResumeAudioContext,
|
||||||
|
installWebRTCTracking,
|
||||||
|
waitForAllPeerAudioFlow,
|
||||||
|
waitForAudioStatsPresent,
|
||||||
|
waitForConnectedPeerCount,
|
||||||
|
waitForOpenDataChannelCount
|
||||||
|
} from '../../helpers/webrtc-helpers';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
|
||||||
|
interface VoiceClient extends Client {
|
||||||
|
displayName: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
|
const VOICE_CHANNEL = 'General';
|
||||||
|
|
||||||
|
test.describe('Voice data-channel recovery', () => {
|
||||||
|
test('keeps two users hearing each other after a data-channel error and close', async ({ createClient }) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
|
||||||
|
const clients = await createVoiceScenario(createClient, 2, `DC Recovery Duo ${Date.now()}`);
|
||||||
|
const [alice, bob] = clients;
|
||||||
|
|
||||||
|
await assertMeshAudio(clients, 1, 'initial two-user voice');
|
||||||
|
|
||||||
|
await test.step('A non-fatal data-channel error does not interrupt audio', async () => {
|
||||||
|
const dispatched = await dispatchDataChannelErrors(alice.page);
|
||||||
|
|
||||||
|
expect(dispatched).toBeGreaterThan(0);
|
||||||
|
await waitForOpenDataChannelCount(alice.page, 1, 15_000);
|
||||||
|
await waitForOpenDataChannelCount(bob.page, 1, 15_000);
|
||||||
|
await assertMeshAudio(clients, 1, 'after synthetic data-channel error');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('A closed data channel is rebuilt and audio resumes both ways', async () => {
|
||||||
|
const closed = await closeOpenDataChannels(alice.page);
|
||||||
|
|
||||||
|
expect(closed).toBeGreaterThan(0);
|
||||||
|
await waitForConnectedPeerCount(alice.page, 1, 60_000);
|
||||||
|
await waitForConnectedPeerCount(bob.page, 1, 60_000);
|
||||||
|
await waitForOpenDataChannelCount(alice.page, 1, 60_000);
|
||||||
|
await waitForOpenDataChannelCount(bob.page, 1, 60_000);
|
||||||
|
await assertMeshAudio(clients, 1, 'after data-channel close recovery');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heals a three-user voice mesh when one client loses every data channel', async ({ createClient }) => {
|
||||||
|
test.setTimeout(300_000);
|
||||||
|
|
||||||
|
const clients = await createVoiceScenario(createClient, 3, `DC Recovery Trio ${Date.now()}`);
|
||||||
|
const bob = clients[1];
|
||||||
|
|
||||||
|
await assertMeshAudio(clients, 2, 'initial three-user mesh');
|
||||||
|
|
||||||
|
await test.step('Bob loses all control channels and the full mesh recovers', async () => {
|
||||||
|
const closed = await closeOpenDataChannels(bob.page);
|
||||||
|
|
||||||
|
expect(closed).toBe(2);
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await waitForConnectedPeerCount(client.page, 2, 90_000);
|
||||||
|
await waitForOpenDataChannelCount(client.page, 2, 90_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertMeshAudio(clients, 2, 'after full control-channel recovery');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createVoiceScenario(
|
||||||
|
createClient: () => Promise<Client>,
|
||||||
|
userCount: number,
|
||||||
|
serverName: string
|
||||||
|
): Promise<VoiceClient[]> {
|
||||||
|
const clients: VoiceClient[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < userCount; index++) {
|
||||||
|
const client = await createClient();
|
||||||
|
const displayName = `DC Voice ${index + 1}`;
|
||||||
|
|
||||||
|
await installDeterministicVoiceSettings(client.page);
|
||||||
|
await installWebRTCTracking(client.page);
|
||||||
|
await installAutoResumeAudioContext(client.page);
|
||||||
|
|
||||||
|
clients.push({
|
||||||
|
...client,
|
||||||
|
displayName,
|
||||||
|
username: `dc_voice_${Date.now()}_${index + 1}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await test.step('Register 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(/\/search/, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Create and join server', async () => {
|
||||||
|
const hostSearch = new ServerSearchPage(clients[0].page);
|
||||||
|
|
||||||
|
await hostSearch.createServer(serverName, { description: 'Data-channel recovery voice test' });
|
||||||
|
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
|
||||||
|
for (const client of clients.slice(1)) {
|
||||||
|
const searchPage = new ServerSearchPage(client.page);
|
||||||
|
|
||||||
|
await searchPage.joinServerFromSearch(serverName);
|
||||||
|
await expect(client.page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Join everyone to voice', async () => {
|
||||||
|
const hostRoom = new ChatRoomPage(clients[0].page);
|
||||||
|
|
||||||
|
await hostRoom.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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedRemotePeers = clients.length - 1;
|
||||||
|
|
||||||
|
for (const client of clients) {
|
||||||
|
await waitForConnectedPeerCount(client.page, expectedRemotePeers, 90_000);
|
||||||
|
await waitForOpenDataChannelCount(client.page, expectedRemotePeers, 90_000);
|
||||||
|
await waitForAudioStatsPresent(client.page, 30_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
|
||||||
|
inputVolume: 100,
|
||||||
|
outputVolume: 100,
|
||||||
|
audioBitrate: 96,
|
||||||
|
latencyProfile: 'balanced',
|
||||||
|
includeSystemAudio: false,
|
||||||
|
noiseReduction: false,
|
||||||
|
screenShareQuality: 'balanced',
|
||||||
|
askScreenShareQuality: false
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertMeshAudio(
|
||||||
|
clients: readonly VoiceClient[],
|
||||||
|
expectedRemotePeers: number,
|
||||||
|
label: string
|
||||||
|
): Promise<void> {
|
||||||
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
|
await waitForAllPeerAudioFlow(client.page, expectedRemotePeers, 60_000);
|
||||||
|
} catch (error) {
|
||||||
|
const dataChannelCount = await getOpenDataChannelCount(client.page);
|
||||||
|
|
||||||
|
console.log(`[${client.displayName} ${label} data channels] ${dataChannelCount}`);
|
||||||
|
console.log(`[${client.displayName} ${label} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, type Page } from '@playwright/test';
|
import { expect, type Page } from '@playwright/test';
|
||||||
import { test, type Client } from '../../fixtures/multi-client';
|
import { test, type Client } from '../../fixtures/multi-client';
|
||||||
import {
|
import {
|
||||||
|
closeOpenDataChannels,
|
||||||
dumpRtcDiagnostics,
|
dumpRtcDiagnostics,
|
||||||
installAutoResumeAudioContext,
|
installAutoResumeAudioContext,
|
||||||
installWebRTCTracking,
|
installWebRTCTracking,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
waitForAudioFlow,
|
waitForAudioFlow,
|
||||||
waitForAudioStatsPresent,
|
waitForAudioStatsPresent,
|
||||||
waitForConnectedPeerCount,
|
waitForConnectedPeerCount,
|
||||||
|
waitForOpenDataChannelCount,
|
||||||
waitForInboundVideoFlow,
|
waitForInboundVideoFlow,
|
||||||
waitForOutboundVideoFlow,
|
waitForOutboundVideoFlow,
|
||||||
waitForPeerConnected
|
waitForPeerConnected
|
||||||
@@ -371,6 +373,35 @@ test.describe('Direct private calls', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps private-call audio flowing after the data channel closes', async ({ createClient }) => {
|
||||||
|
const scenario = await createDirectCallScenario(createClient);
|
||||||
|
|
||||||
|
await test.step('Alice starts a private call and Bob joins', async () => {
|
||||||
|
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
|
||||||
|
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
|
||||||
|
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||||
|
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
|
||||||
|
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await waitForConnectedPeerCount(scenario.alice.page, 1, 45_000);
|
||||||
|
await waitForConnectedPeerCount(scenario.bob.page, 1, 45_000);
|
||||||
|
await waitForOpenDataChannelCount(scenario.alice.page, 1, 45_000);
|
||||||
|
await waitForOpenDataChannelCount(scenario.bob.page, 1, 45_000);
|
||||||
|
await waitForAllPeerAudioFlow(scenario.alice.page, 1, 45_000);
|
||||||
|
await waitForAllPeerAudioFlow(scenario.bob.page, 1, 45_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Data-channel recovery keeps the call audible', async () => {
|
||||||
|
const closed = await closeOpenDataChannels(scenario.alice.page);
|
||||||
|
|
||||||
|
expect(closed).toBeGreaterThan(0);
|
||||||
|
await waitForOpenDataChannelCount(scenario.alice.page, 1, 60_000);
|
||||||
|
await waitForOpenDataChannelCount(scenario.bob.page, 1, 60_000);
|
||||||
|
await waitForAllPeerAudioFlow(scenario.alice.page, 1, 60_000);
|
||||||
|
await waitForAllPeerAudioFlow(scenario.bob.page, 1, 60_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('missing and ended private calls do not leave stale call controls behind', async ({ createClient }) => {
|
test('missing and ended private calls do not leave stale call controls behind', async ({ createClient }) => {
|
||||||
const scenario = await createDirectCallScenario(createClient);
|
const scenario = await createDirectCallScenario(createClient);
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ Join and leave broadcasts are also identity-aware: `handleJoinServer` only broad
|
|||||||
|
|
||||||
Peer routing also has to stay scoped to the signaling server that reported the membership. A `user_left` from one signaling cluster must only subtract that cluster's shared servers; otherwise a leave on `signal.toju.app` can incorrectly tear down a peer that is still shared through `signal-sweden.toju.app` or a local signaling server. Route metadata is therefore kept across peer recreation and only cleared once the renderer no longer shares any servers with that peer.
|
Peer routing also has to stay scoped to the signaling server that reported the membership. A `user_left` from one signaling cluster must only subtract that cluster's shared servers; otherwise a leave on `signal.toju.app` can incorrectly tear down a peer that is still shared through `signal-sweden.toju.app` or a local signaling server. Route metadata is therefore kept across peer recreation and only cleared once the renderer no longer shares any servers with that peer.
|
||||||
|
|
||||||
|
When local voice is active, a transient `user_left` or stale presence snapshot must not immediately mute or tear down a peer whose P2P transport is still alive. The users store receives the current connected peer IDs with signaling presence updates and preserves live voice/camera/screen state while that transport is connected. The signaling handler also preserves an active voice peer route after a `user_left` blip so a later data-channel or peer reconnect can still target the correct signaling URL. Explicit voice leave still travels as a `voice-state` event with `isConnected: false`, and closed/failed peer connections still clean themselves up through the peer recovery path.
|
||||||
|
|
||||||
## Peer connection lifecycle
|
## Peer connection lifecycle
|
||||||
|
|
||||||
Peers connect to each other directly with `RTCPeerConnection`. The initiator is chosen deterministically from the identified logical peer IDs so only one side creates the offer and primary data channel for a given pair. The other side creates an answer. If identity or negotiation is still settling, the retry timer defers instead of comparing against the ephemeral local transport ID or reusing a half-open peer forever.
|
Peers connect to each other directly with `RTCPeerConnection`. The initiator is chosen deterministically from the identified logical peer IDs so only one side creates the offer and primary data channel for a given pair. The other side creates an answer. If identity or negotiation is still settling, the retry timer defers instead of comparing against the ephemeral local transport ID or reusing a half-open peer forever.
|
||||||
@@ -246,6 +248,8 @@ Profile avatar sync follows attachment-style chunk transport plus server-icon-st
|
|||||||
|
|
||||||
Every 5 seconds a PING message is sent to each peer. The peer responds with PONG carrying the original timestamp, and the round-trip latency is stored in a signal.
|
Every 5 seconds a PING message is sent to each peer. The peer responds with PONG carrying the original timestamp, and the round-trip latency is stored in a signal.
|
||||||
|
|
||||||
|
Data-channel failures are treated as control-plane failures, not proof that RTP audio has stopped. When an open channel reports a non-fatal error, the client requests a fresh voice-state snapshot over that same channel. When the channel closes or cannot carry the resync request, the peer manager waits a short grace period so any still-flowing audio is not interrupted by a transient event. If the `RTCPeerConnection` is still connected after that grace period, the elected initiator replaces only the data channel in-place and preserves the media transport. Full peer recreation is reserved for cases where the media transport is no longer connected or the in-place control-channel repair fails.
|
||||||
|
|
||||||
## Media pipeline
|
## Media pipeline
|
||||||
|
|
||||||
### Voice
|
### Voice
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import {
|
||||||
|
DATA_CHANNEL_STATE_OPEN,
|
||||||
|
P2P_TYPE_STATE_REQUEST,
|
||||||
|
P2P_TYPE_VOICE_STATE_REQUEST
|
||||||
|
} from '../../realtime.constants';
|
||||||
|
import { setupDataChannel } from './data-channel';
|
||||||
|
import {
|
||||||
|
createPeerConnectionManagerState,
|
||||||
|
DataChannelLifecycleHandlers,
|
||||||
|
PeerConnectionManagerContext
|
||||||
|
} from '../shared';
|
||||||
|
|
||||||
|
describe('data channel lifecycle', () => {
|
||||||
|
it('sends current state and requests peer state when the channel opens', () => {
|
||||||
|
const context = createContext();
|
||||||
|
const handlers = createHandlers();
|
||||||
|
const channel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
|
||||||
|
|
||||||
|
setupDataChannel(context, channel, 'peer-a', handlers);
|
||||||
|
channel.onopen?.(new Event('open'));
|
||||||
|
|
||||||
|
expect(handlers.clearDataChannelRecoveryTimer).toHaveBeenCalledWith('peer-a');
|
||||||
|
expect(channel.send).toHaveBeenCalledWith(expect.stringContaining('"type":"voice-state"'));
|
||||||
|
expect(channel.send).toHaveBeenCalledWith(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST }));
|
||||||
|
expect(handlers.scheduleDataChannelRecovery).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests a voice-state resync on a non-fatal data channel error', () => {
|
||||||
|
const context = createContext();
|
||||||
|
const handlers = createHandlers();
|
||||||
|
const channel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
|
||||||
|
|
||||||
|
setupDataChannel(context, channel, 'peer-a', handlers);
|
||||||
|
channel.onerror?.(new Event('error'));
|
||||||
|
|
||||||
|
expect(channel.send).toHaveBeenCalledWith(JSON.stringify({ type: P2P_TYPE_VOICE_STATE_REQUEST }));
|
||||||
|
expect(handlers.scheduleDataChannelRecovery).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules peer recovery when an error leaves the channel closed', () => {
|
||||||
|
const context = createContext();
|
||||||
|
const handlers = createHandlers();
|
||||||
|
const channel = createDataChannel('closed');
|
||||||
|
|
||||||
|
setupDataChannel(context, channel, 'peer-a', handlers);
|
||||||
|
channel.onerror?.(new Event('error'));
|
||||||
|
|
||||||
|
expect(channel.send).not.toHaveBeenCalled();
|
||||||
|
expect(handlers.scheduleDataChannelRecovery).toHaveBeenCalledWith('peer-a', channel, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules peer recovery when the data channel closes', () => {
|
||||||
|
const context = createContext();
|
||||||
|
const handlers = createHandlers();
|
||||||
|
const channel = createDataChannel('closed');
|
||||||
|
|
||||||
|
setupDataChannel(context, channel, 'peer-a', handlers);
|
||||||
|
channel.onclose?.(new Event('close'));
|
||||||
|
|
||||||
|
expect(handlers.scheduleDataChannelRecovery).toHaveBeenCalledWith('peer-a', channel, 'close');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createContext(): PeerConnectionManagerContext {
|
||||||
|
return {
|
||||||
|
logger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
logStream: vi.fn(),
|
||||||
|
traffic: vi.fn(),
|
||||||
|
warn: vi.fn()
|
||||||
|
} as unknown as PeerConnectionManagerContext['logger'],
|
||||||
|
callbacks: {
|
||||||
|
getIceServers: vi.fn(() => []),
|
||||||
|
getIdentifyCredentials: vi.fn(() => ({ oderId: 'local-user', displayName: 'Local User' })),
|
||||||
|
getLocalMediaStream: vi.fn(() => null),
|
||||||
|
getLocalPeerId: vi.fn(() => 'local-peer'),
|
||||||
|
getVoiceStateSnapshot: vi.fn(() => ({
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
isScreenSharing: false,
|
||||||
|
roomId: 'voice-room',
|
||||||
|
serverId: 'server-1'
|
||||||
|
})),
|
||||||
|
isCameraEnabled: vi.fn(() => false),
|
||||||
|
isScreenSharingActive: vi.fn(() => false),
|
||||||
|
isSignalingConnected: vi.fn(() => true),
|
||||||
|
sendRawMessage: vi.fn()
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
...createPeerConnectionManagerState(),
|
||||||
|
peerConnected$: new Subject<string>()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHandlers(): DataChannelLifecycleHandlers {
|
||||||
|
return {
|
||||||
|
clearDataChannelRecoveryTimer: vi.fn(),
|
||||||
|
scheduleDataChannelRecovery: vi.fn()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDataChannel(readyState: RTCDataChannelState): RTCDataChannel & { send: ReturnType<typeof vi.fn> } {
|
||||||
|
return {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
binaryType: 'arraybuffer',
|
||||||
|
bufferedAmount: 0,
|
||||||
|
bufferedAmountLowThreshold: 0,
|
||||||
|
close: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
id: 1,
|
||||||
|
label: 'chat',
|
||||||
|
maxPacketLifeTime: null,
|
||||||
|
maxRetransmits: null,
|
||||||
|
negotiated: false,
|
||||||
|
onbufferedamountlow: null,
|
||||||
|
onclose: null,
|
||||||
|
onclosing: null,
|
||||||
|
onerror: null,
|
||||||
|
onmessage: null,
|
||||||
|
onopen: null,
|
||||||
|
ordered: true,
|
||||||
|
protocol: '',
|
||||||
|
readyState,
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
send: vi.fn()
|
||||||
|
} as unknown as RTCDataChannel & { send: ReturnType<typeof vi.fn> };
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
P2P_TYPE_VOICE_STATE_REQUEST
|
P2P_TYPE_VOICE_STATE_REQUEST
|
||||||
} from '../../realtime.constants';
|
} from '../../realtime.constants';
|
||||||
import { recordDebugNetworkDataChannelPayload, recordDebugNetworkPing } from '../../logging/debug-network-metrics';
|
import { recordDebugNetworkDataChannelPayload, recordDebugNetworkPing } from '../../logging/debug-network-metrics';
|
||||||
import { PeerConnectionManagerContext } from '../shared';
|
import { DataChannelLifecycleHandlers, PeerConnectionManagerContext } from '../shared';
|
||||||
import { startPingInterval } from './ping';
|
import { startPingInterval } from './ping';
|
||||||
|
|
||||||
type PeerMessage = Record<string, unknown> & {
|
type PeerMessage = Record<string, unknown> & {
|
||||||
@@ -27,11 +27,14 @@ type PeerMessage = Record<string, unknown> & {
|
|||||||
export function setupDataChannel(
|
export function setupDataChannel(
|
||||||
context: PeerConnectionManagerContext,
|
context: PeerConnectionManagerContext,
|
||||||
channel: RTCDataChannel,
|
channel: RTCDataChannel,
|
||||||
remotePeerId: string
|
remotePeerId: string,
|
||||||
|
handlers: DataChannelLifecycleHandlers
|
||||||
): void {
|
): void {
|
||||||
const { logger, state } = context;
|
const { logger, state } = context;
|
||||||
|
|
||||||
channel.onopen = () => {
|
channel.onopen = () => {
|
||||||
|
handlers.clearDataChannelRecoveryTimer(remotePeerId);
|
||||||
|
|
||||||
logger.info('[data-channel] Data channel open', {
|
logger.info('[data-channel] Data channel open', {
|
||||||
channelLabel: channel.label,
|
channelLabel: channel.label,
|
||||||
negotiated: channel.negotiated,
|
negotiated: channel.negotiated,
|
||||||
@@ -43,22 +46,7 @@ export function setupDataChannel(
|
|||||||
state.peerConnected$.next(remotePeerId);
|
state.peerConnected$.next(remotePeerId);
|
||||||
|
|
||||||
sendCurrentStatesToChannel(context, channel, remotePeerId);
|
sendCurrentStatesToChannel(context, channel, remotePeerId);
|
||||||
|
sendStateRequestToChannel(context, channel, remotePeerId, P2P_TYPE_STATE_REQUEST, 'open');
|
||||||
try {
|
|
||||||
const stateRequest = { type: P2P_TYPE_STATE_REQUEST };
|
|
||||||
const rawPayload = JSON.stringify(stateRequest);
|
|
||||||
|
|
||||||
channel.send(rawPayload);
|
|
||||||
logDataChannelTraffic(context, channel, remotePeerId, 'outbound', rawPayload, stateRequest);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[data-channel] Failed to request peer state on open', error, {
|
|
||||||
bufferedAmount: channel.bufferedAmount,
|
|
||||||
channelLabel: channel.label,
|
|
||||||
peerId: remotePeerId,
|
|
||||||
readyState: channel.readyState,
|
|
||||||
type: P2P_TYPE_STATE_REQUEST
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
startPingInterval(context.state, logger, remotePeerId);
|
startPingInterval(context.state, logger, remotePeerId);
|
||||||
};
|
};
|
||||||
@@ -70,6 +58,8 @@ export function setupDataChannel(
|
|||||||
peerId: remotePeerId,
|
peerId: remotePeerId,
|
||||||
readyState: channel.readyState
|
readyState: channel.readyState
|
||||||
});
|
});
|
||||||
|
|
||||||
|
handlers.scheduleDataChannelRecovery(remotePeerId, channel, 'close');
|
||||||
};
|
};
|
||||||
|
|
||||||
channel.onerror = (error) => {
|
channel.onerror = (error) => {
|
||||||
@@ -79,6 +69,18 @@ export function setupDataChannel(
|
|||||||
peerId: remotePeerId,
|
peerId: remotePeerId,
|
||||||
readyState: channel.readyState
|
readyState: channel.readyState
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const didRequestState = sendStateRequestToChannel(
|
||||||
|
context,
|
||||||
|
channel,
|
||||||
|
remotePeerId,
|
||||||
|
P2P_TYPE_VOICE_STATE_REQUEST,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!didRequestState) {
|
||||||
|
handlers.scheduleDataChannelRecovery(remotePeerId, channel, 'error');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
channel.onmessage = (event) => {
|
channel.onmessage = (event) => {
|
||||||
@@ -103,6 +105,48 @@ export function setupDataChannel(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendStateRequestToChannel(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
channel: RTCDataChannel,
|
||||||
|
remotePeerId: string,
|
||||||
|
type: typeof P2P_TYPE_STATE_REQUEST | typeof P2P_TYPE_VOICE_STATE_REQUEST,
|
||||||
|
reason: string
|
||||||
|
): boolean {
|
||||||
|
const { logger } = context;
|
||||||
|
|
||||||
|
if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||||
|
logger.warn('[data-channel] Cannot request peer state - channel not open', {
|
||||||
|
channelLabel: channel.label,
|
||||||
|
peerId: remotePeerId,
|
||||||
|
readyState: channel.readyState,
|
||||||
|
reason,
|
||||||
|
type
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stateRequest = { type };
|
||||||
|
const rawPayload = JSON.stringify(stateRequest);
|
||||||
|
|
||||||
|
channel.send(rawPayload);
|
||||||
|
logDataChannelTraffic(context, channel, remotePeerId, 'outbound', rawPayload, stateRequest);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[data-channel] Failed to request peer state', error, {
|
||||||
|
bufferedAmount: channel.bufferedAmount,
|
||||||
|
channelLabel: channel.label,
|
||||||
|
peerId: remotePeerId,
|
||||||
|
readyState: channel.readyState,
|
||||||
|
reason,
|
||||||
|
type
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route an incoming peer-to-peer message.
|
* Route an incoming peer-to-peer message.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { ChatEvent } from '../../../shared-kernel';
|
import { ChatEvent } from '../../../shared-kernel';
|
||||||
|
import { DATA_CHANNEL_LABEL } from '../realtime.constants';
|
||||||
import { recordDebugNetworkDownloadRates } from '../logging/debug-network-metrics';
|
import { recordDebugNetworkDownloadRates } from '../logging/debug-network-metrics';
|
||||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||||
import { PeerData } from '../realtime.types';
|
import { PeerData } from '../realtime.types';
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
} from './messaging/data-channel';
|
} from './messaging/data-channel';
|
||||||
import {
|
import {
|
||||||
addToConnectedPeers,
|
addToConnectedPeers,
|
||||||
|
clearDataChannelRecoveryTimer,
|
||||||
clearAllPeerReconnectTimers,
|
clearAllPeerReconnectTimers,
|
||||||
clearPeerDisconnectGraceTimer,
|
clearPeerDisconnectGraceTimer,
|
||||||
clearPeerReconnectTimer,
|
clearPeerReconnectTimer,
|
||||||
@@ -30,6 +32,7 @@ import {
|
|||||||
removePeer as removeManagedPeer,
|
removePeer as removeManagedPeer,
|
||||||
requestVoiceStateFromPeer,
|
requestVoiceStateFromPeer,
|
||||||
resetConnectedPeers,
|
resetConnectedPeers,
|
||||||
|
scheduleDataChannelRecovery,
|
||||||
schedulePeerDisconnectRecovery,
|
schedulePeerDisconnectRecovery,
|
||||||
schedulePeerReconnect,
|
schedulePeerReconnect,
|
||||||
trackDisconnectedPeer
|
trackDisconnectedPeer
|
||||||
@@ -38,6 +41,7 @@ import { clearRemoteScreenShareStream as clearManagedRemoteScreenShareStream, ha
|
|||||||
import {
|
import {
|
||||||
ConnectionLifecycleHandlers,
|
ConnectionLifecycleHandlers,
|
||||||
createPeerConnectionManagerState,
|
createPeerConnectionManagerState,
|
||||||
|
DataChannelLifecycleHandlers,
|
||||||
NegotiationHandlers,
|
NegotiationHandlers,
|
||||||
PeerConnectionCallbacks,
|
PeerConnectionCallbacks,
|
||||||
PeerConnectionManagerContext,
|
PeerConnectionManagerContext,
|
||||||
@@ -285,6 +289,8 @@ export class PeerConnectionManager {
|
|||||||
private get recoveryHandlers(): RecoveryHandlers {
|
private get recoveryHandlers(): RecoveryHandlers {
|
||||||
return {
|
return {
|
||||||
removePeer: (peerId: string, options?: RemovePeerOptions) => this.removePeer(peerId, options),
|
removePeer: (peerId: string, options?: RemovePeerOptions) => this.removePeer(peerId, options),
|
||||||
|
replaceDataChannel: (peerId: string, expectedChannel: RTCDataChannel) =>
|
||||||
|
this.replaceDataChannel(peerId, expectedChannel),
|
||||||
createPeerConnection: (peerId: string, isInitiator: boolean) =>
|
createPeerConnection: (peerId: string, isInitiator: boolean) =>
|
||||||
this.createPeerConnection(peerId, isInitiator),
|
this.createPeerConnection(peerId, isInitiator),
|
||||||
createAndSendOffer: (peerId: string) => this.createAndSendOffer(peerId)
|
createAndSendOffer: (peerId: string) => this.createAndSendOffer(peerId)
|
||||||
@@ -292,7 +298,15 @@ export class PeerConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void {
|
private setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void {
|
||||||
setupDataChannel(this.context, channel, remotePeerId);
|
setupDataChannel(this.context, channel, remotePeerId, this.dataChannelHandlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get dataChannelHandlers(): DataChannelLifecycleHandlers {
|
||||||
|
return {
|
||||||
|
clearDataChannelRecoveryTimer: (peerId: string) => this.clearDataChannelRecoveryTimer(peerId),
|
||||||
|
scheduleDataChannelRecovery: (peerId: string, channel: RTCDataChannel, reason: string) =>
|
||||||
|
this.scheduleDataChannelRecovery(peerId, channel, reason)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleRemoteTrack(event: RTCTrackEvent, remotePeerId: string): void {
|
private handleRemoteTrack(event: RTCTrackEvent, remotePeerId: string): void {
|
||||||
@@ -311,6 +325,36 @@ export class PeerConnectionManager {
|
|||||||
clearPeerDisconnectGraceTimer(this.state, peerId);
|
clearPeerDisconnectGraceTimer(this.state, peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearDataChannelRecoveryTimer(peerId: string): void {
|
||||||
|
clearDataChannelRecoveryTimer(this.state, peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleDataChannelRecovery(peerId: string, channel: RTCDataChannel, reason: string): void {
|
||||||
|
scheduleDataChannelRecovery(this.context, peerId, channel, reason, this.recoveryHandlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private replaceDataChannel(peerId: string, expectedChannel: RTCDataChannel): boolean {
|
||||||
|
const peerData = this.state.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
|
if (!peerData || peerData.dataChannel !== expectedChannel)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const replacement = peerData.connection.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true });
|
||||||
|
|
||||||
|
peerData.dataChannel = replacement;
|
||||||
|
this.setupDataChannel(replacement, peerId);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('[data-channel] Failed to create replacement data channel', {
|
||||||
|
error: (error as Error)?.message ?? String(error),
|
||||||
|
peerId
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private schedulePeerDisconnectRecovery(peerId: string): void {
|
private schedulePeerDisconnectRecovery(peerId: string): void {
|
||||||
schedulePeerDisconnectRecovery(this.context, peerId, this.recoveryHandlers);
|
schedulePeerDisconnectRecovery(this.context, peerId, this.recoveryHandlers);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { DATA_CHANNEL_RECOVERY_GRACE_MS, DATA_CHANNEL_STATE_OPEN } from '../../realtime.constants';
|
||||||
|
import type { PeerData } from '../../realtime.types';
|
||||||
|
import {
|
||||||
|
createPeerConnectionManagerState,
|
||||||
|
PeerConnectionManagerContext,
|
||||||
|
RecoveryHandlers
|
||||||
|
} from '../shared';
|
||||||
|
import { scheduleDataChannelRecovery } from './peer-recovery';
|
||||||
|
|
||||||
|
describe('peer recovery', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits a short grace period before replacing a closed data channel in place', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const channel = createDataChannel('closed');
|
||||||
|
const context = createContext('alice');
|
||||||
|
const handlers = createRecoveryHandlers(context);
|
||||||
|
|
||||||
|
context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected'));
|
||||||
|
|
||||||
|
scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS - 1);
|
||||||
|
expect(handlers.removePeer).not.toHaveBeenCalled();
|
||||||
|
expect(handlers.createPeerConnection).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(handlers.replaceDataChannel).toHaveBeenCalledWith('bob', channel);
|
||||||
|
expect(handlers.removePeer).not.toHaveBeenCalled();
|
||||||
|
expect(handlers.createPeerConnection).not.toHaveBeenCalled();
|
||||||
|
expect(handlers.createAndSendOffer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to full peer recreation when in-place data channel replacement fails', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const channel = createDataChannel('closed');
|
||||||
|
const context = createContext('alice');
|
||||||
|
const handlers = createRecoveryHandlers(context);
|
||||||
|
|
||||||
|
handlers.replaceDataChannel.mockReturnValueOnce(false);
|
||||||
|
context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected'));
|
||||||
|
|
||||||
|
scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers);
|
||||||
|
vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS);
|
||||||
|
|
||||||
|
expect(handlers.removePeer).toHaveBeenCalledWith('bob', { preserveReconnectState: true });
|
||||||
|
expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', true);
|
||||||
|
expect(handlers.createAndSendOffer).toHaveBeenCalledWith('bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not recreate a peer when a replacement data channel is adopted before the grace expires', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const staleChannel = createDataChannel('closed');
|
||||||
|
const replacementChannel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
|
||||||
|
const context = createContext('alice');
|
||||||
|
const handlers = createRecoveryHandlers(context);
|
||||||
|
|
||||||
|
context.state.activePeerConnections.set('bob', createPeerData(staleChannel, 'connected'));
|
||||||
|
|
||||||
|
scheduleDataChannelRecovery(context, 'bob', staleChannel, 'close', handlers);
|
||||||
|
context.state.activePeerConnections.set('bob', createPeerData(replacementChannel, 'connected'));
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS);
|
||||||
|
|
||||||
|
expect(handlers.removePeer).not.toHaveBeenCalled();
|
||||||
|
expect(handlers.replaceDataChannel).not.toHaveBeenCalled();
|
||||||
|
expect(handlers.createPeerConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not schedule recovery for an open data channel', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const channel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
|
||||||
|
const context = createContext('alice');
|
||||||
|
const handlers = createRecoveryHandlers(context);
|
||||||
|
|
||||||
|
context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected'));
|
||||||
|
|
||||||
|
scheduleDataChannelRecovery(context, 'bob', channel, 'error', handlers);
|
||||||
|
vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS);
|
||||||
|
|
||||||
|
expect(handlers.removePeer).not.toHaveBeenCalled();
|
||||||
|
expect(handlers.replaceDataChannel).not.toHaveBeenCalled();
|
||||||
|
expect(handlers.createPeerConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a connected non-initiator peer while waiting for the remote initiator to replace the channel', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const channel = createDataChannel('closed');
|
||||||
|
const context = createContext('zoe');
|
||||||
|
const handlers = createRecoveryHandlers(context);
|
||||||
|
|
||||||
|
context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected', false));
|
||||||
|
scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers);
|
||||||
|
vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS);
|
||||||
|
|
||||||
|
expect(handlers.removePeer).not.toHaveBeenCalled();
|
||||||
|
expect(handlers.replaceDataChannel).not.toHaveBeenCalled();
|
||||||
|
expect(handlers.createPeerConnection).not.toHaveBeenCalled();
|
||||||
|
expect(handlers.createAndSendOffer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for the remote initiator when a non-connected peer needs full reconnect', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const channel = createDataChannel('closed');
|
||||||
|
const context = createContext('zoe');
|
||||||
|
const handlers = createRecoveryHandlers(context);
|
||||||
|
|
||||||
|
context.state.activePeerConnections.set('bob', createPeerData(channel, 'disconnected'));
|
||||||
|
|
||||||
|
scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers);
|
||||||
|
vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS);
|
||||||
|
|
||||||
|
expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', false);
|
||||||
|
expect(handlers.createAndSendOffer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createContext(localOderId: string): PeerConnectionManagerContext {
|
||||||
|
return {
|
||||||
|
logger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
logStream: vi.fn(),
|
||||||
|
traffic: vi.fn(),
|
||||||
|
warn: vi.fn()
|
||||||
|
} as unknown as PeerConnectionManagerContext['logger'],
|
||||||
|
callbacks: {
|
||||||
|
getIceServers: vi.fn(() => []),
|
||||||
|
getIdentifyCredentials: vi.fn(() => ({ oderId: localOderId, displayName: localOderId })),
|
||||||
|
getLocalMediaStream: vi.fn(() => null),
|
||||||
|
getLocalPeerId: vi.fn(() => localOderId),
|
||||||
|
getVoiceStateSnapshot: vi.fn(() => ({
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
isScreenSharing: false,
|
||||||
|
roomId: 'voice-room',
|
||||||
|
serverId: 'server-1'
|
||||||
|
})),
|
||||||
|
isCameraEnabled: vi.fn(() => false),
|
||||||
|
isScreenSharingActive: vi.fn(() => false),
|
||||||
|
isSignalingConnected: vi.fn(() => true),
|
||||||
|
sendRawMessage: vi.fn()
|
||||||
|
},
|
||||||
|
state: createPeerConnectionManagerState()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRecoveryHandlers(context: PeerConnectionManagerContext): RecoveryHandlers & {
|
||||||
|
createAndSendOffer: ReturnType<typeof vi.fn>;
|
||||||
|
createPeerConnection: ReturnType<typeof vi.fn>;
|
||||||
|
removePeer: ReturnType<typeof vi.fn>;
|
||||||
|
replaceDataChannel: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
createAndSendOffer: vi.fn(async () => undefined),
|
||||||
|
createPeerConnection: vi.fn((peerId: string, isInitiator: boolean) => {
|
||||||
|
const peerData = createPeerData(createDataChannel(isInitiator ? 'connecting' : 'closed'), 'new', isInitiator);
|
||||||
|
|
||||||
|
context.state.activePeerConnections.set(peerId, peerData);
|
||||||
|
return peerData;
|
||||||
|
}),
|
||||||
|
removePeer: vi.fn((peerId: string) => {
|
||||||
|
context.state.activePeerConnections.delete(peerId);
|
||||||
|
}),
|
||||||
|
replaceDataChannel: vi.fn((peerId: string, expectedChannel: RTCDataChannel) => {
|
||||||
|
const peerData = context.state.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
|
if (!peerData || peerData.dataChannel !== expectedChannel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
peerData.dataChannel = createDataChannel('connecting');
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPeerData(
|
||||||
|
dataChannel: RTCDataChannel,
|
||||||
|
connectionState: RTCPeerConnectionState,
|
||||||
|
isInitiator = true
|
||||||
|
): PeerData {
|
||||||
|
return {
|
||||||
|
audioSender: undefined,
|
||||||
|
connection: {
|
||||||
|
close: vi.fn(),
|
||||||
|
connectionState
|
||||||
|
} as unknown as RTCPeerConnection,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
dataChannel,
|
||||||
|
isInitiator,
|
||||||
|
pendingIceCandidates: [],
|
||||||
|
remoteCameraStreamIds: new Set<string>(),
|
||||||
|
remoteScreenShareStreamIds: new Set<string>(),
|
||||||
|
remoteVoiceStreamIds: new Set<string>(),
|
||||||
|
videoSender: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDataChannel(readyState: RTCDataChannelState): RTCDataChannel {
|
||||||
|
return {
|
||||||
|
bufferedAmount: 0,
|
||||||
|
label: 'chat',
|
||||||
|
readyState
|
||||||
|
} as unknown as RTCDataChannel;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CONNECTION_STATE_CONNECTED,
|
CONNECTION_STATE_CONNECTED,
|
||||||
|
DATA_CHANNEL_RECOVERY_GRACE_MS,
|
||||||
DATA_CHANNEL_STATE_OPEN,
|
DATA_CHANNEL_STATE_OPEN,
|
||||||
P2P_TYPE_VOICE_STATE_REQUEST,
|
P2P_TYPE_VOICE_STATE_REQUEST,
|
||||||
PEER_DISCONNECT_GRACE_MS,
|
PEER_DISCONNECT_GRACE_MS,
|
||||||
@@ -27,6 +28,7 @@ export function removePeer(
|
|||||||
const preserveReconnectState = options?.preserveReconnectState === true;
|
const preserveReconnectState = options?.preserveReconnectState === true;
|
||||||
|
|
||||||
clearPeerDisconnectGraceTimer(state, peerId);
|
clearPeerDisconnectGraceTimer(state, peerId);
|
||||||
|
clearDataChannelRecoveryTimer(state, peerId);
|
||||||
|
|
||||||
if (!preserveReconnectState) {
|
if (!preserveReconnectState) {
|
||||||
clearPeerReconnectTimer(state, peerId);
|
clearPeerReconnectTimer(state, peerId);
|
||||||
@@ -56,6 +58,7 @@ export function removePeer(
|
|||||||
export function closeAllPeers(state: PeerConnectionManagerState): void {
|
export function closeAllPeers(state: PeerConnectionManagerState): void {
|
||||||
clearAllPeerReconnectTimers(state);
|
clearAllPeerReconnectTimers(state);
|
||||||
clearAllPeerDisconnectGraceTimers(state);
|
clearAllPeerDisconnectGraceTimers(state);
|
||||||
|
clearAllDataChannelRecoveryTimers(state);
|
||||||
clearAllPingTimers(state);
|
clearAllPingTimers(state);
|
||||||
|
|
||||||
state.activePeerConnections.forEach((peerData) => {
|
state.activePeerConnections.forEach((peerData) => {
|
||||||
@@ -106,6 +109,18 @@ export function clearPeerDisconnectGraceTimer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearDataChannelRecoveryTimer(
|
||||||
|
state: PeerConnectionManagerState,
|
||||||
|
peerId: string
|
||||||
|
): void {
|
||||||
|
const timer = state.dataChannelRecoveryTimers.get(peerId);
|
||||||
|
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
state.dataChannelRecoveryTimers.delete(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Cancel all pending peer reconnect timers and clear the tracker. */
|
/** Cancel all pending peer reconnect timers and clear the tracker. */
|
||||||
export function clearAllPeerReconnectTimers(state: PeerConnectionManagerState): void {
|
export function clearAllPeerReconnectTimers(state: PeerConnectionManagerState): void {
|
||||||
state.peerReconnectTimers.forEach((timer) => clearInterval(timer));
|
state.peerReconnectTimers.forEach((timer) => clearInterval(timer));
|
||||||
@@ -118,6 +133,85 @@ export function clearAllPeerDisconnectGraceTimers(state: PeerConnectionManagerSt
|
|||||||
state.peerDisconnectGraceTimers.clear();
|
state.peerDisconnectGraceTimers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearAllDataChannelRecoveryTimers(state: PeerConnectionManagerState): void {
|
||||||
|
state.dataChannelRecoveryTimers.forEach((timer) => clearTimeout(timer));
|
||||||
|
state.dataChannelRecoveryTimers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleDataChannelRecovery(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
peerId: string,
|
||||||
|
channel: RTCDataChannel,
|
||||||
|
reason: string,
|
||||||
|
handlers: RecoveryHandlers
|
||||||
|
): void {
|
||||||
|
const { logger, state } = context;
|
||||||
|
const peerData = state.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
|
if (!peerData || peerData.dataChannel !== channel)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (channel.readyState === DATA_CHANNEL_STATE_OPEN)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (state.dataChannelRecoveryTimers.has(peerId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
logger.warn('[data-channel] Control channel unavailable; waiting before reconnect', {
|
||||||
|
channelLabel: channel.label,
|
||||||
|
peerId,
|
||||||
|
readyState: channel.readyState,
|
||||||
|
reason
|
||||||
|
});
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
state.dataChannelRecoveryTimers.delete(peerId);
|
||||||
|
|
||||||
|
const latestPeerData = state.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
|
if (!latestPeerData || latestPeerData.dataChannel !== channel)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (latestPeerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN)
|
||||||
|
return;
|
||||||
|
|
||||||
|
logger.warn('[data-channel] Control channel did not recover; selecting repair path', {
|
||||||
|
channelLabel: channel.label,
|
||||||
|
connectionState: latestPeerData.connection.connectionState,
|
||||||
|
peerId,
|
||||||
|
readyState: latestPeerData.dataChannel?.readyState ?? null,
|
||||||
|
reason
|
||||||
|
});
|
||||||
|
|
||||||
|
if (latestPeerData.connection.connectionState === CONNECTION_STATE_CONNECTED) {
|
||||||
|
if (latestPeerData.isInitiator && handlers.replaceDataChannel(peerId, channel)) {
|
||||||
|
logger.info('[data-channel] Replaced control channel without recreating media transport', {
|
||||||
|
peerId,
|
||||||
|
reason
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!latestPeerData.isInitiator) {
|
||||||
|
logger.info('[data-channel] Waiting for initiator to replace control channel; preserving media transport', {
|
||||||
|
peerId,
|
||||||
|
reason
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trackDisconnectedPeer(state, peerId);
|
||||||
|
handlers.removePeer(peerId, { preserveReconnectState: true });
|
||||||
|
attemptPeerReconnect(context, peerId, handlers);
|
||||||
|
schedulePeerReconnect(context, peerId, handlers);
|
||||||
|
}, DATA_CHANNEL_RECOVERY_GRACE_MS);
|
||||||
|
|
||||||
|
state.dataChannelRecoveryTimers.set(peerId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
export function schedulePeerDisconnectRecovery(
|
export function schedulePeerDisconnectRecovery(
|
||||||
context: PeerConnectionManagerContext,
|
context: PeerConnectionManagerContext,
|
||||||
peerId: string,
|
peerId: string,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface PeerConnectionManagerState {
|
|||||||
disconnectedPeerTracker: Map<string, DisconnectedPeerEntry>;
|
disconnectedPeerTracker: Map<string, DisconnectedPeerEntry>;
|
||||||
peerReconnectTimers: Map<string, ReturnType<typeof setInterval>>;
|
peerReconnectTimers: Map<string, ReturnType<typeof setInterval>>;
|
||||||
peerDisconnectGraceTimers: Map<string, ReturnType<typeof setTimeout>>;
|
peerDisconnectGraceTimers: Map<string, ReturnType<typeof setTimeout>>;
|
||||||
|
dataChannelRecoveryTimers: Map<string, ReturnType<typeof setTimeout>>;
|
||||||
pendingPings: Map<string, number>;
|
pendingPings: Map<string, number>;
|
||||||
peerPingTimers: Map<string, ReturnType<typeof setInterval>>;
|
peerPingTimers: Map<string, ReturnType<typeof setInterval>>;
|
||||||
peerLatencies: Map<string, number>;
|
peerLatencies: Map<string, number>;
|
||||||
@@ -78,12 +79,18 @@ export interface ConnectionLifecycleHandlers {
|
|||||||
setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void;
|
setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DataChannelLifecycleHandlers {
|
||||||
|
clearDataChannelRecoveryTimer(peerId: string): void;
|
||||||
|
scheduleDataChannelRecovery(peerId: string, channel: RTCDataChannel, reason: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NegotiationHandlers {
|
export interface NegotiationHandlers {
|
||||||
createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData;
|
createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecoveryHandlers {
|
export interface RecoveryHandlers {
|
||||||
removePeer(peerId: string, options?: RemovePeerOptions): void;
|
removePeer(peerId: string, options?: RemovePeerOptions): void;
|
||||||
|
replaceDataChannel(peerId: string, expectedChannel: RTCDataChannel): boolean;
|
||||||
createPeerConnection(peerId: string, isInitiator: boolean): PeerData;
|
createPeerConnection(peerId: string, isInitiator: boolean): PeerData;
|
||||||
createAndSendOffer(peerId: string): Promise<void>;
|
createAndSendOffer(peerId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -98,6 +105,7 @@ export function createPeerConnectionManagerState(): PeerConnectionManagerState {
|
|||||||
disconnectedPeerTracker: new Map<string, DisconnectedPeerEntry>(),
|
disconnectedPeerTracker: new Map<string, DisconnectedPeerEntry>(),
|
||||||
peerReconnectTimers: new Map<string, ReturnType<typeof setInterval>>(),
|
peerReconnectTimers: new Map<string, ReturnType<typeof setInterval>>(),
|
||||||
peerDisconnectGraceTimers: new Map<string, ReturnType<typeof setTimeout>>(),
|
peerDisconnectGraceTimers: new Map<string, ReturnType<typeof setTimeout>>(),
|
||||||
|
dataChannelRecoveryTimers: new Map<string, ReturnType<typeof setTimeout>>(),
|
||||||
pendingPings: new Map<string, number>(),
|
pendingPings: new Map<string, number>(),
|
||||||
peerPingTimers: new Map<string, ReturnType<typeof setInterval>>(),
|
peerPingTimers: new Map<string, ReturnType<typeof setInterval>>(),
|
||||||
peerLatencies: new Map<string, number>(),
|
peerLatencies: new Map<string, number>(),
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ export class WebRTCService implements OnDestroy {
|
|||||||
this.signalingMessageHandler = new IncomingSignalingMessageHandler({
|
this.signalingMessageHandler = new IncomingSignalingMessageHandler({
|
||||||
getLocalOderId: () => this.signalingTransportHandler.getIdentifyCredentials()?.oderId ?? null,
|
getLocalOderId: () => this.signalingTransportHandler.getIdentifyCredentials()?.oderId ?? null,
|
||||||
getEffectiveServerId: () => this.voiceSessionController.getEffectiveServerId(this.state.currentServerId),
|
getEffectiveServerId: () => this.voiceSessionController.getEffectiveServerId(this.state.currentServerId),
|
||||||
|
isVoiceConnected: () => this.state.isVoiceConnectedActive(),
|
||||||
peerManager: this.peerManager,
|
peerManager: this.peerManager,
|
||||||
setServerTime: (serverTime) => this.timeSync.setFromServerTime(serverTime),
|
setServerTime: (serverTime) => this.timeSync.setFromServerTime(serverTime),
|
||||||
signalingCoordinator: this.signalingCoordinator,
|
signalingCoordinator: this.signalingCoordinator,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export const DATA_CHANNEL_LABEL = 'chat';
|
|||||||
export const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024; // 4 MB
|
export const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024; // 4 MB
|
||||||
/** Low-water mark (bytes) - resume sending once buffered amount drops below this */
|
/** Low-water mark (bytes) - resume sending once buffered amount drops below this */
|
||||||
export const DATA_CHANNEL_LOW_WATER_BYTES = 1 * 1024 * 1024; // 1 MB
|
export const DATA_CHANNEL_LOW_WATER_BYTES = 1 * 1024 * 1024; // 1 MB
|
||||||
|
/** Grace period before recreating a peer whose data channel closed while media may still be flowing. */
|
||||||
|
export const DATA_CHANNEL_RECOVERY_GRACE_MS = 2_500;
|
||||||
|
|
||||||
export const SCREEN_SHARE_IDEAL_WIDTH = 1920;
|
export const SCREEN_SHARE_IDEAL_WIDTH = 1920;
|
||||||
export const SCREEN_SHARE_IDEAL_HEIGHT = 1080;
|
export const SCREEN_SHARE_IDEAL_HEIGHT = 1080;
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { PeerData } from '../realtime.types';
|
||||||
|
import { PeerConnectionManager } from '../peer-connection-manager/peer-connection.manager';
|
||||||
|
import { IncomingSignalingMessageHandler } from './signaling-message-handler';
|
||||||
|
import { ServerSignalingCoordinator } from './server-signaling-coordinator';
|
||||||
|
|
||||||
|
describe('IncomingSignalingMessageHandler user_left handling', () => {
|
||||||
|
it('preserves an active voice peer on a transient user_left signal', () => {
|
||||||
|
const context = createHandlerContext({ voiceConnected: true });
|
||||||
|
|
||||||
|
context.coordinator.addJoinedServer('ws://signal-a', 'server-1');
|
||||||
|
context.coordinator.trackPeerInServer('peer-a', 'server-1', 'ws://signal-a');
|
||||||
|
context.peerManager.activePeerConnections.set('peer-a', createPeerData('connected', 'open'));
|
||||||
|
|
||||||
|
context.handler.handleMessage({ type: 'user_left', oderId: 'peer-a', serverId: 'server-1' }, 'ws://signal-a');
|
||||||
|
|
||||||
|
expect(context.peerManager.removePeer).not.toHaveBeenCalled();
|
||||||
|
expect(context.coordinator.getPeerSignalUrl('peer-a')).toBe('ws://signal-a');
|
||||||
|
expect(context.coordinator.hasTrackedPeerServers('peer-a')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a peer on user_left when local voice is not active', () => {
|
||||||
|
const context = createHandlerContext({ voiceConnected: false });
|
||||||
|
|
||||||
|
context.coordinator.trackPeerInServer('peer-a', 'server-1', 'ws://signal-a');
|
||||||
|
context.peerManager.activePeerConnections.set('peer-a', createPeerData('connected', 'open'));
|
||||||
|
|
||||||
|
context.handler.handleMessage({ type: 'user_left', oderId: 'peer-a', serverId: 'server-1' }, 'ws://signal-a');
|
||||||
|
|
||||||
|
expect(context.peerManager.removePeer).toHaveBeenCalledWith('peer-a');
|
||||||
|
expect(context.coordinator.getPeerSignalUrl('peer-a')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a stale voice peer when no active P2P transport remains', () => {
|
||||||
|
const context = createHandlerContext({ voiceConnected: true });
|
||||||
|
|
||||||
|
context.coordinator.trackPeerInServer('peer-a', 'server-1', 'ws://signal-a');
|
||||||
|
context.peerManager.activePeerConnections.set('peer-a', createPeerData('failed', 'closed'));
|
||||||
|
|
||||||
|
context.handler.handleMessage({ type: 'user_left', oderId: 'peer-a', serverId: 'server-1' }, 'ws://signal-a');
|
||||||
|
|
||||||
|
expect(context.peerManager.removePeer).toHaveBeenCalledWith('peer-a');
|
||||||
|
expect(context.coordinator.getPeerSignalUrl('peer-a')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface HandlerContext {
|
||||||
|
coordinator: ServerSignalingCoordinator<unknown>;
|
||||||
|
handler: IncomingSignalingMessageHandler;
|
||||||
|
peerManager: PeerConnectionManager & {
|
||||||
|
activePeerConnections: Map<string, PeerData>;
|
||||||
|
removePeer: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHandlerContext(options: { voiceConnected: boolean }): HandlerContext {
|
||||||
|
const coordinator = new ServerSignalingCoordinator<unknown>({
|
||||||
|
createManager: vi.fn(),
|
||||||
|
handleConnectionStatus: vi.fn(),
|
||||||
|
handleHeartbeatTick: vi.fn(),
|
||||||
|
handleMessage: vi.fn()
|
||||||
|
});
|
||||||
|
const peerManager = {
|
||||||
|
activePeerConnections: new Map<string, PeerData>(),
|
||||||
|
removePeer: vi.fn()
|
||||||
|
} as unknown as HandlerContext['peerManager'];
|
||||||
|
const handler = new IncomingSignalingMessageHandler({
|
||||||
|
getEffectiveServerId: () => 'server-1',
|
||||||
|
getLocalOderId: () => 'local-user',
|
||||||
|
isVoiceConnected: () => options.voiceConnected,
|
||||||
|
logger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
logStream: vi.fn(),
|
||||||
|
traffic: vi.fn(),
|
||||||
|
warn: vi.fn()
|
||||||
|
} as unknown as ConstructorParameters<typeof IncomingSignalingMessageHandler>[0]['logger'],
|
||||||
|
peerManager,
|
||||||
|
setServerTime: vi.fn(),
|
||||||
|
signalingCoordinator: coordinator
|
||||||
|
});
|
||||||
|
|
||||||
|
return { coordinator, handler, peerManager };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPeerData(
|
||||||
|
connectionState: RTCPeerConnectionState,
|
||||||
|
dataChannelState: RTCDataChannelState
|
||||||
|
): PeerData {
|
||||||
|
return {
|
||||||
|
audioSender: undefined,
|
||||||
|
connection: {
|
||||||
|
connectionState
|
||||||
|
} as RTCPeerConnection,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
dataChannel: {
|
||||||
|
readyState: dataChannelState
|
||||||
|
} as RTCDataChannel,
|
||||||
|
isInitiator: true,
|
||||||
|
pendingIceCandidates: [],
|
||||||
|
remoteCameraStreamIds: new Set<string>(),
|
||||||
|
remoteScreenShareStreamIds: new Set<string>(),
|
||||||
|
remoteVoiceStreamIds: new Set<string>(),
|
||||||
|
videoSender: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ interface IncomingSignalingMessageHandlerDependencies {
|
|||||||
logger: WebRTCLogger;
|
logger: WebRTCLogger;
|
||||||
getLocalOderId(): string | null;
|
getLocalOderId(): string | null;
|
||||||
getEffectiveServerId(): string | null;
|
getEffectiveServerId(): string | null;
|
||||||
|
isVoiceConnected(): boolean;
|
||||||
setServerTime(serverTime: number): void;
|
setServerTime(serverTime: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +295,7 @@ export class IncomingSignalingMessageHandler {
|
|||||||
if (message.oderId) {
|
if (message.oderId) {
|
||||||
this.clearUserJoinedFallbackOffer(message.oderId);
|
this.clearUserJoinedFallbackOffer(message.oderId);
|
||||||
this.nonInitiatorWaitStart.delete(message.oderId);
|
this.nonInitiatorWaitStart.delete(message.oderId);
|
||||||
|
const existing = this.dependencies.peerManager.activePeerConnections.get(message.oderId);
|
||||||
const hasRemainingSharedServers = Array.isArray(message.serverIds)
|
const hasRemainingSharedServers = Array.isArray(message.serverIds)
|
||||||
? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, signalUrl, message.serverIds)
|
? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, signalUrl, message.serverIds)
|
||||||
: message.serverId
|
: message.serverId
|
||||||
@@ -302,6 +303,16 @@ export class IncomingSignalingMessageHandler {
|
|||||||
: false;
|
: false;
|
||||||
|
|
||||||
if (!hasRemainingSharedServers) {
|
if (!hasRemainingSharedServers) {
|
||||||
|
const serverIdsToPreserve = Array.isArray(message.serverIds)
|
||||||
|
? message.serverIds
|
||||||
|
: message.serverId
|
||||||
|
? [message.serverId]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (this.shouldPreserveActiveVoicePeerAfterLeave(message.oderId, existing, signalUrl, serverIdsToPreserve)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.dependencies.peerManager.removePeer(message.oderId);
|
this.dependencies.peerManager.removePeer(message.oderId);
|
||||||
this.dependencies.signalingCoordinator.deletePeerTracking(message.oderId);
|
this.dependencies.signalingCoordinator.deletePeerTracking(message.oderId);
|
||||||
}
|
}
|
||||||
@@ -533,6 +544,41 @@ export class IncomingSignalingMessageHandler {
|
|||||||
return connectionState === 'connected' || peer.dataChannel?.readyState === 'open';
|
return connectionState === 'connected' || peer.dataChannel?.readyState === 'open';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldPreserveActiveVoicePeerAfterLeave(
|
||||||
|
peerId: string,
|
||||||
|
peer: PeerData | undefined,
|
||||||
|
signalUrl: string,
|
||||||
|
serverIds: readonly string[]
|
||||||
|
): boolean {
|
||||||
|
if (!this.dependencies.isVoiceConnected() || !this.hasActivePeerConnection(peer)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let restoredServerScope = false;
|
||||||
|
|
||||||
|
for (const serverId of serverIds) {
|
||||||
|
if (!this.dependencies.signalingCoordinator.hasJoinedServer(serverId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dependencies.signalingCoordinator.trackPeerInServer(peerId, serverId, signalUrl);
|
||||||
|
restoredServerScope = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!restoredServerScope) {
|
||||||
|
this.dependencies.signalingCoordinator.setPeerSignalUrl(peerId, signalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dependencies.logger.warn('Preserving active voice peer after transient user_left', {
|
||||||
|
connectionState: peer?.connection.connectionState ?? 'unknown',
|
||||||
|
dataChannelState: peer?.dataChannel?.readyState ?? 'missing',
|
||||||
|
peerId,
|
||||||
|
signalUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private isPeerConnectionNegotiating(peer: PeerData | undefined): boolean {
|
private isPeerConnectionNegotiating(peer: PeerData | undefined): boolean {
|
||||||
if (!peer || this.hasActivePeerConnection(peer))
|
if (!peer || this.hasActivePeerConnection(peer))
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -195,7 +195,8 @@ export class RoomStateSyncEffects {
|
|||||||
UsersActions.userLeft({
|
UsersActions.userLeft({
|
||||||
userId: signalingMessage.oderId,
|
userId: signalingMessage.oderId,
|
||||||
serverId: signalingMessage.serverId,
|
serverId: signalingMessage.serverId,
|
||||||
serverIds: remainingServerIds
|
serverIds: remainingServerIds,
|
||||||
|
connectedPeerIds: this.webrtc.getConnectedPeers()
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -300,6 +300,98 @@ describe('users reducer - status', () => {
|
|||||||
expect(state.entities['u3']?.cameraState?.isEnabled).toBe(false);
|
expect(state.entities['u3']?.cameraState?.isEnabled).toBe(false);
|
||||||
expect(state.entities['u3']?.screenShareState?.isSharing).toBe(false);
|
expect(state.entities['u3']?.screenShareState?.isSharing).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves voice state on user_left while live peer transport still exists', () => {
|
||||||
|
const remoteUser = createUser({
|
||||||
|
id: 'u4',
|
||||||
|
oderId: 'u4',
|
||||||
|
displayName: 'Transient Peer',
|
||||||
|
presenceServerIds: ['s1'],
|
||||||
|
status: 'online',
|
||||||
|
voiceState: {
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
isSpeaking: true,
|
||||||
|
roomId: 'voice-1',
|
||||||
|
serverId: 's1'
|
||||||
|
},
|
||||||
|
cameraState: { isEnabled: true }
|
||||||
|
});
|
||||||
|
const withUser = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
|
||||||
|
const state = usersReducer(withUser, UsersActions.userLeft({
|
||||||
|
userId: 'u4',
|
||||||
|
serverId: 's1',
|
||||||
|
connectedPeerIds: ['u4']
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(state.entities['u4']?.presenceServerIds).toEqual(['s1']);
|
||||||
|
expect(state.entities['u4']?.isOnline).toBe(true);
|
||||||
|
expect(state.entities['u4']?.status).toBe('online');
|
||||||
|
expect(state.entities['u4']?.voiceState?.isConnected).toBe(true);
|
||||||
|
expect(state.entities['u4']?.voiceState?.roomId).toBe('voice-1');
|
||||||
|
expect(state.entities['u4']?.cameraState?.isEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches live user_left transports by oderId and peerId', () => {
|
||||||
|
const remoteUser = createUser({
|
||||||
|
id: 'db-id-5',
|
||||||
|
oderId: 'oder-5',
|
||||||
|
peerId: 'peer-5',
|
||||||
|
displayName: 'Peer Id Match',
|
||||||
|
presenceServerIds: ['s1'],
|
||||||
|
voiceState: {
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
isSpeaking: false,
|
||||||
|
roomId: 'voice-1',
|
||||||
|
serverId: 's1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const withUser = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
|
||||||
|
const byOderId = usersReducer(withUser, UsersActions.userLeft({
|
||||||
|
userId: 'db-id-5',
|
||||||
|
serverId: 's1',
|
||||||
|
connectedPeerIds: ['oder-5']
|
||||||
|
}));
|
||||||
|
const byPeerId = usersReducer(withUser, UsersActions.userLeft({
|
||||||
|
userId: 'db-id-5',
|
||||||
|
serverId: 's1',
|
||||||
|
connectedPeerIds: ['peer-5']
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(byOderId.entities['db-id-5']?.voiceState?.isConnected).toBe(true);
|
||||||
|
expect(byPeerId.entities['db-id-5']?.voiceState?.isConnected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears voice state on user_left when the peer transport is gone', () => {
|
||||||
|
const remoteUser = createUser({
|
||||||
|
id: 'u6',
|
||||||
|
oderId: 'u6',
|
||||||
|
displayName: 'Gone Peer',
|
||||||
|
presenceServerIds: ['s1'],
|
||||||
|
voiceState: {
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
isSpeaking: true,
|
||||||
|
roomId: 'voice-1',
|
||||||
|
serverId: 's1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const withUser = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
|
||||||
|
const state = usersReducer(withUser, UsersActions.userLeft({
|
||||||
|
userId: 'u6',
|
||||||
|
serverId: 's1',
|
||||||
|
connectedPeerIds: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(state.entities['u6']?.presenceServerIds).toBeUndefined();
|
||||||
|
expect(state.entities['u6']?.isOnline).toBe(false);
|
||||||
|
expect(state.entities['u6']?.voiceState?.isConnected).toBe(false);
|
||||||
|
expect(state.entities['u6']?.voiceState?.roomId).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('manual status overrides auto idle', () => {
|
describe('manual status overrides auto idle', () => {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const UsersActions = createActionGroup({
|
|||||||
'Load Room Users Failure': props<{ error: string }>(),
|
'Load Room Users Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
'User Joined': props<{ user: User }>(),
|
'User Joined': props<{ user: User }>(),
|
||||||
'User Left': props<{ userId: string; serverId?: string; serverIds?: string[] }>(),
|
'User Left': props<{ userId: string; serverId?: string; serverIds?: string[]; connectedPeerIds?: string[] }>(),
|
||||||
'Sync Server Presence': props<{ roomId: string; users: User[]; connectedPeerIds?: string[] }>(),
|
'Sync Server Presence': props<{ roomId: string; users: User[]; connectedPeerIds?: string[] }>(),
|
||||||
|
|
||||||
'Update User': props<{ userId: string; updates: Partial<User> }>(),
|
'Update User': props<{ userId: string; updates: Partial<User> }>(),
|
||||||
|
|||||||
@@ -227,7 +227,8 @@ function buildAvatarUser(existingUser: User | undefined, incomingUser: {
|
|||||||
|
|
||||||
function buildPresenceRemovalChanges(
|
function buildPresenceRemovalChanges(
|
||||||
user: User,
|
user: User,
|
||||||
update: { serverId?: string; serverIds?: readonly string[] }
|
update: { serverId?: string; serverIds?: readonly string[] },
|
||||||
|
connectedPeerIds: ReadonlySet<string> = new Set<string>()
|
||||||
): Partial<User> {
|
): Partial<User> {
|
||||||
const nextPresenceServerIds = update.serverIds !== undefined
|
const nextPresenceServerIds = update.serverIds !== undefined
|
||||||
? normalizePresenceServerIds(update.serverIds)
|
? normalizePresenceServerIds(update.serverIds)
|
||||||
@@ -235,6 +236,24 @@ function buildPresenceRemovalChanges(
|
|||||||
const isOnline = (nextPresenceServerIds?.length ?? 0) > 0;
|
const isOnline = (nextPresenceServerIds?.length ?? 0) > 0;
|
||||||
const shouldClearLiveState = !isOnline
|
const shouldClearLiveState = !isOnline
|
||||||
|| (!!user.voiceState?.serverId && !nextPresenceServerIds?.includes(user.voiceState.serverId));
|
|| (!!user.voiceState?.serverId && !nextPresenceServerIds?.includes(user.voiceState.serverId));
|
||||||
|
const hasLiveState = user.voiceState?.isConnected === true
|
||||||
|
|| user.screenShareState?.isSharing === true
|
||||||
|
|| user.cameraState?.isEnabled === true;
|
||||||
|
|
||||||
|
if (shouldClearLiveState && hasLiveState && hasLivePeerTransport(user, connectedPeerIds)) {
|
||||||
|
const preservedPresenceServerIds = user.presenceServerIds
|
||||||
|
?? (user.voiceState?.serverId ? [user.voiceState.serverId] : undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
presenceServerIds: preservedPresenceServerIds,
|
||||||
|
isOnline: true,
|
||||||
|
status: user.status && user.status !== 'offline' && user.status !== 'disconnected' ? user.status : 'online',
|
||||||
|
voiceState: user.voiceState,
|
||||||
|
screenShareState: user.screenShareState,
|
||||||
|
cameraState: user.cameraState,
|
||||||
|
gameActivity: user.gameActivity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
presenceServerIds: nextPresenceServerIds,
|
presenceServerIds: nextPresenceServerIds,
|
||||||
@@ -390,7 +409,7 @@ export const usersReducer = createReducer(
|
|||||||
? usersAdapter.updateMany(stalePresenceUpdates, nextState)
|
? usersAdapter.updateMany(stalePresenceUpdates, nextState)
|
||||||
: nextState;
|
: nextState;
|
||||||
}),
|
}),
|
||||||
on(UsersActions.userLeft, (state, { userId, serverId, serverIds }) => {
|
on(UsersActions.userLeft, (state, { userId, serverId, serverIds, connectedPeerIds }) => {
|
||||||
const existingUser = state.entities[userId];
|
const existingUser = state.entities[userId];
|
||||||
|
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
@@ -409,7 +428,7 @@ export const usersReducer = createReducer(
|
|||||||
changes: buildPresenceRemovalChanges(existingUser, {
|
changes: buildPresenceRemovalChanges(existingUser, {
|
||||||
serverId,
|
serverId,
|
||||||
serverIds
|
serverIds
|
||||||
})
|
}, new Set(connectedPeerIds ?? []))
|
||||||
},
|
},
|
||||||
state
|
state
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user