// native-screen.mjs — the app shares its (synthetic) screen to a browser // for a long stretch while the call is disturbed underneath it, and the // browser watches the received screen for freezes. // // Written for Paul's report of 2026-09-28 (Mac 0.4.1): "after a while, my // screen froze on her end and she was no longer able to see my screen. I // had to stop and re-share again." Every scenario here asserts the same // thing: the receiver's screen keeps decoding, or comes back within a // bound, without anyone sharing again. // // Scenarios, one per run (argv[4]): // renewals the relay on, the stream renewed every ten seconds (which // is when the app used to restart ICE on every connection) and // relay credentials that need renewing every twenty seconds, // so real renewals — ICE restarts — happen mid-share // resume the streams renewed every ten seconds with no relay, so // every resume is a resume and nothing else // capture the synthetic screen stops producing frames (a capture that // has stalled): the app's watchdog must restart it // encoder the app's encoder stops putting frames out (the screen // sender deactivated under it): the watchdog must recover it // loss the browser stops painting and asks for a keyframe // (screenStalled): the app must answer with one // wedge the browser's screen receiver stops passing anything to its // decoder (an encoded transform that drops every frame), which // no keyframe mends: the app must escalate to a rebuild and // the browser decode again within 30 s, the app's notice going // "recovering", "Restarting your share for …", then nothing // (spec §15.22) // wedge-sticky every screen receiver the browser gets is wedged, new // ones too: the app must end at "Couldn’t restore …", and // clear once the wedge is lifted // soak renewals and resumes as `renewals`, for SCREEN_SOAK_SECONDS, // with the mode cycled (balanced, motion, detail) and the // app's uplink capped and lifted every SCREEN_SOAK_STEP_MS: // every freeze is logged with what the ladder did about it // web-soak the same with the browser sharing and the app watching, the // browser's mode cycled // web-wedge, web-wedge-sticky the same the other way round: the // browser shares, the app's screen decoder wedges, and the // browser's ladder has to restore it (or say it cannot) // // Run by TestNativeMacScreenRecovery (make native-browser). import { syntheticMedia } from './synthetic-media.mjs'; import { chromium } from 'playwright'; import { spawn } from 'node:child_process'; import { createInterface } from 'node:readline'; import assert from 'node:assert/strict'; const [origin, room, binary, scenario] = process.argv.slice(2); const SOAK = Number(process.env.SCREEN_SOAK_SECONDS || 60); // The longest the browser's screen may go without a new frame and still // count as healthy. A freeze is anything longer. const FREEZE_MS = Number(process.env.SCREEN_FREEZE_MS || 3000); // How long recovery may take, from the fault to frames again. const RECOVER_S = Number(process.env.SCREEN_RECOVER_SECONDS || 15); const asserting = process.env.SCREEN_ASSERT !== '0'; const browser = await chromium.launch({ headless: true, args: [ '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', '--disable-features=WebRtcHideLocalIpsWithMdns', '--mute-audio', // getDisplayMedia answered with a fake 1280x720 monitor and no picker, // for the web-* scenarios (see call.mjs). '--auto-accept-this-tab-capture', ] }); // bypassCSP: the wedge's encoded transform runs in a worker made from a // blob, which the page's own policy (default-src 'self') would refuse. // The gate's, never the page's. const context = await browser.newContext({ permissions: ['camera', 'microphone'], bypassCSP: true }); if (process.env.CALLS_SYNTHETIC_MEDIA) await context.addInitScript(syntheticMedia); await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]); const page = await context.newPage(); await page.addInitScript(() => { const Native = window.RTCPeerConnection; window.__nativeInteropPCs = new Set(); // §15.22's wedge: a receiver whose frames never reach its decoder. // Chromium takes an encoded transform only on a receiver that has not // started, so in the wedge scenarios every screen receiver gets one as // it arrives — passing everything through — and __wedgeScreens tells // the ones there now to swallow every frame instead. `sticky` has each // one that arrives after start out swallowing. let worker = null; let serial = 0; const wedgeWorker = () => { if (!worker) { const code = [ 'const drops = new Map();', 'onmessage = (m) => { for (const id of m.data.ids) drops.set(id, m.data.drop); };', 'onrtctransform = (e) => {', ' const id = e.transformer.options.id;', ' if (!drops.has(id)) drops.set(id, !!e.transformer.options.drop);', ' e.transformer.readable.pipeThrough(new TransformStream({ transform(f, c) { if (!drops.get(id)) c.enqueue(f); } }))', ' .pipeTo(e.transformer.writable);', '};', ].join('\n'); worker = new Worker(URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))); } return worker; }; window.__wedge = { sticky: false, wedged: 0, ids: new Map() }; const screenReceiver = (tx) => tx && tx.receiver && tx.receiver.track && tx.receiver.track.kind === 'video' && tx.direction === 'recvonly' && tx.currentDirection !== 'stopped'; const watchReceiver = (tx) => { if (!window.__wedgeReady || !screenReceiver(tx) || window.__wedge.ids.has(tx.receiver)) return; const id = ++serial; window.__wedge.ids.set(tx.receiver, id); tx.receiver.transform = new RTCRtpScriptTransform(wedgeWorker(), { id, drop: window.__wedge.sticky }); if (window.__wedge.sticky) window.__wedge.wedged += 1; }; window.__wedgeScreens = (sticky) => { window.__wedge.sticky = !!sticky; const ids = []; for (const pc of window.__nativeInteropPCs) { for (const tx of pc.getTransceivers()) { const id = screenReceiver(tx) && window.__wedge.ids.get(tx.receiver); if (id) ids.push(id); } } wedgeWorker().postMessage({ ids, drop: true }); window.__wedge.wedged += ids.length; return ids.length; }; window.__releaseWedge = () => { window.__wedge.sticky = false; wedgeWorker().postMessage({ ids: [...window.__wedge.ids.values()], drop: false }); }; window.RTCPeerConnection = class extends Native { // The web-* scenarios' screen goes out as H.264: the app can only // wedge a VideoToolbox decoder (DecoderWedge.swift), and the browser // left to itself sends VP8. What is under test, the ladder, does not // turn on the codec. addTransceiver(track, init) { const tx = super.addTransceiver(track, init); if (window.__h264Screen && init && init.direction === 'sendonly' && track && track.kind === 'video') { const codecs = RTCRtpSender.getCapabilities('video').codecs; const h264 = codecs.filter((c) => /h264/i.test(c.mimeType)); try { tx.setCodecPreferences([...h264, ...codecs.filter((c) => !/h264/i.test(c.mimeType))]); } catch {} } return tx; } constructor(...args) { super(...args); window.__nativeInteropPCs.add(this); this.addEventListener('track', (e) => watchReceiver(e.transceiver)); } }; }); // Receivers made ready to wedge only in the scenarios that wedge them, so // every other scenario's browser decodes exactly as a person's would. await page.addInitScript((ready) => { window.__wedgeReady = ready; }, scenario === 'wedge' || scenario === 'wedge-sticky'); let native; let failed = false; try { await page.goto(`${origin}/m/${room}`); await page.waitForFunction(() => !!window.meet); // The app's decoders can be wedged in the web-* scenarios only. const env = scenario.startsWith('web-') ? Object.assign({}, process.env, { CALLS_DECODER_WEDGE: '1' }) : process.env; native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'], env }); native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n'); let sample = null; let exited = false; const replies = []; native.on('exit', () => { exited = true; }); createInterface({ input: native.stdout }).on('line', (line) => { try { const value = JSON.parse(line); if (value.stats) sample = value; else replies.push(value); } catch {} }); native.stderr.resume(); const wait = async (predicate, label, seconds = 35) => { const until = Date.now() + seconds * 1000; while (Date.now() < until) { if (exited) throw new Error('native executable exited'); if (await predicate()) return; await new Promise((r) => setTimeout(r, 250)); } throw new Error(label + ': ' + JSON.stringify(sample)); }; await wait(() => sample?.stats.framesDecoded > 15, 'native receives the browser'); const nativePeer = sample.peer; if (scenario === 'web-soak') { await webSoak(page, native, () => sample, wait); console.log(`PASS ${scenario}`); throw new Done(); } if (scenario.startsWith('web-')) { await webShares(page, native, () => sample, replies, wait, scenario.endsWith('sticky')); console.log(`PASS ${scenario}`); process.exitCode = 0; throw new Done(); } const key = nativePeer + '#share'; // What the browser is receiving on the app's screen, from the // connection's own statistics: the screen is the inbound video that is // not the camera's mid. const received = () => page.evaluate(async (peer) => { const s = window.meet.state(); const frames = Object.fromEntries(s.frames); const out = { frames: frames[peer + '#share'] || 0, camera: frames[peer] || 0, shares: s.shares, ufrags: [], screen: null }; for (const pc of window.__nativeInteropPCs) { if (pc.connectionState === 'closed') continue; const remote = pc.remoteDescription?.sdp || ''; const m = remote.match(/a=ice-ufrag:(\S+)/); if (m) out.ufrags.push(m[1]); const tx = pc.getTransceivers().filter((t) => t.receiver.track?.kind === 'video' && t.direction === 'recvonly'); const mids = new Set(tx.map((t) => t.mid)); for (const r of (await pc.getStats()).values()) { if (r.type === 'inbound-rtp' && r.kind === 'video' && mids.has(r.mid)) { out.screen = { decoded: r.framesDecoded, received: r.framesReceived, pli: r.pliCount, fir: r.firCount, freezes: r.freezeCount, keyframes: r.keyFramesDecoded, lost: r.packetsLost, mid: r.mid }; } } } return out; }, nativePeer); // The mode first: it is remembered across runs of the interop binary, // and the screen's source is made for the mode the share starts in. native.stdin.write(`screen-mode ${process.env.SCREEN_MODE || 'balanced'}\n`); // SCREEN_SOURCE=motion shares the 1920-wide scrolling page (its height // CALLS_SYNTHETIC_SCREEN_HEIGHT) rather than the small still screen: // what meets H.264 level 3.1's limit (§15.22). if (scenario === 'display') native.stdin.write(`screen-display ${process.env.SCREEN_DEPTH || 6}\n`); else if (process.env.SCREEN_SOURCE === 'motion') native.stdin.write('screen-motion\n'); else if (process.env.SCREEN_SOURCE === 'display') native.stdin.write(`screen-display ${process.env.SCREEN_DEPTH || 6}\n`); else native.stdin.write('screen-on\n'); await wait(() => sample?.sharingScreen === true, 'native says it is sharing'); await wait(async () => (await received()).frames > 15, 'the browser decodes the app’s screen'); console.log('PASS the browser decodes the app’s screen'); // The watch: once a second, how long since the screen's frame count // last moved. Every freeze longer than FREEZE_MS is recorded with what // was going on around it. const freezes = []; let last = await received(); let lastMoved = Date.now(); let restarts = 0; const seenUfrags = new Set(last.ufrags); const watchFor = async (seconds, label) => { const until = Date.now() + seconds * 1000; let frozen = null; while (Date.now() < until) { if (exited) throw new Error('native executable exited'); await new Promise((r) => setTimeout(r, 1000)); const now = await received(); for (const u of now.ufrags) if (!seenUfrags.has(u)) { seenUfrags.add(u); restarts += 1; } if (now.frames > last.frames) { if (frozen) { frozen.ms = Date.now() - frozen.since; freezes.push(frozen); console.log(`FREEZE ${label}: ${frozen.ms} ms, then frames again (${JSON.stringify(now.screen)})`); frozen = null; } lastMoved = Date.now(); } else if (!frozen && Date.now() - lastMoved > FREEZE_MS) { frozen = { since: lastMoved, label, at: now.screen, native: sample?.stats }; console.log(`FROZEN ${label}: no screen frame for ${Date.now() - lastMoved} ms ${JSON.stringify(now.screen)} native=${JSON.stringify(pick(sample?.stats))}`); } last = now; } if (frozen) { frozen.ms = Date.now() - frozen.since; frozen.open = true; freezes.push(frozen); } }; const pick = (s) => s && Object.fromEntries(Object.entries(s).filter(([k]) => /^screen|Watchdog|peerConnections/.test(k))); const recovered = async (label) => { const base = (await received()).frames; await wait(async () => (await received()).frames > base + 10, label, RECOVER_S); }; if (scenario === 'display') { if (process.env.SCREEN_UPLINK) native.stdin.write(`uplink ${process.env.SCREEN_UPLINK}\n`); if (process.env.SCREEN_MODE_LATER) native.stdin.write(`screen-mode ${process.env.SCREEN_MODE_LATER}\n`); let prev = null; const until = Date.now() + SOAK * 1000; const tick = setInterval(() => { const s = sample?.stats || {}; const now = { beats: s.screenCaptureHeartbeats, delivered: s.screenCaptureDelivered, encoded: s.screenFramesEncoded, sent: s.screenFramesSent }; if (prev) console.log(`display: +${now.beats - prev.beats} reports, +${now.delivered - prev.delivered} delivered, +${now.encoded - prev.encoded} encoded, +${now.sent - prev.sent} sent, ${s.screenSendWidth}x${s.screenSendHeight}, limit ${s.screenLimitation} target ${s.screenTargetBitrate} kf ${s.screenKeyframes} enc ${s.screenEncodeMsTenths / 10} ms, watchdog r${s.screenWatchdogCaptureRestarts} k${s.screenWatchdogKeyframes} e${s.screenWatchdogEncoderRecoveries} a${s.screenWatchdogReAdds}`); prev = now; }, Number(process.env.SCREEN_TICK_MS || 5000)); await watchFor(SOAK, 'display'); clearInterval(tick); } else if (scenario === 'renewals' || scenario === 'resume') { // §15.23: a renewal or a resume is never said as "Reconnecting…", by // the app or the browser, while the core's phase does go through it. const said = []; let phases = 0; const saidTick = setInterval(async () => { if (sample?.phase === 'reconnecting') phases += 1; if (sample?.reconnecting || sample?.reconnectingPeers) said.push('app'); try { const r = (await page.evaluate(() => window.meet.state())).reconnect; if (r.call || r.peers.length) said.push('browser'); } catch {} }, 250); await watchFor(SOAK, scenario); clearInterval(saidTick); console.log(`reconnecting: the app's core was mid-resume at ${phases} samples; said by ${JSON.stringify([...new Set(said)])}`); if (asserting) assert.equal(said.length, 0, 'a renewal or resume was said as Reconnecting: ' + said.join(',')); console.log(`ICE restarts seen by the browser: ${restarts}`); const ours = sample?.stats.iceRestarts || 0; console.log(`ICE restarts the app started: ${ours}`); if (asserting && scenario === 'renewals') { // Credentials renewed every ~17 s: restarts mid-share, from both // sides, and the app's own nowhere near one per stream renewal // (every 10 s), which is what 0.4.1 did. assert.ok(restarts >= 2, 'relay credentials were renewed by ICE restarts mid-share: ' + restarts); assert.ok(ours >= 1 && ours <= Math.ceil(SOAK / 16) + 1, 'the app restarts ICE only when a renewal is due: ' + ours); } if (asserting && scenario === 'resume') assert.equal(restarts, 0, 'a resume alone restarts nothing'); } else if (scenario === 'capture') { await watchFor(5, 'before capture stall'); native.stdin.write('screen-stall\n'); await wait(() => replies.some((r) => r.screenStalled === true), 'the synthetic screen stops'); await recovered('the app restarts a stalled capture and the browser decodes again'); await wait(() => (sample?.stats.screenWatchdogCaptureRestarts || 0) > 0, 'the watchdog says it restarted the capture', 5); console.log('PASS a stalled capture was restarted without a re-share'); } else if (scenario === 'encoder') { await watchFor(5, 'before encoder stall'); native.stdin.write('screen-encoder-stall\n'); await wait(() => replies.some((r) => r.encoderStalled === true), 'the encoder stops'); await recovered('the app recovers a stalled encoder and the browser decodes again'); await wait(() => (sample?.stats.screenWatchdogKeyframes || 0) + (sample?.stats.screenWatchdogEncoderRecoveries || 0) > 0, 'the watchdog says it recovered the encoder', 5); console.log('PASS a stalled encoder was recovered without a re-share'); } else if (scenario === 'soak') { // §15.22's search for the cause: a long share with everything that // moves under it moving. What matters is not whether a freeze // happened but what it took to end it — the ladder's counts at each // freeze say whether keyframes were enough. const modes = ['balanced', 'motion', 'detail']; const uplinks = [0, 1500000, 600000, 0, 3000000, 900000]; const started = Date.now(); const notices = new Set(); let step = 0; const tick = setInterval(() => { step += 1; const mode = modes[step % modes.length]; const up = uplinks[step % uplinks.length]; native.stdin.write(`screen-mode ${mode}\n`); native.stdin.write(`uplink ${up}\n`); const st = sample?.stats || {}; console.log(`soak ${Math.round((Date.now() - started) / 1000)} s: mode ${mode}, uplink ${up || 'open'} (was sending ${st.screenSendWidth}x${st.screenSendHeight} at ${(st.screenSendFpsTenths || 0) / 10} fps, limitation ${st.screenLimitation})`); }, Number(process.env.SCREEN_SOAK_STEP_MS || 45000)); const noticeTick = setInterval(() => { if (sample?.screenRecovery) notices.add(sample.screenRecovery); }, 500); await watchFor(SOAK, 'soak'); clearInterval(tick); clearInterval(noticeTick); native.stdin.write('uplink 0\n'); const st = sample?.stats || {}; console.log(`SOAK app: ICE restarts ${st.iceRestarts}, stall reports ${st.screenStallReports}, keyframes ${st.screenWatchdogKeyframes}, fresh encoders ${st.screenWatchdogEncoderRecoveries}, re-adds ${st.screenWatchdogReAdds}, rebuilds ${st.screenWatchdogRebuilds}, shares again ${st.screenWatchdogReshares}, given up ${st.screenWatchdogFailures}, capture restarts ${st.screenWatchdogCaptureRestarts}`); console.log(`SOAK browser ICE restarts ${restarts}; notices at the app: ${JSON.stringify([...notices])}`); console.log('the browser\u2019s screen log:\n' + (await page.evaluate(() => window.meet.diagnostics())).split('## Screen')[1]); if (asserting) { assert.equal(st.screenWatchdogFailures || 0, 0, 'the app gave up on the browser\u2019s view'); assert.equal(freezes.filter((f) => f.open).length, 0, 'the screen froze and never came back'); } } else if (scenario === 'wedge' || scenario === 'wedge-sticky') { await watchFor(5, 'before the wedge'); const sticky = scenario === 'wedge-sticky'; const keyframes = sample?.stats.screenWatchdogKeyframes || 0; const notices = new Set(); const note = () => { if (sample?.screenRecovery) notices.add(sample.screenRecovery); }; const wedgedAt = Date.now(); const n = await page.evaluate((s) => window.__wedgeScreens(s), sticky); assert.ok(n >= 1, 'the browser\u2019s screen receiver was wedged'); await wait(async () => (await page.evaluate(() => window.meet.state().screenStalled)).length === 1, 'the browser reports the app\u2019s screen stalled', 15); if (!sticky) { // Painted frames on the share tile, counted across a new tile. const progress = await progressOf(() => received().then((r) => r.frames)); await wait(async () => { note(); return (await progress()) > 15; }, 'the browser decodes the app\u2019s screen again, without a share again', 30); console.log(`the browser decoding again ${Date.now() - wedgedAt} ms after the wedge: rebuilds ${sample?.stats.screenWatchdogRebuilds}, shares again ${sample?.stats.screenWatchdogReshares}, keyframes ${(sample?.stats.screenWatchdogKeyframes || 0) - keyframes}`); assert.ok((sample?.stats.screenWatchdogKeyframes || 0) > keyframes, 'keyframes were tried first'); assert.ok((sample?.stats.screenWatchdogRebuilds || 0) > 0, 'the rebuild is what mended it'); await wait(async () => (await page.evaluate(() => window.meet.state().screenStalled)).length === 0, 'the browser clears its report', 20); await wait(() => { note(); return sample?.screenRecovery === ''; }, 'the app\u2019s notice clears', 20); console.log('notices seen at the app: ' + JSON.stringify([...notices])); assert.ok([...notices].some((x) => x.includes('recovering')), 'the notice said recovering'); assert.ok([...notices].some((x) => x.includes('Restarting your share for')), 'the notice said restarting'); console.log('PASS a wedged browser receiver was restored by the app\u2019s rebuild'); } else { await wait(() => { note(); return (sample?.screenRecovery || '').includes('Couldn\u2019t restore'); }, 'the app says it could not restore the browser\u2019s view', 60); console.log('notices seen at the app: ' + JSON.stringify([...notices])); assert.ok((sample?.stats.screenWatchdogRebuilds || 0) > 0 && (sample?.stats.screenWatchdogReshares || 0) > 0, 'a rebuild and a share again were tried first'); assert.ok([...notices].some((x) => x.includes('Restarting your share for')), 'the notice said restarting'); await page.evaluate(() => window.__releaseWedge()); await recovered('the browser decodes again once the wedge is lifted'); await wait(() => sample?.screenRecovery === '', 'the app\u2019s notice clears once the view is back', 30); console.log('PASS the app said it could not restore a view nothing could, and cleared when it came back'); } } else if (scenario === 'loss') { await watchFor(5, 'before loss'); // The browser's watch treats the app's screen as frozen, as it would // a picture stuck after lost packets, and reports it (screenStalled); // the app must hear it and answer with a keyframe, which the // browser's decoder counts. const before = sample?.stats.screenWatchdogKeyframes || 0; const keyframes = (await received()).screen?.keyframes || 0; await page.evaluate(() => window.meet.stallScreenForTest(8000)); await wait(async () => (await page.evaluate(() => window.meet.state().screenStalled)).length === 1, 'the browser reports the app\u2019s screen stalled', 10); await wait(() => (sample?.stats.screenStallReports || 0) > 0, 'the app hears the browser\u2019s stall report', 10); await wait(() => (sample?.stats.screenWatchdogKeyframes || 0) > before, 'the app forces a keyframe for it', 10); await wait(async () => ((await received()).screen?.keyframes || 0) > keyframes, 'the browser decodes the keyframe', 10); await wait(async () => (await page.evaluate(() => window.meet.state().screenStalled)).length === 0, 'the browser clears its report once frames move', 20); await recovered('the browser decodes again'); console.log('PASS a receiver\u2019s stall report was answered with a keyframe and cleared'); } else { throw new Error('unknown scenario ' + scenario); } // Whatever the scenario, the screen has to be decoding at the end. await recovered('the app’s screen is still decoding at the end'); const open = freezes.filter((f) => f.open); console.log(`SUMMARY ${scenario}: ${freezes.length} freezes, longest ${Math.max(0, ...freezes.map((f) => f.ms))} ms, restarts ${restarts}`); if (asserting && (scenario === 'renewals' || scenario === 'resume')) { assert.equal(open.length, 0, 'the screen froze and never came back'); assert.equal(freezes.length, 0, 'the screen froze during ' + scenario + ': ' + JSON.stringify(freezes.map((f) => f.ms))); } console.log(`PASS ${scenario}: the app’s screen kept arriving`); } catch (err) { if (!(err instanceof Done)) { failed = true; console.log('FAIL', err.message); } } finally { if (native) { native.stdin.write('leave\n'); await new Promise((r) => setTimeout(r, 1000)); native.kill('SIGTERM'); } await browser.close(); if (failed) process.exit(1); } // Done ends a scenario that has passed before the app-shares path below // it: thrown, and caught as not a failure. function Done() {} // progressOf counts how far a counter has moved since now, across a // restart from zero (a new tile, a new receiver): a fall is taken as a // new count starting. async function progressOf(read) { let last = await read(); let total = 0; return async () => { const now = await read(); total += now >= last ? now - last : now; last = now; return total; }; } // webShares is the web-* scenarios: the browser shares its screen (a fake // monitor), the app watches, the app's screen decoder is wedged, and the // browser's own ladder (§15.22) has to put it right — or, sticky, say it // cannot, and clear once the wedge is lifted. async function webShares(page, native, current, replies, wait, sticky) { const stat = (k) => current()?.stats[k] || 0; await page.evaluate(() => { window.__h264Screen = true; }); await page.hover('#share'); await page.click('#share'); await wait(() => stat('remoteScreens') === 1 && stat('screenFramesDecoded') > 15, 'the app decodes the browser\u2019s screen', 30); console.log('PASS the app decodes the browser\u2019s screen'); await new Promise((r) => setTimeout(r, 5000)); const s0 = () => page.evaluate(() => window.meet.state()); const keyframes = (await s0()).shareRecoveries.keyframes; const wedgedAt = Date.now(); native.stdin.write(sticky ? 'screen-decoder-wedge sticky\n' : 'screen-decoder-wedge\n'); await wait(() => replies.some((r) => (r.decoderWedged || 0) >= 1), 'the app\u2019s screen decoder is wedged', 10); await wait(() => stat('screenStalledHere') === 1, 'the app reports the browser\u2019s screen stalled (the wedge hit the screen)', 15); const notices = new Set(); const note = async () => { const s = await s0(); if (s.shareRecovery) notices.add(s.shareRecovery); return s; }; if (!sticky) { const base = stat('screenFramesDecodedTotal'); await wait(async () => { await note(); return stat('screenFramesDecodedTotal') > base + 15; }, 'the app decodes the browser\u2019s screen again, without a share again', 30); const s = await note(); console.log(`the app decoding again ${Date.now() - wedgedAt} ms after the wedge: ${JSON.stringify(s.shareRecoveries)}`); assert.ok(s.shareRecoveries.keyframes > keyframes, 'keyframes were tried first'); assert.ok(s.shareRecoveries.rebuilds > 0, 'the rebuild is what mended it'); await wait(() => stat('screenStalledHere') === 0, 'the app clears its report', 20); await wait(async () => (await note()).shareRecovery === '', 'the browser\u2019s notice clears', 20); console.log('notices seen at the browser: ' + JSON.stringify([...notices])); assert.ok([...notices].some((x) => x.includes('recovering')), 'the notice said recovering'); assert.ok([...notices].some((x) => x.includes('Restarting your share for')), 'the notice said restarting'); } else { await wait(async () => (await note()).shareRecovery.includes('Couldn\u2019t restore'), 'the browser says it could not restore the app\u2019s view', 60); const s = await note(); console.log('notices seen at the browser: ' + JSON.stringify([...notices]) + ' ' + JSON.stringify(s.shareRecoveries)); assert.ok(s.shareRecoveries.rebuilds > 0 && s.shareRecoveries.reshares > 0, 'a rebuild and a share again were tried first'); assert.ok([...notices].some((x) => x.includes('Restarting your share for')), 'the notice said restarting'); native.stdin.write('screen-decoder-release\n'); const base = stat('screenFramesDecodedTotal'); await wait(() => stat('screenFramesDecodedTotal') > base + 10, 'the app decodes again once the wedge is lifted', 30); await wait(async () => (await note()).shareRecovery === '', 'the browser\u2019s notice clears once the view is back', 30); } console.log('the browser\u2019s diagnostics:\n' + (await page.evaluate(() => window.meet.diagnostics())).split('## Screen')[1]); } // webSoak is `soak` with the browser sharing and the app watching: the // app's received screen, counted across rebuilds, watched for freezes // while the renewals run and the browser's mode is cycled. async function webSoak(page, native, current, wait) { const stat = (k) => current()?.stats[k] || 0; await page.hover('#share'); await page.click('#share'); await wait(() => stat('remoteScreens') === 1 && stat('screenFramesDecoded') > 15, 'the app decodes the browser\u2019s screen', 30); const modes = ['balanced', 'motion', 'detail']; const started = Date.now(); let step = 0; const notices = new Set(); const freezes = []; let last = stat('screenFramesDecodedTotal'); let moved = Date.now(); let frozen = null; let nextStep = Date.now() + Number(process.env.SCREEN_SOAK_STEP_MS || 45000); while (Date.now() - started < SOAK * 1000) { await new Promise((r) => setTimeout(r, 1000)); const s = await page.evaluate(() => window.meet.state()); if (s.shareRecovery) notices.add(s.shareRecovery); if (Date.now() >= nextStep) { step += 1; const mode = modes[step % modes.length]; await page.evaluate((m) => { const el = document.getElementById('pick-share-mode'); if (el) { el.value = m; el.dispatchEvent(new Event('change', { bubbles: true })); } }, mode); console.log(`web-soak ${Math.round((Date.now() - started) / 1000)} s: the browser\u2019s mode ${mode}`); nextStep = Date.now() + Number(process.env.SCREEN_SOAK_STEP_MS || 45000); } const now = stat('screenFramesDecodedTotal'); if (now > last) { if (frozen) { frozen.ms = Date.now() - frozen.since; freezes.push(frozen); console.log(`FREEZE web-soak: ${frozen.ms} ms, then frames again; browser ladder ${JSON.stringify(s.shareRecoveries)}`); frozen = null; } moved = Date.now(); } else if (!frozen && Date.now() - moved > FREEZE_MS) { frozen = { since: moved }; console.log(`FROZEN web-soak: no screen frame at the app for ${Date.now() - moved} ms; app stalled-here ${stat('screenStalledHere')}; browser ladder ${JSON.stringify(s.shareRecoveries)}`); } last = now; } const s = await page.evaluate(() => window.meet.state()); console.log(`SUMMARY web-soak: ${freezes.length} freezes, longest ${Math.max(0, ...freezes.map((f) => f.ms))} ms, open ${!!frozen}; app ICE restarts ${stat('iceRestarts')}, pictures attached again ${stat('screenReattaches')}; browser ladder ${JSON.stringify(s.shareRecoveries)}; notices ${JSON.stringify([...notices])}`); console.log('the browser\u2019s screen log:\n' + (await page.evaluate(() => window.meet.diagnostics())).split('## Screen')[1]); if (asserting) { assert.equal(s.shareRecoveries.failures, 0, 'the browser gave up on the app\u2019s view'); assert.ok(!frozen, 'the app\u2019s screen froze and never came back'); } }