oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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).
44import { chromium } from 'playwright';
45import { spawn } from 'node:child_process';
46import { createInterface } from 'node:readline';
47import assert from 'node:assert/strict';
48
49const [origin, room, binary, scenario] = process.argv.slice(2);
50const 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.
53const FREEZE_MS = Number(process.env.SCREEN_FREEZE_MS || 3000);
54// How long recovery may take, from the fault to frames again.
55const RECOVER_S = Number(process.env.SCREEN_RECOVER_SECONDS || 15);
56const asserting = process.env.SCREEN_ASSERT !== '0';
57
58const 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.
69const context = await browser.newContext({ permissions: ['camera', 'microphone'], bypassCSP: true });
70await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]);
71const page = await context.newPage();
72await 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.
151await page.addInitScript((ready) => { window.__wedgeReady = ready; }, scenario === 'wedge' || scenario === 'wedge-sticky');
152let native;
153let failed = false;
154try {
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.
450function 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.
455async 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.
470async 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.
521async 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