| 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 |