oficina / meet Public

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
Paul Campbell pushed by paul@keymail.dev 2f7f63f3dbd4a0a9c8ed9e579e4959f839ef6522 parent 6bd2f00
4 files changed, +110 −4
  • docs/superpowers/copy/2026-09-10-slice-2-strings.md +0 −1
  • docs/superpowers/reviews/2026-09-10-slice-2-record.md +33 −1
  • internal/meettest/browser_test.go +1 −0
  • test/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