From 3517bdf52b5e101b5349ee312426f7a2a051ea53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 22 Sep 2026 12:08:07 +0200 Subject: [PATCH] fix(webrtc): rebuild the RTCPeerConnection on failure instead of restartIce() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 --- public/app.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++++-- server.js | 3 ++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/public/app.js b/public/app.js index 8738743..da96622 100644 --- a/public/app.js +++ b/public/app.js @@ -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'); diff --git a/server.js b/server.js index 11151fe..0dc407f 100644 --- a/server.js +++ b/server.js @@ -240,7 +240,8 @@ wss.on('connection', (socket, req) => { } case 'offer': case 'answer': - case 'candidate': { + case 'candidate': + case 'reset': { if (!msg.to || !room.has(msg.to)) return; const target = room.get(msg.to); if (target.socket.readyState === 1) {