Quazim0t0 commited on
Commit
a7b6d2d
·
verified ·
1 Parent(s): 30bafb7

Fix peers stuck forever at Connecting...

Browse files

Three 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.

Files changed (1) hide show
  1. 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
- .map(([id, n]) => `${n} ${chans.has(id) ? "✓" : "(connecting…)"}`).join(", ");
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") { cleanupPeer(peerId); scheduleReconnect(peerId); }
 
 
 
 
 
 
 
 
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 >= 5) {
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), 6000);
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) { pc = newPC(from); pc.ondatachannel = (e) => setupChannel(from, e.channel); }
 
 
 
 
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 = () => { chans.set(peerId, dc); updatePeers(); log(`connected to ${nmeOf(peerId)}`); sendHello(peerId); };
 
 
 
 
 
 
 
 
 
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); chans.delete(id); pendingCand.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
  }