Fix peers stuck forever at Connecting...
Browse filesThree bugs, all invisible in a same-machine test:
1. no deadline on the initial WebRTC attempt, so the relay fallback was
unreachable and a stalled ICE showed (connecting...) forever
2. the waiting side armed nothing -- only the newest peer dials, so there
was no connection object to time out
3. relay channels never sent HELLO, so a relayed peer silently vanished
from the plan and the ring shrank without saying so
Adds ICE/selected-path logging and ?relay=1 to force the server relay.
- public/app.js +139 -15
public/app.js
CHANGED
|
@@ -144,10 +144,14 @@ const readyStages = new Set();
|
|
| 144 |
|
| 145 |
function nmeOf(id) { return names.get(id) || id; }
|
| 146 |
function room() { return new URLSearchParams(location.search).get("room"); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
function updatePeers() {
|
| 148 |
if (!names.size) { ui.peers.textContent = "(none yet — you can still run solo)"; return; }
|
| 149 |
-
ui.peers.textContent = [...names.entries()]
|
| 150 |
-
|
| 151 |
}
|
| 152 |
function ctxLen() { return +ui.ctxLen.value; }
|
| 153 |
|
|
@@ -183,6 +187,14 @@ function connectSignaling() {
|
|
| 183 |
addJoinRequest(msg.id, msg.name);
|
| 184 |
} else if (msg.type === "peer-joined") {
|
| 185 |
names.set(msg.id, msg.name); updatePeers(); log(`${msg.name} joined`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
} else if (msg.type === "peer-left") {
|
| 187 |
log(`${nmeOf(msg.id)} left`); cleanupPeer(msg.id); names.delete(msg.id); caps.delete(msg.id); updatePeers(); renderPlan();
|
| 188 |
} else if (msg.type === "signal") {
|
|
@@ -216,11 +228,94 @@ function addJoinRequest(id, name) {
|
|
| 216 |
ui.requests.appendChild(row);
|
| 217 |
}
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
function newPC(peerId) {
|
| 220 |
const pc = new RTCPeerConnection(rtcConfig);
|
| 221 |
pc.onicecandidate = (e) => { if (e.candidate) signal(peerId, { candidate: e.candidate }); };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
pc.onconnectionstatechange = () => {
|
| 223 |
-
if (pc.connectionState === "failed") {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
if (pc.connectionState === "disconnected")
|
| 225 |
setTimeout(() => {
|
| 226 |
if (pcs.get(peerId) === pc && pc.connectionState === "disconnected") {
|
|
@@ -232,6 +327,22 @@ function newPC(peerId) {
|
|
| 232 |
pcs.set(peerId, pc);
|
| 233 |
return pc;
|
| 234 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
function b64FromBuf(buf) {
|
| 236 |
const u = new Uint8Array(buf); let s = "";
|
| 237 |
for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode(...u.subarray(i, i + 0x8000));
|
|
@@ -250,14 +361,7 @@ function makeRelayChannel(peerId) {
|
|
| 250 |
const reconnectTimers = new Map();
|
| 251 |
function scheduleReconnect(peerId, attempt = 0) {
|
| 252 |
if (!names.has(peerId) || chans.has(peerId) || reconnectTimers.has(peerId)) return;
|
| 253 |
-
if (attempt >=
|
| 254 |
-
if (names.has(peerId)) {
|
| 255 |
-
log(`no direct WebRTC path to ${nmeOf(peerId)} after 5 attempts — relaying activations through the server instead`);
|
| 256 |
-
chans.set(peerId, makeRelayChannel(peerId));
|
| 257 |
-
updatePeers(); wake();
|
| 258 |
-
}
|
| 259 |
-
return;
|
| 260 |
-
}
|
| 261 |
const delay = 1500 * Math.pow(2, attempt);
|
| 262 |
reconnectTimers.set(peerId, setTimeout(() => {
|
| 263 |
reconnectTimers.delete(peerId);
|
|
@@ -265,20 +369,28 @@ function scheduleReconnect(peerId, attempt = 0) {
|
|
| 265 |
if (+myId.slice(1) > +peerId.slice(1)) {
|
| 266 |
log(`reconnecting to ${nmeOf(peerId)} (attempt ${attempt + 1})…`);
|
| 267 |
cleanupPeer(peerId); initiatePeer(peerId);
|
|
|
|
|
|
|
| 268 |
}
|
| 269 |
-
setTimeout(() => scheduleReconnect(peerId, attempt + 1),
|
| 270 |
}, delay));
|
| 271 |
}
|
| 272 |
function initiatePeer(peerId) {
|
|
|
|
| 273 |
const pc = newPC(peerId);
|
| 274 |
setupChannel(peerId, pc.createDataChannel("daisy"));
|
|
|
|
| 275 |
pc.createOffer().then(o => pc.setLocalDescription(o)).then(() => signal(peerId, { sdp: pc.localDescription }));
|
| 276 |
}
|
| 277 |
const pendingCand = new Map();
|
| 278 |
async function onSignal(from, data) {
|
| 279 |
let pc = pcs.get(from);
|
| 280 |
if (data.sdp) {
|
| 281 |
-
if (!pc) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
await pc.setRemoteDescription(data.sdp);
|
| 283 |
for (const c of pendingCand.get(from) || []) try { await pc.addIceCandidate(c); } catch (e) {}
|
| 284 |
pendingCand.delete(from);
|
|
@@ -293,13 +405,25 @@ async function onSignal(from, data) {
|
|
| 293 |
}
|
| 294 |
function setupChannel(peerId, dc) {
|
| 295 |
dc.binaryType = "arraybuffer";
|
| 296 |
-
dc.onopen = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
dc.onclose = () => { chans.delete(peerId); updatePeers(); wake(); scheduleReconnect(peerId); };
|
| 298 |
dc.onmessage = (e) => onWire(peerId, e.data);
|
| 299 |
}
|
| 300 |
function cleanupPeer(id) {
|
|
|
|
| 301 |
const pc = pcs.get(id); if (pc) pc.close();
|
| 302 |
-
pcs.delete(id);
|
|
|
|
|
|
|
| 303 |
for (const k of fragIn.keys()) if (k.startsWith(id + ":")) fragIn.delete(k);
|
| 304 |
wake();
|
| 305 |
}
|
|
|
|
| 144 |
|
| 145 |
function nmeOf(id) { return names.get(id) || id; }
|
| 146 |
function room() { return new URLSearchParams(location.search).get("room"); }
|
| 147 |
+
// ?relay=1 skips WebRTC entirely and routes through the server. It exists as a
|
| 148 |
+
// diagnostic: if a ring works with it and not without it, the problem is NAT
|
| 149 |
+
// traversal, not this code.
|
| 150 |
+
const forceRelay = new URLSearchParams(location.search).get("relay") === "1";
|
| 151 |
function updatePeers() {
|
| 152 |
if (!names.size) { ui.peers.textContent = "(none yet — you can still run solo)"; return; }
|
| 153 |
+
ui.peers.textContent = [...names.entries()].map(([id, n]) =>
|
| 154 |
+
`${n} ${chans.has(id) ? (relayed.has(id) ? "⇄ via server" : "✓") : "(connecting…)"}`).join(", ");
|
| 155 |
}
|
| 156 |
function ctxLen() { return +ui.ctxLen.value; }
|
| 157 |
|
|
|
|
| 187 |
addJoinRequest(msg.id, msg.name);
|
| 188 |
} else if (msg.type === "peer-joined") {
|
| 189 |
names.set(msg.id, msg.name); updatePeers(); log(`${msg.name} joined`);
|
| 190 |
+
// Only the NEWEST peer dials (so the two never collide with competing
|
| 191 |
+
// offers), which means this side is waiting to be called. It still needs
|
| 192 |
+
// a deadline: if the offer never arrives, or arrives and its ICE never
|
| 193 |
+
// completes, there is no connection object here to fail — and without a
|
| 194 |
+
// watchdog this side shows "(connecting…)" forever with nothing to
|
| 195 |
+
// recover from. That is the failure two separate machines actually hit.
|
| 196 |
+
if (forceRelay) useRelay(msg.id);
|
| 197 |
+
else if (!chans.has(msg.id)) armWatchdog(msg.id);
|
| 198 |
} else if (msg.type === "peer-left") {
|
| 199 |
log(`${nmeOf(msg.id)} left`); cleanupPeer(msg.id); names.delete(msg.id); caps.delete(msg.id); updatePeers(); renderPlan();
|
| 200 |
} else if (msg.type === "signal") {
|
|
|
|
| 228 |
ui.requests.appendChild(row);
|
| 229 |
}
|
| 230 |
|
| 231 |
+
// ---- connection watchdog -----------------------------------------------------
|
| 232 |
+
// WebRTC can fail by never finishing. A peer whose ICE never completes sits in
|
| 233 |
+
// "checking" indefinitely: `dc.onclose` never fires because the channel never
|
| 234 |
+
// opened, and `connectionState` may never reach "failed" either. Without a
|
| 235 |
+
// timeout the UI shows "(connecting…)" forever, the relay fallback below is
|
| 236 |
+
// never reached, and nothing says why — which is exactly what happened on two
|
| 237 |
+
// real machines while two tabs on one machine worked fine, because same-host
|
| 238 |
+
// candidates always succeed.
|
| 239 |
+
//
|
| 240 |
+
// So: give each attempt a deadline, say what state it died in, retry once, then
|
| 241 |
+
// fall back to relaying through the server.
|
| 242 |
+
const CONNECT_TIMEOUT = 12000;
|
| 243 |
+
const MAX_DIRECT_ATTEMPTS = 2;
|
| 244 |
+
const watchdogs = new Map(); // peerId -> timer
|
| 245 |
+
const attempts = new Map(); // peerId -> count
|
| 246 |
+
const relayed = new Set(); // peers we reach via the server
|
| 247 |
+
|
| 248 |
+
function clearWatchdog(peerId) {
|
| 249 |
+
const t = watchdogs.get(peerId);
|
| 250 |
+
if (t) { clearTimeout(t); watchdogs.delete(peerId); }
|
| 251 |
+
}
|
| 252 |
+
function armWatchdog(peerId) {
|
| 253 |
+
clearWatchdog(peerId);
|
| 254 |
+
watchdogs.set(peerId, setTimeout(() => {
|
| 255 |
+
watchdogs.delete(peerId);
|
| 256 |
+
if (chans.has(peerId) || !names.has(peerId)) return;
|
| 257 |
+
const pc = pcs.get(peerId);
|
| 258 |
+
const n = (attempts.get(peerId) || 0) + 1;
|
| 259 |
+
attempts.set(peerId, n);
|
| 260 |
+
log(`no direct path to ${nmeOf(peerId)} after ${CONNECT_TIMEOUT / 1000}s — ` +
|
| 261 |
+
`ICE ${pc ? pc.iceConnectionState : "?"}, gathering ${pc ? pc.iceGatheringState : "?"} ` +
|
| 262 |
+
`(attempt ${n} of ${MAX_DIRECT_ATTEMPTS})`);
|
| 263 |
+
if (n < MAX_DIRECT_ATTEMPTS) {
|
| 264 |
+
// one side redials so the two do not collide (offer glare); the other
|
| 265 |
+
// just waits out another deadline
|
| 266 |
+
if (+myId.slice(1) > +peerId.slice(1)) { cleanupPeer(peerId); initiatePeer(peerId); }
|
| 267 |
+
else armWatchdog(peerId);
|
| 268 |
+
return;
|
| 269 |
+
}
|
| 270 |
+
useRelay(peerId); // symmetric: both sides install it
|
| 271 |
+
}, CONNECT_TIMEOUT));
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// Relay through the signaling server. Both peers already hold a WebSocket to
|
| 275 |
+
// it, so this is a path that cannot fail for NAT reasons — but it means the
|
| 276 |
+
// server carries activations, which the direct path specifically avoids. That
|
| 277 |
+
// is a real change in what the server sees, so it is stated loudly rather than
|
| 278 |
+
// slipped in as a silent recovery.
|
| 279 |
+
function useRelay(peerId) {
|
| 280 |
+
if (chans.has(peerId) || !names.has(peerId)) return;
|
| 281 |
+
cleanupPeer(peerId);
|
| 282 |
+
chans.set(peerId, makeRelayChannel(peerId));
|
| 283 |
+
relayed.add(peerId);
|
| 284 |
+
log(`⚠ falling back to the SERVER RELAY for ${nmeOf(peerId)} — no direct WebRTC path could be ` +
|
| 285 |
+
`established. This works, but activations for that hop now pass through the server instead ` +
|
| 286 |
+
`of peer-to-peer. Supply a TURN server (DAISY_RTC_CONFIG) for a direct path.`);
|
| 287 |
+
// A relay channel is a plain object with no "open" event, so nothing here
|
| 288 |
+
// fires dc.onopen — and without this the capability exchange never happens,
|
| 289 |
+
// the peer never enters the plan, and the ring silently shrinks to whoever
|
| 290 |
+
// had a direct channel. Losing a stage quietly is worse than failing loudly.
|
| 291 |
+
sendHello(peerId);
|
| 292 |
+
updatePeers(); wake();
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
function newPC(peerId) {
|
| 296 |
const pc = new RTCPeerConnection(rtcConfig);
|
| 297 |
pc.onicecandidate = (e) => { if (e.candidate) signal(peerId, { candidate: e.candidate }); };
|
| 298 |
+
// Terse but real diagnostics: without these, a cross-machine failure is
|
| 299 |
+
// indistinguishable from a hang.
|
| 300 |
+
pc.oniceconnectionstatechange = () => {
|
| 301 |
+
const s = pc.iceConnectionState;
|
| 302 |
+
if (s === "failed" || s === "disconnected" || s === "connected" || s === "completed")
|
| 303 |
+
log(`${nmeOf(peerId)}: ICE ${s}`);
|
| 304 |
+
};
|
| 305 |
+
pc.onicegatheringstatechange = () => {
|
| 306 |
+
if (pc.iceGatheringState === "complete" && !chans.has(peerId))
|
| 307 |
+
log(`${nmeOf(peerId)}: finished gathering candidates, still no channel`);
|
| 308 |
+
};
|
| 309 |
pc.onconnectionstatechange = () => {
|
| 310 |
+
if (pc.connectionState === "failed") {
|
| 311 |
+
log(`${nmeOf(peerId)}: connection failed`);
|
| 312 |
+
clearWatchdog(peerId);
|
| 313 |
+
const n = (attempts.get(peerId) || 0) + 1;
|
| 314 |
+
attempts.set(peerId, n);
|
| 315 |
+
cleanupPeer(peerId);
|
| 316 |
+
if (n < MAX_DIRECT_ATTEMPTS) scheduleReconnect(peerId);
|
| 317 |
+
else useRelay(peerId);
|
| 318 |
+
}
|
| 319 |
if (pc.connectionState === "disconnected")
|
| 320 |
setTimeout(() => {
|
| 321 |
if (pcs.get(peerId) === pc && pc.connectionState === "disconnected") {
|
|
|
|
| 327 |
pcs.set(peerId, pc);
|
| 328 |
return pc;
|
| 329 |
}
|
| 330 |
+
|
| 331 |
+
// Which path actually carried the connection — host, srflx (STUN) or relay
|
| 332 |
+
// (TURN). Printed on success because "it connected" and "it connected the way
|
| 333 |
+
// you think" are different facts.
|
| 334 |
+
async function logSelectedPath(peerId, pc) {
|
| 335 |
+
try {
|
| 336 |
+
const stats = await pc.getStats();
|
| 337 |
+
let pair = null;
|
| 338 |
+
stats.forEach(r => {
|
| 339 |
+
if (r.type === "candidate-pair" && r.state === "succeeded" && (r.selected || r.nominated)) pair = r;
|
| 340 |
+
});
|
| 341 |
+
if (!pair) return;
|
| 342 |
+
const loc = stats.get(pair.localCandidateId), rem = stats.get(pair.remoteCandidateId);
|
| 343 |
+
log(`${nmeOf(peerId)}: direct path via ${loc ? loc.candidateType : "?"} → ${rem ? rem.candidateType : "?"}`);
|
| 344 |
+
} catch (e) {}
|
| 345 |
+
}
|
| 346 |
function b64FromBuf(buf) {
|
| 347 |
const u = new Uint8Array(buf); let s = "";
|
| 348 |
for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode(...u.subarray(i, i + 0x8000));
|
|
|
|
| 361 |
const reconnectTimers = new Map();
|
| 362 |
function scheduleReconnect(peerId, attempt = 0) {
|
| 363 |
if (!names.has(peerId) || chans.has(peerId) || reconnectTimers.has(peerId)) return;
|
| 364 |
+
if (attempt >= MAX_DIRECT_ATTEMPTS) return void useRelay(peerId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
const delay = 1500 * Math.pow(2, attempt);
|
| 366 |
reconnectTimers.set(peerId, setTimeout(() => {
|
| 367 |
reconnectTimers.delete(peerId);
|
|
|
|
| 369 |
if (+myId.slice(1) > +peerId.slice(1)) {
|
| 370 |
log(`reconnecting to ${nmeOf(peerId)} (attempt ${attempt + 1})…`);
|
| 371 |
cleanupPeer(peerId); initiatePeer(peerId);
|
| 372 |
+
} else {
|
| 373 |
+
armWatchdog(peerId); // the other side dials; still deadline it
|
| 374 |
}
|
| 375 |
+
setTimeout(() => scheduleReconnect(peerId, attempt + 1), CONNECT_TIMEOUT + 2000);
|
| 376 |
}, delay));
|
| 377 |
}
|
| 378 |
function initiatePeer(peerId) {
|
| 379 |
+
if (forceRelay) return useRelay(peerId); // ?relay=1 — skip WebRTC entirely
|
| 380 |
const pc = newPC(peerId);
|
| 381 |
setupChannel(peerId, pc.createDataChannel("daisy"));
|
| 382 |
+
armWatchdog(peerId); // no silent forever-connecting
|
| 383 |
pc.createOffer().then(o => pc.setLocalDescription(o)).then(() => signal(peerId, { sdp: pc.localDescription }));
|
| 384 |
}
|
| 385 |
const pendingCand = new Map();
|
| 386 |
async function onSignal(from, data) {
|
| 387 |
let pc = pcs.get(from);
|
| 388 |
if (data.sdp) {
|
| 389 |
+
if (!pc) {
|
| 390 |
+
pc = newPC(from);
|
| 391 |
+
pc.ondatachannel = (e) => setupChannel(from, e.channel);
|
| 392 |
+
armWatchdog(from); // the answering side needs a deadline too
|
| 393 |
+
}
|
| 394 |
await pc.setRemoteDescription(data.sdp);
|
| 395 |
for (const c of pendingCand.get(from) || []) try { await pc.addIceCandidate(c); } catch (e) {}
|
| 396 |
pendingCand.delete(from);
|
|
|
|
| 405 |
}
|
| 406 |
function setupChannel(peerId, dc) {
|
| 407 |
dc.binaryType = "arraybuffer";
|
| 408 |
+
dc.onopen = () => {
|
| 409 |
+
clearWatchdog(peerId);
|
| 410 |
+
attempts.delete(peerId);
|
| 411 |
+
relayed.delete(peerId);
|
| 412 |
+
chans.set(peerId, dc); updatePeers();
|
| 413 |
+
log(`connected to ${nmeOf(peerId)}`);
|
| 414 |
+
const pc = pcs.get(peerId);
|
| 415 |
+
if (pc) logSelectedPath(peerId, pc);
|
| 416 |
+
sendHello(peerId);
|
| 417 |
+
};
|
| 418 |
dc.onclose = () => { chans.delete(peerId); updatePeers(); wake(); scheduleReconnect(peerId); };
|
| 419 |
dc.onmessage = (e) => onWire(peerId, e.data);
|
| 420 |
}
|
| 421 |
function cleanupPeer(id) {
|
| 422 |
+
clearWatchdog(id);
|
| 423 |
const pc = pcs.get(id); if (pc) pc.close();
|
| 424 |
+
pcs.delete(id); pendingCand.delete(id);
|
| 425 |
+
const dc = chans.get(id);
|
| 426 |
+
if (dc && !dc.isRelay) chans.delete(id); // keep a working relay channel
|
| 427 |
for (const k of fragIn.keys()) if (k.startsWith(id + ":")) fragIn.delete(k);
|
| 428 |
wake();
|
| 429 |
}
|