oficina / meet Public

browser gate: media is observed over a duration, not raced to a deadline

Codex S4-14 (P2). `sustained` was two early-return waits in a row,
which is a deadline and not an observation: counters reading 0, 1, 2, 3
and then frozen for ever satisfied it with a twenty-second argument,
because it stopped looking the moment it saw an increase. The failure
this slice is about is a tile that runs for a moment after a
renegotiation and then stops, so "it moved once" is exactly the answer
that must not count.

`observe` watches a set of counters over a fixed duration and requires
each of them to move in *every* subwindow — three windows of a second
and a half — and names the window each stream stopped in when one does.
`watchMedia` reads every stream on every page in one pass, so streams
and peers are watched together instead of each getting its own window
after the others have had theirs, and the caller names the keys it
expects: a check that asks the page which screens it is showing and
then requires those to move cannot fail for a screen that never
arrived.

Ten node tests over the lifted helper, in a new media.test.mjs, with
Codex's own reproduction as the control: 0, 1, 2, 3 then frozen must
fail, and it names window 4. Also a stop in the last window, a stop and
restart in the middle, a counter going backwards, one stream answering
for another, and expecting nothing at all — which is a failure, not a
pass.

It found something immediately, which is the point of it: `laterolls`
went red with `28->28->28->28` on one camera. That was my scenario's
own pre-check being vacuous — "the counter is above zero" is answered
by a counter left over from before the collision — and the ordering
being wrong: a reset is one of the ways this scenario is allowed to
recover, so a pair mid-rebuild is not a pair that failed to settle. It
now waits for growth from a fresh baseline (the arrival of recovery,
which takes three polls to notice, two replays and a rebuild), then
settles, and only then opens the windows. Green with the stall really
happening first: rendered frames `[25,25,25,25,25,25]`, recovery at
26 → 28, and 273->296->320->351 through the windows.

node --check, 232 node tests, make ci, go vet -tags browser.
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 85e0a61f4461395f1767cac81c69cd46562a1df3 parent 5a0be1e
3 files changed, +277 −53
  • Makefile +6 −3
  • test/browser/call.mjs +129 −50
  • test/browser/media.test.mjs +142 −0
