feat: signal server tag

This commit is contained in:
2026-06-05 06:16:02 +02:00
parent 6865147e8f
commit bf4e6891d1
69 changed files with 2808 additions and 1269 deletions

View File

@@ -0,0 +1,345 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
SIGNALING_CONNECT_TIMEOUT_MS,
SIGNALING_HEALTH_PROBE_INTERVAL_MS,
SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS,
SIGNALING_KEEPALIVE_INTERVAL_MS,
SIGNALING_RECONNECT_BASE_DELAY_MS,
STATE_HEARTBEAT_INTERVAL_MS
} from '../realtime.constants';
import { WebRTCLogger } from '../logging/webrtc-logger';
import { SignalingManager } from './signaling.manager';
import { probeSignalingEndpointHealth } from './signaling-endpoint-health.rules';
vi.mock('./signaling-endpoint-health.rules', () => ({
probeSignalingEndpointHealth: vi.fn(async () => ({ ok: true, serverInstanceId: 'instance-a' }))
}));
type MockSocketHandler = (event?: { code?: number; reason?: string; wasClean?: boolean }) => void;
class MockWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: MockWebSocket[] = [];
shouldFailSend = false;
autoAckKeepalive = true;
readonly url: string;
readyState = MockWebSocket.CONNECTING;
onopen: MockSocketHandler | null = null;
onclose: MockSocketHandler | null = null;
onerror: MockSocketHandler | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
close(): void {
if (this.readyState === MockWebSocket.CLOSED) {
return;
}
this.readyState = MockWebSocket.CLOSED;
this.onclose?.({ code: 1000, reason: 'closed', wasClean: true });
}
send(payload: string): void {
if (this.shouldFailSend) {
throw new Error('mock send failure');
}
if (!this.autoAckKeepalive) {
return;
}
try {
const message = JSON.parse(payload) as { type?: string };
if (message.type === 'keepalive') {
this.onmessage?.({ data: JSON.stringify({ type: 'keepalive_ack' }) });
}
} catch {
// ignore malformed payloads in tests
}
}
simulateOpen(): void {
this.readyState = MockWebSocket.OPEN;
this.onopen?.();
}
simulateError(): void {
this.onerror?.(new Event('error'));
this.close();
}
}
describe('SignalingManager reconnection', () => {
const serverUrl = 'ws://localhost:3001';
beforeEach(() => {
vi.useFakeTimers();
MockWebSocket.instances = [];
vi.stubGlobal('WebSocket', MockWebSocket);
vi.mocked(probeSignalingEndpointHealth).mockResolvedValue({ ok: true, serverInstanceId: 'instance-a' });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllTimers();
vi.useRealTimers();
});
function createManager(
identifyCredentials: {
oderId: string;
displayName: string;
description?: string;
profileUpdatedAt?: number;
homeSignalServerUrl?: string;
} = {
oderId: 'peer-a',
displayName: 'Peer A',
description: 'hello',
profileUpdatedAt: 42,
homeSignalServerUrl: 'https://signal.example'
}
): SignalingManager {
const sentMessages: Record<string, unknown>[] = [];
const manager = new SignalingManager(
new WebRTCLogger(false),
() => identifyCredentials,
() => ({ serverId: 'server-1', userId: 'peer-a' }),
() => new Set(['server-1'])
);
const originalSendRawMessage = manager.sendRawMessage.bind(manager);
manager.sendRawMessage = (message: Record<string, unknown>) => {
sentMessages.push(message);
originalSendRawMessage(message);
};
Object.assign(manager, { sentMessages });
return manager;
}
it('schedules reconnect after an established socket closes', async () => {
const manager = createManager();
const connected = vi.fn();
manager.connect(serverUrl).subscribe({ next: connected });
MockWebSocket.instances[0]?.simulateOpen();
expect(connected).toHaveBeenCalledWith(true);
MockWebSocket.instances[0]?.close();
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
expect(MockWebSocket.instances).toHaveLength(2);
expect(MockWebSocket.instances[1]?.readyState).toBe(MockWebSocket.CONNECTING);
});
it('keeps retrying after a failed reconnect attempt until the server accepts the socket', async () => {
const manager = createManager();
manager.connect(serverUrl).subscribe();
MockWebSocket.instances[0]?.simulateOpen();
MockWebSocket.instances[0]?.close();
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
MockWebSocket.instances[1]?.simulateError();
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
expect(MockWebSocket.instances).toHaveLength(3);
MockWebSocket.instances[2]?.simulateOpen();
expect(manager.isSocketOpen()).toBe(true);
});
it('discards a stale connecting socket and retries after the connect timeout', async () => {
const manager = createManager();
manager.connect(serverUrl).subscribe();
MockWebSocket.instances[0]?.simulateOpen();
MockWebSocket.instances[0]?.close();
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
expect(MockWebSocket.instances).toHaveLength(2);
vi.advanceTimersByTime(SIGNALING_CONNECT_TIMEOUT_MS);
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(3);
});
it('does not treat a timed-out waitForOpen result as a successful reconnect', async () => {
const manager = createManager();
manager.connect(serverUrl).subscribe();
MockWebSocket.instances[0]?.simulateOpen();
MockWebSocket.instances[0]?.close();
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
expect(MockWebSocket.instances).toHaveLength(2);
const secondAttempt = vi.fn();
manager.connect(serverUrl).subscribe({ next: secondAttempt });
vi.advanceTimersByTime(SIGNALING_CONNECT_TIMEOUT_MS);
expect(secondAttempt).toHaveBeenCalledWith(false);
expect(manager.isSocketOpen()).toBe(false);
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS * 2);
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(3);
});
it('replays the full identify payload after reconnect', () => {
const manager = createManager() as SignalingManager & { sentMessages: Record<string, unknown>[] };
manager.connect(serverUrl).subscribe();
MockWebSocket.instances[0]?.simulateOpen();
manager.sentMessages.length = 0;
MockWebSocket.instances[0]?.close();
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
MockWebSocket.instances[1]?.simulateOpen();
const identifyMessage = manager.sentMessages.find((message) => message['type'] === 'identify');
expect(identifyMessage).toMatchObject({
type: 'identify',
oderId: 'peer-a',
displayName: 'Peer A',
description: 'hello',
profileUpdatedAt: 42,
homeSignalServerUrl: 'https://signal.example'
});
});
it('does not force reconnect on servers that never send keepalive acknowledgements', () => {
const manager = createManager();
manager.connect(serverUrl).subscribe();
const socket = MockWebSocket.instances[0];
if (socket) {
socket.autoAckKeepalive = false;
}
socket?.simulateOpen();
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS);
expect(MockWebSocket.instances).toHaveLength(1);
expect(manager.isSocketOpen()).toBe(true);
});
it('forces reconnect when keepalive acknowledgements stop arriving on ack-capable servers', () => {
const manager = createManager();
manager.connect(serverUrl).subscribe();
const socket = MockWebSocket.instances[0];
socket?.simulateOpen();
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
if (socket) {
socket.autoAckKeepalive = false;
}
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_INTERVAL_MS + STATE_HEARTBEAT_INTERVAL_MS);
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS);
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
expect(manager.isSocketOpen()).toBe(false);
});
it('silently drops transient negotiation messages while the socket is reconnecting', () => {
const logger = new WebRTCLogger(false);
const errorSpy = vi.spyOn(logger, 'error');
const manager = new SignalingManager(
logger,
() => ({ oderId: 'peer-a', displayName: 'Peer A' }),
() => ({ serverId: 'server-1', userId: 'peer-a' }),
() => new Set(['server-1'])
);
expect(manager.sendRawMessage({
type: 'ice_candidate',
targetUserId: 'peer-b',
payload: { candidate: { candidate: 'candidate:1' } }
})).toBe(false);
expect(errorSpy).not.toHaveBeenCalled();
});
it('forces reconnect when the signaling health probe fails on an open socket', async () => {
vi.mocked(probeSignalingEndpointHealth).mockResolvedValue({ ok: false });
const manager = createManager();
manager.connect(serverUrl).subscribe();
MockWebSocket.instances[0]?.simulateOpen();
await Promise.resolve();
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
expect(manager.isSocketOpen()).toBe(false);
});
it('forces reconnect when the signaling server instance changes under an open socket', async () => {
const manager = createManager();
manager.connect(serverUrl).subscribe();
MockWebSocket.instances[0]?.simulateOpen();
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
await Promise.resolve();
vi.mocked(probeSignalingEndpointHealth).mockResolvedValueOnce({
ok: true,
serverInstanceId: 'instance-b'
});
vi.advanceTimersByTime(STATE_HEARTBEAT_INTERVAL_MS);
await Promise.resolve();
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
expect(manager.isSocketOpen()).toBe(false);
});
it('forces reconnect when outbound signaling sends fail on a stale socket', () => {
const manager = createManager();
manager.connect(serverUrl).subscribe();
const socket = MockWebSocket.instances[0];
socket?.simulateOpen();
if (socket) {
socket.shouldFailSend = true;
}
vi.advanceTimersByTime(SIGNALING_KEEPALIVE_INTERVAL_MS + STATE_HEARTBEAT_INTERVAL_MS);
vi.advanceTimersByTime(SIGNALING_RECONNECT_BASE_DELAY_MS);
expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2);
expect(manager.isSocketOpen()).toBe(false);
});
});