oficina / meet Public

gate: measure the deadline, and count the recovery

S2-13 (P3): senddeadline and sendhang accepted anything under sixty
seconds, which a forty-five-second deadline would have satisfied. Both
now measure from the first message the route intercepts — the one whose
deadline is running — and require 25 to 35 seconds. sendhang also
asserts the request really hung and watches requestfailed for the
cancellation, which is the only direct evidence the AbortController
fired: 30018 ms and 30238 ms on this machine, one cancelled request.

staletarget gains the two phases the round-2 client findings need: the
recovery's own probe is refused 503 before it is let through (S2-12),
and every POST A makes while its roster is stale is counted and bounded
at three (S2-11) — the round-1 client posted 450 in thirteen seconds.

Dispositions in the record (§2b), and the copy catalogue records that
this wave introduced no new user-facing string either.
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 f1a0f9c7e4886dc9ff9db9beb0026a41d8bb1b3c parent dc614c9
3 files changed, +112 −8
  • docs/superpowers/copy/2026-09-10-slice-2-strings.md +13 −0
  • docs/superpowers/reviews/2026-09-10-slice-2-record.md +35 −1
  • test/browser/call.mjs +64 −7

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 55843a2..34e80b6 100644
--- a/docs/superpowers/copy/2026-09-10-slice-2-strings.md
+++ b/docs/superpowers/copy/2026-09-10-slice-2-strings.md
@@ -168,3 +168,16 @@ complete record of the branch:
| String | Where | Note |
| --- | --- | --- |
| meet: reset | `room.js`, `requestReset()` — `console.error` prefix | Diagnostic, in the same shape as the file's other two (`meet: pageshow`, `meet: start`). Never rendered. |
+
+## The Codex round 2 fix wave
+
+**No new user-facing string.** The round-2 wave (`de597c6..`, recorded
+in the review record's §2b) changed when the client tears a call down
+and what a probe is allowed to write. Nothing new is rendered, and no
+existing sentence changed. One row is worth restating because the wave
+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. |
+
diff --git a/docs/superpowers/reviews/2026-09-10-slice-2-record.md b/docs/superpowers/reviews/2026-09-10-slice-2-record.md
index 641d227..82a2121 100644
--- a/docs/superpowers/reviews/2026-09-10-slice-2-record.md
+++ b/docs/superpowers/reviews/2026-09-10-slice-2-record.md
@@ -187,6 +187,40 @@ the old truthy-`srcObject` check also still passed.
---
+## 2b. Codex round 2, and what happened to it
+
+The same reviewer, judged at `3eec748` — the round-1 wave — against spec
+revision 9. Six of the nine round-1 findings are closed. Three came back
+as new P2s because the fix was right and incomplete: a probe that still
+wrote, a recovery that recursed, and a recovery that acted on an answer
+that proved nothing. Two P3 test advisories. No new P1.
+
+| # | Finding | Disposition | Commit | Test |
+|---|---|---|---|---|
+| S2-10 (P2) | `memberStandingGone(..., end=false)` honoured the flag only when standing was *absent*; with standing present it wrote the kind, so a probe could demote a member to guest — and broadcast a fresh roster to the whole room while doing it | **Fixed.** A probe writes nothing whatever the answer is. The transition is real and is applied by the next mutating request, or by the tick. | `de597c6` | `TestAProbeNeverWritesAKindNorBroadcastsARoster`: a member taken off the team while holding a live grant, probed — kind unchanged and both subscribers silent — then one relay, which demotes and refreshes both rosters |
+| S2-11 (P2) | A signal 404 whose probe answered 204 asked for a reset to the stale target; that reset was answered 404 too, probed, and asked again — each round with a fresh thirty-second budget | **Fixed.** A target the hub has 404'd is marked stale: `send` drops for it, only the first round may ask for a reset, a reset's own target-404 never asks for another, one reset per target is in flight at a time, and the recovery's retries share the caller's budget. The roster clears the mark and `reconcile` rebuilds the connection if the peer is really there. | `dc614c9` | browser `staletarget` counts every POST A makes while its roster is stale and bounds it: 1 on the fixed client, **450** on the round-1 client |
+| S2-12 (P2) | A probe answering 429, 5xx or nothing fell through to the fresh-entry ladder — S2-3's failure, reached by another door | **Fixed.** Only the probe's own 404 enters afresh; a transient answer is retried on the stream backoff inside the shared budget, and a recovery that never gets an answer leaves the participation alone. | `dc614c9` | browser `staletarget` refuses the recovery's probe 503 before letting it through: A keeps its peer id and never sees the takeover screen |
+| S2-13 (P3) | `senddeadline` and `sendhang` accepted anything under sixty seconds, and `sendhang` never showed the request had hung or that the page aborted it | **Fixed.** Both measure from the first intercepted message and require 25–35 s. `sendhang` asserts the hung count and watches `requestfailed` for the cancellation. | commit below | the two scenarios: 30018 ms and 30238 ms on this machine, one cancelled request |
+| S2-14 (P3) | `TestTheTickDeadlineIsTheTickInterval` asserted a constant equalled itself | **Fixed.** The scheduling path is `tickLoop(ctx, every)` with the pass behind a seam that is nil in production; the test runs the real loop in milliseconds and records the deadline it hands out. | `de597c6` | `TestTheSchedulingPathGivesEachPassTheIntervalAsItsDeadline` |
+| Negotiation note | The six decision cases would stay green with the handler's flag assignment removed | **Fixed.** `negotiation.test.mjs` also drives `onAnswer` then `onOffer` through a fake connection whose `setRemoteDescription` can be held open. | `dc614c9` | the seventh case fails with the assignment removed |
+
+Every test above was run against the unfixed code and seen to fail:
+S2-10's with the kind write restored ("the probe wrote the kind: guest");
+S2-14's with the loop passing three times its interval; S2-12's and
+S2-11's against `room.js` at `3eec748` — the first ended terminal with
+`peer: null`, and the second, with the 503 phase disabled so the cycle
+could run at all, reached 450 POSTs in thirteen seconds. The seventh
+negotiation case fails on "the answer handler must raise the flag before
+it awaits".
+
+The two round-2 client findings share one shape, and it is worth naming
+because it will come round again: **a refusal is evidence about exactly
+one thing.** A 404 from `/signal` is evidence about the target, not
+about us; a 503 from a probe is evidence about neither. Both bugs were a
+client treating an answer as if it settled more than it did.
+
+---
+
## 3. Carried items, and where they go
**Next fix wave**
@@ -310,7 +344,7 @@ outside app middleware.
|---|---|---|
| The one gate | `CGO_ENABLED=0 make ci` | vet, gofmt, tests, migration check. Cgo-free, as AGENTS.md requires. |
| Race | `CGO_ENABLED=1 go test -race ./internal/...`, or `make race` | **Needs `CGO_ENABLED=1`**: `-race` requires cgo, and the repo is cgo-free everywhere else. This builds a test binary, never a release, which is why it is the one exception. |
-| Browser | `make browser` | ~220 s, 22 scenarios, plus one node test (`test/browser/negotiation.test.mjs`, §5.1's glare decision, no browser needed). Needs node, a network fetch of Playwright and Chromium, and **UDP** — inside the Claude Code bash sandbox Chromium gathers zero ICE candidates and every call fails with frames stuck at 0, so **run this target with the sandbox off**. |
+| Browser | `make browser` | ~250 s, 22 scenarios, plus one node test (`test/browser/negotiation.test.mjs`, §5.1's glare decision and the handler race it turns on, no browser needed). Needs node, a network fetch of Playwright and Chromium, and **UDP** — inside the Claude Code bash sandbox Chromium gathers zero ICE candidates and every call fails with frames stuck at 0, so **run this target with the sandbox off**. |
`make browser` is deliberately not part of `make ci`: the one gate the
team runs on every push must not depend on a toolchain the app itself
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index e40afd5..7e3e88c 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -350,9 +350,17 @@ async function leaveRoom(page) {
// six, because these scenarios cut and restore within a few seconds and
// want the cut to bite promptly.
async function cuttableStream(ctx, ttl = '2s') {
- const cut = { on: false, aborted: 0, attaches: 0 };
+ const cut = { on: false, aborted: 0, attaches: 0, probeFail: false, probesFailed: 0 };
await ctx.route('**/stream*', async (route) => {
if (route.request().url().includes('probe=1')) {
+ // probeFail is the other half of the instrument: a probe that
+ // answers 503 proves nothing about anybody's participation, and a
+ // client must not act on it (§4.5's ladder, Codex S2-12).
+ if (cut.probeFail) {
+ cut.probesFailed += 1;
+ await route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"full"}' });
+ return;
+ }
await route.continue();
return;
}
@@ -1037,9 +1045,15 @@ const run = {
const ctxB = await newContext('member B', { cookie: cookieB });
const cut = await cuttableStream(ctxA);
// A's own 404s are counted, to prove the scenario actually produced
- // the answer it is about rather than passing on a quiet call.
+ // the answer it is about rather than passing on a quiet call — and
+ // so is everything A posts once the roster is known to be stale,
+ // because "A is still here" was true of the twelve-round reset cycle
+ // Codex S2-11 found as well.
let notFound = 0;
+ let posts = 0;
+ let counting = false;
await ctxA.route('**/signal', async (route) => {
+ if (counting) posts += 1;
const res = await route.fetch();
if (res.status() === 404) notFound += 1;
await route.fulfill({ response: res });
@@ -1057,6 +1071,11 @@ const run = {
check(await biteCut(cut), "A's attaching stream is cut (" + cut.aborted + ' aborted)');
await leaveRoom(pageB);
+ counting = true;
+ // The recovery's own probe is refused 503 to begin with. That says
+ // nothing about anyone's participation, and acting on it recreated
+ // the very failure this scenario is about (Codex S2-12).
+ cut.probeFail = true;
// The Retry control on A's tile for B is restartIce, which fires
// negotiationneeded and puts an offer on the wire for a peer that
@@ -1065,15 +1084,25 @@ const run = {
await pageA.locator('#tiles figure:not(.meet-tile-self) figcaption button').first().dispatchEvent('click');
const answered = await until(() => (notFound > 0 ? notFound : false), 20000);
check(answered, 'A was answered 404 for a peer that had left (' + notFound + ')');
+ const refusedProbe = await until(() => (cut.probesFailed > 0 ? cut.probesFailed : false), 20000);
+ check(refusedProbe, 'a probe was answered 503 while A was diagnosing (' + cut.probesFailed + ')');
- // Nothing about that is A's own ending, and A is still cut, so the
- // whole window is asserted rather than read once.
+ // Nothing about either answer is A's own ending, and A is still cut,
+ // so the whole window is asserted rather than read once.
const broke = await stayed(pageA, 8000, (s) => s.peer === pair.a.peer && !s.terminal);
- check(broke === null, 'A stayed in the call with its own peer id after the 404 — ' +
+ check(broke === null, 'A stayed in the call with its own peer id after the 404 and the 503 — ' +
(broke ? JSON.stringify(broke) : 'held for eight seconds') + ' — ' + (await diagnose(pageA, 'member A')));
const status = await statusText(pageA);
check(!status.includes('another tab'), 'A was never shown the takeover screen (status: ' + status + ')');
+ // Now the probe answers, A learns its own participation is fine, and
+ // whatever it does about the departed peer it does once: the roster
+ // is what settles a stale target, not a reset cycle with a fresh
+ // budget each round.
+ cut.probeFail = false;
+ await sleep(5000);
+ check(posts <= 3, 'A posted at most three times while its roster was stale (' + posts + ')');
+
cut.on = false;
// And the departure reaches A the way §4.5 says it does: from the
@@ -1083,6 +1112,10 @@ const run = {
return s.peer === pair.a.peer && !s.terminal && !s.peers.includes(pair.b.peer) ? s : false;
}, 45000);
check(reconciled, 'A reconciled the departed peer from its roster — ' + (await diagnose(pageA, 'member A')));
+ // Counted across the whole recovery, not only the first five
+ // seconds of it: a cycle that started late would still be a cycle.
+ // The round-1 client posted four hundred and fifty times here.
+ check(posts <= 6, 'the whole recovery cost a handful of requests, not a cycle (' + posts + ')');
await leaveRoom(pageA);
await ctxA.close();
@@ -1185,7 +1218,9 @@ const run = {
// Only from here: the join and the whole negotiation above went
// through untouched, so what is being timed is one message.
let limited = 0;
+ let firstAt = 0;
await ctxA.route('**/signal', async (route) => {
+ if (!firstAt) firstAt = Date.now();
limited += 1;
await route.fulfill({
status: 429,
@@ -1200,8 +1235,15 @@ const run = {
const s = await state(pageA);
return !s.peers.includes(pair.b.peer) ? s : false;
}, 60000);
- check(gaveUp, 'A gave up on an endlessly rate-limited target inside its deadline (' + limited +
+ check(gaveUp, 'A gave up on an endlessly rate-limited target (' + limited +
' refusals) — ' + (await diagnose(pageA, 'member A')));
+ // §4.6's number, not merely "eventually": measured from the first
+ // message this route saw, which is the one whose deadline is
+ // running. Five seconds of tolerance for the polling and the
+ // scheduler at either end (Codex S2-13).
+ const took = Date.now() - firstAt;
+ check(took >= 25000 && took <= 35000,
+ 'and it gave up on §4.6’s thirty seconds, not on its own clock (' + took + ' ms)');
check(limited > 1, 'and it did retry rather than giving up on the first refusal (' + limited + ')');
check(!(await state(pageA)).terminal, 'giving up on one target is not the end of the call');
@@ -1225,7 +1267,17 @@ const run = {
}
let hung = 0;
+ let firstAt = 0;
+ // The abort itself, watched from outside the page: a fetch the page
+ // gives up on fails, and that is the only direct evidence that the
+ // AbortController fired rather than the request having settled some
+ // other way (Codex S2-13).
+ let cancelled = 0;
+ pageA.on('requestfailed', (req) => {
+ if (req.url().includes('/signal')) cancelled += 1;
+ });
await ctxA.route('**/signal', async () => {
+ if (!firstAt) firstAt = Date.now();
hung += 1;
// Never fulfilled, never continued, never aborted: the request
// stays pending until the page itself gives up on it.
@@ -1237,8 +1289,13 @@ const run = {
const s = await state(pageA);
return !s.peers.includes(pair.b.peer) ? s : false;
}, 60000);
- check(gaveUp, 'A aborted a request that never settled and gave up on that target (' + hung +
+ check(gaveUp, 'A gave up on a target whose requests never settle (' + hung +
' hung requests) — ' + (await diagnose(pageA, 'member A')));
+ check(hung > 0, 'the scenario actually hung the request it meant to (' + hung + ')');
+ check(cancelled > 0, 'and the page aborted it rather than waiting for it (' + cancelled + ' cancelled)');
+ const took = Date.now() - firstAt;
+ check(took >= 25000 && took <= 35000,
+ 'the abort came at §4.6’s thirty seconds (' + took + ' ms)');
check(!(await state(pageA)).terminal, 'a hanging endpoint is not the end of the call either');
await ctxA.close();