| 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') + ')'); |
| |