| index 80e1e0d..61ed76c 100644 |
| --- a/internal/meet/static/room.js |
| +++ b/internal/meet/static/room.js |
| @@ -102,6 +102,13 @@ |
| const lastIn = new Map(); // peer id -> highest seq received (resume cursor) |
| const queue = new Map(); // peer id -> [{seq, body}] outbound, in order |
| const inflight = new Set();// peer ids with a POST in flight |
| + // Targets the hub has answered 404 for while our own participation is |
| + // alive: they have left, and our roster has not caught up yet. Nothing |
| + // is sent to them and no reset is asked for until a roster settles it — |
| + // otherwise every message to a departed peer starts its own 404 → |
| + // probe → reset → 404 cycle, each with a fresh budget (Codex S2-11). |
| + const stale = new Set(); |
| + const resetting = new Set(); // targets with a reset already in flight |
| const names = new Map(); // peer id -> display name, from the server only |
| const frames = new Map(); // peer id -> frames rendered, for the test hook |
| const tiles = new Map(); // tile key -> {figure, video} |
| @@ -649,6 +656,11 @@ |
| state.admitted = true; |
| state.joined = true; |
| state.full = false; |
| + // The roster is the authority on who is here (§4.5), so it settles |
| + // every target a 404 marked stale: the ones it lists get their |
| + // connection back through ensurePC below, and the ones it does not |
| + // are closed a few lines further down. |
| + stale.clear(); |
| const present = new Set(); |
| for (const rp of roster.peers || []) { |
| if (!rp || !rp.peer) continue; |
| @@ -806,6 +818,7 @@ |
| } |
| queue.delete(id); |
| if (forget) { |
| + stale.delete(id); |
| gen.delete(id); |
| seqOut.delete(id); |
| lastIn.delete(id); |
| @@ -952,6 +965,11 @@ |
| |
| function send(to, type, payload) { |
| if (!state.peer) return; |
| + // A target the hub has already said it does not have. Queueing for |
| + // it would buy one more 404 and one more recovery round each time, |
| + // and ICE alone produces a dozen candidates a renegotiation; the |
| + // roster is what settles this, and it clears the mark. |
| + if (stale.has(to)) return; |
| const seq = (seqOut.get(to) || 0) + 1; |
| seqOut.set(to, seq); |
| const q = queue.get(to) || []; |
| @@ -1144,10 +1162,21 @@ |
| // and by the same rules: Retry-After on a 429, doubling backoff on |
| // the transient failures, terminal on a session that has been signed |
| // out, and the 404 ladder. |
| - async function resetPair(to) { |
| + async function resetPair(to, budget) { |
| const peer = state.peer; |
| if (!peer) return; |
| - const deadline = Date.now() + SEND_DEADLINE; |
| + // One reset per target at a time. Two in flight can only ask the hub |
| + // for two generations, and the second is stale before it is sent. |
| + if (resetting.has(to)) return; |
| + resetting.add(to); |
| + try { |
| + await resetLoop(to, peer, budget || Date.now() + SEND_DEADLINE); |
| + } finally { |
| + resetting.delete(to); |
| + } |
| + } |
| + |
| + async function resetLoop(to, peer, deadline) { |
| let wait = BACKOFF_MIN; |
| for (;;) { |
| if (state.stopped || state.peer !== peer) return; |
| @@ -1158,7 +1187,10 @@ |
| } |
| if (res.status === 200) return; |
| if (res.status === 404) { |
| - await recoverFromNotFound(res.data.boot, to); |
| + // Whatever this 404 turns out to be, it is not worth another |
| + // reset: `fromReset` is what stops the cycle, and the budget is |
| + // the same one, not a fresh thirty seconds (Codex S2-11). |
| + await recoverFromNotFound(res.data.boot, to, { deadline, fromReset: true }); |
| return; |
| } |
| if (res.status !== 429 && res.status !== 0 && res.status < 500) { |
| @@ -1185,9 +1217,19 @@ |
| // So the sender's own participation is probed, and that is the |
| // authority: 204 keeps the call and lets the next roster reconcile |
| // the target; 410 is terminal with the reason the tombstone carries; |
| - // 404 is the fresh-entry ladder, which is what this function always |
| - // was. |
| - async function recoverFromNotFound(boot, to) { |
| + // 404 — and only 404 — is the fresh-entry ladder, which is what this |
| + // function always was. |
| + // |
| + // A 429 or a 5xx from the probe proves nothing either way, and used |
| + // to fall through to the fresh-entry ladder and re-create exactly the |
| + // failure above (Codex S2-12). It is retried instead, on the stream's |
| + // own backoff, inside one budget shared with whatever asked for the |
| + // diagnosis — a recovery that cannot get an answer inside thirty |
| + // seconds leaves the participation alone and lets the stream ladder, |
| + // which is running anyway, be the thing that decides. |
| + async function recoverFromNotFound(boot, to, opts) { |
| + const o = opts || {}; |
| + const deadline = o.deadline || Date.now() + SEND_DEADLINE; |
| if (state.stopped) return; |
| if (boot) state.boot = boot; |
| const peer = state.peer; |
| @@ -1195,35 +1237,62 @@ |
| enterAfresh(); |
| return; |
| } |
| - let res; |
| - try { |
| - res = await probe('/m/' + encodeURIComponent(sqid) + '/stream?probe=1&peer=' + encodeURIComponent(peer)); |
| - } catch { |
| - // The probe could not be made at all. Nothing is proved, so |
| - // nothing is torn down: the stream's own ladder is already |
| - // running, and the next roster reconciles whatever this was. |
| - return; |
| - } |
| - if (state.stopped || state.peer !== peer) return; |
| - if (refused(res)) return; |
| - if (res.status === 410) { |
| - terminal(res.data.reason || 'ended'); |
| - return; |
| + const path = '/m/' + encodeURIComponent(sqid) + '/stream?probe=1&peer=' + encodeURIComponent(peer); |
| + let wait = BACKOFF_MIN; |
| + for (;;) { |
| + let res; |
| + try { |
| + res = await probe(path); |
| + } catch { |
| + // The probe could not be made at all: the same "nothing is |
| + // proved" as a 5xx, and treated the same way. |
| + res = { status: 0, data: {}, signedOut: false, retryAfter: null }; |
| + } |
| + if (state.stopped || state.peer !== peer) return; |
| + if (refused(res)) return; |
| + if (res.status === 410) { |
| + terminal(res.data.reason || 'ended'); |
| + return; |
| + } |
| + if (res.status === 204) { |
| + staleTarget(to, !!o.fromReset); |
| + return; |
| + } |
| + if (res.status === 404) { |
| + enterAfresh(); |
| + return; |
| + } |
| + // 429, 5xx, a network failure, or a status the contract does not |
| + // name: try the diagnosis again rather than acting on it. |
| + const ms = res.status === 429 ? retryAfter(res.retryAfter) : wait; |
| + wait = Math.min(wait * 2, STREAM_BACKOFF_MAX); |
| + if (!(await waitWithin(ms, deadline))) return; |
| } |
| - if (res.status === 204) { |
| - // Still ours. The 404 was about the target, so drop what was |
| - // queued for it — none of it can ever be delivered — and ask for |
| - // a fresh generation only if the roster still lists it. If it |
| - // does not, `gone` or the next roster has already closed it, and |
| - // this browser asks for nothing. |
| - if (!to) return; |
| - queue.set(to, []); |
| - if (rosterGen(to)) requestReset(to); |
| + } |
| + |
| + // staleTarget is what a 404 means once the probe has said this |
| + // browser's own participation is fine: the peer we were writing to has |
| + // gone, and our roster has not caught up. Nothing queued for it can be |
| + // delivered, and nothing new may be queued until a roster settles it. |
| + // |
| + // The single reset is worth asking for once, while the roster still |
| + // lists the target, because a `pair` is how a live peer that merely |
| + // lost its own participation comes back. Asking again after that reset |
| + // has itself been answered 404 is the cycle S2-11 found — twelve |
| + // 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) { |
| + if (!to) return; |
| + queue.set(to, []); |
| + const first = !stale.has(to); |
| + stale.add(to); |
| + if (first && !fromReset && rosterGen(to)) { |
| + requestReset(to); |
| return; |
| } |
| - // 404, and anything the contract does not name: this participation |
| - // really is gone. |
| - enterAfresh(); |
| + closePC(to, false); |
| + renderTiles(); |
| } |
| |
| // enterAfresh is the fresh-entry ladder: drop every connection, forget |
| @@ -1524,7 +1593,7 @@ |
| return; |
| } |
| if (res.status === 404) { |
| - recoverFromNotFound(res.data.boot); |
| + await recoverFromNotFound(res.data.boot); |
| return; |
| } |
| if (res.status !== 200) button.disabled = false; |