fix(realtime): survive signal outages and per-server identities
Peer recovery burned its whole retry budget while signaling was down, then dropped the tracker with no re-arm, so a peer stayed dead until an unrelated roster event healed it. Recovery now waits for a usable transport before spending an attempt. Initiator election also compared a home actor id against foreign roster ids, which is not antisymmetric across signal servers - both sides offered, or neither did. Election moves into `peer-role.rules` and compares ids only within one signal server's identity space.
This commit is contained in:
+174
-18
@@ -7,6 +7,7 @@ import {
|
||||
PEER_RECONNECT_INTERVAL_MS,
|
||||
PEER_RECONNECT_MAX_ATTEMPTS
|
||||
} from '../../realtime.constants';
|
||||
import { shouldInitiatePeerConnection } from '../../peer-role.rules';
|
||||
import {
|
||||
PeerConnectionManagerContext,
|
||||
PeerConnectionManagerState,
|
||||
@@ -205,6 +206,15 @@ export function scheduleDataChannelRecovery(
|
||||
state.dataChannelRecoveryTimers.set(peerId, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair a failed control channel with the least destructive option available.
|
||||
*
|
||||
* While the `RTCPeerConnection` is still connected the channel is replaced on that same
|
||||
* connection, so voice, camera, and screen share keep flowing. Only the deterministically
|
||||
* elected initiator creates the replacement; the other side adopts the incoming channel.
|
||||
* A full peer rebuild is the fallback when the connection itself is gone, when the
|
||||
* replacement cannot be created, or when the replacement never opens.
|
||||
*/
|
||||
function repairUnavailableDataChannel(
|
||||
context: PeerConnectionManagerContext,
|
||||
peerId: string,
|
||||
@@ -212,7 +222,7 @@ function repairUnavailableDataChannel(
|
||||
reason: string,
|
||||
handlers: RecoveryHandlers
|
||||
): void {
|
||||
const { logger, state } = context;
|
||||
const { callbacks, logger, state } = context;
|
||||
const peerData = state.activePeerConnections.get(peerId);
|
||||
|
||||
if (!peerData || peerData.dataChannel !== channel)
|
||||
@@ -221,11 +231,103 @@ function repairUnavailableDataChannel(
|
||||
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN)
|
||||
return;
|
||||
|
||||
logger.warn('[data-channel] Recreating peer transport after control channel failure', {
|
||||
if (peerData.connection.connectionState !== CONNECTION_STATE_CONNECTED) {
|
||||
rebuildPeerAfterDataChannelFailure(context, peerId, reason, handlers);
|
||||
return;
|
||||
}
|
||||
|
||||
const localOderId = callbacks.getIdentifyCredentialsForPeer(peerId)?.oderId ?? null;
|
||||
|
||||
if (!localOderId) {
|
||||
logger.warn('[data-channel] Logical identity unknown; rebuilding peer instead of replacing channel', {
|
||||
peerId,
|
||||
reason
|
||||
});
|
||||
|
||||
rebuildPeerAfterDataChannelFailure(context, peerId, reason, handlers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldInitiatePeerConnection(localOderId, peerId)) {
|
||||
logger.warn('[data-channel] Replacing control channel on the live connection', {
|
||||
channelLabel: channel.label,
|
||||
peerId,
|
||||
reason
|
||||
});
|
||||
|
||||
if (!handlers.replaceDataChannel(peerId, channel)) {
|
||||
rebuildPeerAfterDataChannelFailure(context, peerId, reason, handlers);
|
||||
return;
|
||||
}
|
||||
|
||||
watchDataChannelReplacement(context, peerId, reason, handlers);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[data-channel] Waiting for the remote replacement control channel', {
|
||||
channelLabel: channel.label,
|
||||
connectionState: peerData.connection.connectionState,
|
||||
localOderId,
|
||||
peerId,
|
||||
readyState: peerData.dataChannel?.readyState ?? null,
|
||||
reason
|
||||
});
|
||||
|
||||
watchDataChannelReplacement(context, peerId, reason, handlers);
|
||||
}
|
||||
|
||||
/** Rebuild the peer if the replacement control channel does not open within the grace period. */
|
||||
function watchDataChannelReplacement(
|
||||
context: PeerConnectionManagerContext,
|
||||
peerId: string,
|
||||
reason: string,
|
||||
handlers: RecoveryHandlers
|
||||
): void {
|
||||
const { logger, state } = context;
|
||||
|
||||
clearDataChannelRecoveryTimer(state, peerId);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
state.dataChannelRecoveryTimers.delete(peerId);
|
||||
|
||||
const latestPeerData = state.activePeerConnections.get(peerId);
|
||||
|
||||
if (!latestPeerData)
|
||||
return;
|
||||
|
||||
if (latestPeerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
|
||||
logger.info('[data-channel] Replacement control channel is open; peer kept alive', {
|
||||
peerId,
|
||||
reason
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn('[data-channel] Replacement control channel never opened; rebuilding peer', {
|
||||
connectionState: latestPeerData.connection.connectionState,
|
||||
peerId,
|
||||
readyState: latestPeerData.dataChannel?.readyState ?? null,
|
||||
reason
|
||||
});
|
||||
|
||||
rebuildPeerAfterDataChannelFailure(context, peerId, reason, handlers);
|
||||
}, DATA_CHANNEL_RECOVERY_GRACE_MS);
|
||||
|
||||
state.dataChannelRecoveryTimers.set(peerId, timer);
|
||||
}
|
||||
|
||||
function rebuildPeerAfterDataChannelFailure(
|
||||
context: PeerConnectionManagerContext,
|
||||
peerId: string,
|
||||
reason: string,
|
||||
handlers: RecoveryHandlers
|
||||
): void {
|
||||
const { logger, state } = context;
|
||||
const peerData = state.activePeerConnections.get(peerId);
|
||||
|
||||
logger.warn('[data-channel] Recreating peer transport after control channel failure', {
|
||||
connectionState: peerData?.connection.connectionState ?? null,
|
||||
peerId,
|
||||
readyState: peerData?.dataChannel?.readyState ?? null,
|
||||
reason
|
||||
});
|
||||
|
||||
@@ -299,30 +401,84 @@ export function schedulePeerReconnect(
|
||||
return;
|
||||
}
|
||||
|
||||
// An attempt costs nothing while the socket is down and there is no way to send an
|
||||
// offer, so the budget must only be spent on attempts that can actually reach the peer.
|
||||
if (!callbacks.isSignalingConnected()) {
|
||||
logger.info('Deferring P2P reconnect - no signaling connection', {
|
||||
peerId,
|
||||
attemptsSpent: info.reconnectAttempts
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (info.reconnectAttempts >= PEER_RECONNECT_MAX_ATTEMPTS) {
|
||||
failPeerRecovery(context, peerId, info.reconnectAttempts);
|
||||
return;
|
||||
}
|
||||
|
||||
info.reconnectAttempts++;
|
||||
logger.info('P2P reconnect attempt', {
|
||||
peerId,
|
||||
attempt: info.reconnectAttempts
|
||||
});
|
||||
|
||||
if (info.reconnectAttempts >= PEER_RECONNECT_MAX_ATTEMPTS) {
|
||||
logger.info('P2P reconnect max attempts reached', { peerId });
|
||||
clearPeerReconnectTimer(state, peerId);
|
||||
state.disconnectedPeerTracker.delete(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!callbacks.isSignalingConnected()) {
|
||||
logger.info('Skipping P2P reconnect - no signaling connection', { peerId });
|
||||
return;
|
||||
}
|
||||
|
||||
attemptPeerReconnect(context, peerId, handlers);
|
||||
}, PEER_RECONNECT_INTERVAL_MS);
|
||||
|
||||
state.peerReconnectTimers.set(peerId, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop retrying a peer and say so. The tracker entry is kept (marked failed) so
|
||||
* `resumeStalledPeerRecovery` can re-arm it once signaling is usable again.
|
||||
*/
|
||||
function failPeerRecovery(
|
||||
context: PeerConnectionManagerContext,
|
||||
peerId: string,
|
||||
attempts: number
|
||||
): void {
|
||||
const { logger, state } = context;
|
||||
const info = state.disconnectedPeerTracker.get(peerId);
|
||||
|
||||
logger.warn('P2P reconnect gave up', { attempts, peerId });
|
||||
clearPeerReconnectTimer(state, peerId);
|
||||
|
||||
if (info) {
|
||||
info.recoveryFailed = true;
|
||||
}
|
||||
|
||||
state.peerRecoveryStatus$.next({ attempts, peerId, status: 'failed' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-arm every peer whose recovery gave up. Called when signaling reconnects, so a peer
|
||||
* that ran out of attempts during an outage is retried instead of staying dead silently.
|
||||
*/
|
||||
export function resumeStalledPeerRecovery(
|
||||
context: PeerConnectionManagerContext,
|
||||
handlers: RecoveryHandlers
|
||||
): void {
|
||||
const { callbacks, logger, state } = context;
|
||||
|
||||
if (!callbacks.isSignalingConnected())
|
||||
return;
|
||||
|
||||
state.disconnectedPeerTracker.forEach((info, peerId) => {
|
||||
if (!info.recoveryFailed)
|
||||
return;
|
||||
|
||||
logger.info('Re-arming P2P recovery after signaling returned', { peerId });
|
||||
|
||||
info.recoveryFailed = false;
|
||||
info.reconnectAttempts = 0;
|
||||
state.peerRecoveryStatus$.next({ attempts: 0, peerId, status: 'retrying' });
|
||||
|
||||
attemptPeerReconnect(context, peerId, handlers);
|
||||
schedulePeerReconnect(context, peerId, handlers);
|
||||
});
|
||||
}
|
||||
|
||||
export function attemptPeerReconnect(
|
||||
context: PeerConnectionManagerContext,
|
||||
peerId: string,
|
||||
@@ -334,7 +490,7 @@ export function attemptPeerReconnect(
|
||||
handlers.removePeer(peerId, { preserveReconnectState: true });
|
||||
}
|
||||
|
||||
const localOderId = callbacks.getIdentifyCredentials()?.oderId ?? null;
|
||||
const localOderId = callbacks.getIdentifyCredentialsForPeer(peerId)?.oderId ?? null;
|
||||
|
||||
if (!localOderId) {
|
||||
logger.info('Skipping reconnect offer until logical identity is ready', { peerId });
|
||||
@@ -342,7 +498,7 @@ export function attemptPeerReconnect(
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldInitiate = peerId !== localOderId && localOderId < peerId;
|
||||
const shouldInitiate = shouldInitiatePeerConnection(localOderId, peerId);
|
||||
|
||||
handlers.createPeerConnection(peerId, shouldInitiate);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user