oficina / meet Public

browser gate: frames from a baseline taken now, and a late collision

Codex S4-6 (P2). Two ways the gate could pass on media that was not
moving.

`frames[key] > 0` reads a counter `dropShare` deliberately leaves
behind — it is what lets a gate see that a screen *was* arriving before
it went away — so `mutualshare`'s rounds two and three were answered by
round one's frames, and a new screen delivering nothing at all would
have passed them. And every camera check compared against a snapshot
taken *before* the gesture, which the frames delivered between that
snapshot and a rollback stall satisfy: the exact failure this slice
spent two fix rounds on would slip through the check written to catch
it. The five-second settle watched signalling, which a connection whose
camera has stopped encoding passes perfectly.

So: one `frameGrowth` helper that reads the counter and only then
starts waiting for it to rise, and every screen and camera assertion in
`share` and `mutualshare` rebuilt on it — after each transition
settles, and again through the observation window, both screens and
both cameras, in every round and on both sides of the asymmetric
ending.

And `laterolls`, a thirty-first scenario for the residual §15.16 names.
It holds one context's own `offer` POSTs for four times POLITE_YIELD,
so the collision really is late and the polite side really does roll
back, then asserts the camera is encoding again inside the repair
budget. It asserts the recovery, never the failure: a Chromium that no
longer rebuilds the streams passes it exactly as a repaired one does,
and whether the camera stalled on the way is recorded as a line rather
than a check.

The gate's own timeout is now 20 minutes: it was passing 10 by a margin
that a thirty-first scenario and four more media windows removed.

This is the run that found the negotiation defect fixed in the commit
before it — the scenario earned its keep before it was committed.
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 d8316e8d94301d152ac94fbc59a9cc3f5c598bd0 parent f982bdf
3 files changed, +231 −58
  • Makefile +1 −1
  • internal/meettest/browser_test.go +1 −0
  • test/browser/call.mjs +229 −57
