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
3 files changed,
+112
−8
docs/superpowers/copy/2026-09-10-slice-2-strings.md+13 −0docs/superpowers/reviews/2026-09-10-slice-2-record.md+35 −1test/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(); |