oficina / meet Public

room.js: ICE servers from /ice, renewal proven by a new ufrag

The first roster now waits for GET /m/{sqid}/ice before the first
connection is built (§5.2), every RTCPeerConnection takes the list it
returned, and each connection renews on its own at the server's
renewAt with setConfiguration + restartIce, proven by a new ICE
username fragment on the selected succeeded candidate pair (§5.3).
A failed fetch keeps the old list, shows the warning in its own
role="status" region and retries every minute; /ice's 404 goes through
the same probe-first recovery /signal's does. #room carries
data-relay, which chooses between §5.5's two failed-tile sentences.

Deviations from the brief, and the decisions it left open:

- state().ice's three clocks are milliseconds since the epoch, not the
  wire's unix seconds. fetchIce converts once, so everything — the
  client and Task 5's gate — compares them with Date.now() and with
  nothing else. `fetches` counts fetches that produced a list, not
  attempts; `servers` is the list's length, never the list.
- state().renewals[peer] carries `expired` alongside the brief's
  {attempts, renewed, ufrag}, so Task 5 can assert the absence of the
  expired sentence without reading the DOM.
- §5.3 step 3's must-succeed-by is read between a connection's restart
  attempts and by a watchdog that skips any connection with a restart
  still running, or built after the current list. Cutting a restart
  that is about to land would show the expired sentence to someone
  whose call was coming back — and under the gate's shortened TTL the
  old expiry falls two seconds after renewAt, so an interrupting check
  would fail Task 5's "no tile shows the expired sentence".
- The proof requires the restart's exchange to have completed, counted
  as remote descriptions applied (entry.desc), which is §5.3 step 2's
  clause (a) read literally rather than inferred from signalingState.
- adoptServers covers the window §5.2's ordering leaves open: a `peer`
  arriving while the very first fetch is out builds a connection on
  the floor's empty list, which is then handed the servers and
  restarted rather than rebuilt.
