| 1 | // native-screen.mjs — the app shares its (synthetic) screen to a browser |
| 2 | // for a long stretch while the call is disturbed underneath it, and the |
| 3 | // browser watches the received screen for freezes. |
| 4 | // |
| 5 | // Written for Paul's report of 2026-09-28 (Mac 0.4.1): "after a while, my |
| 6 | // screen froze on her end and she was no longer able to see my screen. I |
| 7 | // had to stop and re-share again." Every scenario here asserts the same |
| 8 | // thing: the receiver's screen keeps decoding, or comes back within a |
| 9 | // bound, without anyone sharing again. |
| 10 | // |
| 11 | // Scenarios, one per run (argv[4]): |
| 12 | // renewals the relay on, the stream renewed every ten seconds (which |
| 13 | // is when the app used to restart ICE on every connection) and |
| 14 | // relay credentials that need renewing every twenty seconds, |
| 15 | // so real renewals — ICE restarts — happen mid-share |
| 16 | // resume the streams renewed every ten seconds with no relay, so |
| 17 | // every resume is a resume and nothing else |
| 18 | // capture the synthetic screen stops producing frames (a capture that |
| 19 | // has stalled): the app's watchdog must restart it |
| 20 | // encoder the app's encoder stops putting frames out (the screen |
| 21 | // sender deactivated under it): the watchdog must recover it |
| 22 | // loss the browser stops painting and asks for a keyframe |
| 23 | // (screenStalled): the app must answer with one |
| 24 | // wedge the browser's screen receiver stops passing anything to its |
| 25 | // decoder (an encoded transform that drops every frame), which |
| 26 | // no keyframe mends: the app must escalate to a rebuild and |
| 27 | // the browser decode again within 30 s, the app's notice going |
| 28 | // "recovering", "Restarting your share for …", then nothing |
| 29 | // (spec §15.22) |
| 30 | // wedge-sticky every screen receiver the browser gets is wedged, new |
| 31 | // ones too: the app must end at "Couldn’t restore …", and |
| 32 | // clear once the wedge is lifted |
| 33 | // soak renewals and resumes as `renewals`, for SCREEN_SOAK_SECONDS, |
| 34 | // with the mode cycled (balanced, motion, detail) and the |
| 35 | // app's uplink capped and lifted every SCREEN_SOAK_STEP_MS: |
| 36 | // every freeze is logged with what the ladder did about it |
| 37 | // web-soak the same with the browser sharing and the app watching, the |
| 38 | // browser's mode cycled |
| 39 | // web-wedge, web-wedge-sticky the same the other way round: the |
| 40 | // browser shares, the app's screen decoder wedges, and the |
| 41 | // browser's ladder has to restore it (or say it cannot) |
| 42 | // |
| 43 | // Run by TestNativeMacScreenRecovery (make native-browser). |
| 44 | import { chromium } from 'playwright'; |
| 45 | import { spawn } from 'node:child_process'; |
| 46 | import { createInterface } from 'node:readline'; |
| 47 | import assert from 'node:assert/strict'; |
| 48 | |
| 49 | const [origin, room, binary, scenario] = process.argv.slice(2); |
| 50 | const SOAK = Number(process.env.SCREEN_SOAK_SECONDS || 60); |
| 51 | // The longest the browser's screen may go without a new frame and still |
| 52 | // count as healthy. A freeze is anything longer. |
| 53 | const FREEZE_MS = Number(process.env.SCREEN_FREEZE_MS || 3000); |
| 54 | // How long recovery may take, from the fault to frames again. |
| 55 | const RECOVER_S = Number(process.env.SCREEN_RECOVER_SECONDS || 15); |
| 56 | const asserting = process.env.SCREEN_ASSERT !== '0'; |
| 57 | |
| 58 | const browser = await chromium.launch({ headless: true, args: [ |
| 59 | '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', |
| 60 | '--autoplay-policy=no-user-gesture-required', '--disable-features=WebRtcHideLocalIpsWithMdns', |
| 61 | '--mute-audio', |
| 62 | // getDisplayMedia answered with a fake 1280x720 monitor and no picker, |
| 63 | // for the web-* scenarios (see call.mjs). |
| 64 | '--auto-accept-this-tab-capture', |
| 65 | ] }); |
| 66 | // bypassCSP: the wedge's encoded transform runs in a worker made from a |
| 67 | // blob, which the page's own policy (default-src 'self') would refuse. |
| 68 | // The gate's, never the page's. |
| 69 | const context = await browser.newContext({ permissions: ['camera', 'microphone'], bypassCSP: true }); |
| 70 | await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]); |
| 71 | const page = await context.newPage(); |
| 72 | await page.addInitScript(() => { |
| 73 | const Native = window.RTCPeerConnection; |
| 74 | window.__nativeInteropPCs = new Set(); |
| 75 | // §15.22's wedge: a receiver whose frames never reach its decoder. |
| 76 | // Chromium takes an encoded transform only on a receiver that has not |
| 77 | // started, so in the wedge scenarios every screen receiver gets one as |
| 78 | // it arrives — passing everything through — and __wedgeScreens tells |
| 79 | // the ones there now to swallow every frame instead. `sticky` has each |
| 80 | // one that arrives after start out swallowing. |
| 81 | let worker = null; |
| 82 | let serial = 0; |
| 83 | const wedgeWorker = () => { |
| 84 | if (!worker) { |
| 85 | const code = [ |
| 86 | 'const drops = new Map();', |
| 87 | 'onmessage = (m) => { for (const id of m.data.ids) drops.set(id, m.data.drop); };', |
| 88 | 'onrtctransform = (e) => {', |
| 89 | ' const id = e.transformer.options.id;', |
| 90 | ' if (!drops.has(id)) drops.set(id, !!e.transformer.options.drop);', |
| 91 | ' e.transformer.readable.pipeThrough(new TransformStream({ transform(f, c) { if (!drops.get(id)) c.enqueue(f); } }))', |
| 92 | ' .pipeTo(e.transformer.writable);', |
| 93 | '};', |
| 94 | ].join('\n'); |
| 95 | worker = new Worker(URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))); |
| 96 | } |
| 97 | return worker; |
| 98 | }; |
| 99 | window.__wedge = { sticky: false, wedged: 0, ids: new Map() }; |
| 100 | const screenReceiver = (tx) => tx && tx.receiver && tx.receiver.track && tx.receiver.track.kind === 'video' && |
| 101 | tx.direction === 'recvonly' && tx.currentDirection !== 'stopped'; |
| 102 | const watchReceiver = (tx) => { |
| 103 | if (!window.__wedgeReady || !screenReceiver(tx) || window.__wedge.ids.has(tx.receiver)) return; |
| 104 | const id = ++serial; |
| 105 | window.__wedge.ids.set(tx.receiver, id); |
| 106 | tx.receiver.transform = new RTCRtpScriptTransform(wedgeWorker(), { id, drop: window.__wedge.sticky }); |
| 107 | if (window.__wedge.sticky) window.__wedge.wedged += 1; |
| 108 | }; |
| 109 | window.__wedgeScreens = (sticky) => { |
| 110 | window.__wedge.sticky = !!sticky; |
| 111 | const ids = []; |
| 112 | for (const pc of window.__nativeInteropPCs) { |
| 113 | for (const tx of pc.getTransceivers()) { |
| 114 | const id = screenReceiver(tx) && window.__wedge.ids.get(tx.receiver); |
| 115 | if (id) ids.push(id); |
| 116 | } |
| 117 | } |
| 118 | wedgeWorker().postMessage({ ids, drop: true }); |
| 119 | window.__wedge.wedged += ids.length; |
| 120 | return ids.length; |
| 121 | }; |
| 122 | window.__releaseWedge = () => { |
| 123 | window.__wedge.sticky = false; |
| 124 | wedgeWorker().postMessage({ ids: [...window.__wedge.ids.values()], drop: false }); |
| 125 | }; |
| 126 | window.RTCPeerConnection = class extends Native { |
| 127 | // The web-* scenarios' screen goes out as H.264: the app can only |
| 128 | // wedge a VideoToolbox decoder (DecoderWedge.swift), and the browser |
| 129 | // left to itself sends VP8. What is under test, the ladder, does not |
| 130 | // turn on the codec. |
| 131 | addTransceiver(track, init) { |
| 132 | const tx = super.addTransceiver(track, init); |
| 133 | if (window.__h264Screen && init && init.direction === 'sendonly' && track && track.kind === 'video') { |
| 134 | const codecs = RTCRtpSender.getCapabilities('video').codecs; |
| 135 | const h264 = codecs.filter((c) => /h264/i.test(c.mimeType)); |
| 136 | try { |
| 137 | tx.setCodecPreferences([...h264, ...codecs.filter((c) => !/h264/i.test(c.mimeType))]); |
| 138 | } catch {} |
| 139 | } |
| 140 | return tx; |
| 141 | } |
| 142 | constructor(...args) { |
| 143 | super(...args); |
| 144 | window.__nativeInteropPCs.add(this); |
| 145 | this.addEventListener('track', (e) => watchReceiver(e.transceiver)); |
| 146 | } |
| 147 | }; |
| 148 | }); |
| 149 | // Receivers made ready to wedge only in the scenarios that wedge them, so |
| 150 | // every other scenario's browser decodes exactly as a person's would. |
| 151 | await page.addInitScript((ready) => { window.__wedgeReady = ready; }, scenario === 'wedge' || scenario === 'wedge-sticky'); |
| 152 | let native; |
| 153 | let failed = false; |
| 154 | try { |
| 155 | await page.goto(`${origin}/m/${room}`); |
| 156 | await page.waitForFunction(() => !!window.meet); |
| 157 | // The app's decoders can be wedged in the web-* scenarios only. |
| 158 | const env = scenario.startsWith('web-') ? Object.assign({}, process.env, { CALLS_DECODER_WEDGE: '1' }) : process.env; |
| 159 | native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'], env }); |
| 160 | native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n'); |
| 161 | let sample = null; |
| 162 | let exited = false; |
| 163 | const replies = []; |
| 164 | native.on('exit', () => { exited = true; }); |
| 165 | createInterface({ input: native.stdout }).on('line', (line) => { |
| 166 | try { |
| 167 | const value = JSON.parse(line); |
| 168 | if (value.stats) sample = value; |
| 169 | else replies.push(value); |
| 170 | } catch {} |
| 171 | }); |
| 172 | native.stderr.resume(); |
| 173 | const wait = async (predicate, label, seconds = 35) => { |
| 174 | const until = Date.now() + seconds * 1000; |
| 175 | while (Date.now() < until) { |
| 176 | if (exited) throw new Error('native executable exited'); |
| 177 | if (await predicate()) return; |
| 178 | await new Promise((r) => setTimeout(r, 250)); |
| 179 | } |
| 180 | throw new Error(label + ': ' + JSON.stringify(sample)); |
| 181 | }; |
| 182 | await wait(() => sample?.stats.framesDecoded > 15, 'native receives the browser'); |
| 183 | const nativePeer = sample.peer; |
| 184 | if (scenario === 'web-soak') { |
| 185 | await webSoak(page, native, () => sample, wait); |
| 186 | console.log(`PASS ${scenario}`); |
| 187 | throw new Done(); |
| 188 | } |
| 189 | if (scenario.startsWith('web-')) { |
| 190 | await webShares(page, native, () => sample, replies, wait, scenario.endsWith('sticky')); |
| 191 | console.log(`PASS ${scenario}`); |
| 192 | process.exitCode = 0; |
| 193 | throw new Done(); |
| 194 | } |
| 195 | const key = nativePeer + '#share'; |
| 196 | // What the browser is receiving on the app's screen, from the |
| 197 | // connection's own statistics: the screen is the inbound video that is |
| 198 | // not the camera's mid. |
| 199 | const received = () => page.evaluate(async (peer) => { |
| 200 | const s = window.meet.state(); |
| 201 | const frames = Object.fromEntries(s.frames); |
| 202 | const out = { frames: frames[peer + '#share'] || 0, camera: frames[peer] || 0, shares: s.shares, ufrags: [], screen: null }; |
| 203 | for (const pc of window.__nativeInteropPCs) { |
| 204 | if (pc.connectionState === 'closed') continue; |
| 205 | const remote = pc.remoteDescription?.sdp || ''; |
| 206 | const m = remote.match(/a=ice-ufrag:(\S+)/); |
| 207 | if (m) out.ufrags.push(m[1]); |
| 208 | const tx = pc.getTransceivers().filter((t) => t.receiver.track?.kind === 'video' && t.direction === 'recvonly'); |
| 209 | const mids = new Set(tx.map((t) => t.mid)); |
| 210 | for (const r of (await pc.getStats()).values()) { |
| 211 | if (r.type === 'inbound-rtp' && r.kind === 'video' && mids.has(r.mid)) { |
| 212 | out.screen = { decoded: r.framesDecoded, received: r.framesReceived, pli: r.pliCount, fir: r.firCount, |
| 213 | freezes: r.freezeCount, keyframes: r.keyFramesDecoded, lost: r.packetsLost, mid: r.mid }; |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | return out; |
| 218 | }, nativePeer); |
| 219 | |
| 220 | // The mode first: it is remembered across runs of the interop binary, |
| 221 | // and the screen's source is made for the mode the share starts in. |
| 222 | native.stdin.write(`screen-mode ${process.env.SCREEN_MODE || 'balanced'}\n`); |
| 223 | // SCREEN_SOURCE=motion shares the 1920-wide scrolling page (its height |
| 224 | // CALLS_SYNTHETIC_SCREEN_HEIGHT) rather than the small still screen: |
| 225 | // what meets H.264 level 3.1's limit (§15.22). |
| 226 | if (scenario === 'display') native.stdin.write(`screen-display ${process.env.SCREEN_DEPTH || 6}\n`); |
| 227 | else if (process.env.SCREEN_SOURCE === 'motion') native.stdin.write('screen-motion\n'); |
| 228 | else if (process.env.SCREEN_SOURCE === 'display') native.stdin.write(`screen-display ${process.env.SCREEN_DEPTH || 6}\n`); |
| 229 | else native.stdin.write('screen-on\n'); |
| 230 | await wait(() => sample?.sharingScreen === true, 'native says it is sharing'); |
| 231 | await wait(async () => (await received()).frames > 15, 'the browser decodes the app’s screen'); |
| 232 | console.log('PASS the browser decodes the app’s screen'); |
| 233 | |
| 234 | // The watch: once a second, how long since the screen's frame count |
| 235 | // last moved. Every freeze longer than FREEZE_MS is recorded with what |
| 236 | // was going on around it. |
| 237 | const freezes = []; |
| 238 | let last = await received(); |
| 239 | let lastMoved = Date.now(); |
| 240 | let restarts = 0; |
| 241 | const seenUfrags = new Set(last.ufrags); |
| 242 | const watchFor = async (seconds, label) => { |
| 243 | const until = Date.now() + seconds * 1000; |
| 244 | let frozen = null; |
| 245 | while (Date.now() < until) { |
| 246 | if (exited) throw new Error('native executable exited'); |
| 247 | await new Promise((r) => setTimeout(r, 1000)); |
| 248 | const now = await received(); |
| 249 | for (const u of now.ufrags) if (!seenUfrags.has(u)) { seenUfrags.add(u); restarts += 1; } |
| 250 | if (now.frames > last.frames) { |
| 251 | if (frozen) { |
| 252 | frozen.ms = Date.now() - frozen.since; |
| 253 | freezes.push(frozen); |
| 254 | console.log(`FREEZE ${label}: ${frozen.ms} ms, then frames again (${JSON.stringify(now.screen)})`); |
| 255 | frozen = null; |
| 256 | } |
| 257 | lastMoved = Date.now(); |
| 258 | } else if (!frozen && Date.now() - lastMoved > FREEZE_MS) { |
| 259 | frozen = { since: lastMoved, label, at: now.screen, native: sample?.stats }; |
| 260 | console.log(`FROZEN ${label}: no screen frame for ${Date.now() - lastMoved} ms ${JSON.stringify(now.screen)} native=${JSON.stringify(pick(sample?.stats))}`); |
| 261 | } |
| 262 | last = now; |
| 263 | } |
| 264 | if (frozen) { |
| 265 | frozen.ms = Date.now() - frozen.since; |
| 266 | frozen.open = true; |
| 267 | freezes.push(frozen); |
| 268 | } |
| 269 | }; |
| 270 | const pick = (s) => s && Object.fromEntries(Object.entries(s).filter(([k]) => /^screen|Watchdog|peerConnections/.test(k))); |
| 271 | const recovered = async (label) => { |
| 272 | const base = (await received()).frames; |
| 273 | await wait(async () => (await received()).frames > base + 10, label, RECOVER_S); |
| 274 | }; |
| 275 | |
| 276 | if (scenario === 'display') { |
| 277 | if (process.env.SCREEN_UPLINK) native.stdin.write(`uplink ${process.env.SCREEN_UPLINK}\n`); |
| 278 | if (process.env.SCREEN_MODE_LATER) native.stdin.write(`screen-mode ${process.env.SCREEN_MODE_LATER}\n`); |
| 279 | let prev = null; |
| 280 | const until = Date.now() + SOAK * 1000; |
| 281 | const tick = setInterval(() => { |
| 282 | const s = sample?.stats || {}; |
| 283 | const now = { beats: s.screenCaptureHeartbeats, delivered: s.screenCaptureDelivered, encoded: s.screenFramesEncoded, sent: s.screenFramesSent }; |
| 284 | 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}`); |
| 285 | prev = now; |
| 286 | }, Number(process.env.SCREEN_TICK_MS || 5000)); |
| 287 | await watchFor(SOAK, 'display'); |
| 288 | clearInterval(tick); |
| 289 | } else if (scenario === 'renewals' || scenario === 'resume') { |
| 290 | // §15.23: a renewal or a resume is never said as "Reconnecting…", by |
| 291 | // the app or the browser, while the core's phase does go through it. |
| 292 | const said = []; |
| 293 | let phases = 0; |
| 294 | const saidTick = setInterval(async () => { |
| 295 | if (sample?.phase === 'reconnecting') phases += 1; |
| 296 | if (sample?.reconnecting || sample?.reconnectingPeers) said.push('app'); |
| 297 | try { |
| 298 | const r = (await page.evaluate(() => window.meet.state())).reconnect; |
| 299 | if (r.call || r.peers.length) said.push('browser'); |
| 300 | } catch {} |
| 301 | }, 250); |
| 302 | await watchFor(SOAK, scenario); |
| 303 | clearInterval(saidTick); |
| 304 | console.log(`reconnecting: the app's core was mid-resume at ${phases} samples; said by ${JSON.stringify([...new Set(said)])}`); |
| 305 | if (asserting) assert.equal(said.length, 0, 'a renewal or resume was said as Reconnecting: ' + said.join(',')); |
| 306 | console.log(`ICE restarts seen by the browser: ${restarts}`); |
| 307 | const ours = sample?.stats.iceRestarts || 0; |
| 308 | console.log(`ICE restarts the app started: ${ours}`); |
| 309 | if (asserting && scenario === 'renewals') { |
| 310 | // Credentials renewed every ~17 s: restarts mid-share, from both |
| 311 | // sides, and the app's own nowhere near one per stream renewal |
| 312 | // (every 10 s), which is what 0.4.1 did. |
| 313 | assert.ok(restarts >= 2, 'relay credentials were renewed by ICE restarts mid-share: ' + restarts); |
| 314 | assert.ok(ours >= 1 && ours <= Math.ceil(SOAK / 16) + 1, 'the app restarts ICE only when a renewal is due: ' + ours); |
| 315 | } |
| 316 | if (asserting && scenario === 'resume') assert.equal(restarts, 0, 'a resume alone restarts nothing'); |
| 317 | } else if (scenario === 'capture') { |
| 318 | await watchFor(5, 'before capture stall'); |
| 319 | native.stdin.write('screen-stall\n'); |
| 320 | await wait(() => replies.some((r) => r.screenStalled === true), 'the synthetic screen stops'); |
| 321 | await recovered('the app restarts a stalled capture and the browser decodes again'); |
| 322 | await wait(() => (sample?.stats.screenWatchdogCaptureRestarts || 0) > 0, 'the watchdog says it restarted the capture', 5); |
| 323 | console.log('PASS a stalled capture was restarted without a re-share'); |
| 324 | } else if (scenario === 'encoder') { |
| 325 | await watchFor(5, 'before encoder stall'); |
| 326 | native.stdin.write('screen-encoder-stall\n'); |
| 327 | await wait(() => replies.some((r) => r.encoderStalled === true), 'the encoder stops'); |
| 328 | await recovered('the app recovers a stalled encoder and the browser decodes again'); |
| 329 | await wait(() => (sample?.stats.screenWatchdogKeyframes || 0) + (sample?.stats.screenWatchdogEncoderRecoveries || 0) > 0, |
| 330 | 'the watchdog says it recovered the encoder', 5); |
| 331 | console.log('PASS a stalled encoder was recovered without a re-share'); |
| 332 | } else if (scenario === 'soak') { |
| 333 | // §15.22's search for the cause: a long share with everything that |
| 334 | // moves under it moving. What matters is not whether a freeze |
| 335 | // happened but what it took to end it — the ladder's counts at each |
| 336 | // freeze say whether keyframes were enough. |
| 337 | const modes = ['balanced', 'motion', 'detail']; |
| 338 | const uplinks = [0, 1500000, 600000, 0, 3000000, 900000]; |
| 339 | const started = Date.now(); |
| 340 | const notices = new Set(); |
| 341 | let step = 0; |
| 342 | const tick = setInterval(() => { |
| 343 | step += 1; |
| 344 | const mode = modes[step % modes.length]; |
| 345 | const up = uplinks[step % uplinks.length]; |
| 346 | native.stdin.write(`screen-mode ${mode}\n`); |
| 347 | native.stdin.write(`uplink ${up}\n`); |
| 348 | const st = sample?.stats || {}; |
| 349 | 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})`); |
| 350 | }, Number(process.env.SCREEN_SOAK_STEP_MS || 45000)); |
| 351 | const noticeTick = setInterval(() => { if (sample?.screenRecovery) notices.add(sample.screenRecovery); }, 500); |
| 352 | await watchFor(SOAK, 'soak'); |
| 353 | clearInterval(tick); |
| 354 | clearInterval(noticeTick); |
| 355 | native.stdin.write('uplink 0\n'); |
| 356 | const st = sample?.stats || {}; |
| 357 | 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}`); |
| 358 | console.log(`SOAK browser ICE restarts ${restarts}; notices at the app: ${JSON.stringify([...notices])}`); |
| 359 | console.log('the browser\u2019s screen log:\n' + (await page.evaluate(() => window.meet.diagnostics())).split('## Screen')[1]); |
| 360 | if (asserting) { |
| 361 | assert.equal(st.screenWatchdogFailures || 0, 0, 'the app gave up on the browser\u2019s view'); |
| 362 | assert.equal(freezes.filter((f) => f.open).length, 0, 'the screen froze and never came back'); |
| 363 | } |
| 364 | } else if (scenario === 'wedge' || scenario === 'wedge-sticky') { |
| 365 | await watchFor(5, 'before the wedge'); |
| 366 | const sticky = scenario === 'wedge-sticky'; |
| 367 | const keyframes = sample?.stats.screenWatchdogKeyframes || 0; |
| 368 | const notices = new Set(); |
| 369 | const note = () => { if (sample?.screenRecovery) notices.add(sample.screenRecovery); }; |
| 370 | const wedgedAt = Date.now(); |
| 371 | const n = await page.evaluate((s) => window.__wedgeScreens(s), sticky); |
| 372 | assert.ok(n >= 1, 'the browser\u2019s screen receiver was wedged'); |
| 373 | await wait(async () => (await page.evaluate(() => window.meet.state().screenStalled)).length === 1, |
| 374 | 'the browser reports the app\u2019s screen stalled', 15); |
| 375 | if (!sticky) { |
| 376 | // Painted frames on the share tile, counted across a new tile. |
| 377 | const progress = await progressOf(() => received().then((r) => r.frames)); |
| 378 | await wait(async () => { note(); return (await progress()) > 15; }, |
| 379 | 'the browser decodes the app\u2019s screen again, without a share again', 30); |
| 380 | 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}`); |
| 381 | assert.ok((sample?.stats.screenWatchdogKeyframes || 0) > keyframes, 'keyframes were tried first'); |
| 382 | assert.ok((sample?.stats.screenWatchdogRebuilds || 0) > 0, 'the rebuild is what mended it'); |
| 383 | await wait(async () => (await page.evaluate(() => window.meet.state().screenStalled)).length === 0, |
| 384 | 'the browser clears its report', 20); |
| 385 | await wait(() => { note(); return sample?.screenRecovery === ''; }, 'the app\u2019s notice clears', 20); |
| 386 | console.log('notices seen at the app: ' + JSON.stringify([...notices])); |
| 387 | assert.ok([...notices].some((x) => x.includes('recovering')), 'the notice said recovering'); |
| 388 | assert.ok([...notices].some((x) => x.includes('Restarting your share for')), 'the notice said restarting'); |
| 389 | console.log('PASS a wedged browser receiver was restored by the app\u2019s rebuild'); |
| 390 | } else { |
| 391 | await wait(() => { note(); return (sample?.screenRecovery || '').includes('Couldn\u2019t restore'); }, |
| 392 | 'the app says it could not restore the browser\u2019s view', 60); |
| 393 | console.log('notices seen at the app: ' + JSON.stringify([...notices])); |
| 394 | assert.ok((sample?.stats.screenWatchdogRebuilds || 0) > 0 && (sample?.stats.screenWatchdogReshares || 0) > 0, |
| 395 | 'a rebuild and a share again were tried first'); |
| 396 | assert.ok([...notices].some((x) => x.includes('Restarting your share for')), 'the notice said restarting'); |
| 397 | await page.evaluate(() => window.__releaseWedge()); |
| 398 | await recovered('the browser decodes again once the wedge is lifted'); |
| 399 | await wait(() => sample?.screenRecovery === '', 'the app\u2019s notice clears once the view is back', 30); |
| 400 | console.log('PASS the app said it could not restore a view nothing could, and cleared when it came back'); |
| 401 | } |
| 402 | } else if (scenario === 'loss') { |
| 403 | await watchFor(5, 'before loss'); |
| 404 | // The browser's watch treats the app's screen as frozen, as it would |
| 405 | // a picture stuck after lost packets, and reports it (screenStalled); |
| 406 | // the app must hear it and answer with a keyframe, which the |
| 407 | // browser's decoder counts. |
| 408 | const before = sample?.stats.screenWatchdogKeyframes || 0; |
| 409 | const keyframes = (await received()).screen?.keyframes || 0; |
| 410 | await page.evaluate(() => window.meet.stallScreenForTest(8000)); |
| 411 | await wait(async () => (await page.evaluate(() => window.meet.state().screenStalled)).length === 1, |
| 412 | 'the browser reports the app\u2019s screen stalled', 10); |
| 413 | await wait(() => (sample?.stats.screenStallReports || 0) > 0, 'the app hears the browser\u2019s stall report', 10); |
| 414 | await wait(() => (sample?.stats.screenWatchdogKeyframes || 0) > before, 'the app forces a keyframe for it', 10); |
| 415 | await wait(async () => ((await received()).screen?.keyframes || 0) > keyframes, 'the browser decodes the keyframe', 10); |
| 416 | await wait(async () => (await page.evaluate(() => window.meet.state().screenStalled)).length === 0, |
| 417 | 'the browser clears its report once frames move', 20); |
| 418 | await recovered('the browser decodes again'); |
| 419 | console.log('PASS a receiver\u2019s stall report was answered with a keyframe and cleared'); |
| 420 | } else { |
| 421 | throw new Error('unknown scenario ' + scenario); |
| 422 | } |
| 423 | |
| 424 | // Whatever the scenario, the screen has to be decoding at the end. |
| 425 | await recovered('the app’s screen is still decoding at the end'); |
| 426 | const open = freezes.filter((f) => f.open); |
| 427 | console.log(`SUMMARY ${scenario}: ${freezes.length} freezes, longest ${Math.max(0, ...freezes.map((f) => f.ms))} ms, restarts ${restarts}`); |
| 428 | if (asserting && (scenario === 'renewals' || scenario === 'resume')) { |
| 429 | assert.equal(open.length, 0, 'the screen froze and never came back'); |
| 430 | assert.equal(freezes.length, 0, 'the screen froze during ' + scenario + ': ' + JSON.stringify(freezes.map((f) => f.ms))); |
| 431 | } |
| 432 | console.log(`PASS ${scenario}: the app’s screen kept arriving`); |
| 433 | } catch (err) { |
| 434 | if (!(err instanceof Done)) { |
| 435 | failed = true; |
| 436 | console.log('FAIL', err.message); |
| 437 | } |
| 438 | } finally { |
| 439 | if (native) { |
| 440 | native.stdin.write('leave\n'); |
| 441 | await new Promise((r) => setTimeout(r, 1000)); |
| 442 | native.kill('SIGTERM'); |
| 443 | } |
| 444 | await browser.close(); |
| 445 | if (failed) process.exit(1); |
| 446 | } |
| 447 | |
| 448 | // Done ends a scenario that has passed before the app-shares path below |
| 449 | // it: thrown, and caught as not a failure. |
| 450 | function Done() {} |
| 451 | |
| 452 | // progressOf counts how far a counter has moved since now, across a |
| 453 | // restart from zero (a new tile, a new receiver): a fall is taken as a |
| 454 | // new count starting. |
| 455 | async function progressOf(read) { |
| 456 | let last = await read(); |
| 457 | let total = 0; |
| 458 | return async () => { |
| 459 | const now = await read(); |
| 460 | total += now >= last ? now - last : now; |
| 461 | last = now; |
| 462 | return total; |
| 463 | }; |
| 464 | } |
| 465 | |
| 466 | // webShares is the web-* scenarios: the browser shares its screen (a fake |
| 467 | // monitor), the app watches, the app's screen decoder is wedged, and the |
| 468 | // browser's own ladder (§15.22) has to put it right — or, sticky, say it |
| 469 | // cannot, and clear once the wedge is lifted. |
| 470 | async function webShares(page, native, current, replies, wait, sticky) { |
| 471 | const stat = (k) => current()?.stats[k] || 0; |
| 472 | await page.evaluate(() => { window.__h264Screen = true; }); |
| 473 | await page.hover('#share'); |
| 474 | await page.click('#share'); |
| 475 | await wait(() => stat('remoteScreens') === 1 && stat('screenFramesDecoded') > 15, 'the app decodes the browser\u2019s screen', 30); |
| 476 | console.log('PASS the app decodes the browser\u2019s screen'); |
| 477 | await new Promise((r) => setTimeout(r, 5000)); |
| 478 | const s0 = () => page.evaluate(() => window.meet.state()); |
| 479 | const keyframes = (await s0()).shareRecoveries.keyframes; |
| 480 | const wedgedAt = Date.now(); |
| 481 | native.stdin.write(sticky ? 'screen-decoder-wedge sticky\n' : 'screen-decoder-wedge\n'); |
| 482 | await wait(() => replies.some((r) => (r.decoderWedged || 0) >= 1), 'the app\u2019s screen decoder is wedged', 10); |
| 483 | await wait(() => stat('screenStalledHere') === 1, 'the app reports the browser\u2019s screen stalled (the wedge hit the screen)', 15); |
| 484 | const notices = new Set(); |
| 485 | const note = async () => { |
| 486 | const s = await s0(); |
| 487 | if (s.shareRecovery) notices.add(s.shareRecovery); |
| 488 | return s; |
| 489 | }; |
| 490 | if (!sticky) { |
| 491 | const base = stat('screenFramesDecodedTotal'); |
| 492 | await wait(async () => { await note(); return stat('screenFramesDecodedTotal') > base + 15; }, |
| 493 | 'the app decodes the browser\u2019s screen again, without a share again', 30); |
| 494 | const s = await note(); |
| 495 | console.log(`the app decoding again ${Date.now() - wedgedAt} ms after the wedge: ${JSON.stringify(s.shareRecoveries)}`); |
| 496 | assert.ok(s.shareRecoveries.keyframes > keyframes, 'keyframes were tried first'); |
| 497 | assert.ok(s.shareRecoveries.rebuilds > 0, 'the rebuild is what mended it'); |
| 498 | await wait(() => stat('screenStalledHere') === 0, 'the app clears its report', 20); |
| 499 | await wait(async () => (await note()).shareRecovery === '', 'the browser\u2019s notice clears', 20); |
| 500 | console.log('notices seen at the browser: ' + JSON.stringify([...notices])); |
| 501 | assert.ok([...notices].some((x) => x.includes('recovering')), 'the notice said recovering'); |
| 502 | assert.ok([...notices].some((x) => x.includes('Restarting your share for')), 'the notice said restarting'); |
| 503 | } else { |
| 504 | await wait(async () => (await note()).shareRecovery.includes('Couldn\u2019t restore'), |
| 505 | 'the browser says it could not restore the app\u2019s view', 60); |
| 506 | const s = await note(); |
| 507 | console.log('notices seen at the browser: ' + JSON.stringify([...notices]) + ' ' + JSON.stringify(s.shareRecoveries)); |
| 508 | assert.ok(s.shareRecoveries.rebuilds > 0 && s.shareRecoveries.reshares > 0, 'a rebuild and a share again were tried first'); |
| 509 | assert.ok([...notices].some((x) => x.includes('Restarting your share for')), 'the notice said restarting'); |
| 510 | native.stdin.write('screen-decoder-release\n'); |
| 511 | const base = stat('screenFramesDecodedTotal'); |
| 512 | await wait(() => stat('screenFramesDecodedTotal') > base + 10, 'the app decodes again once the wedge is lifted', 30); |
| 513 | await wait(async () => (await note()).shareRecovery === '', 'the browser\u2019s notice clears once the view is back', 30); |
| 514 | } |
| 515 | console.log('the browser\u2019s diagnostics:\n' + (await page.evaluate(() => window.meet.diagnostics())).split('## Screen')[1]); |
| 516 | } |
| 517 | |
| 518 | // webSoak is `soak` with the browser sharing and the app watching: the |
| 519 | // app's received screen, counted across rebuilds, watched for freezes |
| 520 | // while the renewals run and the browser's mode is cycled. |
| 521 | async function webSoak(page, native, current, wait) { |
| 522 | const stat = (k) => current()?.stats[k] || 0; |
| 523 | await page.hover('#share'); |
| 524 | await page.click('#share'); |
| 525 | await wait(() => stat('remoteScreens') === 1 && stat('screenFramesDecoded') > 15, 'the app decodes the browser\u2019s screen', 30); |
| 526 | const modes = ['balanced', 'motion', 'detail']; |
| 527 | const started = Date.now(); |
| 528 | let step = 0; |
| 529 | const notices = new Set(); |
| 530 | const freezes = []; |
| 531 | let last = stat('screenFramesDecodedTotal'); |
| 532 | let moved = Date.now(); |
| 533 | let frozen = null; |
| 534 | let nextStep = Date.now() + Number(process.env.SCREEN_SOAK_STEP_MS || 45000); |
| 535 | while (Date.now() - started < SOAK * 1000) { |
| 536 | await new Promise((r) => setTimeout(r, 1000)); |
| 537 | const s = await page.evaluate(() => window.meet.state()); |
| 538 | if (s.shareRecovery) notices.add(s.shareRecovery); |
| 539 | if (Date.now() >= nextStep) { |
| 540 | step += 1; |
| 541 | const mode = modes[step % modes.length]; |
| 542 | await page.evaluate((m) => { |
| 543 | const el = document.getElementById('pick-share-mode'); |
| 544 | if (el) { el.value = m; el.dispatchEvent(new Event('change', { bubbles: true })); } |
| 545 | }, mode); |
| 546 | console.log(`web-soak ${Math.round((Date.now() - started) / 1000)} s: the browser\u2019s mode ${mode}`); |
| 547 | nextStep = Date.now() + Number(process.env.SCREEN_SOAK_STEP_MS || 45000); |
| 548 | } |
| 549 | const now = stat('screenFramesDecodedTotal'); |
| 550 | if (now > last) { |
| 551 | if (frozen) { |
| 552 | frozen.ms = Date.now() - frozen.since; |
| 553 | freezes.push(frozen); |
| 554 | console.log(`FREEZE web-soak: ${frozen.ms} ms, then frames again; browser ladder ${JSON.stringify(s.shareRecoveries)}`); |
| 555 | frozen = null; |
| 556 | } |
| 557 | moved = Date.now(); |
| 558 | } else if (!frozen && Date.now() - moved > FREEZE_MS) { |
| 559 | frozen = { since: moved }; |
| 560 | 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)}`); |
| 561 | } |
| 562 | last = now; |
| 563 | } |
| 564 | const s = await page.evaluate(() => window.meet.state()); |
| 565 | 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])}`); |
| 566 | console.log('the browser\u2019s screen log:\n' + (await page.evaluate(() => window.meet.diagnostics())).split('## Screen')[1]); |
| 567 | if (asserting) { |
| 568 | assert.equal(s.shareRecoveries.failures, 0, 'the browser gave up on the app\u2019s view'); |
| 569 | assert.ok(!frozen, 'the app\u2019s screen froze and never came back'); |
| 570 | } |
| 571 | } |
| 572 | |