gate: a probe that never answers, and a real reconciliation
probehang (S2-15): the signal 404 is answered twenty seconds late and the probe that follows never answers at all. The target is let go at 30236 ms from the first intercepted signal — a recovery that restarted the clock lands near 50000 — and A stays in the call. Against the round-2 client the target was never let go: 70056 ms, the scenario's own ceiling. S2-16 (P3): staletarget called recovery complete on `peers`, which is the page's own connection map, and staleTarget empties that while the stream is still cut. Reconciliation now needs a stream that reattached and a roster that arrived before the final POST bound is read. With the stream deliberately left cut it never becomes true (attaches 1→1, rosters 1→1), where the old predicate passed at once. The copy catalogue loses its trailing blank line, so the whitespace check is clean again. Dispositions in the record (§2c).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
4 files changed,
+110
−4
docs/superpowers/copy/2026-09-10-slice-2-strings.md+0 −1docs/superpowers/reviews/2026-09-10-slice-2-record.md+33 −1internal/meettest/browser_test.go+1 −0test/browser/call.mjs+76 −2
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 34e80b6..f8d5152 100644 |
| --- a/docs/superpowers/copy/2026-09-10-slice-2-strings.md |
| +++ b/docs/superpowers/copy/2026-09-10-slice-2-strings.md |
| @@ -180,4 +180,3 @@ 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 82a2121..6a68232 100644 |
| --- a/docs/superpowers/reviews/2026-09-10-slice-2-record.md |
| +++ b/docs/superpowers/reviews/2026-09-10-slice-2-record.md |
| @@ -221,6 +221,38 @@ client treating an answer as if it settled more than it did. |
| --- |
| +## 2c. Codex round 3, and what happened to it |
| + |
| +Judged at `f1a0f9c`. S2-10, S2-13, S2-14 and the negotiation note are |
| +closed. S2-11 and S2-12 came back half-closed as one P2 — the shared |
| +budget was named in the commit message and not actually shared — and one |
| +P3 on a test predicate. No new P1. |
| + |
| +| # | Finding | Disposition | Commit | Test | |
| +|---|---|---|---|---| |
| +| S2-15 (P2) | The recovery's "one budget" was three: the pump passed no deadline (a fresh thirty seconds), a successful diagnosis asked for a reset with another, and `probe()` had no cancellation, so the deadline was checked only between attempts and a hanging probe held its target's pump or reset marked in flight indefinitely | **Fixed.** The message's deadline goes from the queue head through `recoverFromNotFound` and into any reset it initiates; `probe(path, deadline)` bounds the fetch **and** the body read with an `AbortController`; the remaining time is checked before every attempt; and a recovery that runs out marks the target stale and releases it, leaving the next roster to settle it. The resume and fresh-entry probes are bounded too — a hanging probe there would have ended this browser's reconnecting. | `6bd2f00` | browser `probehang`: the 404 is answered twenty seconds late and the probe that follows never answers. The target is let go at **30236 ms** from the first intercepted signal — a restarted clock lands near 50000 — and A stays in the call | |
| +| S2-16 (P3) | `staletarget` could call the recovery complete before the stream came back: `s.peers` is local `pcs` keys, and `staleTarget` closes that connection while the stream is still cut | **Fixed.** Reconciliation now requires a stream that actually reattached (the route's own attach counter) and a roster that actually arrived — `state()` gained a roster counter for it — before the final POST bound is checked. | commit below | the strengthened `staletarget`: attaches 1→2, rosters 1→2 | |
| +| Negotiation note | Case seven could not tell whose cleanup it watched, because `onOffer` clears the pending-answer flag too | **Fixed.** An eighth case drives an answer with no offer behind it. | `6bd2f00` | `the answer handler clears its own flag` | |
| +| `git diff --check` | A blank line at the end of the copy catalogue | **Fixed.** | commit below | `git diff --check` is clean | |
| + |
| +Verified failing first: `probehang` against `room.js` at `f1a0f9c` — |
| +the target was never let go, and the measurement read 70056 ms at the |
| +scenario's own ceiling; the strengthened `staletarget` predicate with |
| +the stream deliberately left cut — attaches 1→1, rosters 1→1, never |
| +true, where the round-2 predicate was satisfied the moment |
| +`staleTarget` closed the connection locally; the eighth negotiation |
| +case fails with the answer handler's flag assignment removed. |
| + |
| +Also recorded from this round, not changed: the targeted-404 assumption |
| +behind `stale` is exact for the path the client actually walks — an |
| +admitted sender addressing a previously admitted, different peer — and |
| +the reviewer confirmed the server's other 404s (unadmitted sender, |
| +unadmitted target, self-target) are not reachable from it. A stale |
| +generation is a 409, not a 404. Slice 3's `/ice` and slice 4's chat |
| +should re-check that list before reusing `stale`. |
| + |
| +--- |
| + |
| ## 3. Carried items, and where they go |
| **Next fix wave** |
| @@ -344,7 +376,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` | ~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**. | |
| +| Browser | `make browser` | ~300 s, 23 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/internal/meettest/browser_test.go b/internal/meettest/browser_test.go| index a2c261c..52fcd50 100644 |
| --- a/internal/meettest/browser_test.go |
| +++ b/internal/meettest/browser_test.go |
| @@ -79,6 +79,7 @@ var scenarios = []string{ |
| "allowlist", |
| "departure", |
| "staletarget", |
| + "probehang", |
| "replay", |
| "gap", |
| "resetretry", |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 7e3e88c..cf70f90 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -196,6 +196,7 @@ function state(page) { |
| boot: s.boot, |
| admitted: s.admitted, |
| terminal: s.terminal, |
| + rosters: s.rosters, |
| peers: s.peers, |
| frames: Object.fromEntries(s.frames), |
| signaling: Object.fromEntries(s.signaling), |
| @@ -1103,15 +1104,25 @@ const run = { |
| await sleep(5000); |
| check(posts <= 3, 'A posted at most three times while its roster was stale (' + posts + ')'); |
| + // What "reconciled" has to mean: not that A's own `pcs` no longer |
| + // has B — `staleTarget` closes that connection locally, while the |
| + // stream is still cut, so that was already true — but that A's |
| + // stream came back and the server told it who is here (Codex |
| + // S2-16). Both are counted from here. |
| + const attachesBefore = cut.attaches; |
| + const rostersBefore = (await state(pageA)).rosters; |
| cut.on = false; |
| // And the departure reaches A the way §4.5 says it does: from the |
| // roster on its own resume, not from the 404. |
| const reconciled = await until(async () => { |
| const s = await state(pageA); |
| - return s.peer === pair.a.peer && !s.terminal && !s.peers.includes(pair.b.peer) ? s : false; |
| + return cut.attaches > attachesBefore && s.rosters > rostersBefore && |
| + 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'))); |
| + check(reconciled, 'A resumed its stream, was sent a fresh roster, and has let the departed peer go — ' + |
| + 'attaches ' + attachesBefore + '→' + cut.attaches + ', rosters ' + rostersBefore + '→' + |
| + ((await state(pageA)).rosters) + ' — ' + (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. |
| @@ -1122,6 +1133,69 @@ const run = { |
| await ctxB.close(); |
| }, |
| + // §4.6's thirty seconds again, this time over the *diagnosis* rather |
| + // than the message (Codex S2-15). A signal 404 sends the client to |
| + // probe its own participation; that probe never settles. Two things |
| + // have to hold: the probe is aborted rather than awaited for ever, and |
| + // the clock it is aborted on is the one the message started — the 404 |
| + // here is deliberately answered twenty seconds late, so a recovery |
| + // that took a fresh thirty seconds would land at fifty and fail the |
| + // measurement rather than merely being slower. |
| + async probehang() { |
| + const ctxA = await newContext('member A', { cookie: cookieA }); |
| + const ctxB = await newContext('member B', { cookie: cookieB }); |
| + const pageA = await openRoom(ctxA); |
| + const pageB = await openRoom(ctxB); |
| + const pair = await assertPair(pageA, pageB, 'member A', 'member B'); |
| + if (!pair) { |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + return; |
| + } |
| + |
| + let firstAt = 0; |
| + let signals = 0; |
| + let hungProbes = 0; |
| + // Everything A posts from here is answered 404, twenty seconds |
| + // after it was sent: "that peer is not here", arriving with a third |
| + // of the message's budget left. |
| + await ctxA.route('**/signal', async (route) => { |
| + if (!firstAt) firstAt = Date.now(); |
| + signals += 1; |
| + await sleep(20000); |
| + await route.fulfill({ status: 404, contentType: 'application/json', body: '{"error":"not-found"}' }); |
| + }); |
| + // And the probe that follows never answers at all. Only the probe |
| + // forms: A's own stream stays up, so nothing else is being tested. |
| + await ctxA.route('**/stream*', async (route) => { |
| + if (!route.request().url().includes('probe=1')) { |
| + await route.continue(); |
| + return; |
| + } |
| + hungProbes += 1; |
| + await new Promise(() => {}); |
| + }); |
| + |
| + await pageA.locator('#tiles figure:not(.meet-tile-self) figcaption button').first().dispatchEvent('click'); |
| + |
| + const dropped = await until(async () => { |
| + const s = await state(pageA); |
| + return !s.peers.includes(pair.b.peer) ? s : false; |
| + }, 70000); |
| + check(dropped, 'A let the target go rather than waiting on a probe that never answers (' + signals + |
| + ' signals, ' + hungProbes + ' hung probes) — ' + (await diagnose(pageA, 'member A'))); |
| + check(hungProbes > 0, 'the scenario actually hung the probe it meant to (' + hungProbes + ')'); |
| + const took = Date.now() - firstAt; |
| + check(took >= 25000 && took <= 35000, |
| + 'and it did so on the message’s own clock, not a fresh one (' + took + ' ms; a restarted clock lands near 50000)'); |
| + const after = await state(pageA); |
| + check(after.peer === pair.a.peer && !after.terminal, |
| + 'A is still in the call with its own peer id — ' + (await diagnose(pageA, 'member A'))); |
| + |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| // §4.6 and Codex S2-5: a `reset` refused with 429 used to be dropped |
| // on the floor, and after an outbound deadline that reset is the only |
| // thing left that can rebuild the pair — the queue has been thrown |