diff --git a/Makefile b/Makefile
index 2d22d0c..b267784 100644
--- a/Makefile
+++ b/Makefile
@@ -146,4 +146,4 @@ browser:
node --test test/browser/room-lane.test.mjs
npm install --no-save playwright@1.62.1
npx playwright install chromium
- go test -tags browser -count=1 -run TestCallInARealBrowser -v ./internal/meettest/
+ go test -tags browser -count=1 -timeout 20m -run TestCallInARealBrowser -v ./internal/meettest/
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go
index 64e7963..edaee28 100644
--- a/internal/meettest/browser_test.go
+++ b/internal/meettest/browser_test.go
@@ -84,6 +84,7 @@ var scenarios = []string{
"call",
"share",
"mutualshare",
+ "laterolls",
"renewal",
"ice",
"icerenewal",
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index e63d2e5..09e1c2a 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -200,6 +200,37 @@ async function bothStable(watch, ms) {
}
}
+// frameGrowth is the only honest way to ask "is this media arriving
+// *now*" (Codex S4-6). It reads the counter, and only then starts
+// waiting for it to rise, so nothing delivered before the question was
+// asked can answer it.
+//
+// Two ways the gate got this wrong. `frames[key] > 0` reads a counter
+// `dropShare` deliberately leaves behind, so a second round of sharing
+// could pass on the first round's frames with no media at all. And a
+// baseline taken *before* a gesture is answered by the frames that
+// arrived between the snapshot and the stall the gesture caused —
+// which is exactly the shape of the camera failure this slice spent two
+// fix rounds on.
+//
+// The answer carries both numbers, because a failure message that says
+// only "no frames" cannot tell a stall from a screen that never
+// started.
+async function frameGrowth(page, key, ms) {
+ const base = (await state(page)).frames[key] || 0;
+ const grown = await until(async () => {
+ const now = (await state(page)).frames[key] || 0;
+ return now > base ? now : false;
+ }, ms);
+ return { ok: grown !== null, base, now: grown === null ? (await state(page)).frames[key] || 0 : grown };
+}
+
+// growth is frameGrowth's answer as a sentence, for the check that
+// reads it.
+function growth(g) {
+ return g.base + ' -> ' + g.now;
+}
+
// ── the browser ──────────────────────────────────────────────────────
const browser = await chromium.launch({ args: LAUNCH_ARGS });
@@ -1087,7 +1118,6 @@ const run = {
check((await shareTiles(viewer.page)).length === 0,
'nobody is showing a screen before anybody shares one');
- const cameraBefore = (await state(viewer.page)).frames[sharer.peer] || 0;
await sharer.page.click('#share');
// The message first: `shares` follows the tile, and the tile follows
@@ -1107,19 +1137,29 @@ const run = {
check(captioned, 'a second tile says whose screen it is (' +
JSON.stringify(await shareTiles(viewer.page)) + ', want ' + JSON.stringify(caption) + ')');
- const pixels = await until(async () => {
- const s = await state(viewer.page);
- return (s.frames[key] || 0) > 0 ? s : false;
- }, 5000);
- check(pixels, 'the screen is decoding real frames and not only labelled — ' +
- (await diagnose(viewer.page, viewer.label)));
-
- const cameraOn = await until(async () => {
- const s = await state(viewer.page);
- return (s.frames[sharer.peer] || 0) > cameraBefore ? s : false;
- }, 10000);
- check(cameraOn, 'the camera kept flowing across the renegotiation (' + cameraBefore + ' -> ' +
- ((await state(viewer.page)).frames[sharer.peer] || 0) + ')');
+ const pixels = await frameGrowth(viewer.page, key, 10000);
+ check(pixels.ok, 'the screen is decoding real frames and not only labelled (' +
+ growth(pixels) + ') — ' + (await diagnose(viewer.page, viewer.label)));
+
+ // From a baseline taken now that the transition has settled, not
+ // from one taken before the gesture: frames delivered between a
+ // pre-gesture snapshot and a rollback stall would satisfy a
+ // pre-gesture baseline while the camera was already dead (Codex
+ // S4-6).
+ const cameraOn = await frameGrowth(viewer.page, sharer.peer, 10000);
+ check(cameraOn.ok, 'the camera kept flowing across the renegotiation (' +
+ growth(cameraOn) + ')');
+
+ // And still flowing a moment later, both of them: the failure this
+ // slice hunted is a camera that goes on being negotiated, live and
+ // sendrecv, having stopped encoding — which the check above can
+ // catch on its way down and this one cannot miss.
+ const stillScreen = await frameGrowth(viewer.page, key, 5000);
+ check(stillScreen.ok, 'the screen is still arriving a moment later (' +
+ growth(stillScreen) + ')');
+ const stillCameraOn = await frameGrowth(viewer.page, sharer.peer, 5000);
+ check(stillCameraOn.ok, 'the camera is still arriving a moment later (' +
+ growth(stillCameraOn) + ') — ' + (await diagnose(viewer.page, viewer.label)));
const pressed = await shareControl(sharer.page);
check(pressed && pressed.pressed === 'true' && pressed.state === 'On',
@@ -1146,7 +1186,6 @@ const run = {
check(armed && armed.readyState === 'live',
'the received screen track is live, and watched for its ending (' + JSON.stringify(armed) + ')');
- const cameraMid = (await state(viewer.page)).frames[sharer.peer] || 0;
await sharer.page.click('#share');
const removed = await until(async () => {
@@ -1162,12 +1201,10 @@ const run = {
check(ended, 'the received track ended rather than merely stopping being drawn (readyState ' +
(await viewer.page.evaluate(() => (window.__shareTrack || {}).readyState || '(no track)')) + ')');
- const cameraAfter = await until(async () => {
- const s = await state(viewer.page);
- return (s.frames[sharer.peer] || 0) > cameraMid ? s : false;
- }, 10000);
- check(cameraAfter, 'the camera is still flowing after the share stopped (' + cameraMid + ' -> ' +
- ((await state(viewer.page)).frames[sharer.peer] || 0) + ')');
+ // Again from a fresh baseline, taken now that the stop has settled.
+ const cameraAfter = await frameGrowth(viewer.page, sharer.peer, 10000);
+ check(cameraAfter.ok, 'the camera is still flowing after the share stopped (' +
+ growth(cameraAfter) + ') — ' + (await diagnose(viewer.page, viewer.label)));
const off = await until(async () => {
const b = await shareControl(sharer.page);
@@ -1243,10 +1280,6 @@ const run = {
for (let round = 1; round <= 3; round++) {
const at = 'round ' + round + ': ';
- for (const side of sides) {
- side.cameraBefore = (await state(side.page)).frames[side.other] || 0;
- }
-
await bothClick();
for (const side of sides) {
@@ -1259,27 +1292,32 @@ const run = {
check(announced, at + side.label + " was told about the other's screen — " +
(await diagnose(side.page, side.label)));
- // The half a message cannot fake. A share m-line the far side
- // took for its own, or one left inactive by a rollback, is
- // announced exactly like a working one and carries nothing.
- const pixels = await until(async () => {
- const s = await state(side.page);
- return (s.frames[key] || 0) > 0 ? s : false;
- }, 10000);
- check(pixels, at + side.label + ' is decoding the other screen, not just showing its name — ' +
- (await diagnose(side.page, side.label)));
-
const tiles = await shareTiles(side.page);
check(tiles.length === 1 && tiles[0].name === side.otherName + '’s screen',
at + side.label + ' shows one screen, captioned with the other name (' +
JSON.stringify(tiles) + ', want ' + JSON.stringify(side.otherName + '’s screen') + ')');
- const camera = await until(async () => {
- const s = await state(side.page);
- return (s.frames[side.other] || 0) > side.cameraBefore ? s : false;
- }, 10000);
- check(camera, at + side.label + " is still receiving the other's camera (" +
- side.cameraBefore + ' -> ' + ((await state(side.page)).frames[side.other] || 0) + ')');
+ // The half a message cannot fake. A share m-line the far side
+ // took for its own, or one left inactive by a rollback, is
+ // announced exactly like a working one and carries nothing.
+ //
+ // From a baseline taken now, in every round: `frames[key] > 0`
+ // reads a counter `dropShare` deliberately leaves behind, so
+ // rounds two and three used to pass on round one's frames even
+ // if the new screen delivered none at all (Codex S4-6).
+ const pixels = await frameGrowth(side.page, key, 15000);
+ check(pixels.ok, at + side.label + ' is decoding the other screen, not just showing its name (' +
+ growth(pixels) + ') — ' + (await diagnose(side.page, side.label)));
+
+ // The camera, from a baseline taken after this round's
+ // transition settled rather than before the gesture that caused
+ // it: frames delivered between a pre-gesture snapshot and a
+ // rollback stall satisfy a pre-gesture baseline while the camera
+ // is already dead, which is the exact failure this scenario was
+ // written to catch.
+ const camera = await frameGrowth(side.page, side.other, 15000);
+ check(camera.ok, at + side.label + " is still receiving the other's camera (" +
+ growth(camera) + ') — ' + (await diagnose(side.page, side.label)));
}
check((await state(pageA)).sharing === true && (await state(pageB)).sharing === true,
@@ -1292,6 +1330,20 @@ const run = {
const wobbled = await bothStable(sides, 5000);
check(wobbled === null, at + 'negotiation settled and stayed settled — ' + JSON.stringify(wobbled));
+ // And the media through that same window. The five seconds above
+ // watch signalling, which a connection whose camera has stopped
+ // encoding passes perfectly: `connected`, `stable`, sendrecv, a
+ // live track, and nothing coming out (Codex S4-6). Four fresh
+ // baselines, both screens and both cameras.
+ for (const side of sides) {
+ const screenOn = await frameGrowth(side.page, side.other + '#share', 8000);
+ check(screenOn.ok, at + side.label + ' is still decoding the other screen after the wait (' +
+ growth(screenOn) + ') — ' + (await diagnose(side.page, side.label)));
+ const cameraOn = await frameGrowth(side.page, side.other, 8000);
+ check(cameraOn.ok, at + side.label + " is still receiving the other's camera after the wait (" +
+ growth(cameraOn) + ') — ' + (await diagnose(side.page, side.label)));
+ }
+
if (round === 1) {
for (const side of sides) {
const surface = await capturedSurface(side.page);
@@ -1322,8 +1374,6 @@ const run = {
// not. The m-line that goes has to be the one that was stopped.
const [stopper, keeper] = sides;
const keptKey = stopper.other + '#share';
- const keptBefore = (await state(stopper.page)).frames[keptKey] || 0;
- const cameraBefore = (await state(keeper.page)).frames[keeper.other] || 0;
await stopper.page.click('#share');
@@ -1335,20 +1385,20 @@ const run = {
check(dropped, keeper.label + ' lost only the screen that stopped — ' +
(await diagnose(keeper.page, keeper.label)));
- const kept = await until(async () => {
- const s = await state(stopper.page);
- return s.shares.includes(stopper.other) && (s.frames[keptKey] || 0) > keptBefore ? s : false;
- }, 10000);
- check(kept, stopper.label + ' still has the other screen, and it is still arriving (' +
- keptBefore + ' -> ' + ((await state(stopper.page)).frames[keptKey] || 0) + ') — ' +
- (await diagnose(stopper.page, stopper.label)));
-
- const stillCamera = await until(async () => {
- const s = await state(keeper.page);
- return (s.frames[keeper.other] || 0) > cameraBefore ? s : false;
- }, 10000);
- check(stillCamera, keeper.label + ' still has the camera through the one-sided stop (' +
- cameraBefore + ' -> ' + ((await state(keeper.page)).frames[keeper.other] || 0) + ')');
+ // Fresh baselines again, taken after the one-sided stop settled:
+ // the screen that was kept, and both cameras.
+ check((await state(stopper.page)).shares.includes(stopper.other),
+ stopper.label + ' still holds the other screen through its own stop');
+ const kept = await frameGrowth(stopper.page, keptKey, 10000);
+ check(kept.ok, stopper.label + ' still has the other screen, and it is still arriving (' +
+ growth(kept) + ') — ' + (await diagnose(stopper.page, stopper.label)));
+
+ const stillCamera = await frameGrowth(keeper.page, keeper.other, 10000);
+ check(stillCamera.ok, keeper.label + ' still has the camera through the one-sided stop (' +
+ growth(stillCamera) + ') — ' + (await diagnose(keeper.page, keeper.label)));
+ const stopperCamera = await frameGrowth(stopper.page, stopper.other, 10000);
+ check(stopperCamera.ok, stopper.label + ' still has the camera through its own stop (' +
+ growth(stopperCamera) + ') — ' + (await diagnose(stopper.page, stopper.label)));
check((await state(stopper.page)).sharing === false,
stopper.label + ' says it has stopped');
@@ -1369,6 +1419,128 @@ const run = {
await ctxB.close();
},
+ // The residual spec §15.16 names, forced rather than waited for.
+ //
+ // POLITE_YIELD is 300 ms, and a pair whose signalling round trip
+ // exceeds it still collides: the polite side gives way, offers
+ // anyway, and the remote offer lands afterwards — so it rolls back,
+ // and Chromium rebuilds every send stream on the connection. The
+ // camera's comes back having encoded nothing and never encodes again,
+ // on an m-line that goes on reading sendrecv with a live track. A
+ // slow network is only one way to get there: a 429 retry and a slow
+ // offer arrive just as late.
+ //
+ // The lateness here is real rather than simulated. One context's own
+ // `offer` POSTs are held for four times the yield before they reach
+ // the hub, so the other side's offer really does arrive after the
+ // yield has ended, on a connection that has already offered.
+ //
+ // What this asserts is the recovery, not the failure: a Chromium that
+ // no longer rebuilds the streams passes it exactly as a repaired one
+ // does, which is the right way round for a gate — the assertion is
+ // "the camera is encoding again", never "the camera broke first".
+ // Whether it stalled on the way is recorded as a line rather than a
+ // check, because that half is the browser's behaviour and not this
+ // app's promise.
+ async laterolls() {
+ const HOLD = 1200; // four times POLITE_YIELD
+ 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;
+ }
+
+ // §5.1's tie-break, read the way room.js reads it: the lower id is
+ // the polite one, and the polite one is the side that rolls back.
+ const aPolite = String(pair.a.peer) < String(pair.b.peer);
+ const polite = aPolite
+ ? { page: pageA, ctx: ctxA, label: 'member A', peer: pair.a.peer, other: pair.b.peer }
+ : { page: pageB, ctx: ctxB, label: 'member B', peer: pair.b.peer, other: pair.a.peer };
+ const impolite = aPolite
+ ? { page: pageB, ctx: ctxB, label: 'member B', peer: pair.b.peer, other: pair.a.peer }
+ : { page: pageA, ctx: ctxA, label: 'member A', peer: pair.a.peer, other: pair.b.peer };
+ line('the polite side is ' + polite.label + ', and its offers are the ones that roll back');
+
+ // Only the impolite side's offers, and only the offers: everything
+ // else — candidates, the share announcement, the leave — goes at
+ // its own speed, so what is delayed is exactly the thing that makes
+ // the collision late.
+ const held = [];
+ await impolite.ctx.route('**/signal', async (route) => {
+ const body = route.request().postData() || '';
+ if (body.includes('"type":"offer"')) {
+ held.push(Date.now());
+ await sleep(HOLD);
+ }
+ await route.continue();
+ });
+
+ await Promise.all([polite.page.hover('#share'), impolite.page.hover('#share')]);
+ await Promise.all([polite.page.click('#share'), impolite.page.click('#share')]);
+
+ check(await until(() => held.length > 0, 15000) !== null,
+ 'the impolite side had an offer to hold back (' + held.length + ' held)');
+
+ for (const side of [polite, impolite]) {
+ const announced = await until(async () => {
+ const s = await state(side.page);
+ return s.shares.includes(side.other) ? s : false;
+ }, 20000);
+ check(announced, side.label + " was told about the other's screen through the delay — " +
+ (await diagnose(side.page, side.label)));
+ }
+
+ // The stall itself, watched but not required. Two and a half
+ // seconds is longer than the watch takes to notice one and shorter
+ // than it takes to repair it, so this reads the failure rather than
+ // the recovery when there is one to read.
+ const before = (await state(impolite.page)).frames[polite.peer] || 0;
+ await sleep(2500);
+ const during = (await state(impolite.page)).frames[polite.peer] || 0;
+ line(during > before
+ ? 'the polite side\'s camera never stalled through the late offer (' + before + ' -> ' + during + ')'
+ : 'the polite side\'s camera stalled after the late offer (' + before + ' -> ' + during +
+ '), which is what the repair path exists for');
+
+ // And the recovery, which is the promise: whatever happened to the
+ // encoder, the camera is encoding again inside the repair budget —
+ // two polls to notice, a replaceTrack cycle, two more polls, and a
+ // reset behind that.
+ const repaired = await frameGrowth(impolite.page, polite.peer, 30000);
+ check(repaired.ok, impolite.label + " is receiving the polite side's camera again (" +
+ growth(repaired) + ') — ' + (await diagnose(impolite.page, impolite.label)));
+ const otherWay = await frameGrowth(polite.page, impolite.peer, 20000);
+ check(otherWay.ok, polite.label + " is receiving the impolite side's camera (" +
+ growth(otherWay) + ') — ' + (await diagnose(polite.page, polite.label)));
+
+ for (const side of [polite, impolite]) {
+ const screen = await frameGrowth(side.page, side.other + '#share', 20000);
+ check(screen.ok, side.label + ' is decoding the other screen through all of it (' +
+ growth(screen) + ') — ' + (await diagnose(side.page, side.label)));
+ }
+
+ const settled = await bothStable(
+ [{ page: polite.page, label: polite.label, other: polite.other },
+ { page: impolite.page, label: impolite.label, other: impolite.other }], 5000);
+ check(settled === null, 'the late collision left both connections settled — ' + JSON.stringify(settled));
+
+ check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
+ check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
+
+ await impolite.ctx.unroute('**/signal');
+ await polite.page.click('#share');
+ await impolite.page.click('#share');
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
// A stream lives five minutes and is then renewed by a resume. Six
// seconds here, through a header only development honours, so the
// renewal happens several times while the call is watched — and six