| index d3ffce3..21e814b 100644 |
| --- a/internal/meet/static/room.js |
| +++ b/internal/meet/static/room.js |
| @@ -13,8 +13,13 @@ |
| // §4.6 delivery per-target outbound queue with `seq`, pair |
| // generations, inbound dedupe, candidate buffer |
| // §5.1 negotiation the W3C perfect-negotiation pattern |
| -// §5.5 the floor no ICE servers in this slice; host candidates |
| -// only (slice 3 adds GET /ice) |
| +// §5.2 ICE servers GET /m/{sqid}/ice before the first connection |
| +// §5.3 renewal per connection, at the server's `renewAt`, and |
| +// complete only when a new ICE username fragment |
| +// is on the selected candidate pair |
| +// §5.5 the floor an empty list is the floor: host candidates |
| +// only, and the failed tile says whether a relay |
| +// was there to fail |
| // |
| // House rules this file is written under: no library, no inline script |
| // (the page's CSP is `default-src 'self'`), nothing built with |
| @@ -34,6 +39,11 @@ |
| const sqid = root.dataset.sqid || ''; |
| const selfName = root.dataset.selfName || ''; |
| const memberHint = root.dataset.isMember === '1'; |
| + // §5.5: whether this instance has a relay at all. It changes one |
| + // sentence — the one a failed tile shows — and nothing else. Like |
| + // data-is-member it is a rendering hint: the ICE list the server |
| + // actually mints is what decides whether a call has a relay to use. |
| + const relayConfigured = root.dataset.relay === '1'; |
| |
| const $ = (id) => document.getElementById(id); |
| const tilesEl = $('tiles'); |
| @@ -76,6 +86,16 @@ |
| // refusal: only that resets the backoff. A stream dying moments after |
| // its `hello` would otherwise reconnect in a tight loop. |
| const HEALTHY_STREAM = 5000; |
| + // §5.3: a fetch of /ice that fails is retried every minute, and the |
| + // warning stands from the first failure until one succeeds. The gate |
| + // shortens the *schedule* through X-Meet-Test-Ice-TTL's effect on |
| + // `renewAt`, never this: there is no client knob for it. |
| + const ICE_RETRY = 60000; |
| + // §5.3 step 2: each ICE restart gets thirty seconds to prove itself, |
| + // and a connection gets three of them. |
| + const RENEW_ATTEMPTS = 3; |
| + const RENEW_WINDOW = 30000; |
| + const RENEW_POLL = 500; |
| const PEER_ID = /^[0-9a-f]{32}$/; |
| const HAS_SINK_ID = typeof HTMLMediaElement !== 'undefined' && |
| typeof HTMLMediaElement.prototype.setSinkId === 'function'; |
| @@ -113,6 +133,23 @@ |
| const frames = new Map(); // peer id -> frames rendered, for the test hook |
| const tiles = new Map(); // tile key -> {figure, video} |
| |
| + // §5.2's list and §5.3's clocks. `expiresAt`, `renewAt`, `fetchedAt` |
| + // and `lastFetchAt` are all milliseconds since the epoch: the server |
| + // sends unix seconds and fetchIce is the one place they are converted, |
| + // so everything below — and everything the test hook exposes — |
| + // compares them with Date.now() and with nothing else. |
| + const ice = { |
| + servers: [], // the RTCIceServer list, [] on the floor (§5.5) |
| + expiresAt: 0, // the credential's hard deadline on the relay |
| + renewAt: 0, // when the server asks us to come back |
| + fetchedAt: 0, // when the list we hold arrived |
| + failedSince: 0, // the first failure of the current run, or 0 |
| + fetches: 0, // *successful* fetches, for the gate |
| + lastFetchAt: 0, |
| + }; |
| + // peer id -> §5.3's record for one connection's renewal. |
| + const renewals = new Map(); |
| + |
| let waiting = []; // the admission queue, members only (§4.3) |
| let lastRoster = null; |
| let local = null; // the local MediaStream, or null |
| @@ -127,6 +164,14 @@ |
| let reconnectTimer = 0; |
| let statsTimer = 0; |
| let fullTimer = 0; |
| + let renewTimer = 0; // §5.3: the scheduled renewal, at `renewAt` |
| + let iceRetryTimer = 0; // §5.3 step 1: the one-minute retry |
| + let expiryTimer = 0; // §5.3 step 3: the must-succeed-by verdict |
| + let icePrimed = false; // a first fetch has been made (success or not) |
| + let firstIce = null; // that fetch, shared by concurrent callers |
| + let renewing = false; // a renewal fetch is in flight |
| + let renewDue = false; // the current run is a renewal, not a first fetch |
| + let iceWarningEl = null; |
| // How many rosters this tab has applied. Nothing in the page reads |
| // it: it is the one fact a gate cannot infer from the DOM or from |
| // `peers`, because a connection can be closed locally without any |
| @@ -515,7 +560,7 @@ |
| function handle(type, msg) { |
| switch (type) { |
| case 'hello': return onHello(msg); |
| - case 'roster': return reconcile(msg.roster || { peers: [] }); |
| + case 'roster': return onRoster(msg); |
| case 'peer': return onPeer(msg); |
| case 'gone': return onGone(msg); |
| case 'knock': return onKnock(msg); |
| @@ -681,6 +726,29 @@ |
| // Reconciliation (§4.5: the roster is the authority) |
| // --------------------------------------------------------------- |
| |
| + // onRoster is the roster's door, and it keeps `handle` synchronous: |
| + // §5.2 puts one await in front of the first connection this browser |
| + // builds, and a rejection escaping an event listener goes where |
| + // neither a person nor a gate could see it. Same shape as the |
| + // pageshow handler's. |
| + function onRoster(msg) { |
| + applyRoster(msg).catch((err) => { |
| + console.error('meet: roster', err); |
| + }); |
| + } |
| + |
| + // applyRoster is §5.2's ordering: the ICE list is fetched before the |
| + // first connection is built, and only before the first — every later |
| + // roster reconciles straight away. A fetch that fails does not hold |
| + // the room up: it leaves the floor's empty list in place, shows the |
| + // warning and arms the minute retry, and the roster is applied. |
| + async function applyRoster(msg) { |
| + const roster = msg.roster || { peers: [] }; |
| + if (!icePrimed) await iceReady(); |
| + if (state.stopped) return; |
| + reconcile(roster); |
| + } |
| + |
| function reconcile(roster) { |
| rosters += 1; |
| lastRoster = roster; |
| @@ -749,13 +817,15 @@ |
| } |
| |
| function createPC(id, g) { |
| - // No ICE servers in this slice. §5.5's floor: browsers gather host |
| - // candidates only, which connects on a permissive local network and |
| - // usually not across the internet. Slice 3 adds GET /m/{sqid}/ice |
| - // and fills this array from it. |
| + // §5.2: the list `fetchIce` took from GET /m/{sqid}/ice, which the |
| + // first roster waits for. An empty one is §5.5's floor — browsers |
| + // gather host candidates only, which connects on a permissive local |
| + // network and usually not across the internet — and it is what a |
| + // connection built during the very first fetch gets, until |
| + // `adoptServers` hands it the real list. |
| let pc; |
| try { |
| - pc = new RTCPeerConnection({ iceServers: [] }); |
| + pc = new RTCPeerConnection({ iceServers: ice.servers }); |
| } catch { |
| // No WebRTC at all: an old browser, or one where it is switched |
| // off. Nothing here can recover, so the room says so once. |
| @@ -786,6 +856,19 @@ |
| gen: g, |
| stream: null, |
| failed: false, |
| + // §5.2: true while this connection is running on the empty list, |
| + // so `adoptServers` can find the ones built during the first |
| + // fetch and give them the servers when they arrive. |
| + floor: !ice.servers.length, |
| + // Which list this connection was built on. A connection built |
| + // after a fetch is already on that credential and has nothing to |
| + // renew, which is what keeps §5.3 step 3's deadline off a peer |
| + // who joined the call two minutes ago. |
| + listAt: ice.fetchedAt, |
| + // Remote descriptions applied, which is how §5.3 step 2's "the |
| + // restart's offer/answer exchange has completed" is read without |
| + // guessing from signalingState alone. |
| + desc: 0, |
| }; |
| pcs.set(id, entry); |
| |
| @@ -854,6 +937,10 @@ |
| pcs.delete(id); |
| } |
| queue.delete(id); |
| + // §5.3's record belongs to a connection, not to a peer id: a pair |
| + // rebuilt under a new generation has renewed nothing yet, and a |
| + // record left behind would say it had. |
| + renewals.delete(id); |
| if (forget) { |
| stale.delete(id); |
| gen.delete(id); |
| @@ -928,6 +1015,8 @@ |
| entry.descSeq = msg.seq || entry.descSeq; |
| await drainCandidates(entry); |
| await entry.pc.setLocalDescription(); |
| + // One exchange completed, on the answering side (§5.3 step 2). |
| + entry.desc += 1; |
| send(msg.from, 'answer', { description: describe(entry.pc.localDescription) }); |
| } catch { |
| // A description that will not apply leaves the pair to recover |
| @@ -958,6 +1047,8 @@ |
| // over: candidate failures after this point are real again. |
| entry.ignoreOffer = false; |
| entry.descSeq = msg.seq || entry.descSeq; |
| + // One exchange completed, on the offering side (§5.3 step 2). |
| + entry.desc += 1; |
| await drainCandidates(entry); |
| } catch { |
| // Same as above: a stale answer is not worth an exception. |
| @@ -996,6 +1087,443 @@ |
| } |
| } |
| |
| + // --------------------------------------------------------------- |
| + // ICE servers and renewal (§5.2, §5.3, §5.5) |
| + // --------------------------------------------------------------- |
| + // |
| + // The shape of it: the first roster waits for `fetchIce`, every |
| + // connection is built with the list it took, and each connection is |
| + // renewed on its own at the time the server named — with new servers, |
| + // an ICE restart, and proof that the restart landed. Nothing about the |
| + // servers is logged: the list carries a credential, and the three |
| + // diagnostics below print an error object and never a response. |
| + |
| + const ICE_WARNING = 'Meet could not reach the relay. Calls may not connect until it can.'; |
| + const ICE_EXPIRED = 'The relay credential expired for this connection.'; |
| + const FAILED_RELAY = 'Could not connect, even through the relay.'; |
| + const FAILED_PLAIN = 'Could not connect.'; |
| + |
| + // iceReady is the one await §5.2 puts in front of the first connection |
| + // this browser builds. One shared promise, so two rosters in the same |
| + // breath cannot start two fetches, and a failure primes exactly as a |
| + // success does: the room carries on with the floor's empty list while |
| + // the minute retry runs behind it. |
| + function iceReady() { |
| + if (icePrimed) return Promise.resolve(false); |
| + if (!firstIce) { |
| + firstIce = fetchIce().finally(() => { |
| + firstIce = null; |
| + }); |
| + } |
| + return firstIce; |
| + } |
| + |
| + // fetchIce is §5.2's client half: GET /m/{sqid}/ice for this |
| + // participation, answering {iceServers, expiresAt, renewAt}. It never |
| + // throws and never rejects — every caller carries on either way — and |
| + // answers whether a new list is in hand. |
| + // |
| + // The stream's `probe` is borrowed whole: same-origin credentials, no |
| + // store, no Accept the endpoint did not ask for, and an absolute |
| + // deadline, so a request that never settles cannot be the end of this |
| + // browser's renewing. |
| + async function fetchIce() { |
| + const peer = state.peer; |
| + if (state.stopped || !peer) return false; |
| + const path = '/m/' + encodeURIComponent(sqid) + '/ice?peer=' + encodeURIComponent(peer); |
| + let res; |
| + try { |
| + res = await probe(path, Date.now() + SEND_DEADLINE); |
| + } catch { |
| + icePrimed = true; |
| + iceFailed(); |
| + return false; |
| + } |
| + if (state.stopped || state.peer !== peer) return false; |
| + if (res.signedOut) { |
| + terminal('signedout'); |
| + return false; |
| + } |
| + if (res.status === 404) { |
| + // Not a relay failure: a 404 here says this participation is gone, |
| + // and /signal's own 404 says no more than that either. So it goes |
| + // through the same probe-first recovery — this browser's own |
| + // participation is the authority on what the 404 meant — and the |
| + // next roster fetches again, because whatever comes back from that |
| + // ladder is a call that has not got a list yet. |
| + icePrimed = false; |
| + firstIce = null; |
| + await recoverFromNotFound(res.data.boot); |
| + return false; |
| + } |
| + if (res.status !== 200 || !Array.isArray(res.data.iceServers)) { |
| + icePrimed = true; |
| + iceFailed(); |
| + return false; |
| + } |
| + icePrimed = true; |
| + ice.servers = res.data.iceServers; |
| + ice.expiresAt = millis(res.data.expiresAt); |
| + ice.renewAt = millis(res.data.renewAt); |
| + ice.fetchedAt = Date.now(); |
| + ice.lastFetchAt = ice.fetchedAt; |
| + ice.fetches += 1; |
| + ice.failedSince = 0; |
| + clearTimeout(iceRetryTimer); |
| + iceWarning(false); |
| + scheduleRenewal(); |
| + adoptServers(); |
| + return true; |
| + } |
| + |
| + // The wire carries unix seconds; everything in this file works in |
| + // milliseconds since the epoch, and this is the one place the two |
| + // meet. A field that is missing, zero or not a number means "no |
| + // clock", which is exactly what the server sends when there is no |
| + // relay to renew. |
| + function millis(v) { |
| + const n = Number(v); |
| + return Number.isFinite(n) && n > 0 ? n * 1000 : 0; |
| + } |
| + |
| + // iceFailed is §5.3 step 1: keep the list we have — or the floor's |
| + // empty one, on the very first fetch — say so from the first failure, |
| + // and come back in a minute. `ice.expiresAt` is deliberately left |
| + // alone: the old credential's deadline is the must-succeed-by for |
| + // every connection that has not renewed (§5.3 step 3), and a failed |
| + // fetch is the case that makes it matter. |
| + function iceFailed() { |
| + if (state.stopped) return; |
| + if (!ice.failedSince) ice.failedSince = Date.now(); |
| + iceWarning(true); |
| + clearTimeout(iceRetryTimer); |
| + iceRetryTimer = setTimeout(() => { |
| + if (state.stopped) return; |
| + const run = renewDue ? renewAll() : fetchIce(); |
| + run.catch((err) => { |
| + console.error('meet: ice', err); |
| + }); |
| + }, ICE_RETRY); |
| + } |
| + |
| + // iceWarning is §5.3 step 1's sentence, in a live region of its own |
| + // created once beside #status. Its own, because the status paragraph |
| + // is busy with the call's state and a warning coming and going from |
| + // there would take whatever a person was reading with it. Never colour |
| + // alone: the sentence is the whole signal. |
| + function iceWarning(show) { |
| + const el = warningEl(); |
| + if (!el) return; |
| + if (show) { |
| + // Unhidden first, then written: a live region announces the change |
| + // it can see, and text set into a hidden element is not one. |
| + el.hidden = false; |
| + el.textContent = ICE_WARNING; |
| + return; |
| + } |
| + el.textContent = ''; |
| + el.hidden = true; |
| + } |
| + |
| + function warningEl() { |
| + if (iceWarningEl) return iceWarningEl; |
| + if (!statusEl) return null; |
| + const el = document.createElement('p'); |
| + el.id = 'ice-warning'; |
| + el.className = 'meet-ice-warning'; |
| + el.setAttribute('role', 'status'); |
| + el.hidden = true; |
| + statusEl.after(el); |
| + iceWarningEl = el; |
| + return el; |
| + } |
| + |
| + // adoptServers closes the one window §5.2's ordering leaves open: a |
| + // `peer` event can arrive while the very first fetch is still out, and |
| + // that connection was built on the floor's empty list. Nothing about |
| + // it is broken — it is gathering host candidates — so it is handed the |
| + // servers and restarted rather than torn down and rebuilt. |
| + function adoptServers() { |
| + if (!ice.servers.length) return; |
| + for (const entry of pcs.values()) { |
| + if (!entry.floor) continue; |
| + entry.floor = false; |
| + entry.listAt = ice.fetchedAt; |
| + try { |
| + entry.pc.setConfiguration({ iceServers: ice.servers }); |
| + entry.pc.restartIce(); |
| + } catch { |
| + // No setConfiguration, or no restartIce: the connection keeps |
| + // the host candidates it has, which is §5.5's floor and still a |
| + // call on a permissive network. |
| + } |
| + } |
| + } |
| + |
| + // scheduleRenewal is §5.3 step 4: the next renewal is the `renewAt` |
| + // the server named, counted from the fetch that succeeded. Nothing is |
| + // scheduled on the floor — there is no credential to renew — and a |
| + // `renewAt` already behind us runs at once. |
| + function scheduleRenewal() { |
| + clearTimeout(renewTimer); |
| + if (state.stopped || !ice.renewAt || !ice.servers.length) return; |
| + renewTimer = setTimeout(renew, Math.max(0, ice.renewAt - Date.now())); |
| + } |
| + |
| + // §5.3's last paragraph: a suspended tab stops its timers, so a tab |
| + // coming back to visible compares the clock with `renewAt` rather than |
| + // trusting one, and runs the sequence at once if it is overdue. |
| + function catchUpRenewal() { |
| + if (state.stopped || !ice.renewAt || !ice.servers.length) return; |
| + if (Date.now() >= ice.renewAt) renew(); |
| + } |
| + |
| + function renew() { |
| + renewAll().catch((err) => { |
| + console.error('meet: renew', err); |
| + }); |
| + } |
| + |
| + // renewAll is §5.3's sequence for the whole mesh: fetch, then renew |
| + // every live connection. The deadline it hands each of them is the |
| + // *old* credential's expiry (step 3) — the one honest signal, because |
| + // the browser cannot see which credential an allocation is using — and |
| + // it survives the minute retries, because a failed fetch leaves |
| + // `ice.expiresAt` where it was. |
| + async function renewAll() { |
| + if (state.stopped || renewing) return; |
| + renewing = true; |
| + renewDue = true; |
| + const mustSucceedBy = ice.expiresAt; |
| + armMustSucceedBy(mustSucceedBy); |
| + try { |
| + // A failed fetch has already armed the warning and the minute |
| + // retry, and that retry comes back here. |
| + if (!(await fetchIce())) return; |
| + renewDue = false; |
| + for (const id of Array.from(pcs.keys())) { |
| + renewOne(id, mustSucceedBy).catch((err) => { |
| + console.error('meet: renew', err); |
| + }); |
| + } |
| + } finally { |
| + renewing = false; |
| + } |
| + } |
| + |
| + // armMustSucceedBy sets §5.3 step 3's verdict for the connections that |
| + // have nothing running: with the fetch still failing a minute at a |
| + // time, the old credential's deadline passes and their allocation dies |
| + // with it, and the person is owed the sentence. A connection whose own |
| + // restart is still in flight is left alone — it is the thing that |
| + // would clear this, and interrupting it would show the sentence to |
| + // someone whose call is about to come back. |
| + function armMustSucceedBy(at) { |
| + clearTimeout(expiryTimer); |
| + if (!at) return; |
| + expiryTimer = setTimeout(expireUnrenewed, Math.max(0, at - Date.now())); |
| + } |
| + |
| + function expireUnrenewed() { |
| + if (state.stopped) return; |
| + for (const [id, entry] of pcs) { |
| + // Built on the list we hold now: this connection never had the |
| + // credential that just expired. |
| + if (entry.listAt >= ice.fetchedAt) continue; |
| + const record = renewals.get(id); |
| + if (record && (record.renewed || record.running)) continue; |
| + markRelayExpired(id); |
| + } |
| + } |
| + |
| + function markRelayExpired(id) { |
| + const record = renewalRecord(id); |
| + if (record.renewed) return; |
| + record.relayExpired = true; |
| + renderTiles(); |
| + } |
| + |
| + function renewalRecord(id) { |
| + let record = renewals.get(id); |
| + if (!record) { |
| + record = { attempts: 0, renewed: false, ufrag: '', at: 0, running: false, relayExpired: false }; |
| + renewals.set(id, record); |
| + } |
| + return record; |
| + } |
| + |
| + // renewOne is §5.3 step 2 for one connection: the new servers under |
| + // the existing transport, an ICE restart, perfect negotiation carrying |
| + // the offer, and then proof. Three attempts, thirty seconds each, and |
| + // the must-succeed-by is read *between* attempts rather than during |
| + // one — cutting a restart that is about to land would show a person |
| + // the expired sentence for a call that was coming back. |
| + async function renewOne(id, mustSucceedBy) { |
| + const entry = pcs.get(id); |
| + if (!entry || state.stopped) return false; |
| + const record = renewalRecord(id); |
| + if (record.running) return false; |
| + record.running = true; |
| + record.renewed = false; |
| + try { |
| + for (let attempt = 1; attempt <= RENEW_ATTEMPTS; attempt++) { |
| + if (state.stopped || pcs.get(id) !== entry) return false; |
| + record.attempts = attempt; |
| + // The generation this connection is on now, and the count of |
| + // exchanges it has completed: the two things the proof is |
| + // measured against. |
| + const oldUfrag = await selectedUfrag(entry.pc); |
| + const from = entry.desc; |
| + try { |
| + entry.pc.setConfiguration({ iceServers: ice.servers }); |
| + entry.floor = !ice.servers.length; |
| + } catch { |
| + // An older browser without setConfiguration: the restart still |
| + // re-gathers, on the servers this connection was built with. |
| + } |
| + try { |
| + entry.pc.restartIce(); |
| + } catch { |
| + // No restartIce at all: a fresh generation is the only way to |
| + // put a new credential under this pair, and the `pair` that |
| + // answers rebuilds the connection from nothing. |
| + requestReset(id); |
| + return false; |
| + } |
| + const proven = await proveRenewed(entry, oldUfrag, from, Date.now() + RENEW_WINDOW); |
| + if (state.stopped || pcs.get(id) !== entry) return false; |
| + if (proven !== null) { |
| + record.renewed = true; |
| + record.ufrag = proven; |
| + record.at = Date.now(); |
| + entry.listAt = ice.fetchedAt; |
| + record.relayExpired = false; |
| + renderTiles(); |
| + return true; |
| + } |
| + if (mustSucceedBy && Date.now() > mustSucceedBy) break; |
| + } |
| + markRelayExpired(id); |
| + return false; |
| + } finally { |
| + record.running = false; |
| + } |
| + } |
| + |
| + // retryRenewal is the control §5.3 step 3 puts on an expired tile: a |
| + // fresh fetch, then step 2 again for that one connection. It passes no |
| + // must-succeed-by — the deadline it would carry is already behind us, |
| + // and a person who asked for this is owed the three attempts, not an |
| + // instant repeat of the same sentence. |
| + async function retryRenewal(id) { |
| + if (!(await fetchIce())) return; |
| + await renewOne(id, 0); |
| + } |
| + |
| + // proveRenewed polls getStats until the decision below says the |
| + // restart landed, or until the window runs out. Half a second between |
| + // polls: an ICE restart is a round trip and a handful of candidate |
| + // exchanges, and asking faster only costs the call CPU. |
| + async function proveRenewed(entry, oldUfrag, from, deadline) { |
| + for (;;) { |
| + if (state.stopped) return null; |
| + const proven = renewedUfrag(await statsList(entry.pc), oldUfrag, |
| + entry.pc.signalingState, entry.desc > from); |
| + if (proven !== null) return proven; |
| + const left = deadline - Date.now(); |
| + if (left <= 0) return null; |
| + await sleep(Math.min(RENEW_POLL, left)); |
| + } |
| + } |
| + |
| + // renewedUfrag is §5.3 step 2's decision, written as a pure function |
| + // of one getStats report so it can be reasoned about — and gated — |
| + // without a browser (test/browser/negotiation.test.mjs lifts it out of |
| + // this file by name). |
| + // |
| + // A connection is renewed when the restart's offer/answer exchange has |
| + // completed, `signalingState` is stable with that answer applied, and |
| + // the selected succeeded pair's local candidate carries a *different* |
| + // ICE username fragment from the one the connection was on. An |
| + // `iceConnectionState` change proves nothing on its own: a `completed` |
| + // transport moves to `connected` merely by starting a restart. |
| + // |
| + // It answers the new fragment, '' when the restart is proven but the |
| + // browser does not name fragments, and null when it is not proven — |
| + // so callers test `!== null`, never truthiness. |
| + function renewedUfrag(reports, oldUfrag, signalingState, negotiated) { |
| + if (!negotiated || signalingState !== 'stable') return null; |
| + const ufrag = selectedPairUfrag(reports); |
| + if (ufrag === null) return null; |
| + // Nothing was recorded before the restart — no pair yet, or a |
| + // browser that will not name the fragment — so §5.3 step 2's |
| + // fallback applies: any succeeded selected pair after the restart's |
| + // answer counts. |
| + if (!oldUfrag) return ufrag; |
| + return ufrag && ufrag !== oldUfrag ? ufrag : null; |
| + } |
| + |
| + // selectedPairUfrag reads one getStats report and answers the ICE |
| + // username fragment on the local candidate of the selected, succeeded |
| + // candidate pair: the fragment when the browser names it, '' when the |
| + // pair is there and the fragment is not, and null when there is no |
| + // such pair at all. Chromium marks the pair through the transport's |
| + // selectedCandidatePairId and through `nominated`, Firefox through |
| + // `selected`; all three are read, so the same function answers on any |
| + // of them. Pure, like the decision above. |
| + function selectedPairUfrag(reports) { |
| + const locals = new Map(); |
| + const pairs = []; |
| + let selectedId = ''; |
| + for (const r of reports || []) { |
| + if (!r || !r.type) continue; |
| + if (r.type === 'local-candidate') locals.set(r.id, r); |
| + else if (r.type === 'candidate-pair') pairs.push(r); |
| + else if (r.type === 'transport' && r.selectedCandidatePairId) selectedId = r.selectedCandidatePairId; |
| + } |
| + let found = null; |
| + for (const p of pairs) { |
| + if (p.state !== 'succeeded') continue; |
| + if (!(p.nominated || p.selected || (selectedId && p.id === selectedId))) continue; |
| + const local = locals.get(p.localCandidateId); |
| + const ufrag = (local && local.usernameFragment) || ''; |
| + // A pair that names the fragment is worth more than one that does |
| + // not, so keep looking rather than settling for the first. |
| + if (ufrag) return ufrag; |
| + found = ''; |
| + } |
| + return found; |
| + } |
| + |
| + // selectedUfrag is the generation a connection is on right now. null |
| + // means the browser will not say — no selected pair yet, or no |
| + // fragment reported — which is the case renewedUfrag falls back for. |
| + async function selectedUfrag(pc) { |
| + return selectedPairUfrag(await statsList(pc)) || null; |
| + } |
| + |
| + // statsList flattens one RTCStatsReport into an array, and answers an |
| + // empty one for a connection that is closing — getStats can reject, |
| + // and on some browsers throw outright, and neither is worth an |
| + // exception in the middle of a renewal. |
| + function statsList(pc) { |
| + try { |
| + return pc.getStats().then((stats) => { |
| + const list = []; |
| + stats.forEach((report) => list.push(report)); |
| + return list; |
| + }, () => []); |
| + } catch { |
| + return Promise.resolve([]); |
| + } |
| + } |
| + |
| + function stopIceTimers() { |
| + clearTimeout(renewTimer); |
| + clearTimeout(iceRetryTimer); |
| + clearTimeout(expiryTimer); |
| + } |
| + |
| // --------------------------------------------------------------- |
| // Outbound queue (§4.6) |
| // --------------------------------------------------------------- |
| @@ -1412,10 +1940,12 @@ |
| clearTimeout(reconnectTimer); |
| clearTimeout(fullTimer); |
| clearInterval(statsTimer); |
| + stopIceTimers(); |
| closeStream(); |
| closeAllPCs(); |
| stopLocal(); |
| renderTiles(); |
| + iceWarning(false); |
| controlsEnabled(false); |
| say(TERMINAL[reason] || TERMINAL.ended); |
| wayOut(reason); |
| @@ -1499,18 +2029,31 @@ |
| tiles.delete(key); |
| } |
| } |
| - if (local) tile('self', selfName, local, true, false); |
| + if (local) tile('self', selfName, local, true, ''); |
| for (const [id, entry] of pcs) { |
| - tile(id, names.get(id) || 'Someone', entry.stream, false, entry.failed); |
| + tile(id, names.get(id) || 'Someone', entry.stream, false, tileNote(id, entry)); |
| } |
| renderStatus(); |
| } |
| |
| + // tileNote is the sentence a tile carries when its connection is not |
| + // working, and the empty string when it is. §5.3 step 3's relay |
| + // sentence comes first: it says *why*, where §5.5's says only that. |
| + // §5.5's own wording depends on whether there was a relay to fail — |
| + // "even through the relay" is a claim about this instance, and |
| + // data-relay is where the server states it. |
| + function tileNote(id, entry) { |
| + const record = renewals.get(id); |
| + if (record && record.relayExpired) return ICE_EXPIRED; |
| + if (!entry.failed) return ''; |
| + return relayConfigured ? FAILED_RELAY : FAILED_PLAIN; |
| + } |
| + |
| // tile builds or updates one <figure> per participant. Nothing here |
| // uses innerHTML, and every name is written with textContent: names |
| // come from the server's roster, peer and knock events and are other |
| // people's text. |
| - function tile(key, name, stream, isSelf, failed) { |
| + function tile(key, name, stream, isSelf, noteText) { |
| let t = tiles.get(key); |
| if (!t) { |
| const figure = document.createElement('figure'); |
| @@ -1573,10 +2116,13 @@ |
| startPlaying(key, t); |
| } |
| } |
| - const show = !!failed; |
| + // One note element and one Retry beside it, whichever sentence the |
| + // tile is carrying: `restartIce` is the handler for both, and it |
| + // decides whether a retry means a bare ICE restart or a renewal. |
| + const show = !!noteText; |
| t.note.hidden = !show; |
| t.retry.hidden = !show; |
| - t.note.textContent = show ? 'Could not connect.' : ''; |
| + t.note.textContent = noteText || ''; |
| } |
| |
| // startPlaying asks the element to play and, if the browser refuses |
| @@ -1604,11 +2150,26 @@ |
| return link; |
| } |
| |
| + // restartIce is the Retry on a tile, and it answers both sentences. |
| + // A connection whose relay credential ran out needs a fetch before a |
| + // restart, and so does one whose list is past its `renewAt` — a bare |
| + // restart there would re-gather on a credential the relay has already |
| + // stopped honouring (§5.3 step 3). Everything else is §5.1's plain |
| + // restart, which is what this control always was. |
| function restartIce(id) { |
| const entry = pcs.get(id); |
| if (!entry) return; |
| entry.failed = false; |
| + const record = renewals.get(id); |
| + const expired = !!(record && record.relayExpired); |
| + if (record) record.relayExpired = false; |
| renderTiles(); |
| + if (expired || iceStale()) { |
| + retryRenewal(id).catch((err) => { |
| + console.error('meet: renew', err); |
| + }); |
| + return; |
| + } |
| try { |
| entry.pc.restartIce(); |
| } catch { |
| @@ -1618,6 +2179,12 @@ |
| } |
| } |
| |
| + // iceStale: we hold a list, and the server's own renew time for it has |
| + // passed. A retry then is a renewal, not a restart. |
| + function iceStale() { |
| + return !!(ice.servers.length && ice.renewAt && Date.now() >= ice.renewAt); |
| + } |
| + |
| // applySink routes a remote tile's audio to the speaker the holding |
| // screen remembered. Feature-detected on the prototype, because a |
| // browser without setSinkId would otherwise leave that picker as a |
| @@ -1709,6 +2276,7 @@ |
| clearTimeout(reconnectTimer); |
| clearTimeout(fullTimer); |
| clearInterval(statsTimer); |
| + stopIceTimers(); |
| if (state.peer) postSignal({ peer: state.peer, type: 'leave' }, { keepalive: true }); |
| closeStream(); |
| closeAllPCs(); |
| @@ -1738,6 +2306,14 @@ |
| }); |
| }); |
| |
| + // §5.3's last paragraph: a suspended tab stops its timers, so a tab |
| + // coming back compares the clock with `renewAt` rather than trusting |
| + // one. The listener is not async for the same reason the pageshow one |
| + // is not. |
| + document.addEventListener('visibilitychange', () => { |
| + if (document.visibilityState === 'visible') catchUpRenewal(); |
| + }); |
| + |
| async function restoreFromCache(e) { |
| if (!e.persisted || state.stopped || state.leaveSent) return; |
| if (!local) await startLocal(); |
| @@ -1788,6 +2364,22 @@ |
| } |
| } |
| |
| + // renewalState is a plain object rather than a Map: the gate reads it |
| + // by peer id, and this is the one shape that survives being handed |
| + // straight out of page.evaluate(). |
| + function renewalState() { |
| + const out = {}; |
| + for (const [id, r] of renewals) { |
| + out[id] = { |
| + attempts: r.attempts, |
| + renewed: !!r.renewed, |
| + ufrag: r.ufrag || '', |
| + expired: !!r.relayExpired, |
| + }; |
| + } |
| + return out; |
| + } |
| + |
| window.meet = { |
| state() { |
| // signaling and connection are the two per-peer facts a gate |
| @@ -1819,6 +2411,25 @@ |
| frames: new Map(frames), |
| signaling, |
| connection, |
| + // §5.2 and §5.3, for the gate. The three clocks are |
| + // milliseconds since the epoch, converted once in fetchIce, so |
| + // they compare with Date.now(); `fetches` counts the fetches |
| + // that produced a list, not the attempts; `servers` is the |
| + // length of that list, never the list — nothing about a |
| + // credential leaves this file. |
| + ice: { |
| + fetches: ice.fetches, |
| + lastFetchAt: ice.lastFetchAt, |
| + expiresAt: ice.expiresAt, |
| + renewAt: ice.renewAt, |
| + warning: !!ice.failedSince, |
| + servers: ice.servers.length, |
| + }, |
| + // Per connection: how many restarts §5.3 step 2 has spent, and |
| + // whether one of them was proven. `ufrag` is the ICE username |
| + // fragment the proof found — a generation name, not a |
| + // credential. |
| + renewals: renewalState(), |
| }; |
| }, |
| }; |
| @@ -1829,6 +2440,11 @@ |
| |
| async function start() { |
| controlsEnabled(true); |
| + // The relay warning's live region is built now, empty and hidden, |
| + // rather than at the moment it has something to say: a live region |
| + // inserted along with its own text is a region the reader was not |
| + // watching, and the change goes unannounced. |
| + warningEl(); |
| renderStatus(); |
| renderQueue(); |
| await startLocal(); |