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,121 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import {
applyBrowserDatabaseSchema,
ensureObjectStoreDuringUpgrade,
ensureStoreIndex
} from './browser-database-schema';
describe('browser-database-schema', () => {
it('reuses the upgrade transaction when an object store already exists', () => {
const existingStore = { indexNames: { contains: () => false } };
const database = {
objectStoreNames: { contains: (name: string) => name === 'messages' },
createObjectStore: vi.fn(),
transaction: vi.fn()
};
const upgradeTransaction = {
objectStore: vi.fn(() => existingStore)
};
const store = ensureObjectStoreDuringUpgrade(
database as unknown as IDBDatabase,
upgradeTransaction as unknown as IDBTransaction,
'messages',
{ keyPath: 'id' }
);
expect(store).toBe(existingStore);
expect(upgradeTransaction.objectStore).toHaveBeenCalledWith('messages');
expect(database.createObjectStore).not.toHaveBeenCalled();
expect(database.transaction).not.toHaveBeenCalled();
});
it('creates missing object stores during upgrade', () => {
const createdStore = { indexNames: { contains: () => false } };
const database = {
objectStoreNames: { contains: () => false },
createObjectStore: vi.fn(() => createdStore),
transaction: vi.fn()
};
const upgradeTransaction = {
objectStore: vi.fn()
};
const store = ensureObjectStoreDuringUpgrade(
database as unknown as IDBDatabase,
upgradeTransaction as unknown as IDBTransaction,
'customEmojis',
{ keyPath: 'id' }
);
expect(store).toBe(createdStore);
expect(database.createObjectStore).toHaveBeenCalledWith('customEmojis', { keyPath: 'id' });
expect(upgradeTransaction.objectStore).not.toHaveBeenCalled();
expect(database.transaction).not.toHaveBeenCalled();
});
it('creates missing indexes on an existing store', () => {
const store = {
indexNames: { contains: () => false },
createIndex: vi.fn()
};
ensureStoreIndex(store as unknown as IDBObjectStore, 'roomId', 'roomId');
expect(store.createIndex).toHaveBeenCalledWith('roomId', 'roomId', { unique: false });
});
it('applies the full schema through the upgrade transaction only', () => {
const stores = new Map<string, { indexNames: { contains: (name: string) => boolean }; createIndex: ReturnType<typeof vi.fn> }>();
const database = {
objectStoreNames: {
contains: (name: string) => stores.has(name)
},
createObjectStore: vi.fn((name: string) => {
const store = {
indexNames: { contains: () => false },
createIndex: vi.fn()
};
stores.set(name, store);
return store;
}),
transaction: vi.fn()
};
const upgradeTransaction = {
objectStore: vi.fn((name: string) => {
const store = stores.get(name);
if (!store) {
throw new Error(`Missing store ${name}`);
}
return store;
})
};
stores.set('messages', {
indexNames: { contains: () => true },
createIndex: vi.fn()
});
stores.set('users', {
indexNames: { contains: () => true },
createIndex: vi.fn()
});
expect(() => applyBrowserDatabaseSchema(
database as unknown as IDBDatabase,
upgradeTransaction as unknown as IDBTransaction
)).not.toThrow();
expect(database.transaction).not.toHaveBeenCalled();
expect(upgradeTransaction.objectStore).toHaveBeenCalledWith('messages');
expect(database.createObjectStore).toHaveBeenCalledWith('customEmojis', { keyPath: 'id' });
});
});

View File

@@ -0,0 +1,67 @@
/** IndexedDB schema version - bump when adding/changing object stores. */
export const BROWSER_DATABASE_VERSION = 3;
const STORE_MESSAGES = 'messages';
const STORE_USERS = 'users';
const STORE_ROOMS = 'rooms';
const STORE_REACTIONS = 'reactions';
const STORE_BANS = 'bans';
const STORE_META = 'meta';
const STORE_ATTACHMENTS = 'attachments';
const STORE_CUSTOM_EMOJIS = 'customEmojis';
export function ensureObjectStoreDuringUpgrade(
database: IDBDatabase,
upgradeTransaction: IDBTransaction,
name: string,
options?: IDBObjectStoreParameters
): IDBObjectStore {
if (database.objectStoreNames.contains(name)) {
return upgradeTransaction.objectStore(name);
}
return database.createObjectStore(name, options);
}
export function ensureStoreIndex(store: IDBObjectStore, name: string, keyPath: string): void {
if (!store.indexNames.contains(name)) {
store.createIndex(name, keyPath, { unique: false });
}
}
export function applyBrowserDatabaseSchema(
database: IDBDatabase,
upgradeTransaction: IDBTransaction
): void {
const messagesStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_MESSAGES, { keyPath: 'id' });
ensureStoreIndex(messagesStore, 'roomId', 'roomId');
ensureStoreIndex(messagesStore, 'timestamp', 'timestamp');
ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_USERS, { keyPath: 'id' });
const roomsStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_ROOMS, { keyPath: 'id' });
ensureStoreIndex(roomsStore, 'timestamp', 'timestamp');
const reactionsStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_REACTIONS, { keyPath: 'id' });
ensureStoreIndex(reactionsStore, 'messageId', 'messageId');
ensureStoreIndex(reactionsStore, 'userId', 'userId');
const bansStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_BANS, { keyPath: 'oderId' });
ensureStoreIndex(bansStore, 'roomId', 'roomId');
ensureStoreIndex(bansStore, 'expiresAt', 'expiresAt');
ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_META, { keyPath: 'id' });
const attachmentsStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_ATTACHMENTS, { keyPath: 'id' });
ensureStoreIndex(attachmentsStore, 'messageId', 'messageId');
const customEmojisStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_CUSTOM_EMOJIS, { keyPath: 'id' });
ensureStoreIndex(customEmojisStore, 'updatedAt', 'updatedAt');
ensureStoreIndex(customEmojisStore, 'creatorUserId', 'creatorUserId');
}

