| index 61ed76c..ac8006e 100644 |
| --- a/internal/meet/static/room.js |
| +++ b/internal/meet/static/room.js |
| @@ -127,6 +127,12 @@ |
| let reconnectTimer = 0; |
| let statsTimer = 0; |
| let fullTimer = 0; |
| + // 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 |
| + // roster having arrived, and "recovered" has to mean the server said |
| + // so (Codex S2-16). |
| + let rosters = 0; |
| |
| // --------------------------------------------------------------- |
| // Preferences, stored by the holding screen (devices.js) |
| @@ -279,7 +285,10 @@ |
| } |
| let res; |
| try { |
| - res = await probe('/m/' + encodeURIComponent(sqid) + '/stream?probe=1&peer=' + encodeURIComponent(peer)); |
| + // Bounded like every other probe: a request that never settles |
| + // would otherwise be the end of this browser's reconnecting. |
| + res = await probe('/m/' + encodeURIComponent(sqid) + '/stream?probe=1&peer=' + encodeURIComponent(peer), |
| + Date.now() + SEND_DEADLINE); |
| } catch { |
| later(resume); |
| return; |
| @@ -326,7 +335,7 @@ |
| if (state.stopped) return; |
| let res; |
| try { |
| - res = await probe('/m/' + encodeURIComponent(sqid) + '/stream?probe=1'); |
| + res = await probe('/m/' + encodeURIComponent(sqid) + '/stream?probe=1', Date.now() + SEND_DEADLINE); |
| } catch { |
| later(connectStream); |
| return; |
| @@ -418,11 +427,32 @@ |
| }; |
| } |
| |
| - async function probe(path) { |
| - // No Accept header: the probe forms are exempt from the stream's |
| - // text/event-stream gate and must not claim to be one. |
| - const res = await fetch(path, { credentials: 'same-origin', cache: 'no-store' }); |
| - return await readRefusal(res, 204, path); |
| + // `deadline` is an absolute instant, and it bounds the whole exchange |
| + // — the request and the reading of its body — because both are the |
| + // same fetch and an AbortController ends both. Without it a probe that |
| + // never settles never returns, and every caller here awaits it: the |
| + // recovery's deadline was checked only between attempts, so a hanging |
| + // probe held its target's pump or reset marked in flight for as long |
| + // as the request stayed open (Codex S2-15). |
| + async function probe(path, deadline) { |
| + let stop = 0; |
| + let controller = null; |
| + if (deadline && typeof AbortController === 'function') { |
| + controller = new AbortController(); |
| + stop = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now())); |
| + } |
| + try { |
| + // No Accept header: the probe forms are exempt from the stream's |
| + // text/event-stream gate and must not claim to be one. |
| + const res = await fetch(path, { |
| + credentials: 'same-origin', |
| + cache: 'no-store', |
| + signal: controller ? controller.signal : undefined, |
| + }); |
| + return await readRefusal(res, 204, path); |
| + } finally { |
| + if (stop) clearTimeout(stop); |
| + } |
| } |
| |
| // readRefusal turns one response into the shape every caller here |
| @@ -652,6 +682,7 @@ |
| // --------------------------------------------------------------- |
| |
| function reconcile(roster) { |
| + rosters += 1; |
| lastRoster = roster; |
| state.admitted = true; |
| state.joined = true; |
| @@ -1043,7 +1074,11 @@ |
| return; // `conflict` belongs to admit, which is not queued here |
| } |
| if (res.status === 404) { |
| - await recoverFromNotFound(res.data.boot, to); |
| + // The message's own deadline, not a fresh one: the diagnosis |
| + // and any reset it leads to are part of getting this message |
| + // delivered, and §4.6 gives that thirty seconds in total |
| + // (Codex S2-15). |
| + await recoverFromNotFound(res.data.boot, to, { deadline: head.deadline }); |
| return; |
| } |
| if (res.status === 400 || res.status === 413) { |
| @@ -1147,8 +1182,8 @@ |
| // asks for a reset and carries on, and a rejection escaping an async |
| // function nobody awaits would go where neither a person nor a gate |
| // could see it. Same shape as the pageshow handler's. |
| - function requestReset(to) { |
| - resetPair(to).catch((err) => { |
| + function requestReset(to, budget) { |
| + resetPair(to, budget).catch((err) => { |
| console.error('meet: reset', err); |
| }); |
| } |
| @@ -1240,9 +1275,19 @@ |
| const path = '/m/' + encodeURIComponent(sqid) + '/stream?probe=1&peer=' + encodeURIComponent(peer); |
| let wait = BACKOFF_MIN; |
| for (;;) { |
| + if (Date.now() >= deadline) { |
| + // Out of budget with nothing decided. The participation is left |
| + // alone — nothing here proved anything about it — but the target |
| + // this recovery was for is treated as stale, so the queue is |
| + // dropped and the in-flight mark released rather than held by a |
| + // diagnosis that is never going to answer (Codex S2-15). The |
| + // next roster settles it. |
| + staleTarget(to, true, deadline); |
| + return; |
| + } |
| let res; |
| try { |
| - res = await probe(path); |
| + res = await probe(path, deadline); |
| } catch { |
| // The probe could not be made at all: the same "nothing is |
| // proved" as a 5xx, and treated the same way. |
| @@ -1255,7 +1300,7 @@ |
| return; |
| } |
| if (res.status === 204) { |
| - staleTarget(to, !!o.fromReset); |
| + staleTarget(to, !!o.fromReset, deadline); |
| return; |
| } |
| if (res.status === 404) { |
| @@ -1282,13 +1327,15 @@ |
| // rounds and counting — so `fromReset` skips straight to closing the |
| // connection and waiting for the roster, which recreates it through |
| // `reconcile` if the peer is still there. |
| - function staleTarget(to, fromReset) { |
| + function staleTarget(to, fromReset, deadline) { |
| if (!to) return; |
| queue.set(to, []); |
| const first = !stale.has(to); |
| stale.add(to); |
| if (first && !fromReset && rosterGen(to)) { |
| - requestReset(to); |
| + // The same budget again: one message, one thirty seconds, however |
| + // many requests it takes to settle (Codex S2-15). |
| + requestReset(to, deadline); |
| return; |
| } |
| closePC(to, false); |
| @@ -1737,6 +1784,9 @@ |
| boot: state.boot, |
| admitted: state.admitted, |
| terminal: state.terminal, |
| + // Rosters applied, so a gate can tell "this tab closed a |
| + // connection" from "the server told this tab who is here". |
| + rosters, |
| peers: Array.from(pcs.keys()), |
| frames: new Map(frames), |
| signaling, |