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) {