fix(webrtc): rebuild the RTCPeerConnection on failure instead of restartIce()

"restarting ICE" on a connection that has reached 'failed' only restarts
ICE gathering — it reuses the same DTLS transport and certificate. If
that transport is what's actually broken, the next renegotiation throws
"Failed to set SSL role for the transport" (DTLS role can't be flipped
on an established transport), which is exactly what showed up in the
console on a LAN test, ruling out NAT/TURN as the cause.

Fix: on 'failed', fully close and recreate the RTCPeerConnection (fresh
certificate, nothing to conflict with) via recreatePeerConnection().
Since ICE/DTLS failure isn't guaranteed to fire on both ends at once,
the side that notices sends a new 'reset' signal (relayed by the
server like offer/answer/candidate) telling the other side to tear
down and wait for a fresh offer too — otherwise its old, now-mismatched
transport would just fail the same way against our new one. Capped at
3 rebuild attempts per peer before giving up with a toast, reset on any
successful 'connected' state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5 committed 2026-09-22 12:08:07 +02:00
1 parent 8474a85ed2
commit 3517bdf52b
2 files changed
+50 -3

No files matched your search

+48 -2
View File
@@ -350,6 +350,15 @@ function handleSignal(msg) {
onCandidate(peer, msg.candidate).catch((e) => console.error('candidate failed', e));
break;
}
case 'reset': {
// The other side's connection to us failed and it's rebuilding from
// scratch — tear down our half so a fresh offer lands on a clean
// RTCPeerConnection instead of an already-established (and now
// mismatched) DTLS transport. See recreatePeerConnection().
const peer = S.peers.get(msg.from);
if (peer) recreatePeerConnection(peer, { announce: false });
break;
}
default:
break;
}
@@ -365,6 +374,7 @@ function makePeerRecord(p) {
pc: null, polite: false, makingOffer: false, ignoreOffer: false,
pendingCandidates: [], micStream: null, screenStream: null,
gain: null, panner: null, analyser: null, vol: 1, speaking: false,
resetAttempts: 0,
els: {},
};
}
@@ -398,8 +408,10 @@ function ensurePC(peer, initiator) {
pc.onconnectionstatechange = () => {
renderTiles();
if (pc.connectionState === 'failed') {
console.warn('peer connection failed, restarting ICE', peer.id);
try { pc.restartIce(); } catch {}
console.warn('peer connection failed, rebuilding it from scratch', peer.id);
recreatePeerConnection(peer, { announce: true });
} else if (pc.connectionState === 'connected') {
peer.resetAttempts = 0; // a successful connection clears the failure count
}
};
pc.ontrack = (ev) => onRemoteTrack(peer, ev);
@@ -424,6 +436,40 @@ function ensurePC(peer, initiator) {
return peer;
}
const RESET_MAX_ATTEMPTS = 3;
// Once a connection reaches 'failed', its DTLS transport is done — restartIce()
// only restarts ICE gathering and reuses the same transport/certificate, which
// throws "Failed to set SSL role for the transport" if you try to renegotiate
// on it. The only real fix is a brand-new RTCPeerConnection (fresh certificate,
// no established role to conflict with). Since only one side may notice the
// failure first, whichever side detects it also tells the other to rebuild via
// a 'reset' signal — otherwise the other side keeps its old, now-mismatched
// transport and the fresh offer we send will fail against it too.
function recreatePeerConnection(peer, { announce }) {
if (announce) {
peer.resetAttempts++;
if (peer.resetAttempts > RESET_MAX_ATTEMPTS) {
toast(`Connection to ${peer.name} keeps failing — try leaving and rejoining the channel`, 'error');
return;
}
send({ type: 'reset', to: peer.id });
}
if (peer.pc) { try { peer.pc.close(); } catch {} }
peer.pc = null;
peer.makingOffer = false;
peer.ignoreOffer = false;
peer.pendingCandidates = [];
detachSpatial(peer);
if (peer.audioEl) { try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {} peer.audioEl = null; }
peer.micStream = null;
peer.screenStream = null;
S.screenSenders.delete(peer.id);
renderScreens();
renderTiles();
ensurePC(peer, S.myId < peer.id); // same deterministic-initiator rule as the first connection
}
async function onDescription(peer, desc) {
const pc = peer.pc;
const collision = desc.type === 'offer' && (peer.makingOffer || pc.signalingState !== 'stable');