- relay_floor_test.go (not handlers_room.go's own file) gains the two
  data-relay assertions: it is already the room page's render test,
  and the template value would otherwise ship untested — the same gap
  its own comment records for Floor.
- test/browser/negotiation.test.mjs gains ten cases: renewedUfrag and
  selectedPairUfrag over fake getStats reports, and proveRenewed's
  polling and deadline. Both decisions are pure and lifted from the
  shipped source by name.

Gates: node --check, make ci, make browser 23/23 on the first run (no
rosterpair flake), node --test negotiation 18/18.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev b55403eed3c5f192de8c94a330355b5d2aa08a6f parent 62853ed
7 files changed, +807 −16
  • docs/superpowers/copy/2026-09-10-slice-2-strings.md +26 −0
  • internal/meet/handlers_room.go +6 −1
  • internal/meet/static/meet.css +11 −0
  • internal/meet/static/room.js +629 −13
  • internal/meet/templates/room.html +1 −1
  • internal/meettest/relay_floor_test.go +12 −1
  • test/browser/negotiation.test.mjs +122 −0

CI passed — run details

diff --git a/docs/superpowers/copy/2026-09-10-slice-2-strings.md b/docs/superpowers/copy/2026-09-10-slice-2-strings.md
index f8d5152..421886b 100644
--- a/docs/superpowers/copy/2026-09-10-slice-2-strings.md
+++ b/docs/superpowers/copy/2026-09-10-slice-2-strings.md
@@ -180,3 +180,29 @@ narrowed what can reach it:
| String | Where | Note |
| --- | --- | --- |
| This meeting is open in another tab. | `room.js`, `TERMINAL.replaced` | Already catalogued. Two ways of reaching it wrongly are now closed: a transient probe answer no longer sends the client round the fresh-entry ladder into its own participation (S2-12), and neither does a 404 about a peer that merely left (S2-3, round 1). It is now shown only when this session really does hold a peer somewhere else. |
+
+## Slice 3
+
+The relay slice (`docs/superpowers/plans/2026-09-10-meet-slice-3-relay.md`).
+Copy review is waived this round per suite discussion 5; every new string
+is catalogued here instead. Four are user-facing, all in `room.js`, and
+one existing string is now reached by a second path.
+
+| String | Where | Note |
+| --- | --- | --- |
+| Meet could not reach the relay. Calls may not connect until it can. | `room.js`, `iceWarning()` — `#ice-warning`, a `role="status"` paragraph the script creates once beside `#status` | Spec §5.3 step 1. Shown from the first failed `/ice` fetch until one succeeds; the client retries every minute behind it. Its own live region rather than `#status`, so it cannot take the call's own sentence with it. Says what happened and what it may cost, and does not diagnose the network — §5.5's rule. |
+| The relay credential expired for this connection. | `room.js`, `tileNote()` — a tile's `.meet-tile-note` | Spec §5.3 step 3. Shown per connection, not per call: the must-succeed-by is the old credential's expiry, and the browser cannot see which credential an allocation is using, so this is the honest signal and not a diagnosis. The existing "Retry" beside it runs a fresh fetch and then another ICE restart. |
+| Could not connect, even through the relay. | `room.js`, `tileNote()` | Spec §5.5, in the spec's own words. Replaces "Could not connect." on a failed tile when `#room` carries `data-relay="1"` — i.e. when this instance has a relay configured, so "even through the relay" is a true claim about this deployment and not a guess. The README's TCP-443-only note (slice 6) is where the possible causes are named; the tile does not diagnose. |
+| Retry | `room.js`, `tile()` | Already catalogued for the failed tile. Now also the control on an expired tile, where it means "fetch a new credential and restart", not "restart". One label, because from a person's side it is one thing: try this connection again. |
+| Could not connect. | `room.js`, `tileNote()` | Already catalogued. Unchanged, and still what a failed tile says on an instance with no relay (§5.5's floor). |
+
+Developer-facing, in `console.error` prefixes rather than on a screen,
+listed only so this file stays a complete record of the branch. Both
+print an error object and never a response body: the `/ice` answer
+carries a credential, and nothing about the servers reaches the console.
+
+| String | Where | Note |
+| --- | --- | --- |
+| meet: ice | `room.js`, the one-minute retry's rejection handler | Same shape as `meet: reset`, `meet: pageshow`, `meet: start`. Never rendered. |
+| meet: renew | `room.js`, the renewal's rejection handlers | As above. |
+| meet: roster | `room.js`, `onRoster()` — the roster path is asynchronous now that §5.2's fetch is in front of the first connection | As above. |
diff --git a/internal/meet/handlers_room.go b/internal/meet/handlers_room.go
index 467d3fd..b003161 100644
--- a/internal/meet/handlers_room.go
+++ b/internal/meet/handlers_room.go
@@ -67,7 +67,12 @@ func (a *app) room(w http.ResponseWriter, r *http.Request) {
render(w, "room", page(r, map[string]any{
"Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
"Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember,
- "Floor": !a.relayConfigured(), "Boot": a.hub.Boot(),
+ // Floor and Relay are the same fact read twice, and both
+ // are rendering hints: Floor is §5.5's sentence for a
+ // scriptless page, Relay is what room.js reads to choose
+ // between the two failed-tile sentences. The list the
+ // server actually mints at /ice is the authority.
+ "Floor": !a.relayConfigured(), "Relay": a.relayConfigured(), "Boot": a.hub.Boot(),
}))
}
diff --git a/internal/meet/static/meet.css b/internal/meet/static/meet.css
index 6dd85d1..4888cf4 100644
--- a/internal/meet/static/meet.css
+++ b/internal/meet/static/meet.css
@@ -170,3 +170,14 @@
.meet-status a {
margin-inline-start: var(--rst-sp-1);
}
+
+/* The relay warning (spec §5.3 step 1) sits under the status line in a
+ live region of its own. Never colour alone: the sentence is the whole
+ signal and the weight is a second cue on top of it. No display of its
+ own, so the hidden attribute still hides it — see the note at the top
+ of this file. */
+.meet-ice-warning {
+ color: var(--rst-text);
+ font-weight: 600;
+ margin-block: var(--rst-sp-2);
+}
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js
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();
diff --git a/internal/meet/templates/room.html b/internal/meet/templates/room.html
index eb37853..fe05c5e 100644
--- a/internal/meet/templates/room.html
+++ b/internal/meet/templates/room.html
@@ -9,7 +9,7 @@
<p>You are {{.Name}}, joining as a {{.Standing}}.</p>
{{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}}
<noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. <a href="/m/{{.Sqid}}/join">Back to the holding screen</a>.</p></noscript>
-<div id="room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" data-peer="" hidden>
+<div id="room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" data-relay="{{if .Relay}}1{{end}}" data-peer="" hidden>
<div id="tiles" class="meet-grid"></div>
<div id="controls" class="meet-controls">
<button type="button" id="mute" rst-btn aria-label="Microphone" aria-pressed="false">Microphone<span id="mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button>
diff --git a/internal/meettest/relay_floor_test.go b/internal/meettest/relay_floor_test.go
index b299573..4af94d5 100644
--- a/internal/meettest/relay_floor_test.go
+++ b/internal/meettest/relay_floor_test.go
@@ -39,6 +39,11 @@ func TestRoomPageShowsFloorWithNoRelay(t *testing.T) {
if !strings.Contains(body, "no relay yet") {
t.Fatalf("room page lacks the floor sentence with no relay configured:\n%s", body)
}
+ // The same fact room.js reads, and the only thing that chooses
+ // between §5.5's two failed-tile sentences. Empty here.
+ if !strings.Contains(body, `data-relay=""`) {
+ t.Fatalf("room page does not render an empty data-relay with no relay configured:\n%s", body)
+ }
}
// TestSetRelayHidesTheFloorSentenceOnBothPages: the floor sentence,
@@ -56,7 +61,13 @@ func TestSetRelayHidesTheFloorSentenceOnBothPages(t *testing.T) {
if body := c.get("/m/" + sqid + "/join").Body.String(); strings.Contains(body, "no relay yet") {
t.Fatalf("holding page still shows the floor sentence with a relay configured:\n%s", body)
}
- if body := c.get("/m/" + sqid).Body.String(); strings.Contains(body, "no relay yet") {
+ body := c.get("/m/" + sqid).Body.String()
+ if strings.Contains(body, "no relay yet") {
t.Fatalf("room page still shows the floor sentence with a relay configured:\n%s", body)
}
+ // §5.5's other half: room.js says "even through the relay" on a
+ // failed tile only when the server states there is one.
+ if !strings.Contains(body, `data-relay="1"`) {
+ t.Fatalf("room page does not render data-relay=\"1\" with a relay configured:\n%s", body)
+ }
}
diff --git a/test/browser/negotiation.test.mjs b/test/browser/negotiation.test.mjs
index 1d25b1c..243f189 100644
--- a/test/browser/negotiation.test.mjs
+++ b/test/browser/negotiation.test.mjs
@@ -227,3 +227,125 @@ test('the answer handler clears its own flag', async () => {
await answering;
assert.equal(e.isSettingRemoteAnswerPending, false, 'nothing else was there to clear it');
});
+
+// ── §5.3's renewal proof ──────────────────────────────────────────────
+//
+// The other piece of room.js that can be decided without a browser: what
+// counts as proof that an ICE restart landed. §5.3 step 2 is exact about
+// it — the exchange completed, `signalingState` stable, and a selected,
+// succeeded candidate pair whose local candidate carries a *different*
+// ICE username fragment — because the obvious signal is a false one: a
+// `completed` transport moves to `connected` merely by starting a
+// restart, so iceConnectionState proves nothing at all.
+//
+// Both functions are lifted together: the decision calls the reader.
+const proof = new Function(
+ [lift(src, 'renewedUfrag'), lift(src, 'selectedPairUfrag')].join('\n') +
+ '\nreturn { renewedUfrag, selectedPairUfrag };',
+);
+const { renewedUfrag, selectedPairUfrag } = proof();
+
+// report builds a Chromium-shaped getStats answer: a transport naming
+// the selected pair, that pair, and its local candidate.
+function report(ufrag, opts) {
+ const o = opts || {};
+ return [
+ { type: 'transport', id: 'T', selectedCandidatePairId: o.unselected ? 'other' : 'P' },
+ {
+ type: 'candidate-pair',
+ id: 'P',
+ state: o.state || 'succeeded',
+ nominated: o.nominated !== false,
+ localCandidateId: 'L',
+ },
+ { type: 'local-candidate', id: 'L', usernameFragment: ufrag },
+ { type: 'inbound-rtp', id: 'I', kind: 'video' },
+ ];
+}
+
+test('a new username fragment on the selected pair is the proof', () => {
+ assert.equal(renewedUfrag(report('new1'), 'old0', 'stable', true), 'new1');
+});
+
+test('the same fragment is the same generation, not a renewal', () => {
+ assert.equal(renewedUfrag(report('old0'), 'old0', 'stable', true), null);
+});
+
+test('an exchange that has not completed is never proof', () => {
+ // The half that iceConnectionState cannot see: the restart's answer
+ // has not been applied, so whatever the candidate pairs say, the new
+ // generation is not up yet.
+ assert.equal(renewedUfrag(report('new1'), 'old0', 'stable', false), null);
+});
+
+test('a signaling state that is not stable is never proof', () => {
+ assert.equal(renewedUfrag(report('new1'), 'old0', 'have-local-offer', true), null);
+});
+
+test('a pair that is not succeeded, or not selected, is not proof', () => {
+ assert.equal(renewedUfrag(report('new1', { state: 'in-progress' }), 'old0', 'stable', true), null);
+ assert.equal(renewedUfrag(report('new1', { nominated: false, unselected: true }), 'old0', 'stable', true), null);
+});
+
+test('nothing recorded before the restart falls back to any selected succeeded pair', () => {
+ // §5.3 step 2's fallback, for a connection that had no selected pair
+ // to read, or a browser that will not name fragments.
+ assert.equal(renewedUfrag(report('new1'), null, 'stable', true), 'new1');
+ assert.equal(renewedUfrag(report(''), null, 'stable', true), '');
+ // And it is still bounded by the exchange having completed.
+ assert.equal(renewedUfrag(report('new1'), null, 'stable', false), null);
+});
+
+test('the reader tells "no such pair" from "a pair with no fragment"', () => {
+ assert.equal(selectedPairUfrag([]), null, 'no pair at all must be null, not empty');
+ assert.equal(selectedPairUfrag(report('')), '', 'a pair with no fragment is proof of a pair');
+ assert.equal(selectedPairUfrag(report('u1')), 'u1');
+ // Firefox marks the pair with `selected` and names no transport.
+ assert.equal(selectedPairUfrag([
+ { type: 'candidate-pair', id: 'P', state: 'succeeded', selected: true, localCandidateId: 'L' },
+ { type: 'local-candidate', id: 'L', usernameFragment: 'ff1' },
+ ]), 'ff1');
+});
+
+// And the poller around that decision: it must keep asking until the
+// report changes, and stop at its own deadline rather than at the first
+// disappointing answer.
+// RENEW_POLL is passed in rather than lifted: the shipped half second
+// between polls is the right interval for a real ICE restart and the
+// wrong one for a test, and the interval is not what is under test here.
+const prover = new Function(
+ 'state', 'RENEW_POLL',
+ [lift(src, 'proveRenewed'), lift(src, 'renewedUfrag'), lift(src, 'selectedPairUfrag'),
+ lift(src, 'statsList'), lift(src, 'sleep')].join('\n') +
+ '\nreturn { proveRenewed };',
+);
+
+// fakeStatsPC answers a Map-shaped report, as RTCStatsReport is, and
+// counts how many times it was asked.
+function fakeStatsPC(reports) {
+ let n = 0;
+ return {
+ signalingState: 'stable',
+ asked: () => n,
+ getStats() {
+ const r = reports[Math.min(n, reports.length - 1)];
+ n += 1;
+ return Promise.resolve(new Map(r.map((x) => [x.id, x])));
+ },
+ };
+}
+
+test('the proof polls until the restart lands', async () => {
+ const pc = fakeStatsPC([report('old0'), report('old0'), report('new1')]);
+ const { proveRenewed } = prover({ stopped: false }, 10);
+ const got = await proveRenewed({ pc, desc: 1 }, 'old0', 0, Date.now() + 5000);
+ assert.equal(got, 'new1');
+ assert.ok(pc.asked() >= 3, 'it must have kept asking: ' + pc.asked());
+});
+
+test('the proof gives up at its deadline, and says so with null', async () => {
+ const pc = fakeStatsPC([report('old0')]);
+ const { proveRenewed } = prover({ stopped: false }, 10);
+ const got = await proveRenewed({ pc, desc: 1 }, 'old0', 0, Date.now() + 60);
+ assert.equal(got, null, 'a restart that never landed must not read as renewed');
+});