oficina / meet Public

client: one clock for a message and everything it takes

S2-15 (P2): the shared budget of round 2 was not shared. The pump handed
the recovery no deadline, so a diagnosis got a fresh thirty seconds; a
diagnosis that succeeded asked for a reset with another fresh thirty;
and probe() had no cancellation at all, so the deadline was only ever
checked between attempts — a probe that never settled held its target's
pump or reset marked in flight for as long as the request stayed open.

Now: the message's own deadline goes from the queue head through
recoverFromNotFound and into any reset it initiates; probe() takes that
deadline and bounds the fetch and the reading of its body with an
AbortController; the remaining time is checked before every attempt; and
a recovery that runs out releases the target as stale rather than
holding it, so the next roster settles it. The resume and fresh-entry
probes are bounded too, at SEND_DEADLINE — a hanging probe there would
have been the end of this browser's reconnecting.

state() gains a roster counter, which is the one fact a gate cannot
infer from the DOM: a connection can be closed locally with no roster
having arrived, and "recovered" has to mean the server said so (S2-16).

negotiation.test.mjs gains an eighth case: an answer with no offer
behind it, because onOffer clears the pending-answer flag too and the
seventh case could not tell whose cleanup it was watching.
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 6bd2f0042dbb028d6f8f5868af3d98457376bf74 parent f1a0f9c
2 files changed, +86 −14
  • internal/meet/static/room.js +64 −14
  • test/browser/negotiation.test.mjs +22 −0
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js
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,
diff --git a/test/browser/negotiation.test.mjs b/test/browser/negotiation.test.mjs
index 5cf393a..cc84c54 100644
--- a/test/browser/negotiation.test.mjs
+++ b/test/browser/negotiation.test.mjs
@@ -161,3 +161,25 @@ test('an offer arriving while an answer is being applied is answered, not discar
await answering;
assert.equal(e.isSettingRemoteAnswerPending, false, 'the flag must be cleared once the answer is applied');
});
+
+// The case above cannot prove the answer handler clears the flag on its
+// own, because onOffer clears it too (round-3 note). So: an answer with
+// no offer behind it.
+test('the answer handler clears its own flag', async () => {
+ const pc = fakePC();
+ const e = {
+ pc,
+ polite: false,
+ makingOffer: false,
+ ignoreOffer: false,
+ isSettingRemoteAnswerPending: false,
+ pending: [],
+ descSeq: 0,
+ };
+ const { onAnswer } = handlers(() => e, () => {}, (d) => d, async () => {});
+ const answering = onAnswer({ from: 'other', seq: 1, payload: { description: { type: 'answer', sdp: 'a' } } });
+ assert.equal(e.isSettingRemoteAnswerPending, true);
+ pc.finishAnswer();
+ await answering;
+ assert.equal(e.isSettingRemoteAnswerPending, false, 'nothing else was there to clear it');
+});