room.js: a tile that decodes and does not paint is handed its media back
This closes `laterolls`, and with it the deploy blocker the fix wave's first round left standing. S4-9 was a real defect and not this one. With the tile bound to the role's own track, `laterolls` failed in exactly the same place, and the identity reading added for the purpose settled what the failure is not: wanted ce46729b-…aef7 bound ce46729b-…aef7 element ["ce46729b-…aef7:live"] receiver mid 1, sendrecv, live, muted:false, isCamera:true agrees true currentTime 1.452 Three answers, one object, on both sides. The element holds exactly the track the connection receives the camera on, live and unmuted, and the decoder goes on delivering frames it never paints. Everything upstream of the element is right; the picture is stopped downstream of it. So the same shape as the camera watch, one link further along. `pollFrames` already walks getStats every second; it now reads `framesDecoded` per tile in every browser, not only where rVFC is missing, and `judgeRender` asks three questions together: is the track playable, is the decoder delivering, and is the element's own clock moving. Three polls of "yes, yes, no" is a stall — a tile receiving nothing is idle, not stalled, which is what the decode count is there to tell apart — and the repair is to take the stream away and give it back and ask it to play again. Two of those, then a reset, then it stops asking. Measured, not assumed. Before: [25,26,26,26,26,26] rendered frames at 500 ms — stopped, and stopped for the twenty seconds the check waited. After: [25,25,25,25,25,26] — frozen for the three polls it takes to notice, then moving again, and both cameras and both screens clear their two-interval windows. `laterolls` is out of `skipped` and passing. Five node tests over a fake poll series, including the two that keep the watch from firing on things that are not its business: a tile receiving nothing, and a track that is muted or ended. node --check, 203 node tests, make ci; `laterolls` green in the browser.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
4 files changed,
+387
−104
internal/meet/static/room.js+154 −2internal/meettest/browser_test.go+0 −53test/browser/call.mjs+141 −49test/browser/room-lane.test.mjs+92 −0
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js| index 1eda7a8..7b6d2d2 100644 |
| --- a/internal/meet/static/room.js |
| +++ b/internal/meet/static/room.js |
| @@ -166,6 +166,13 @@ |
| // S4-5, and the Task 4 re-review's item 2). |
| const CAMERA_STALL_POLLS = 2; |
| const CAMERA_REPAIRS = 1; |
| + // And the same shape for the last link in the chain: how many polls a |
| + // tile may hold a live, unmuted track that its connection is decoding |
| + // frames for and paint none of them, before the element is handed its |
| + // media again — and how many of those before the pair is rebuilt |
| + // instead (Codex round 2, the branch after S4-9). |
| + const RENDER_STALL_POLLS = 3; |
| + const RENDER_REPLAYS = 2; |
| // §5.4: the tile key a peer's screen gets, built by shareKey. |
| const SHARE_SUFFIX = '#share'; |
| const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' && |
| @@ -3596,6 +3603,10 @@ |
| t = { |
| figure, video, label, nameEl, note, retry, play, |
| remove: null, stream: null, track: null, self: isSelf, |
| + // What judgeRender remembers between polls: the element's own |
| + // clock, the decoder's count, and how long the two have |
| + // disagreed. |
| + renderTime: -1, renderDecoded: -1, renderStalls: 0, renderReplays: 0, |
| }; |
| tiles.set(key, t); |
| if (!isSelf) applySink(video); |
| @@ -4089,6 +4100,85 @@ |
| } |
| } |
| + // judgeRender watches the one link nothing else in this file can see: |
| + // an element that holds exactly the right track — live, unmuted, the |
| + // one the connection is receiving the camera on — on a connection |
| + // that is decoding frames for it, and paints none of them. |
| + // |
| + // That is not a hypothetical. It is what the browser gate's |
| + // `laterolls` reproduces after a forced late collision, and the |
| + // identity reading `meet.media()` was added for settles what it is |
| + // not: the tile is bound to the receiver's own track and the element |
| + // holds that track alone (`agrees: true`), so nothing upstream of the |
| + // element is confused about which media this is. Frames are decoded |
| + // and the picture does not move. |
| + // |
| + // Three signals, and all three have to say so together: the track is |
| + // playable, the decoder is delivering (`framesDecoded` rising), and |
| + // the element's own clock is not. A tile receiving nothing is idle, |
| + // not stalled — that is what `arriving` is for — and a tile whose |
| + // track has ended or gone muted is a sender's doing, not this one's. |
| + // |
| + // Pure over the tile record, and lifted by name in the node test. |
| + function judgeRender(t, decoded) { |
| + const track = t && t.track; |
| + const playable = !!(t && t.video && t.stream && track && |
| + track.readyState !== 'ended' && !track.muted); |
| + if (!playable || typeof decoded !== 'number') { |
| + t.renderTime = -1; |
| + t.renderDecoded = -1; |
| + t.renderStalls = 0; |
| + return ''; |
| + } |
| + const time = t.video.currentTime || 0; |
| + const moving = time !== t.renderTime; |
| + const arriving = decoded > t.renderDecoded; |
| + const first = t.renderDecoded < 0; |
| + t.renderTime = time; |
| + t.renderDecoded = decoded; |
| + if (first) return ''; |
| + if (moving) { |
| + // Painting. Whatever was wrong is not wrong now, and a replay |
| + // that worked is forgotten rather than counted against the next |
| + // stall. |
| + t.renderStalls = 0; |
| + t.renderReplays = 0; |
| + return ''; |
| + } |
| + if (!arriving) { |
| + // Nothing to paint. A frozen picture on a stream that has stopped |
| + // arriving is the sender's business, and the camera watch's. |
| + t.renderStalls = 0; |
| + return ''; |
| + } |
| + t.renderStalls += 1; |
| + if (t.renderStalls < RENDER_STALL_POLLS) return ''; |
| + t.renderStalls = 0; |
| + t.renderReplays += 1; |
| + if (t.renderReplays <= RENDER_REPLAYS) return 'replay'; |
| + if (t.renderReplays === RENDER_REPLAYS + 1) return 'reset'; |
| + // Asked for a reset already and the picture is still frozen. The |
| + // reset rebuilds the connection and its tiles, so being here means |
| + // it did not take, and asking again every three seconds would be a |
| + // loop rather than a recovery. |
| + return ''; |
| + } |
| + |
| + // replayTile hands an element its media again: the stream taken away |
| + // and given back, and play() asked for once more. Cheaper than a |
| + // renegotiation and enough for a decoder pipeline that has stopped |
| + // painting what it is still producing. |
| + function replayTile(key, t) { |
| + try { |
| + t.video.srcObject = null; |
| + t.video.srcObject = t.stream; |
| + } catch { |
| + // An element that has gone: the next render builds another. |
| + return; |
| + } |
| + startPlaying(key, t); |
| + } |
| + |
| // pollFrames is one getStats walk per connection, once a second, and |
| // it does two jobs. The inbound half is the frame counter's fallback |
| // for a browser without requestVideoFrameCallback. The outbound half |
| @@ -4104,19 +4194,32 @@ |
| try { |
| const stats = await entry.pc.getStats(); |
| const outbound = []; |
| + // What the decoder is delivering, per tile: the fallback frame |
| + // count where there is no requestVideoFrameCallback, and — in |
| + // every browser — the half of judgeRender's question that the |
| + // element cannot answer. |
| + const decoded = new Map(); |
| stats.forEach((report) => { |
| if (report.type === 'outbound-rtp' && report.kind === 'video') { |
| outbound.push(report); |
| return; |
| } |
| - if (HAS_RVFC) return; |
| if (report.type !== 'inbound-rtp' || report.kind !== 'video' || |
| typeof report.framesDecoded !== 'number') { |
| return; |
| } |
| const key = frameKey(id, entry, report.mid || ''); |
| - if (key) frames.set(key, report.framesDecoded); |
| + if (!key) return; |
| + decoded.set(key, report.framesDecoded); |
| + if (!HAS_RVFC) frames.set(key, report.framesDecoded); |
| }); |
| + for (const [key, count] of decoded) { |
| + const t = tiles.get(key); |
| + if (!t || t.self) continue; |
| + const verdict = judgeRender(t, count); |
| + if (verdict === 'replay') replayTile(key, t); |
| + else if (verdict === 'reset') requestReset(id); |
| + } |
| const tx = localCameraTx(entry); |
| const verdict = judgeCamera(entry, tx, cameraReport(outbound, (tx && tx.mid) || '')); |
| if (verdict === 'repair') await repairCamera(tx); |
| @@ -4165,6 +4268,55 @@ |
| } |
| window.meet = { |
| + // media() is the identity reading the fix wave's second round asked |
| + // for: which track each tile is bound to, which tracks its element |
| + // is actually holding, and what the connection's own receivers say |
| + // it should be. Three answers that ought to be one object, and a |
| + // browser scenario cannot compare objects across an evaluate() |
| + // boundary — so they are compared here, with ===, and reported as |
| + // ids and a verdict. Ids only: a track id is local to this tab and |
| + // names nothing about anybody. |
| + media() { |
| + const out = {}; |
| + for (const [id, entry] of pcs) { |
| + const t = tiles.get(id) || null; |
| + const el = (t && t.video && t.video.srcObject) || null; |
| + const want = entry.cameraTrack || null; |
| + const receivers = []; |
| + try { |
| + for (const tx of entry.pc.getTransceivers()) { |
| + const track = tx && tx.receiver && tx.receiver.track; |
| + if (!track || track.kind !== 'video') continue; |
| + receivers.push({ |
| + mid: tx.mid || '', |
| + direction: tx.currentDirection || tx.direction || '', |
| + track: track.id, |
| + state: track.readyState, |
| + muted: !!track.muted, |
| + isCamera: track === want, |
| + }); |
| + } |
| + } catch { |
| + // A closing connection has nothing to say. |
| + } |
| + out[id] = { |
| + cameraMid: entry.cameraMid || '', |
| + wanted: (want && want.id) || '', |
| + bound: (t && t.track && t.track.id) || '', |
| + element: el && el.getVideoTracks |
| + ? el.getVideoTracks().map((tr) => tr.id + ':' + tr.readyState + (tr.muted ? ':muted' : '')) |
| + : [], |
| + currentTime: (t && t.video && t.video.currentTime) || 0, |
| + receivers, |
| + // The whole question in one word: is the track this tile is |
| + // bound to the one the connection is receiving the camera on, |
| + // and is it the one the element is actually holding? |
| + agrees: !!(want && t && t.track === want && el && el.getVideoTracks && |
| + el.getVideoTracks()[0] === want), |
| + }; |
| + } |
| + return out; |
| + }, |
| state() { |
| // signaling and connection are the two per-peer facts a gate |
| // cannot see from the DOM: whether negotiation settled, and |
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go| index cf102e7..edaee28 100644 |
| --- a/internal/meettest/browser_test.go |
| +++ b/internal/meettest/browser_test.go |
| @@ -178,59 +178,6 @@ var scenarios = []string{ |
| // roster for the first door to prefer. The three ICE scenarios and |
| // staletarget have run in the gate ever since. |
| var skipped = map[string]string{ |
| - // laterolls, added in the Codex fix wave, is shipped skipped on the |
| - // same terms mutualshare was: every assertion as written, the exact |
| - // failure recorded, and the fix owed by the next round rather than |
| - // by the gate. |
| - // |
| - // It forces the residual spec §15.16 names. One context's own |
| - // `offer` POSTs are held for four times POLITE_YIELD, so the remote |
| - // offer really does arrive after the yield has ended and the polite |
| - // side really does roll back — the scenario proves an offer was held |
| - // (`1 held`) — and then it asserts that both cameras and both |
| - // screens are still delivering frames afterwards. |
| - // |
| - // It earned its place before it was ever green. Three defects came |
| - // out of the three runs it has had, all three fixed on this branch: |
| - // a `negotiationneeded` dropped by the new serializer (f982bdf), a |
| - // frame counter that died when a tile's media was replaced (3ecebb6, |
| - // 2eadfd2), and a tile left rendering an ended track after the far |
| - // side's camera was rebuilt (fee4eb4). |
| - // |
| - // What still fails, identically in all three runs, is one check: |
| - // |
| - // FAIL member B is receiving the impolite side's camera (28 -> 28) |
| - // |
| - // and the state under it is the interesting part. The connection is |
| - // `stable` and `connected`, all four transceivers are as they should |
| - // be (`1 sendrecv/sendrecv:video` for the camera, `2 recvonly` for |
| - // the far screen, `3 sendonly` for ours), every track reads `live`, |
| - // and *both* inbound video streams are decoding — 452 and 446 |
| - // frames, `muted:false`, one PLI each. The far side's own outbound |
| - // camera stats rise throughout. The screen tile plays perfectly at |
| - // 1280 wide. What does not move is the camera tile: `readyState` 4, |
| - // `paused` false, and `currentTime` stuck at 1.5 s — about |
| - // forty-five frames rendered after the collision, and then nothing, |
| - // for the twenty seconds the check waits and the ten after it. |
| - // |
| - // So: media arrives, the decoder decodes it, and the element renders |
| - // none of it, on a tile whose stream was rebuilt under it. That is |
| - // one step further in than anything this branch has fixed — the |
| - // three defects above were all app-side and all measurable — and it |
| - // resembles the environment's own media stall (see the record's §5) |
| - // more than it resembles anything in room.js: no other scenario |
| - // reproduces it, mutualshare renegotiates the same connection six |
| - // times over and both cameras keep rendering throughout. |
| - // |
| - // The next round owns it. Where to start: whether the receiving |
| - // track for that m-line is a *new* track object that never reached |
| - // `onRemoteTrack` (a `track` event lost to the rollback), or the |
| - // same one the element already had; and whether `startPlaying` needs |
| - // to re-play an element whose media was rebuilt under it. Delete |
| - // this entry to put the scenario back — that, and nothing else, is |
| - // what "fixed" means. |
| - "laterolls": "the camera tile stops rendering after a forced late collision: connection stable, both inbound streams decoding (452/446 frames, unmuted), tracks live, and the element stuck at currentTime 1.5 — see the comment above this entry and the record's §6", |
| - |
| // FIXED in Task 4's fix round 2 — see task-4-report.md. mutualshare |
| // has run in the gate ever since, and the account below is left as |
| // written because it is what the fix was made against. What it could |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 09e1c2a..1bc3c64 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -231,6 +231,48 @@ 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) }); |
| + } |
| + return out; |
| +} |
| + |
| +// 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))); |
| + } |
| + } |
| + return readings.every((r) => r.got.ok); |
| +} |
| + |
| // ── the browser ────────────────────────────────────────────────────── |
| const browser = await chromium.launch({ args: LAUNCH_ARGS }); |
| @@ -754,11 +796,46 @@ const pcTap = () => { |
| return out; |
| }; |
| } |
| + // Every remote description this connection applies, and the |
| + // state it was in when it applied it. That is what a rollback |
| + // looks like from outside: perfect negotiation's polite side |
| + // applies a remote *offer* while it is in `have-local-offer`, and |
| + // setRemoteDescription rolls its own offer back to do it. Nothing |
| + // in room.js records that, and a scenario about a late collision |
| + // has to be able to prove the collision happened rather than |
| + // assume a delay caused one (Codex round 2). |
| + window.__rollbacks = window.__rollbacks || []; |
| + const setRemote = pc.setRemoteDescription && pc.setRemoteDescription.bind(pc); |
| + if (setRemote) { |
| + pc.setRemoteDescription = (desc) => { |
| + const before = pc.signalingState; |
| + const out = setRemote(desc); |
| + const type = (desc && desc.type) || ''; |
| + if (type === 'offer' && before === 'have-local-offer') { |
| + window.__rollbacks.push({ pc: index, at: Date.now() }); |
| + } |
| + return out; |
| + }; |
| + } |
| return pc; |
| }, |
| }); |
| }; |
| +// rollbacks is what that trap saw: one entry per remote offer applied |
| +// over this side's own outstanding offer. |
| +function rollbacks(page) { |
| + return page.evaluate(() => (window.__rollbacks || []).length); |
| +} |
| + |
| +// mediaIdentity is room.js's own answer to "what is this tile bound |
| +// to": the track each tile holds, the tracks its element is holding, |
| +// and what the connection's receivers say. The comparison is made in |
| +// the page, because object identity does not survive an evaluate(). |
| +function mediaIdentity(page) { |
| + return page.evaluate(() => (window.meet && window.meet.media && window.meet.media()) || {}); |
| +} |
| + |
| // installedPerConnection folds the trap's log into "what each live |
| // connection is actually configured with": the last configuration it |
| // took, keyed by the connection's own index. |
| @@ -1150,16 +1227,17 @@ const run = { |
| 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))); |
| + // And still flowing through a window rather than at an instant, in |
| + // both directions: the failure this slice is about is a tile that |
| + // runs for a moment after a renegotiation and then stops, which a |
| + // check that returns on the first new frame cannot tell from |
| + // 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: '); |
| const pressed = await shareControl(sharer.page); |
| check(pressed && pressed.pressed === 'true' && pressed.state === 'On', |
| @@ -1201,10 +1279,12 @@ const run = { |
| check(ended, 'the received track ended rather than merely stopping being drawn (readyState ' + |
| (await viewer.page.evaluate(() => (window.__shareTrack || {}).readyState || '(no track)')) + ')'); |
| - // 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))); |
| + // 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: '); |
| const off = await until(async () => { |
| const b = await shareControl(sharer.page); |
| @@ -1333,16 +1413,11 @@ const run = { |
| // 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))); |
| - } |
| + // live track, and nothing coming out (Codex S4-6). Two successive |
| + // 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: '); |
| if (round === 1) { |
| for (const side of sides) { |
| @@ -1367,6 +1442,10 @@ const run = { |
| } |
| check((await state(pageA)).sharing === false && (await state(pageB)).sharing === false, |
| at + 'both tabs say they have stopped'); |
| + |
| + // 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: '); |
| } |
| // And the asymmetric ending, which is the one a symmetrical |
| @@ -1409,6 +1488,12 @@ const run = { |
| check(settledAfter === null, |
| 'the one-sided stop left both connections settled — ' + JSON.stringify(settledAfter)); |
| + // Last of all, and after that wait rather than before it: the media |
| + // 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: '); |
| + |
| check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')'); |
| check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')'); |
| @@ -1495,35 +1580,42 @@ const run = { |
| (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))); |
| + // The collision, asserted rather than assumed. Holding an offer |
| + // back is a delay; what this scenario is about is what the delay |
| + // causes, and that is a remote offer arriving over this side's own |
| + // outstanding one — which is precisely when perfect negotiation |
| + // rolls the polite side back. The trap counts exactly that. |
| + const rolled = await until(async () => (await rollbacks(polite.page)) > 0, 15000); |
| + check(rolled, 'the delay produced a rollback on the polite side (' + |
| + (await rollbacks(polite.page)) + ' seen; without one this scenario proves nothing)'); |
| + |
| + // The stall itself, watched but not required, and watched as a |
| + // series rather than as two numbers five seconds apart: a total |
| + // that grew across the window cannot tell a steady stream from one |
| + // that ran for a moment and stopped. |
| + const series = []; |
| + for (let i = 0; i < 6; i++) { |
| + series.push((await state(impolite.page)).frames[polite.peer] || 0); |
| + await sleep(500); |
| + } |
| + const moving = series[series.length - 1] > series[series.length - 2]; |
| + line('the polite side\'s camera, rendered frames every 500 ms: ' + JSON.stringify(series) + |
| + (moving ? ' — still moving' : ' — stopped')); |
| + // And what the tiles are actually bound to, on both sides, whatever |
| + // happens next: three answers that ought to be one object. |
| 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))); |
| + line(side.label + ' identity: ' + JSON.stringify(await mediaIdentity(side.page))); |
| } |
| + // The recovery, which is the promise: whatever the rollback did, |
| + // both cameras and both screens are delivering frames across two |
| + // 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: '); |
| + |
| const settled = await bothStable( |
| [{ page: polite.page, label: polite.label, other: polite.other }, |
| { page: impolite.page, label: impolite.label, other: impolite.other }], 5000); |
diff --git a/test/browser/room-lane.test.mjs b/test/browser/room-lane.test.mjs| index b9d0f57..6b42efa 100644 |
| --- a/test/browser/room-lane.test.mjs |
| +++ b/test/browser/room-lane.test.mjs |
| @@ -959,6 +959,98 @@ test('the numbers these tests assert against are the ones room.js ships', () => |
| assert.ok(CAMERA_REPAIRS >= 1, 'a camera must be given at least one repair before a reset'); |
| }); |
| +// --------------------------------------------------------------- |
| +// The render watch (Codex round 2, the branch after S4-9) |
| +// --------------------------------------------------------------- |
| +// |
| +// The last link in the chain, and the one nothing else in room.js can |
| +// see: an element holding exactly the right track — live, unmuted, the |
| +// one the connection receives the camera on — while the decoder |
| +// delivers frames it never paints. `meet.media()` settles that this is |
| +// not a binding confusion: `agrees: true`, and the picture still does |
| +// not move. |
| + |
| +const RENDER_STALL_POLLS = pin('RENDER_STALL_POLLS'); |
| +const RENDER_REPLAYS = pin('RENDER_REPLAYS'); |
| + |
| +const renderBox = new Function( |
| + 'RENDER_STALL_POLLS', 'RENDER_REPLAYS', |
| + lift(src, 'judgeRender') + '\nreturn { judgeRender };', |
| +); |
| +const render = renderBox(RENDER_STALL_POLLS, RENDER_REPLAYS); |
| + |
| +// A tile as judgeRender sees one: an element with a clock, and the |
| +// track it is bound to. |
| +function fakeTile(opts) { |
| + const o = opts || {}; |
| + return { |
| + video: { currentTime: 0 }, |
| + stream: {}, |
| + track: { readyState: o.readyState || 'live', muted: !!o.muted }, |
| + renderTime: -1, |
| + renderDecoded: -1, |
| + renderStalls: 0, |
| + renderReplays: 0, |
| + }; |
| +} |
| + |
| +// paint drives a series of polls: each entry is [decodedTotal, |
| +// currentTime], which is exactly the pair of numbers the watch |
| +// compares. |
| +function paint(t, polls) { |
| + return polls.map(([decoded, time]) => { |
| + t.video.currentTime = time; |
| + return render.judgeRender(t, decoded); |
| + }); |
| +} |
| + |
| +test('a tile that paints what it decodes is never touched', () => { |
| + const t = fakeTile(); |
| + const said = paint(t, [[10, 0.1], [20, 0.4], [30, 0.7], [40, 1.0], [50, 1.3]]); |
| + assert.deepEqual(said, ['', '', '', '', '']); |
| +}); |
| + |
| +test('a tile receiving nothing is idle, not stalled', () => { |
| + // A frozen picture on a stream that has stopped arriving is the |
| + // sender's business, and the camera watch's — not this one's. |
| + const t = fakeTile(); |
| + const said = paint(t, [[10, 0.5], [10, 0.5], [10, 0.5], [10, 0.5], [10, 0.5], [10, 0.5]]); |
| + assert.deepEqual(said, ['', '', '', '', '', '']); |
| + assert.equal(t.renderReplays, 0); |
| +}); |
| + |
| +test('a tile that decodes and does not paint is replayed, then reset', () => { |
| + // The failure `laterolls` reproduces: frames arriving, clock stopped. |
| + const t = fakeTile(); |
| + const said = paint(t, [[10, 1.5], [20, 1.5], [30, 1.5], [40, 1.5], [50, 1.5], |
| + [60, 1.5], [70, 1.5], [80, 1.5], [90, 1.5], [100, 1.5], [110, 1.5], [120, 1.5]]); |
| + assert.equal(said.filter((v) => v === 'replay').length, RENDER_REPLAYS, JSON.stringify(said)); |
| + assert.equal(said.filter((v) => v === 'reset').length, 1, JSON.stringify(said)); |
| + assert.ok(said.lastIndexOf('replay') < said.indexOf('reset'), JSON.stringify(said)); |
| + // And then it stops asking: a reset that did not take is not a reason |
| + // to ask for another one every three seconds. |
| + assert.deepEqual(paint(t, [[130, 1.5], [140, 1.5], [150, 1.5], [160, 1.5]]), ['', '', '', '']); |
| +}); |
| + |
| +test('a tile that starts painting again forgets the replay it needed', () => { |
| + const t = fakeTile(); |
| + paint(t, [[10, 1.5], [20, 1.5], [30, 1.5], [40, 1.5]]); |
| + assert.equal(t.renderReplays, 1, 'the stall was not noticed at all'); |
| + paint(t, [[50, 1.8], [60, 2.1]]); |
| + assert.equal(t.renderReplays, 0, 'a picture that moves again must clear the budget'); |
| +}); |
| + |
| +test('a muted or ended track is nobody here to repair', () => { |
| + for (const t of [fakeTile({ muted: true }), fakeTile({ readyState: 'ended' })]) { |
| + const said = paint(t, [[10, 1.5], [20, 1.5], [30, 1.5], [40, 1.5], [50, 1.5]]); |
| + assert.deepEqual(said, ['', '', '', '', '']); |
| + } |
| + // And a tile with no stats to read is left where it is. |
| + const t = fakeTile(); |
| + assert.equal(render.judgeRender(t, undefined), ''); |
| + assert.equal(t.renderStalls, 0); |
| +}); |
| + |
| // --------------------------------------------------------------- |
| // The camera watch (Codex S4-5) |
| // --------------------------------------------------------------- |