gate: four scenarios, and §15.12 for the row that argued
S2-9 (P3): playfallback asserted only that the button disappeared, which a handler that never calls play() satisfies; it now asserts the tile is paused before the gesture and that frames and currentTime both advance after it — and the rig pauses the element on the refusal, as a real autoplay policy does, without which "after" was true "before" too. devices asserted a truthy srcObject; it now asserts videoWidth > 0 and a rising currentTime. New browser scenarios for the client wave: staletarget (S2-3), a signal 404 about a peer that left; resetretry (S2-5), the reset after an outbound deadline refused once with 429; senddeadline and sendhang (S2-6), the deadline under continuous 429s and under a request that never settles. The signedout scenario gains a followed 303 to /signin (S2-8). Spec revision 9 gains §15.12: §2.5's last row said standing is not consulted and the paragraph under the table said a member re-derives it on every request. The paragraph wins; the row is corrected to name the guest, and the cost of the change is written down. Dispositions in the record (§2a), and the copy catalogue records that the wave introduced no new user-facing string.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
5 files changed,
+428
−11
docs/superpowers/copy/2026-09-10-slice-2-strings.md+22 −0docs/superpowers/reviews/2026-09-10-slice-2-record.md+39 −5docs/superpowers/specs/2026-09-07-meet-design.md+48 −1internal/meettest/browser_test.go+4 −0test/browser/call.mjs+315 −5
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 08ac311..55843a2 100644 |
| --- a/docs/superpowers/copy/2026-09-10-slice-2-strings.md |
| +++ b/docs/superpowers/copy/2026-09-10-slice-2-strings.md |
| @@ -146,3 +146,25 @@ lines are the holding screen's own wording, reused: noted per row. |
| | Screens work with JavaScript disabled, with one exception: browser media participation (the holding screen's camera preview and join button, and the call itself in the room) needs script; both pages render their title, standing, floor notice and navigation without it. Destructive actions get their own confirm-page URL. | `AGENTS.md`, "Rules the family holds hard" | Replaces the prior unqualified "Screens work with JavaScript disabled; destructive actions get their own confirm-page URL." — the exception is scoped to exactly the two pages above. | |
| | `make browser` is the second gate, for any change to a page or to the client: a real call in real Chromium. It needs the Claude Code bash sandbox **off** — the sandbox blocks UDP, so Chromium gathers no ICE candidates and every call fails with `frames` stuck at 0, which reads exactly like a client bug and is not one. | `AGENTS.md`, "Rules the family holds hard", new bullet | Developer-facing (this doc, and whoever else reads `AGENTS.md`), not end-user copy, but a string introduced on this branch a person reads. | |
| | Accessible by construction, progressively enhanced, and never dependent on JavaScript to be usable, with the one exception named above: browser media participation, on the holding screen and in the room. | `AGENTS.md`, "UX conventions" intro line | Same exception restated where the UX-conventions section makes the blanket claim; kept consistent with the Rules-section wording rather than contradicting it (task-6-report.md item 10). | |
| + |
| +## The Codex round 1 fix wave |
| + |
| +**No new user-facing string.** The wave (`3c8c6ac..`, recorded in |
| +`docs/superpowers/reviews/2026-09-10-slice-2-record.md` §2a) changed |
| +what the server and the client *do* and nothing a person reads: the |
| +refusals it adds are the existing 404 body and the existing `TERMINAL` |
| +sentences, reached by more paths than before. Two of those sentences |
| +are now reachable for a new reason, and the wording is unchanged: |
| + |
| +| String | Where | Note | |
| +| --- | --- | --- | |
| +| You no longer have access to this meeting. | `room.js`, `TERMINAL.standing` | Already catalogued. Now also reached without waiting for the tick: a removed member's next relay, reset or resume is refused, and the probe that follows a signal 404 reads the `standing` tombstone (spec §15.12). | |
| +| This meeting is already open in another tab. | `join.html`, `OpenElsewhere` notice | Already catalogued, with the standing note that it is untrue during the resume window. One of the two cases behind that note is gone: a participation past its window no longer counts as open elsewhere (S2-2). The suggested rewording still stands for the rest. | |
| + |
| +One developer-facing string is new, in a code comment and a `Makefile` |
| +comment rather than on a screen, and is listed only so this file stays a |
| +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. | |
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 9db9477..641d227 100644 |
| --- a/docs/superpowers/reviews/2026-09-10-slice-2-record.md |
| +++ b/docs/superpowers/reviews/2026-09-10-slice-2-record.md |
| @@ -18,16 +18,17 @@ found, and what is still open with the slice it belongs to. |
| ## 1. Rulings and decisions |
| -Ten of these changed what the spec says and are written into §15 of the |
| -spec itself, with their reasoning. They are listed here by name only, so |
| -this file stays the index and the spec stays the contract: |
| +Eleven of these changed what the spec says and are written into §15 of |
| +the spec itself, with their reasoning. They are listed here by name |
| +only, so this file stays the index and the spec stays the contract: |
| §15.1 the hub session key · §15.2 the `Accept` gate and its 406 · |
| §15.3 the `?probe=1` resume probe · §15.4 the scoped 410 · |
| §15.5 `/signal`'s refusals before the limiter · §15.6 the narrowed |
| room-page row · §15.7 the resume window enforced without waiting for the |
| tick · §15.8 `leave` is not idempotent · §15.9 the stale-gen 409 carries |
| -no gen · §15.10 the four client-only terminal reasons. |
| +no gen · §15.10 the four client-only terminal reasons · §15.12 the |
| +member's standing re-derived on every request (Codex round 1, §2a). |
| The rest are implementation decisions the spec has no opinion about. |
| @@ -153,6 +154,39 @@ Mutation survivors the reviewer reported, and where each is killed now: |
| --- |
| +## 2a. Codex round 1, and what happened to it |
| + |
| +An independent review by Codex, judged at `cfc0dc0` and applied on top |
| +of the whole-branch fix wave (`67d0ead`). One P1, six P2s, two P3s; |
| +verdict: request changes. Every one is fixed. Line numbers in the |
| +original moved by a few lines between `cfc0dc0` and the wave that |
| +applied these. |
| + |
| +| # | Finding | Disposition | Commit | Test | |
| +|---|---|---|---|---| |
| +| S2-1 (P1) | A removed member keeps relay, reset and resume authority until the tick: `Relay`, `Reset`, `CanResume` and `Resume` check the participation and never re-derive standing, which contradicts §2.5's own paragraph | **Fixed.** The paragraph wins over the table row, and the row is corrected in spec §15.12. All four re-derive standing for a member-kind participation, outside the hub lock, re-find under it, and apply through `standingLostLocked` — so the admitted guest's ruling-14 exemption is untouched. The member is ended with `lost{standing}` and answered 404, byte-identical to an unknown peer's. `CanResume` answers without ending: a probe changes nothing (§15.3). | `3c8c6ac` | `TestAMemberWithNoStandingIsRefusedByTheNextRequestNotTheNextTick` (relay, reset, resume, probe, each before any tick, each checking the other stream sees `gone`) and `TestAnAdmittedGuestWithNoStandingKeepsRelayingResettingAndResuming` | |
| +| S2-2 (P2) | A participation past its resume window is refused a resume (§15.7) but still 409s the fresh stream that refusal sends the browser to, until the tick — a false "open in another tab" | **Fixed.** `Open` ends a lapsed participation under the lock and takes its place, on the tick's own terms (`lost` unobservable, departure announced); `LiveForSession` and `LiveKindForSession` no longer count one, so the holding screen's notice and the peerless probe agree with it. Probes stay non-mutating. | `3c8c6ac` | `TestAResumePastItsWindowIsRefusedAndAFreshStreamTakesItsPlace` (renamed from `...BeforeTheTickRemovesIt`) and `TestAFreshStreamAfterALapsedResumeSucceedsBeforeAnyTick` over HTTP | |
| +| S2-3 (P2) | `recoverFromNotFound` reads a signal 404 as this participation's ending, when it can equally mean the target left: the client closed a live call, then met 409 from its own participation and showed the takeover screen | **Fixed.** The sender's own participation is probed first: 204 keeps the call, drops that target's queue and resets only if the roster still lists it; 410 is terminal with the tombstone's reason; 404 is the fresh-entry ladder. | `5e8db6c` | browser `staletarget`: A's stream is cut, B leaves, A posts to B, A is answered 404 and stays in the call with its own peer id and no takeover screen, then reconciles from the roster | |
| +| S2-4 (P2) | `Sub.terminate`'s eviction receive can block on a channel the reader drained, with `h.mu` held — the hub's one lock, against every room in the process | **Fixed.** The receive is non-blocking and the final send is retried once; two attempts is the ceiling. | `3c8c6ac` | `TestTerminateNeverBlocksOnAChannelTheReaderDrained` — a reader draining concurrently, a per-round deadline, and run under `-race` | |
| +| S2-5 (P2) | `requestReset` discards the POST result, so a 429 or a 5xx loses the reset silently — and after an outbound deadline that reset is the only thing that can rebuild the pair | **Fixed.** Bounded retry inside the same thirty seconds, with `Retry-After`, the 429/5xx backoff, the signed-out arm and the 404 ladder. | `5e8db6c` | browser `resetretry`: every POST refused until the deadline fires, then the reset it asks for refused once with 429 — A asks again and the pair comes back | |
| +| S2-6 (P2) | The outbound queue's thirty seconds is not enforced across 429s or a hanging fetch, and sleeps are not bounded by it | **Fixed.** An absolute per-message deadline covering requests, backoff and 429s, with an `AbortController` on the fetch at the deadline. | `5e8db6c` | browser `senddeadline` (continuous 429s) and `sendhang` (a route that never settles) | |
| +| S2-7 (P2) | Perfect negotiation has no `isSettingRemoteAnswerPending`, so an offer arriving while an answer is being applied reads as glare and the impolite side discards it | **Fixed.** The W3C guard exactly, factored into `offerCollision(entry, signalingState)`. | `5e8db6c` | `test/browser/negotiation.test.mjs` lifts that function out of the shipped source and checks the truth table under `node --test`, wired into `make browser`; the `gap` and `replay` scenarios stay green | |
| +| S2-8 (P3) | `join` and the pump process a 200 before checking `signedOut`, so a followed redirect to sign-in reads as success | **Fixed.** `signedOut` is checked first in both. | `5e8db6c` | the browser `signedout` scenario gains a second phase: `/signal` answers 303 to `/signin`, fetch follows it into a 200 HTML page, and the room ends as signed out | |
| +| S2-9 (P3) | Two browser assertions pass vacuously: `playfallback` checks only that the button disappears, `devices` only that `srcObject` is truthy | **Fixed.** `playfallback` asserts the tile is paused before the gesture and that frames and the element's clock both advance after it — and the rig now emulates a blocking autoplay policy properly, pausing the element rather than only rejecting the promise, without which the assertion was true before the gesture too. `devices` asserts `videoWidth > 0` and a rising `currentTime`. | commit below | the two scenarios themselves | |
| + |
| +Every test above was run against the unfixed code and seen to fail: |
| +S2-1's four sub-cases with `memberStandingGone` stubbed to "not gone"; |
| +S2-2's with `lapsedLocked` disabled, and again with only `Open`'s use of |
| +it disabled; S2-4's a few thousand rounds in, with and without `-race`; |
| +S2-3, S2-5, S2-6 and S2-8's against `room.js` at `67d0ead`; S2-7's |
| +against the old one-line decision; `playfallback`'s against a Play |
| +handler that hides the control without calling `startPlaying` (the |
| +mutation Codex names), which the old assertion still passed; `devices`' |
| +against a preview handed a stream with its video track removed, which |
| +the old truthy-`srcObject` check also still passed. |
| + |
| +--- |
| + |
| ## 3. Carried items, and where they go |
| **Next fix wave** |
| @@ -276,7 +310,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` | ~130 s, 18 scenarios. 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` | ~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**. | |
| `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/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index 1b7b966..022e7c8 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -273,7 +273,7 @@ One rule per endpoint, so §4 and §5 do not each restate it: |
| | `POST /m/{sqid}/signal`, `join` | Participation, **and** standing at that moment, because `join` is the entry | On the request, under the hub lock. | |
| | `POST /m/{sqid}/signal`, `admit` | An **admitted** participation with **current** `member` kind, re-derived from standing on this request, not only on the tick; **and** the target's entry authority (`member` standing or an unexpired grant) at this moment, under the same lock as the transition | On the request. | |
| | `POST /m/{sqid}/signal`, `leave` | Participation, any state | On the request. A waiting guest may leave. | |
| -| `POST /m/{sqid}/signal`, every other type; `GET /m/{sqid}/ice` | Participation, admitted | On the request. Standing is not consulted; a guest's expired grant does not matter here (ruling 14). | |
| +| `POST /m/{sqid}/signal`, every other type; `GET /m/{sqid}/ice` | Participation, admitted | On the request. Standing is not consulted **for a guest**; a guest's expired grant does not matter here (ruling 14). A participation whose kind is `member` re-derives standing, per the paragraph below (§15.12). | |
| | `/api/v1/*` | Agent token unrevoked **and** operator's current member standing | On the request. | |
| A request by a participation whose kind is `member` re-derives standing |
| @@ -1439,3 +1439,50 @@ they were otherwise recorded only in an untracked ledger: |
| rejoin automatically; guests go back to waiting and must be admitted |
| again. Deploys should avoid meeting hours until slice 3, or until a |
| drain exists. |
| + |
| +### 15.12 §2.5 — the last row corrected: standing is not consulted *for a guest* |
| + |
| +**This corrects a published contract that contradicted itself.** |
| +Revision 8's §2.5 gives the last row — `POST /m/{sqid}/signal`, every |
| +other type, and `GET /m/{sqid}/ice` — as "Participation, admitted. |
| +Standing is not consulted; a guest's expired grant does not matter here |
| +(ruling 14)." The paragraph immediately under the same table says "A |
| +request by a participation whose kind is `member` re-derives standing on |
| +every request as well as on the tick; a member who has just been removed |
| +is dropped by the next thing they do, not the next tick." |
| + |
| +Both cannot be true. **The paragraph wins**, and the row now reads: |
| + |
| +> Participation, admitted. Standing is not consulted **for a guest**; a |
| +> participation whose kind is `member` re-derives it, per the paragraph |
| +> below (ruling 14 is about the guest). |
| + |
| +**Why.** The row was written for ruling 14 — the admitted guest whose |
| +grant lapses and keeps everything — and it says so. Reading it as |
| +covering members too gives an ex-member the room for up to fifteen |
| +seconds after their removal: their offers relay, their resets execute, |
| +and their resume returns the roster and the replay. That is exactly what |
| +the paragraph exists to forbid, and it is the only place in §2.5 where a |
| +removal is not immediate. |
| + |
| +**What this changes in the implementation.** `Relay`, `Reset`, `Resume` |
| +and `CanResume` — and therefore `GET /m/{sqid}/stream?peer=` and its |
| +probe — re-derive standing for a member-kind participation. The read |
| +happens outside the hub lock, like every other standing read; the |
| +participation is re-found under the lock and the answer applied through |
| +the one place that decides what a loss of standing costs, so the |
| +admitted guest's exemption is untouched. A member whose standing has |
| +gone is ended with `lost {reason: "standing"}` and the request answers |
| +404 — byte-identical to the answer an unknown peer gets, because §2.5's |
| +"a peer that is not yours does not exist" does not make an exception for |
| +a peer that was yours a moment ago. The probe is the one exception to |
| +the ending: it answers 404 and writes nothing, because §15.3 defines it |
| +as changing nothing at all; the next mutating request, or the tick, does |
| +the ending. |
| + |
| +**The cost.** One standing read — two small database reads — per request |
| +made by a member-kind participation. It is bounded by the signal |
| +limiter's thirty requests a second per session and room, which was |
| +already the ceiling on this endpoint, and by the four streams per |
| +person of §4.4. A guest costs nothing extra: the kind is checked first |
| +and a guest never reaches the read. |
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go| index 6ba58a5..a2c261c 100644 |
| --- a/internal/meettest/browser_test.go |
| +++ b/internal/meettest/browser_test.go |
| @@ -78,8 +78,12 @@ var scenarios = []string{ |
| "removed", |
| "allowlist", |
| "departure", |
| + "staletarget", |
| "replay", |
| "gap", |
| + "resetretry", |
| + "senddeadline", |
| + "sendhang", |
| "rosterpair", |
| "restart", |
| "takeover", |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index f0c2df4..e40afd5 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -417,6 +417,26 @@ async function admitKnock(page, name) { |
| return false; |
| } |
| +// previewState is the holding screen's own <video>: whether it has a |
| +// stream, and whether that stream is actually being decoded and played. |
| +function previewState(page) { |
| + return page.evaluate(() => { |
| + const v = document.getElementById('preview'); |
| + if (!v) return null; |
| + return { hasStream: !!v.srcObject, width: v.videoWidth, time: v.currentTime, paused: v.paused }; |
| + }); |
| +} |
| + |
| +// remoteTile reads the first remote tile's <video> the same way, for the |
| +// scenarios about a tile that is playing or not. |
| +function remoteTile(page) { |
| + return page.evaluate(() => { |
| + const v = document.querySelector('#tiles figure:not(.meet-tile-self) video'); |
| + if (!v) return null; |
| + return { hasStream: !!v.srcObject, width: v.videoWidth, time: v.currentTime, paused: v.paused }; |
| + }); |
| +} |
| + |
| // queueNames is the admission queue as this page has it, which is |
| // exactly what the server decided this page may know: room.js fills it |
| // from knock, knock-gone and roster.waiting and from nothing else. |
| @@ -447,11 +467,22 @@ const run = { |
| const enabled = await until(async () => await page.locator('#join').isEnabled(), 10000); |
| check(enabled, 'the Join button is enabled within ten seconds — status: ' + (await page.locator('#status').textContent())); |
| - const preview = await page.evaluate(() => { |
| - const v = document.getElementById('preview'); |
| - return { hasStream: !!(v && v.srcObject), width: v ? v.videoWidth : null }; |
| - }); |
| - check(preview.hasStream, 'the preview has a live stream (' + JSON.stringify(preview) + ')'); |
| + // A truthy srcObject was all this used to ask (Codex S2-9), and a |
| + // preview that never decoded a pixel satisfies that. Real dimensions |
| + // and a clock that moves are what "previewing" means. |
| + const preview = await until(async () => { |
| + const p = await previewState(page); |
| + return p && p.hasStream && p.width > 0 ? p : false; |
| + }, 15000); |
| + check(preview, 'the preview is decoding real frames (' + JSON.stringify(preview || (await previewState(page))) + ')'); |
| + if (preview) { |
| + const rising = await until(async () => { |
| + const p = await previewState(page); |
| + return p && p.time > preview.time ? p : false; |
| + }, 10000); |
| + check(rising, 'the preview is playing rather than holding one frame (' + |
| + JSON.stringify(rising || (await previewState(page))) + ')'); |
| + } |
| check(errorsFor('member A') === '', 'no console errors on the holding screen (' + errorsFor('member A') + ')'); |
| await ctx.close(); |
| @@ -994,6 +1025,226 @@ const run = { |
| await ctxB.close(); |
| }, |
| + // A signal 404 that is about the *target*, not about us (Codex S2-3). |
| + // A's stream is cut, B leaves, and A — which cannot have heard `gone` |
| + // — sends B an offer. The hub answers 404 because B is not there any |
| + // more. That says nothing about A's own participation, and A must |
| + // stay in the call: the old client closed everything, opened a fresh |
| + // stream, met a 409 from its own still-live participation and put up |
| + // the takeover screen for a tab that does not exist. |
| + async staletarget() { |
| + const ctxA = await newContext('member A', { cookie: cookieA }); |
| + 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. |
| + let notFound = 0; |
| + await ctxA.route('**/signal', async (route) => { |
| + const res = await route.fetch(); |
| + if (res.status() === 404) notFound += 1; |
| + await route.fulfill({ response: res }); |
| + }); |
| + 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; |
| + } |
| + |
| + cut.on = true; |
| + check(await biteCut(cut), "A's attaching stream is cut (" + cut.aborted + ' aborted)'); |
| + |
| + await leaveRoom(pageB); |
| + |
| + // 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 |
| + // is no longer in the room. Dispatched rather than clicked: the |
| + // scenario is about the answer, not about the control. |
| + 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 + ')'); |
| + |
| + // Nothing about that 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 — ' + |
| + (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 + ')'); |
| + |
| + 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; |
| + }, 45000); |
| + check(reconciled, 'A reconciled the departed peer from its roster — ' + (await diagnose(pageA, 'member A'))); |
| + |
| + await leaveRoom(pageA); |
| + 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 |
| + // away and the connection closed, the hub's generation has not moved, |
| + // and no `pair` is coming. So this scenario walks A into exactly that |
| + // corner: everything it posts is refused until the deadline fires, |
| + // and then the reset the deadline asks for is refused once more. |
| + async resetretry() { |
| + const ctxA = await newContext('member A', { cookie: cookieA }); |
| + const ctxB = await newContext('member B', { cookie: cookieB }); |
| + let failing = false; |
| + let resets = 0; |
| + await ctxA.route('**/signal', async (route) => { |
| + const isReset = (route.request().postData() || '').includes('"type":"reset"'); |
| + if (isReset) { |
| + resets += 1; |
| + // The deadline has done its work; the room is reachable again |
| + // and only the reset itself is in question. |
| + failing = false; |
| + if (resets === 1) { |
| + await route.fulfill({ |
| + status: 429, |
| + headers: { 'retry-after': '1' }, |
| + contentType: 'application/json', |
| + body: '{"error":"rate"}', |
| + }); |
| + return; |
| + } |
| + await route.continue(); |
| + return; |
| + } |
| + if (failing) { |
| + await route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"full"}' }); |
| + return; |
| + } |
| + await route.continue(); |
| + }); |
| + 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; |
| + } |
| + check(resets === 0, 'nothing had reset this pair before this (' + resets + ')'); |
| + |
| + failing = true; |
| + await pageA.locator('#tiles figure:not(.meet-tile-self) figcaption button').first().dispatchEvent('click'); |
| + |
| + const asked = await until(() => (resets > 0 ? resets : false), 60000); |
| + check(asked, 'A hit its outbound deadline and asked for a reset (' + resets + ')'); |
| + const askedTwice = await until(() => (resets > 1 ? resets : false), 30000); |
| + check(askedTwice, 'A asked again after the 429 rather than losing the reset (' + resets + ' asks)'); |
| + |
| + const rebuilt = await until(async () => { |
| + const a = await state(pageA); |
| + const b = await state(pageB); |
| + return a.peer === pair.a.peer && !a.terminal && |
| + a.signaling[pair.b.peer] === 'stable' && a.connection[pair.b.peer] === 'connected' && |
| + b.signaling[pair.a.peer] === 'stable' && b.connection[pair.a.peer] === 'connected' ? { a, b } : false; |
| + }, 45000); |
| + check(rebuilt, 'the pair came back, on a reset that was refused the first time — ' + |
| + (await diagnose(pageA, 'member A')) + ' ' + (await diagnose(pageB, 'member B'))); |
| + |
| + const before = (await state(pageA)).frames[pair.b.peer] || 0; |
| + const rising = await until(async () => ((await state(pageA)).frames[pair.b.peer] || 0) > before, 25000); |
| + check(rising, 'A is decoding video over the rebuilt connection (' + before + ' -> ' + |
| + ((await state(pageA)).frames[pair.b.peer] || 0) + ')'); |
| + |
| + await leaveRoom(pageA); |
| + await leaveRoom(pageB); |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| + // §4.6's thirty seconds, under continuous 429s (Codex S2-6). Every |
| + // retry used to skip the deadline check, so a rate-limited target was |
| + // retried for ever and the connection to it was never rebuilt. What |
| + // is asserted is the verdict — A closes the connection to B — not how |
| + // long it took to reach it. |
| + async senddeadline() { |
| + 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; |
| + } |
| + |
| + // Only from here: the join and the whole negotiation above went |
| + // through untouched, so what is being timed is one message. |
| + let limited = 0; |
| + await ctxA.route('**/signal', async (route) => { |
| + limited += 1; |
| + await route.fulfill({ |
| + status: 429, |
| + headers: { 'retry-after': '1' }, |
| + contentType: 'application/json', |
| + body: '{"error":"rate"}', |
| + }); |
| + }); |
| + await pageA.locator('#tiles figure:not(.meet-tile-self) figcaption button').first().dispatchEvent('click'); |
| + |
| + const gaveUp = await until(async () => { |
| + 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 + |
| + ' refusals) — ' + (await diagnose(pageA, 'member A'))); |
| + 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'); |
| + |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| + // The other half of the same deadline: a request that never settles at |
| + // all. Without an abort on the fetch the pump simply waits for ever, |
| + // and the deadline check is never even reached (Codex S2-6). |
| + async sendhang() { |
| + 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 hung = 0; |
| + await ctxA.route('**/signal', async () => { |
| + hung += 1; |
| + // Never fulfilled, never continued, never aborted: the request |
| + // stays pending until the page itself gives up on it. |
| + await new Promise(() => {}); |
| + }); |
| + await pageA.locator('#tiles figure:not(.meet-tile-self) figcaption button').first().dispatchEvent('click'); |
| + |
| + const gaveUp = await until(async () => { |
| + 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 + |
| + ' hung requests) — ' + (await diagnose(pageA, 'member A'))); |
| + check(!(await state(pageA)).terminal, 'a hanging endpoint is not the end of the call either'); |
| + |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| // The process goes away and comes back. Both browsers see the stream |
| // die, find a boot they do not know, and rejoin (§4.5). |
| async restart() { |
| @@ -1227,6 +1478,26 @@ const run = { |
| check(href === '/signin', 'the signed-out room offers the way back in (' + href + ')'); |
| await ctx.close(); |
| + |
| + // The other half of the same rule, and the one the client explicitly |
| + // implements the compatibility for: a session that has expired can |
| + // answer a POST with a redirect to sign-in instead of a 403. fetch |
| + // follows it, so what comes back is a 200 carrying the sign-in page |
| + // — which read as a status alone is success, and was being taken as |
| + // success (Codex S2-8). Member B, because A's participation from the |
| + // phase above is still inside its resume window. |
| + const ctxB = await newContext('member B', { cookie: cookieB }); |
| + await ctxB.route('**/signal', (route) => |
| + route.fulfill({ status: 303, headers: { location: origin + '/signin' } })); |
| + const pageB = await openRoom(ctxB); |
| + |
| + const redirected = await until(async () => ((await state(pageB)).terminal === 'signedout' ? true : false), 30000); |
| + check(redirected, 'a POST redirected to sign-in ends the call as signed out, not as a success — ' + |
| + (await diagnose(pageB, 'member B'))); |
| + check(await waitStatus(pageB, 'You have been signed out.', 10000), |
| + 'the redirected room says the same thing — status: ' + (await statusText(pageB))); |
| + |
| + await ctxB.close(); |
| }, |
| // A browser with no WebRTC at all. The room needs another peer before |
| @@ -1266,11 +1537,30 @@ const run = { |
| await ctxA.addInitScript(() => { |
| const real = HTMLMediaElement.prototype.play; |
| let refused = 0; |
| + let blocked = null; |
| + // A rejected promise on its own is not what a blocking autoplay |
| + // policy does: the element also stays paused. Rejecting and |
| + // letting the element play anyway (its own `autoplay` attribute |
| + // does that, and the launch flag permits it) left every "the |
| + // gesture started the video" assertion true before the gesture |
| + // as well, which is the hole Codex S2-9 is about. So the refusal |
| + // pauses the element and keeps it paused until the gesture. |
| + const holdPaused = (e) => { |
| + if (blocked === e.target) e.target.pause(); |
| + }; |
| HTMLMediaElement.prototype.play = function play() { |
| if (!this.muted && refused < 1) { |
| refused += 1; |
| + blocked = this; |
| + this.autoplay = false; |
| + this.pause(); |
| + this.addEventListener('play', holdPaused); |
| return Promise.reject(new DOMException('autoplay refused', 'NotAllowedError')); |
| } |
| + if (blocked === this) { |
| + blocked = null; |
| + this.removeEventListener('play', holdPaused); |
| + } |
| return real.apply(this, arguments); |
| }; |
| }); |
| @@ -1289,9 +1579,29 @@ const run = { |
| const offered = await until(async () => await play.isVisible(), 25000); |
| check(offered, 'the refused remote video offers a Play control — ' + (await diagnose(pageA, 'member A'))); |
| if (offered) { |
| + // What the control has to actually do. The click handler hides the |
| + // button before it attempts playback, so "the button went" was |
| + // satisfied by a handler that never called play() at all (Codex |
| + // S2-9): the assertion is frames arriving and the element's own |
| + // clock moving, both of which stop while it is paused. |
| + const before = (await state(pageA)).frames[b.peer] || 0; |
| + const still = await remoteTile(pageA); |
| + check(still && still.paused, 'the refused video is paused before the gesture (' + JSON.stringify(still) + ')'); |
| + |
| await play.click(); |
| const taken = await until(async () => !(await play.isVisible()), 20000); |
| check(taken, 'the gesture starts the video and the control goes — ' + (await diagnose(pageA, 'member A'))); |
| + |
| + const advancing = await until(async () => { |
| + const s = await state(pageA); |
| + const t = await remoteTile(pageA); |
| + return (s.frames[b.peer] || 0) > before && t && !t.paused && t.time > (still ? still.time : 0) |
| + ? { frames: s.frames[b.peer], tile: t } |
| + : false; |
| + }, 25000); |
| + check(advancing, 'the gesture starts the frames as well as hiding the control (from ' + before + |
| + ' frames; ' + JSON.stringify(advancing || (await remoteTile(pageA))) + ') — ' + |
| + (await diagnose(pageA, 'member A'))); |
| } |
| await leaveRoom(pageA); |