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:
1 parent
8474a85ed2
commit
3517bdf52b
2 files changed
+50
-3
No files matched your search
+48
-2
@@ -350,6 +350,15 @@ function handleSignal(msg) {
|
|||||||
onCandidate(peer, msg.candidate).catch((e) => console.error('candidate failed', e));
|
onCandidate(peer, msg.candidate).catch((e) => console.error('candidate failed', e));
|
||||||
break;
|
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:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -365,6 +374,7 @@ function makePeerRecord(p) {
|
|||||||
pc: null, polite: false, makingOffer: false, ignoreOffer: false,
|
pc: null, polite: false, makingOffer: false, ignoreOffer: false,
|
||||||
pendingCandidates: [], micStream: null, screenStream: null,
|
pendingCandidates: [], micStream: null, screenStream: null,
|
||||||
gain: null, panner: null, analyser: null, vol: 1, speaking: false,
|
gain: null, panner: null, analyser: null, vol: 1, speaking: false,
|
||||||
|
resetAttempts: 0,
|
||||||
els: {},
|
els: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -398,8 +408,10 @@ function ensurePC(peer, initiator) {
|
|||||||
pc.onconnectionstatechange = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
renderTiles();
|
renderTiles();
|
||||||
if (pc.connectionState === 'failed') {
|
if (pc.connectionState === 'failed') {
|
||||||
console.warn('peer connection failed, restarting ICE', peer.id);
|
console.warn('peer connection failed, rebuilding it from scratch', peer.id);
|
||||||
try { pc.restartIce(); } catch {}
|
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);
|
pc.ontrack = (ev) => onRemoteTrack(peer, ev);
|
||||||
@@ -424,6 +436,40 @@ function ensurePC(peer, initiator) {
|
|||||||
return peer;
|
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) {
|
async function onDescription(peer, desc) {
|
||||||
const pc = peer.pc;
|
const pc = peer.pc;
|
||||||
const collision = desc.type === 'offer' && (peer.makingOffer || pc.signalingState !== 'stable');
|
const collision = desc.type === 'offer' && (peer.makingOffer || pc.signalingState !== 'stable');
|
||||||
|
|||||||
@@ -240,7 +240,8 @@ wss.on('connection', (socket, req) => {
|
|||||||
}
|
}
|
||||||
case 'offer':
|
case 'offer':
|
||||||
case 'answer':
|
case 'answer':
|
||||||
case 'candidate': {
|
case 'candidate':
|
||||||
|
case 'reset': {
|
||||||
if (!msg.to || !room.has(msg.to)) return;
|
if (!msg.to || !room.has(msg.to)) return;
|
||||||
const target = room.get(msg.to);
|
const target = room.get(msg.to);
|
||||||
if (target.socket.readyState === 1) {
|
if (target.socket.readyState === 1) {
|
||||||
|
|||||||
Reference in new issue
Block a user