View File

@@ -11,12 +11,11 @@ import {
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
import type { RoomMessageStats } from './database.service';
import { applyBrowserDatabaseSchema, BROWSER_DATABASE_VERSION } from './browser-database-schema';
/** IndexedDB database name for the MetoYou application. */
const DATABASE_NAME = 'metoyou';
const ANONYMOUS_DATABASE_SCOPE = 'anonymous';
/** IndexedDB schema version - bump when adding/changing object stores. */
const DATABASE_VERSION = 3;
/** Names of every object store used by the application. */
const STORE_MESSAGES = 'messages';
const STORE_USERS = 'users';
@@ -432,10 +431,26 @@ export class BrowserDatabaseService {
private openDatabase(databaseName: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(databaseName, DATABASE_VERSION);
const request = indexedDB.open(databaseName, BROWSER_DATABASE_VERSION);
request.onerror = () => reject(request.error);
request.onupgradeneeded = () => this.setupSchema(request.result);
request.onupgradeneeded = (event) => {
const upgradeTransaction = (event.target as IDBOpenDBRequest).transaction;
if (!upgradeTransaction) {
reject(new Error('IndexedDB upgrade transaction is unavailable'));
return;
}
try {
applyBrowserDatabaseSchema(request.result, upgradeTransaction);
} catch (error) {
upgradeTransaction.abort();
reject(error);
}
};
request.onsuccess = () => resolve(request.result);
});
}
@@ -446,58 +461,6 @@ export class BrowserDatabaseService {
this.activeDatabaseName = null;
}
private setupSchema(database: IDBDatabase): void {
const messagesStore = this.ensureStore(database, STORE_MESSAGES, { keyPath: 'id' });
this.ensureIndex(messagesStore, 'roomId', 'roomId');
this.ensureIndex(messagesStore, 'timestamp', 'timestamp');
this.ensureStore(database, STORE_USERS, { keyPath: 'id' });
const roomsStore = this.ensureStore(database, STORE_ROOMS, { keyPath: 'id' });
this.ensureIndex(roomsStore, 'timestamp', 'timestamp');
const reactionsStore = this.ensureStore(database, STORE_REACTIONS, { keyPath: 'id' });
this.ensureIndex(reactionsStore, 'messageId', 'messageId');
this.ensureIndex(reactionsStore, 'userId', 'userId');
const bansStore = this.ensureStore(database, STORE_BANS, { keyPath: 'oderId' });
this.ensureIndex(bansStore, 'roomId', 'roomId');
this.ensureIndex(bansStore, 'expiresAt', 'expiresAt');
this.ensureStore(database, STORE_META, { keyPath: 'id' });
const attachmentsStore = this.ensureStore(database, STORE_ATTACHMENTS, { keyPath: 'id' });
this.ensureIndex(attachmentsStore, 'messageId', 'messageId');
const customEmojisStore = this.ensureStore(database, STORE_CUSTOM_EMOJIS, { keyPath: 'id' });
this.ensureIndex(customEmojisStore, 'updatedAt', 'updatedAt');
this.ensureIndex(customEmojisStore, 'creatorUserId', 'creatorUserId');
}
private ensureStore(
database: IDBDatabase,
name: string,
options?: IDBObjectStoreParameters
): IDBObjectStore {
if (database.objectStoreNames.contains(name)) {
return (database.transaction(name, 'readonly') as IDBTransaction).objectStore(name);
}
return database.createObjectStore(name, options);
}
private ensureIndex(store: IDBObjectStore, name: string, keyPath: string): void {
if (!store.indexNames.contains(name)) {
store.createIndex(name, keyPath, { unique: false });
}
}
private createTransaction(
storeNames: string | string[],
mode: IDBTransactionMode

View File

@@ -158,7 +158,7 @@ sequenceDiagram
### Reconnection
When the WebSocket drops, `SignalingManager` schedules reconnection with exponential backoff (1s, 2s, 4s, ... up to 30s). On reconnect it replays the cached `identify` and `join_server` messages so presence is restored without the UI doing anything.
When the WebSocket drops, `SignalingManager` schedules reconnection with exponential backoff (1s, 2s, 4s, ... up to 30s). Each connect attempt is bounded by `SIGNALING_CONNECT_TIMEOUT_MS` (5s); stale sockets stuck in `CONNECTING` are discarded so retries keep running while the server is down. The local dev server and current production builds answer application keepalives with `keepalive_ack`; once a server has answered at least one keepalive the client enforces the ack timeout so half-open sockets do not linger after a process restart. The first keepalive and HTTP health probe run on the first heartbeat tick after connect (not after the full 25s keepalive interval), so localhost restarts are detected in roughly 515s instead of waiting for the periodic keepalive cadence. While a socket appears open the client also probes `/api/health` every `SIGNALING_HEALTH_PROBE_INTERVAL_MS` (5s); failed probes, health recovery after an outage, or a changed `serverInstanceId` all force a fresh websocket so zombie connections after a process restart cannot keep stale presence. Failed outbound sends still force a reconnect. On reconnect it replays the cached `identify` and `join_server` messages so presence is restored without the UI doing anything, and `RoomsEffects.resyncRoomsOnSignalingReconnect$` re-runs the room join flow as a safety net.
The browser also sends a lightweight `keepalive` message on the signaling socket during long-lived sessions. The server treats both WebSocket pong frames and any inbound client message as liveness, so users who are still active in voice or chat are not removed from server presence just because control-frame pong delivery stalls behind a proxy or runtime quirk.

View File

@@ -62,6 +62,10 @@ export function createPeerConnection(
connection.onicecandidate = (event) => {
if (event.candidate) {
if (!callbacks.isSignalingConnected()) {
return;
}
logger.info('ICE candidate gathered', {
remotePeerId,
candidateType: (event.candidate as RTCIceCandidate & { type?: string }).type

View File

@@ -74,6 +74,9 @@ export class WebRTCService implements OnDestroy {
private readonly signalingMessage$ = new Subject<IncomingSignalingMessage>();
readonly onSignalingMessage = this.signalingMessage$.asObservable();
private readonly signalingReconnectedSubject$ = new Subject<string>();
readonly signalingReconnected$ = this.signalingReconnectedSubject$.asObservable();
// Delegates to managers
get onMessageReceived(): Observable<ChatEvent> {
return this.peerMediaFacade.onMessageReceived;
@@ -132,8 +135,8 @@ export class WebRTCService implements OnDestroy {
getLastJoinedServer,
getMemberServerIds
),
handleConnectionStatus: (_signalUrl, connected, errorMessage) =>
this.handleSignalingConnectionStatus(connected, errorMessage),
handleConnectionStatus: (signalUrl, connected, errorMessage) =>
this.handleSignalingConnectionStatus(signalUrl, connected, errorMessage),
handleHeartbeatTick: () => this.peerMediaFacade.broadcastCurrentStates(),
handleMessage: (message, signalUrl) => this.handleSignalingMessage(message, signalUrl)
});
@@ -247,12 +250,16 @@ export class WebRTCService implements OnDestroy {
);
}
private handleSignalingConnectionStatus(connected: boolean, errorMessage?: string): void {
private handleSignalingConnectionStatus(signalUrl: string, connected: boolean, errorMessage?: string): void {
this.state.updateSignalingConnectionStatus(
this.signalingCoordinator.isAnySignalingConnected(),
connected ? true : this.signalingCoordinator.isAnySignalingConnected(),
connected,
errorMessage
);
if (connected) {
this.signalingReconnectedSubject$.next(signalUrl);
}
}
private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
@@ -341,7 +348,7 @@ export class WebRTCService implements OnDestroy {
oderId: string,
displayName: string,
signalUrl?: string,
profile?: { description?: string; profileUpdatedAt?: number }
profile?: { description?: string; profileUpdatedAt?: number; homeSignalServerUrl?: string }
): void {
this.signalingTransportHandler.identify(oderId, displayName, signalUrl, profile);
}

View File

@@ -0,0 +1,22 @@
import {
describe,
expect,
it
} from 'vitest';
import { isTransientSignalingOutboundType } from './realtime.constants';
describe('isTransientSignalingOutboundType', () => {
it('treats negotiation traffic as droppable during reconnect', () => {
expect(isTransientSignalingOutboundType('ice_candidate')).toBe(true);
expect(isTransientSignalingOutboundType('offer')).toBe(true);
expect(isTransientSignalingOutboundType('answer')).toBe(true);
expect(isTransientSignalingOutboundType('keepalive')).toBe(true);
expect(isTransientSignalingOutboundType('status_update')).toBe(true);
});
it('keeps membership traffic as non-transient', () => {
expect(isTransientSignalingOutboundType('identify')).toBe(false);
expect(isTransientSignalingOutboundType('join_server')).toBe(false);
expect(isTransientSignalingOutboundType(undefined)).toBe(false);
});
});

View File

@@ -23,6 +23,10 @@ export const PEER_DISCONNECT_GRACE_MS = 10_000;
export const STATE_HEARTBEAT_INTERVAL_MS = 5_000;
/** Interval (ms) for application-level signaling keepalive messages */
export const SIGNALING_KEEPALIVE_INTERVAL_MS = 25_000;
/** How long to wait for a keepalive_ack before treating the socket as dead (ms) */
export const SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS = 10_000;
/** How often to probe the signaling HTTP health endpoint while connected (ms) */
export const SIGNALING_HEALTH_PROBE_INTERVAL_MS = 5_000;
/** Interval (ms) for broadcasting voice presence */
export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000;
@@ -82,6 +86,20 @@ export const SIGNALING_TYPE_USER_JOINED = 'user_joined';
export const SIGNALING_TYPE_USER_LEFT = 'user_left';
export const SIGNALING_TYPE_ACCESS_DENIED = 'access_denied';
export const SIGNALING_TYPE_KEEPALIVE = 'keepalive';
export const SIGNALING_TYPE_KEEPALIVE_ACK = 'keepalive_ack';
/** Signaling payloads that can be dropped quietly while the socket is reconnecting. */
export const TRANSIENT_SIGNALING_OUTBOUND_TYPES = new Set<string>([
SIGNALING_TYPE_ICE_CANDIDATE,
SIGNALING_TYPE_OFFER,
SIGNALING_TYPE_ANSWER,
SIGNALING_TYPE_KEEPALIVE,
'status_update'
]);
export function isTransientSignalingOutboundType(type: string | undefined): boolean {
return typeof type === 'string' && TRANSIENT_SIGNALING_OUTBOUND_TYPES.has(type);
}
export const P2P_TYPE_STATE_REQUEST = 'state-request';
export const P2P_TYPE_VOICE_STATE_REQUEST = 'voice-state-request';

View File

@@ -40,6 +40,8 @@ export interface IdentifyCredentials {
description?: string;
/** Monotonic profile version for late-join reconciliation. */
profileUpdatedAt?: number;
/** Public signal-server URL where this user registered. */
homeSignalServerUrl?: string;
}
/** Last-joined server info, used for reconnection. */

View File

@@ -0,0 +1,54 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import { probeSignalingEndpointHealth, resolveSignalingHttpBaseUrl } from './signaling-endpoint-health.rules';
describe('signaling endpoint health rules', () => {
it('maps websocket urls to http health bases', () => {
expect(resolveSignalingHttpBaseUrl('wss://46.59.68.77:3001/ws')).toBe('https://46.59.68.77:3001');
expect(resolveSignalingHttpBaseUrl('ws://localhost:3001')).toBe('http://localhost:3001');
});
it('treats unreachable endpoints as unhealthy', async () => {
const fetchFn = vi.fn(async () => {
throw new Error('network down');
});
await expect(probeSignalingEndpointHealth('ws://localhost:3001', { fetchFn })).resolves.toEqual({ ok: false });
});
it('accepts /api/health and falls back to /api/servers', async () => {
const fetchFn = vi.fn(async (url: string) => {
if (url.endsWith('/api/health')) {
return new Response(null, { status: 503 });
}
if (url.endsWith('/api/servers')) {
return new Response('[]', { status: 200 });
}
throw new Error(`unexpected url: ${url}`);
});
await expect(probeSignalingEndpointHealth('ws://localhost:3001', { fetchFn })).resolves.toEqual({ ok: true });
expect(fetchFn).toHaveBeenCalledTimes(2);
});
it('returns serverInstanceId from /api/health when available', async () => {
const fetchFn = vi.fn(async (url: string) => {
if (url.endsWith('/api/health')) {
return new Response(JSON.stringify({ serverInstanceId: 'instance-a' }), { status: 200 });
}
throw new Error(`unexpected url: ${url}`);
});
await expect(probeSignalingEndpointHealth('ws://localhost:3001', { fetchFn })).resolves.toEqual({
ok: true,
serverInstanceId: 'instance-a'
});
});
});

View File

@@ -0,0 +1,92 @@
export interface SignalingEndpointHealthSnapshot {
ok: boolean;
serverInstanceId?: string;
}
export function resolveSignalingHttpBaseUrl(signalUrl: string | null | undefined): string | null {
if (!signalUrl) {
return null;
}
try {
const parsed = new URL(signalUrl);
if (parsed.protocol === 'wss:') {
parsed.protocol = 'https:';
} else if (parsed.protocol === 'ws:') {
parsed.protocol = 'http:';
} else {
return null;
}
parsed.pathname = '';
parsed.search = '';
parsed.hash = '';
return parsed.toString().replace(/\/$/, '');
} catch {
return null;
}
}
async function readHealthSnapshot(
baseUrl: string,
fetchFn: typeof fetch,
timeoutMs: number
): Promise<SignalingEndpointHealthSnapshot | null> {
try {
const response = await fetchFn(`${baseUrl}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(timeoutMs)
});
if (!response.ok) {
return null;
}
const payload = await response.json() as { serverInstanceId?: unknown };
const serverInstanceId = typeof payload.serverInstanceId === 'string' && payload.serverInstanceId.trim().length > 0
? payload.serverInstanceId.trim()
: undefined;
return {
ok: true,
serverInstanceId
};
} catch {
return null;
}
}
export async function probeSignalingEndpointHealth(
signalUrl: string | null | undefined,
options: {
fetchFn?: typeof fetch;
timeoutMs?: number;
} = {}
): Promise<SignalingEndpointHealthSnapshot> {
const baseUrl = resolveSignalingHttpBaseUrl(signalUrl);
if (!baseUrl) {
return { ok: true };
}
const fetchFn = options.fetchFn ?? fetch;
const timeoutMs = options.timeoutMs ?? 5_000;
const healthSnapshot = await readHealthSnapshot(baseUrl, fetchFn, timeoutMs);
if (healthSnapshot?.ok) {
return healthSnapshot;
}
try {
const response = await fetchFn(`${baseUrl}/api/servers`, {
method: 'GET',
signal: AbortSignal.timeout(timeoutMs)
});
return { ok: response.ok };
} catch {
return { ok: false };
}
}

View File

@@ -1,6 +1,10 @@
import { Observable, of } from 'rxjs';
import type { SignalingMessage } from '../../../shared-kernel';
import { DEFAULT_DISPLAY_NAME, SIGNALING_TYPE_IDENTIFY } from '../realtime.constants';
import {
DEFAULT_DISPLAY_NAME,
SIGNALING_TYPE_IDENTIFY,
isTransientSignalingOutboundType
} from '../realtime.constants';
import { IdentifyCredentials } from '../realtime.types';
import { ConnectedSignalingManager, ServerSignalingCoordinator } from './server-signaling-coordinator';
import { WebRTCLogger } from '../logging/webrtc-logger';
@@ -108,6 +112,10 @@ export class SignalingTransportHandler<TMessage> {
return;
}
if (isTransientSignalingOutboundType(messageType)) {
return;
}
this.dependencies.logger.warn('[signaling] Missing peer signal route for outbound raw message', {
targetPeerId,
type: messageType
@@ -134,15 +142,11 @@ export class SignalingTransportHandler<TMessage> {
const connectedManagers = this.getConnectedSignalingManagers();
if (connectedManagers.length === 0) {
if (messageType === 'status_update') {
this.dependencies.logger.warn('[signaling] Skipping status update without an active signaling connection', {
type: messageType
});
if (isTransientSignalingOutboundType(messageType)) {
return;
}
this.dependencies.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), {
this.dependencies.logger.warn('[signaling] No active signaling connection for outbound message', {
type: messageType
});
@@ -164,19 +168,18 @@ export class SignalingTransportHandler<TMessage> {
sendRawMessageToSignalUrl(signalUrl: string, message: Record<string, unknown>): boolean {
const manager = this.dependencies.signalingCoordinator.getSignalingManager(signalUrl);
if (!manager) {
if (!manager || !manager.isSocketOpen()) {
return false;
}
manager.sendRawMessage(message);
return true;
return manager.sendRawMessage(message);
}
identify(
oderId: string,
displayName: string,
signalUrl?: string,
profile?: Pick<IdentifyCredentials, 'description' | 'profileUpdatedAt'>
profile?: Pick<IdentifyCredentials, 'description' | 'profileUpdatedAt' | 'homeSignalServerUrl'>
): void {
const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME;
const normalizedDescription = typeof profile?.description === 'string'
@@ -187,12 +190,16 @@ export class SignalingTransportHandler<TMessage> {
&& profile.profileUpdatedAt > 0
? profile.profileUpdatedAt
: undefined;
const normalizedHomeSignalServerUrl = typeof profile?.homeSignalServerUrl === 'string'
? (profile.homeSignalServerUrl.trim().replace(/\/+$/, '') || undefined)
: undefined;
this.lastIdentifyCredentials = {
oderId,
displayName: normalizedDisplayName,
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt
profileUpdatedAt: normalizedProfileUpdatedAt,
homeSignalServerUrl: normalizedHomeSignalServerUrl
};
if (signalUrl) {
@@ -202,6 +209,7 @@ export class SignalingTransportHandler<TMessage> {
displayName: normalizedDisplayName,
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
homeSignalServerUrl: normalizedHomeSignalServerUrl,
connectionScope: signalUrl
});
@@ -221,6 +229,7 @@ export class SignalingTransportHandler<TMessage> {
displayName: normalizedDisplayName,
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
homeSignalServerUrl: normalizedHomeSignalServerUrl,
connectionScope: managerSignalUrl
});
}

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

View File

@@ -18,11 +18,16 @@ import {
SIGNALING_CONNECT_TIMEOUT_MS,
STATE_HEARTBEAT_INTERVAL_MS,
SIGNALING_KEEPALIVE_INTERVAL_MS,
SIGNALING_HEALTH_PROBE_INTERVAL_MS,
SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS,
SIGNALING_TYPE_IDENTIFY,
SIGNALING_TYPE_JOIN_SERVER,
SIGNALING_TYPE_KEEPALIVE,
SIGNALING_TYPE_VIEW_SERVER
SIGNALING_TYPE_KEEPALIVE_ACK,
SIGNALING_TYPE_VIEW_SERVER,
isTransientSignalingOutboundType
} from '../realtime.constants';
import { probeSignalingEndpointHealth } from './signaling-endpoint-health.rules';
interface ParsedSignalingPayload {
sdp?: RTCSessionDescriptionInit;
@@ -42,6 +47,13 @@ export class SignalingManager {
private signalingReconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
private lastKeepaliveSentAt = 0;
private lastKeepaliveAckAt = 0;
private serverSupportsKeepaliveAck = false;
private lastEndpointHealthOk: boolean | null = null;
private lastKnownServerInstanceId: string | null = null;
private lastEndpointHealthProbeAt = 0;
private endpointHealthProbeInFlight = false;
private connectAttemptStartedAt = 0;
/** Fires every heartbeat tick - the main service hooks this to broadcast state. */
readonly heartbeatTick$ = new Subject<void>();
@@ -67,23 +79,43 @@ export class SignalingManager {
}
if (this.isSocketConnecting()) {
return this.waitForOpen();
const connectAge = Date.now() - this.connectAttemptStartedAt;
if (connectAge < SIGNALING_CONNECT_TIMEOUT_MS) {
return this.waitForOpen();
}
this.discardCurrentSocket();
}
}
this.lastSignalingUrl = serverUrl;
return new Observable<boolean>((observer) => {
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
const clearConnectTimeout = (): void => {
if (!connectTimeout) {
return;
}
clearTimeout(connectTimeout);
connectTimeout = null;
};
try {
this.logger.info('[signaling] Connecting to signaling server', { serverUrl });
const previousSocket = this.signalingWebSocket;
this.lastSignalingUrl = serverUrl;
this.connectAttemptStartedAt = Date.now();
const socket = new WebSocket(serverUrl);
this.signalingWebSocket = socket;
if (previousSocket && previousSocket !== socket) {
this.detachSocketHandlers(previousSocket);
try {
previousSocket.close();
} catch {
@@ -93,16 +125,44 @@ export class SignalingManager {
}
}
connectTimeout = setTimeout(() => {
if (socket !== this.signalingWebSocket || !this.isSocketConnecting()) {
return;
}
this.logger.warn('[signaling] Signaling connect attempt timed out', {
timeoutMs: SIGNALING_CONNECT_TIMEOUT_MS,
url: serverUrl
});
clearConnectTimeout();
this.discardCurrentSocket();
this.connectionStatus$.next({
connected: false,
errorMessage: 'Timed out connecting to signaling server'
});
this.scheduleReconnect();
observer.next(false);
observer.complete();
}, SIGNALING_CONNECT_TIMEOUT_MS);
socket.onopen = () => {
if (socket !== this.signalingWebSocket)
return;
clearConnectTimeout();
this.logger.info('[signaling] Connected to signaling server', {
serverUrl,
readyState: this.getSocketReadyStateLabel()
});
this.clearReconnect();
this.lastKeepaliveAckAt = Date.now();
this.lastEndpointHealthOk = null;
this.lastKnownServerInstanceId = null;
this.lastEndpointHealthProbeAt = 0;
this.startHeartbeat();
this.connectionStatus$.next({ connected: true });
this.reIdentifyAndRejoin();
@@ -132,7 +192,12 @@ export class SignalingManager {
url: serverUrl
});
this.messageReceived$.next(message);
if (message.type === SIGNALING_TYPE_KEEPALIVE_ACK) {
this.serverSupportsKeepaliveAck = true;
this.lastKeepaliveAckAt = Date.now();
} else {
this.messageReceived$.next(message);
}
} catch (error) {
this.logger.error('[signaling] Failed to parse signaling message', error, {
bytes: payloadBytes ?? undefined,
@@ -147,6 +212,8 @@ export class SignalingManager {
if (socket !== this.signalingWebSocket)
return;
clearConnectTimeout();
this.logger.error('[signaling] Signaling socket error', error, {
readyState: this.getSocketReadyStateLabel(),
url: serverUrl
@@ -162,6 +229,8 @@ export class SignalingManager {
if (socket !== this.signalingWebSocket)
return;
clearConnectTimeout();
this.logger.warn('[signaling] Disconnected from signaling server', {
attempts: this.signalingReconnectAttempts,
code: event.code,
@@ -203,7 +272,13 @@ export class SignalingManager {
}, timeoutMs);
this.connect(this.lastSignalingUrl!).subscribe({
next: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(true); } },
next: (connected) => {
if (!settled) {
settled = true;
clearTimeout(timeout);
resolve(connected);
}
},
error: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(false); } }
});
});
@@ -212,7 +287,11 @@ export class SignalingManager {
/** Send a signaling message (with `from` / `timestamp` populated). */
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>, localPeerId: string): void {
if (!this.isSocketOpen()) {
this.logger.error('[signaling] Signaling socket not connected', new Error('Socket not open'), {
if (isTransientSignalingOutboundType(message.type)) {
return;
}
this.logger.warn('[signaling] Signaling socket not connected', {
readyState: this.getSocketReadyStateLabel(),
type: message.type,
url: this.lastSignalingUrl
@@ -233,22 +312,30 @@ export class SignalingManager {
}
/** Send a raw JSON payload (for identify, join_server, etc.). */
sendRawMessage(message: Record<string, unknown>): void {
sendRawMessage(message: Record<string, unknown>): boolean {
const messageType = typeof message['type'] === 'string' ? message['type'] : 'unknown';
if (!this.isSocketOpen()) {
this.logger.error('[signaling] Signaling socket not connected', new Error('Socket not open'), {
if (isTransientSignalingOutboundType(messageType)) {
return false;
}
this.logger.warn('[signaling] Signaling socket not connected', {
readyState: this.getSocketReadyStateLabel(),
type: typeof message['type'] === 'string' ? message['type'] : 'unknown',
type: messageType,
url: this.lastSignalingUrl
});
return;
return false;
}
this.sendSerializedPayload(message, {
targetPeerId: typeof message['targetUserId'] === 'string' ? message['targetUserId'] : undefined,
type: typeof message['type'] === 'string' ? message['type'] : 'unknown',
type: messageType,
url: this.lastSignalingUrl
});
return true;
}
/** Gracefully close the WebSocket. */
@@ -284,10 +371,15 @@ export class SignalingManager {
const credentials = this.getLastIdentify();
if (credentials) {
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
this.sendRawMessage({
type: SIGNALING_TYPE_IDENTIFY,
oderId: credentials.oderId,
displayName: credentials.displayName,
connectionScope: this.lastSignalingUrl ?? undefined });
description: credentials.description,
profileUpdatedAt: credentials.profileUpdatedAt,
homeSignalServerUrl: credentials.homeSignalServerUrl,
connectionScope: this.lastSignalingUrl ?? undefined
});
}
const memberIds = this.getMemberServerIds();
@@ -337,7 +429,14 @@ export class SignalingManager {
});
this.connect(this.lastSignalingUrl!).subscribe({
next: () => { this.signalingReconnectAttempts = 0; },
next: (connected) => {
if (connected) {
this.signalingReconnectAttempts = 0;
return;
}
this.scheduleReconnect();
},
error: () => { this.scheduleReconnect(); }
});
}, delay);
@@ -381,6 +480,56 @@ export class SignalingManager {
});
}
private handleSocketTransportFailure(reason: string, error?: unknown): void {
if (!this.signalingWebSocket) {
return;
}
this.logger.warn('[signaling] Signaling transport failed; forcing reconnect', {
error,
reason,
readyState: this.getSocketReadyStateLabel(),
url: this.lastSignalingUrl
});
this.stopHeartbeat();
this.discardCurrentSocket();
this.connectionStatus$.next({
connected: false,
errorMessage: reason
});
this.scheduleReconnect();
}
private discardCurrentSocket(): void {
const socket = this.signalingWebSocket;
this.signalingWebSocket = null;
this.connectAttemptStartedAt = 0;
if (!socket) {
return;
}
this.detachSocketHandlers(socket);
try {
socket.close();
} catch {
this.logger.warn('[signaling] Failed to discard signaling socket', {
url: this.lastSignalingUrl
});
}
}
private detachSocketHandlers(socket: WebSocket): void {
socket.onopen = null;
socket.onclose = null;
socket.onerror = null;
socket.onmessage = null;
}
/** Cancel any pending reconnect timer and reset the attempt counter. */
private clearReconnect(): void {
if (this.signalingReconnectTimer) {
@@ -394,11 +543,22 @@ export class SignalingManager {
/** Start the heartbeat interval that drives periodic state broadcasts. */
private startHeartbeat(): void {
this.stopHeartbeat();
this.lastKeepaliveSentAt = Date.now();
// Prime timers so the first heartbeat tick can send keepalives and health probes immediately.
const now = Date.now();
this.lastKeepaliveSentAt = now - SIGNALING_KEEPALIVE_INTERVAL_MS;
this.lastEndpointHealthProbeAt = now - SIGNALING_HEALTH_PROBE_INTERVAL_MS;
this.stateHeartbeatTimer = setInterval(() => {
this.heartbeatTick$.next();
this.sendKeepaliveIfDue();
this.runHeartbeatChecks();
}, STATE_HEARTBEAT_INTERVAL_MS);
void this.runHeartbeatChecks();
}
private runHeartbeatChecks(): void {
this.heartbeatTick$.next();
this.verifyKeepaliveAckIfDue();
this.sendKeepaliveIfDue();
void this.probeEndpointHealthIfDue();
}
/** Stop the heartbeat interval. */
@@ -409,6 +569,75 @@ export class SignalingManager {
}
this.lastKeepaliveSentAt = 0;
this.lastKeepaliveAckAt = 0;
this.serverSupportsKeepaliveAck = false;
this.lastEndpointHealthOk = null;
this.lastKnownServerInstanceId = null;
this.lastEndpointHealthProbeAt = 0;
this.endpointHealthProbeInFlight = false;
}
private async probeEndpointHealthIfDue(): Promise<void> {
if (!this.lastSignalingUrl || this.endpointHealthProbeInFlight || this.isSocketConnecting()) {
return;
}
if (!this.isSocketOpen()) {
return;
}
const now = Date.now();
if (now - this.lastEndpointHealthProbeAt < SIGNALING_HEALTH_PROBE_INTERVAL_MS) {
return;
}
this.lastEndpointHealthProbeAt = now;
this.endpointHealthProbeInFlight = true;
try {
const snapshot = await probeSignalingEndpointHealth(this.lastSignalingUrl);
const wasHealthy = this.lastEndpointHealthOk;
const previousServerInstanceId = this.lastKnownServerInstanceId;
this.lastEndpointHealthOk = snapshot.ok;
if (!snapshot.ok) {
this.handleSocketTransportFailure('Signaling server health check failed');
return;
}
if (snapshot.serverInstanceId) {
if (previousServerInstanceId && snapshot.serverInstanceId !== previousServerInstanceId) {
this.handleSocketTransportFailure('Signaling server instance changed; refreshing websocket');
return;
}
this.lastKnownServerInstanceId = snapshot.serverInstanceId;
}
if (wasHealthy === false) {
this.handleSocketTransportFailure('Signaling server recovered; refreshing websocket');
}
} finally {
this.endpointHealthProbeInFlight = false;
}
}
private verifyKeepaliveAckIfDue(): void {
if (!this.serverSupportsKeepaliveAck || !this.isSocketOpen() || this.lastKeepaliveSentAt === 0) {
return;
}
if (this.lastKeepaliveAckAt >= this.lastKeepaliveSentAt) {
return;
}
if (Date.now() - this.lastKeepaliveSentAt < SIGNALING_KEEPALIVE_ACK_TIMEOUT_MS) {
return;
}
this.handleSocketTransportFailure('Signaling keepalive acknowledgement timed out');
}
private sendKeepaliveIfDue(): void {
@@ -421,13 +650,13 @@ export class SignalingManager {
this.lastKeepaliveSentAt = now;
try {
this.sendRawMessage({ type: SIGNALING_TYPE_KEEPALIVE });
const sent = this.sendRawMessage({ type: SIGNALING_TYPE_KEEPALIVE });
if (sent && !this.serverSupportsKeepaliveAck) {
this.lastKeepaliveAckAt = this.lastKeepaliveSentAt;
}
} catch (error) {
this.logger.warn('[signaling] Failed to send signaling keepalive', {
error,
readyState: this.getSocketReadyStateLabel(),
url: this.lastSignalingUrl
});
this.handleSocketTransportFailure('Failed to send signaling keepalive', error);
}
}
@@ -482,6 +711,8 @@ export class SignalingManager {
url: details.url
});
this.handleSocketTransportFailure('Failed to send signaling payload', error);
throw error;
}
}