diff --git a/Makefile b/Makefile
index b267784..383fa5e 100644
--- a/Makefile
+++ b/Makefile
@@ -128,15 +128,17 @@ ci: vet fmt-check test migration-check
# -count=1 because call.mjs is not an input to Go's test cache: node
# opens it, not the test, so an edited browser script would otherwise be
# answered with a cached pass from before the edit.
-# The four .test.mjs files run first because they need no browser at
+# The five .test.mjs files run first because they need no browser at
# all: they lift functions out of room.js and chat.js by name — pure
# ones for their answers, whole loops for what they do — and check
# §5.1's glare decision against the W3C truth table; §6's DOM safety,
# the log cap and §4.4's byte counter; §5.4's camera-or-screen judgement
# with §4.3's fan-out wire shape; and the two sequencing contracts,
# every sender on the broadcast lane answered exactly once and one
-# gesture taking one capture. Under a second between them. Named
-# explicitly rather than by directory — node's test runner treats
+# gesture taking one capture; and the gate's own observation helper,
+# which is the one piece of call.mjs that can be wrong in a way that
+# makes the gate quieter rather than louder. Under a second between
+# them. Named explicitly rather than by directory — node's test runner treats
# everything under test/ as a test file, and call.mjs is not one.
browser:
go vet -tags browser ./internal/meettest/
@@ -144,6 +146,7 @@ browser:
node --test test/browser/chat.test.mjs
node --test test/browser/room-share.test.mjs
node --test test/browser/room-lane.test.mjs
+ node --test test/browser/media.test.mjs
npm install --no-save playwright@1.62.1
npx playwright install chromium
go test -tags browser -count=1 -timeout 20m -run TestCallInARealBrowser -v ./internal/meettest/
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 1bc3c64..f8c15ea 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -231,46 +231,82 @@ function growth(g) {
return g.base + ' -> ' + g.now;
}
-// sustained is frameGrowth twice over, and it is the difference between
-// "media started again" and "media is running" (Codex's S4-6 gap).
-// frameGrowth returns on the first increase, so its timeout is a
-// deadline and not an observation: a stream that delivers three frames
-// and stops satisfies it. Two consecutive intervals, each from a
-// baseline taken when the one before it finished, is a window — and the
-// failure this slice is about is exactly a tile that runs for a moment
-// after a renegotiation and then stops.
-async function sustained(page, key, ms) {
- const first = await frameGrowth(page, key, ms);
- if (!first.ok) return { ok: false, base: first.base, now: first.now, interval: 1 };
- const second = await frameGrowth(page, key, ms);
- return { ok: second.ok, base: first.base, now: second.now, interval: second.ok ? 2 : 2 };
-}
-
-// media asks the same question of everything a side should be receiving
-// at once: the other camera, and every screen it is currently showing.
-// One call per settled transition, so no check is about a moment and
-// all of them are about a window.
-async function media(side, ms) {
- const out = [];
- const camera = await sustained(side.page, side.other, ms);
- out.push({ what: 'camera', got: camera });
- for (const key of (await state(side.page)).shares) {
- out.push({ what: key + ' screen', got: await sustained(side.page, key + '#share', ms) });
+// MEDIA_WINDOWS and MEDIA_WINDOW_MS are the shape of an observation:
+// three subwindows of a second and a half, and growth required in every
+// one of them. Four and a half seconds of watching, not four and a half
+// seconds of deadline.
+const MEDIA_WINDOWS = 3;
+const MEDIA_WINDOW_MS = 1500;
+
+// observe watches a set of counters over a fixed duration and requires
+// each of them to move in *every* subwindow (Codex S4-14).
+//
+// The helper it replaces was two early-return waits in a row, which is
+// a deadline and not an observation: counters reading 0, 1, 2, 3 and
+// then frozen for ever satisfied it, because it stopped looking the
+// moment it saw an increase. The failure this whole slice is about is a
+// tile that runs for a moment after a renegotiation and then stops, so
+// "it moved once" is precisely the answer that must not count.
+//
+// `read` returns every counter at once, so streams and peers are
+// watched together rather than one after another — a check that walks
+// them in turn gives each one a different window, and the last one gets
+// its window after the others have had theirs. Lifted by name in
+// test/browser/media.test.mjs, with a burst-then-freeze control.
+async function observe(read, keys, opts) {
+ const o = opts || {};
+ const windows = o.windows === undefined ? MEDIA_WINDOWS : o.windows;
+ const windowMs = o.windowMs === undefined ? MEDIA_WINDOW_MS : o.windowMs;
+ const wait = o.sleep || sleep;
+ if (!keys.length) return { ok: false, stalled: [['(nothing was expected)', 0]], series: [] };
+ const seen = [await read()];
+ const stalled = new Map();
+ for (let w = 1; w <= windows; w++) {
+ await wait(windowMs);
+ const now = await read();
+ const before = seen[seen.length - 1];
+ for (const key of keys) {
+ if ((now[key] || 0) > (before[key] || 0)) continue;
+ if (!stalled.has(key)) stalled.set(key, w);
+ }
+ seen.push(now);
}
- return out;
+ return {
+ ok: stalled.size === 0,
+ stalled: Array.from(stalled.entries()),
+ series: keys.map((k) => k + ' ' + seen.map((s) => s[k] || 0).join('->')),
+ };
}
-// checkMedia turns that into one check per stream, so a failure names
-// which one stopped rather than saying "something did".
-async function checkMedia(side, ms, at) {
- const readings = await media(side, ms);
- for (const r of readings) {
- if (!check(r.got.ok, at + side.label + ' is receiving the ' + r.what +
- ' in two successive intervals (' + growth(r.got) + ')')) {
- line(' ' + (await diagnose(side.page, side.label)));
- }
+// watchMedia is `observe` over what a set of sides should be receiving,
+// named explicitly by the caller rather than read back off the page:
+// a check that asks the page which screens it is showing and then
+// requires those to move cannot fail for a screen that never arrived.
+//
+// Each side is {page, label, keys} — every frame key that side should
+// be receiving, the other peer's camera included.
+async function watchMedia(sides, at, opts) {
+ const keys = [];
+ for (const side of sides) {
+ for (const key of side.keys) keys.push(side.label + ' | ' + key);
}
- return readings.every((r) => r.got.ok);
+ const read = async () => {
+ const snaps = await Promise.all(sides.map((side) => state(side.page)));
+ const out = {};
+ sides.forEach((side, i) => {
+ for (const key of side.keys) out[side.label + ' | ' + key] = snaps[i].frames[key] || 0;
+ });
+ return out;
+ };
+ const seen = await observe(read, keys, opts);
+ const ok = check(seen.ok, at + 'every stream kept arriving through ' +
+ ((opts && opts.windows) || MEDIA_WINDOWS) + ' windows of ' +
+ (((opts && opts.windowMs) || MEDIA_WINDOW_MS) / 1000) + ' s (' + seen.series.join('; ') + ')');
+ if (!ok) {
+ for (const [key, window] of seen.stalled) line(' ' + key + ' stopped in window ' + window);
+ for (const side of sides) line(' ' + (await diagnose(side.page, side.label)));
+ }
+ return ok;
}
// ── the browser ──────────────────────────────────────────────────────
@@ -1234,10 +1270,10 @@ const run = {
// health (Codex's S4-6 gap). Both sides, because the sharer's own
// view of the viewer's camera is renegotiated just as hard and was
// never looked at.
- await checkMedia({ page: viewer.page, label: viewer.label, other: sharer.peer }, 5000,
- 'after the share started: ');
- await checkMedia({ page: sharer.page, label: sharer.label, other: viewer.peer }, 5000,
- 'after the share started: ');
+ await watchMedia([
+ { page: viewer.page, label: viewer.label, keys: [sharer.peer, key] },
+ { page: sharer.page, label: sharer.label, keys: [viewer.peer] },
+ ], 'after the share started: ');
const pressed = await shareControl(sharer.page);
check(pressed && pressed.pressed === 'true' && pressed.state === 'On',
@@ -1281,10 +1317,12 @@ const run = {
// Again from a fresh baseline, taken now that the stop has settled,
// and again across a window rather than at an instant.
- await checkMedia({ page: viewer.page, label: viewer.label, other: sharer.peer }, 10000,
- 'after the share stopped: ');
- await checkMedia({ page: sharer.page, label: sharer.label, other: viewer.peer }, 10000,
- 'after the share stopped: ');
+ // The screen is gone by now and is not expected; the two cameras
+ // are, and they are the half a stopped share can take with it.
+ await watchMedia([
+ { page: viewer.page, label: viewer.label, keys: [sharer.peer] },
+ { page: sharer.page, label: sharer.label, keys: [viewer.peer] },
+ ], 'after the share stopped: ');
const off = await until(async () => {
const b = await shareControl(sharer.page);
@@ -1417,7 +1455,13 @@ const run = {
// intervals per stream, both screens and both cameras, so a
// stream that delivers a frame and stops cannot answer for one
// that is running.
- for (const side of sides) await checkMedia(side, 8000, at + 'after the wait: ');
+ // Both cameras and both screens, named rather than read back off
+ // the page, and all four watched through the same windows.
+ await watchMedia(sides.map((side) => ({
+ page: side.page,
+ label: side.label,
+ keys: [side.other, side.other + '#share'],
+ })), at + 'after the wait: ');
if (round === 1) {
for (const side of sides) {
@@ -1445,7 +1489,11 @@ const run = {
// The stop is a renegotiation too, and the round that follows
// would happily start on a camera this one had already killed.
- for (const side of sides) await checkMedia(side, 8000, at + 'after both stopped: ');
+ await watchMedia(sides.map((side) => ({
+ page: side.page,
+ label: side.label,
+ keys: [side.other],
+ })), at + 'after both stopped: ');
}
// And the asymmetric ending, which is the one a symmetrical
@@ -1492,7 +1540,12 @@ const run = {
// checks above all ran before those five seconds, so a camera that
// froze during them left every one of them green (Codex's S4-6
// gap). Both sides, and the screen that is still being shared.
- for (const side of sides) await checkMedia(side, 8000, 'after the one-sided stop settled: ');
+ // The stopper still holds the keeper's screen; the keeper holds
+ // none. Both cameras, either way.
+ await watchMedia([
+ { page: stopper.page, label: stopper.label, keys: [stopper.other, keptKey] },
+ { page: keeper.page, label: keeper.label, keys: [keeper.other] },
+ ], 'after the one-sided stop 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') + ')');
@@ -1613,14 +1666,40 @@ const run = {
// successive intervals inside the repair budget — two polls to
// notice, a replaceTrack cycle, two more polls, and a reset behind
// that.
- await checkMedia(polite, 20000, 'after the late collision: ');
- await checkMedia(impolite, 20000, 'after the late collision: ');
+ // Recovery has to *arrive* before it can be required to last, and
+ // arriving takes real time: three polls for the watch to notice,
+ // two replays, and behind those a reset that rebuilds the pair. So
+ // a deadline for the arrival — growth from a baseline taken now,
+ // never "the counter is above zero", which a counter left over from
+ // before the collision answers on its own — and then, separately,
+ // an observation for the lasting.
+ for (const side of [polite, impolite]) {
+ for (const key of [side.other, side.other + '#share']) {
+ const back = await frameGrowth(side.page, key, 40000);
+ if (!check(back.ok, side.label + ' has ' + key + ' arriving again after the collision (' +
+ growth(back) + ')')) {
+ line(' ' + (await diagnose(side.page, side.label)));
+ line(' identity: ' + JSON.stringify(await mediaIdentity(side.page)));
+ }
+ }
+ }
+ // Settled *after* the recovery, because a reset is one of the ways
+ // this scenario is allowed to recover and a pair mid-rebuild is not
+ // a pair that failed to settle.
const settled = await bothStable(
[{ page: polite.page, label: polite.label, other: polite.other },
- { page: impolite.page, label: impolite.label, other: impolite.other }], 5000);
+ { page: impolite.page, label: impolite.label, other: impolite.other }], 15000);
check(settled === null, 'the late collision left both connections settled — ' + JSON.stringify(settled));
+ // And then the windows: every stream, both pages, growth in each
+ // one. A recovery that lasts as long as a check and stops
+ // afterwards is the shape this scenario exists to refuse.
+ await watchMedia([
+ { page: polite.page, label: polite.label, keys: [polite.other, polite.other + '#share'] },
+ { page: impolite.page, label: impolite.label, keys: [impolite.other, impolite.other + '#share'] },
+ ], 'and kept arriving once it had 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') + ')');
diff --git a/test/browser/media.test.mjs b/test/browser/media.test.mjs
new file mode 100644
index 0000000..7be793a
--- /dev/null
+++ b/test/browser/media.test.mjs
@@ -0,0 +1,142 @@
+// media.test.mjs — the browser gate's own observation helper, gated
+// without a browser.
+//
+// node --test test/browser/media.test.mjs
+//
+// `observe` is the thing every media assertion in call.mjs now stands
+// on, and it is the one piece of the gate that can be wrong in a way
+// that makes the gate quieter rather than louder. The helper it
+// replaced was two early-return waits in a row — a deadline, not an
+// observation — and counters reading 0, 1, 2, 3 and then frozen for
+// ever satisfied it, because it stopped looking the moment it saw an
+// increase (Codex S4-14). The failure this slice is about is a tile
+// that runs for a moment after a renegotiation and then stops, so "it
+// moved once" is exactly the answer that must not count.
+//
+// Lifted out of call.mjs by name, the way the room.js tests lift room.js,
+// so what is under test is the shipped helper and not a copy of it.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const callJS = fileURLToPath(new URL('./call.mjs', import.meta.url));
+const src = readFileSync(callJS, 'utf8');
+
+function lift(source, name) {
+ let start = source.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not in call.mjs any more — this test is stale, not passing');
+ if (source.slice(start - 6, start) === 'async ') start -= 6;
+ let depth = 0;
+ for (let i = source.indexOf('{', start); i < source.length; i++) {
+ if (source[i] === '{') depth += 1;
+ else if (source[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return source.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name + ' out of call.mjs');
+}
+
+function readNumber(name) {
+ const m = new RegExp('const ' + name + ' = (\\d+);').exec(src);
+ assert.notEqual(m, null, name + ' is not a number constant in call.mjs any more');
+ return Number(m[1]);
+}
+
+const MEDIA_WINDOWS = readNumber('MEDIA_WINDOWS');
+const MEDIA_WINDOW_MS = readNumber('MEDIA_WINDOW_MS');
+
+// eslint-disable-next-line no-new-func
+const observe = new Function(
+ 'MEDIA_WINDOWS', 'MEDIA_WINDOW_MS', 'sleep',
+ lift(src, 'observe') + '\nreturn observe;',
+)(MEDIA_WINDOWS, MEDIA_WINDOW_MS, async () => {});
+
+// counters plays a script: one entry per read, each a map of key to
+// total. Nothing sleeps — the helper's own waiting is stubbed out, so
+// these tests are about what it *requires*, not how long it takes.
+function counters(script) {
+ let at = 0;
+ return async () => script[Math.min(at++, script.length - 1)];
+}
+
+test('the shape of an observation is what call.mjs ships', () => {
+ assert.ok(MEDIA_WINDOWS >= 2, 'one window is a deadline, not an observation');
+ assert.ok(MEDIA_WINDOW_MS >= 1000, 'a window shorter than a second says little about video');
+});
+
+test('a stream that keeps arriving passes', async () => {
+ const seen = await observe(counters([{ a: 0 }, { a: 10 }, { a: 20 }, { a: 30 }]), ['a']);
+ assert.equal(seen.ok, true, JSON.stringify(seen));
+ assert.deepEqual(seen.stalled, []);
+ assert.deepEqual(seen.series, ['a 0->10->20->30']);
+});
+
+test('a burst and then a freeze does not pass', async () => {
+ // The control, and Codex's own reproduction: 0, 1, 2, 3 and then 3
+ // for ever satisfied the helper this replaced.
+ const seen = await observe(counters([{ a: 0 }, { a: 1 }, { a: 2 }, { a: 3 }, { a: 3 }, { a: 3 }]),
+ ['a'], { windows: 5 });
+ assert.equal(seen.ok, false, 'a stream that stopped was called healthy');
+ assert.deepEqual(seen.stalled, [['a', 4]], 'the window it stopped in is what a reader needs');
+});
+
+test('a stream that stops in the last window does not pass', async () => {
+ const seen = await observe(counters([{ a: 0 }, { a: 10 }, { a: 20 }, { a: 20 }]), ['a']);
+ assert.equal(seen.ok, false);
+ assert.deepEqual(seen.stalled, [['a', 3]]);
+});
+
+test('a stream that stops and starts again does not pass', async () => {
+ // Growth in every window, not growth overall: a tile that freezes for
+ // a second and a half in the middle of a call is a tile that froze.
+ const seen = await observe(counters([{ a: 0 }, { a: 10 }, { a: 10 }, { a: 30 }]), ['a']);
+ assert.equal(seen.ok, false);
+ assert.deepEqual(seen.stalled, [['a', 2]]);
+});
+
+test('every stream is watched, and each is named when it stops', async () => {
+ const seen = await observe(counters([
+ { a: 0, b: 0, c: 0 },
+ { a: 1, b: 1, c: 1 },
+ { a: 2, b: 1, c: 2 },
+ { a: 3, b: 1, c: 2 },
+ ]), ['a', 'b', 'c']);
+ assert.equal(seen.ok, false);
+ assert.deepEqual(seen.stalled, [['b', 2], ['c', 3]], 'one stream answered for another');
+ assert.equal(seen.series.length, 3);
+});
+
+test('a counter that goes backwards is not growth', async () => {
+ const seen = await observe(counters([{ a: 30 }, { a: 10 }, { a: 20 }, { a: 30 }]), ['a']);
+ assert.equal(seen.ok, false);
+ assert.deepEqual(seen.stalled, [['a', 1]]);
+});
+
+test('expecting nothing is a failure, not a pass', async () => {
+ // A check that walks the screens a page says it is showing would
+ // otherwise pass vacuously for a screen that never arrived, which is
+ // why the caller names its keys.
+ const seen = await observe(counters([{}]), []);
+ assert.equal(seen.ok, false);
+ assert.equal(seen.series.length, 0);
+});
+
+test('a stream nobody is sending reads as zero throughout, and fails', async () => {
+ const seen = await observe(counters([{ a: 5 }, { a: 5 }, { a: 5 }, { a: 5 }]), ['a', 'missing']);
+ assert.equal(seen.ok, false);
+ assert.deepEqual(seen.stalled, [['a', 1], ['missing', 1]]);
+ assert.deepEqual(seen.series, ['a 5->5->5->5', 'missing 0->0->0->0']);
+});
+
+test('the observation waits the window it says it does', async () => {
+ const waited = [];
+ const timed = new Function(
+ 'MEDIA_WINDOWS', 'MEDIA_WINDOW_MS', 'sleep',
+ lift(src, 'observe') + '\nreturn observe;',
+ )(MEDIA_WINDOWS, MEDIA_WINDOW_MS, async (ms) => { waited.push(ms); });
+ await timed(counters([{ a: 0 }, { a: 1 }, { a: 2 }, { a: 3 }]), ['a']);
+ assert.deepEqual(waited, new Array(MEDIA_WINDOWS).fill(MEDIA_WINDOW_MS),
+ 'the windows are not the duration the constants name');
+});