@homie-rocks/studio 0.46.0 → 0.46.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ /** Authenticated, bounded capture, including named QA rooms absent from office lists. */
3
+ import { appendFile, mkdir } from 'node:fs/promises';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { withKey } from './office.mjs';
6
+ const opts = {};
7
+ for (let i = 2; i < process.argv.length; i += 2) {
8
+ if (!process.argv[i].startsWith('--') || !process.argv[i + 1])
9
+ throw Error('Use --key value');
10
+ opts[process.argv[i].slice(2)] = process.argv[i + 1];
11
+ }
12
+ const room = opts.room,
13
+ game = opts.game ?? 'crowd-circuit',
14
+ seconds = Number(opts.seconds ?? 300),
15
+ out = resolve(opts.out ?? 'diagnostics.jsonl');
16
+ if (
17
+ !/^[A-Za-z0-9_-]{1,32}$/.test(room ?? '') ||
18
+ !Number.isFinite(seconds) ||
19
+ seconds < 1 ||
20
+ seconds > 3600
21
+ )
22
+ throw Error('--room is required; --seconds 1..3600');
23
+ await mkdir(dirname(out), { recursive: true });
24
+ let stopped = false;
25
+ for (const s of ['SIGINT', 'SIGTERM']) process.on(s, () => (stopped = true));
26
+ const result = await withKey(
27
+ resolve(opts.root ?? process.cwd()),
28
+ opts.url,
29
+ async (call) => {
30
+ const end = Date.now() + seconds * 1000;
31
+ while (!stopped && Date.now() < end) {
32
+ const start = Date.now();
33
+ let r;
34
+ try {
35
+ r = await call(
36
+ `/_studio/api/room?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}`
37
+ );
38
+ } catch (error) {
39
+ r = { ok: false, error: String(error?.message ?? error) };
40
+ }
41
+ const f = r.facts;
42
+ // The endpoint answers from the room's diagnostics object: a second's
43
+ // facts from the room (tick rate, input transit) and from each Gate
44
+ // (frames written per player, world age, pacing), with the copy's age.
45
+ await appendFile(
46
+ out,
47
+ JSON.stringify({
48
+ at: Date.now(),
49
+ requestMs: Date.now() - start,
50
+ status: r.status,
51
+ ok: r.ok,
52
+ toolkit: f?.toolkit,
53
+ room: f?.room,
54
+ counts: f?.counts,
55
+ ticks: f?.ticks,
56
+ tickRate: f?.tickRate,
57
+ inputTransitMs: f?.inputTransitMs,
58
+ transport: f?.transport,
59
+ gates: f?.gates,
60
+ durability: f?.durability,
61
+ diagnostics: f?.diagnostics,
62
+ error: r.error
63
+ }) + '\n'
64
+ );
65
+ // A failed poll is evidence, not the end of the capture. Authentication
66
+ // errors are also retained; withKey renews and revokes its one key.
67
+ await new Promise((r) => setTimeout(r, 1000));
68
+ }
69
+ return { ok: true };
70
+ }
71
+ );
72
+ console.log(JSON.stringify({ out, ...result }));
73
+ if (!result.ok) process.exitCode = 1;
@@ -0,0 +1,603 @@
1
+ #!/usr/bin/env node
2
+ /** Public-room load probe. Two real Chrome players plus N simulated; no private admission route. */
3
+ import { Worker } from 'node:worker_threads';
4
+ import { mkdir, writeFile, mkdtemp, rm, readFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { resolve } from 'node:path';
7
+ import { createHash } from 'node:crypto';
8
+ import { performance, monitorEventLoopDelay } from 'node:perf_hooks';
9
+ import puppeteer from 'puppeteer-core';
10
+ import { Histogram } from './crowd-worker.mjs';
11
+ const opts = {};
12
+ const args = process.argv.slice(2);
13
+ for (let i = 0; i < args.length; i++) {
14
+ if (!args[i].startsWith('--') || !args[i + 1] || args[i + 1].startsWith('--'))
15
+ throw Error('Use --key value');
16
+ opts[args[i].slice(2)] = args[++i];
17
+ }
18
+ for (const k of Object.keys(opts))
19
+ if (
20
+ ![
21
+ 'url',
22
+ 'n',
23
+ 'total',
24
+ 'seconds',
25
+ 'room',
26
+ 'out',
27
+ 'ramp-ms',
28
+ 'chrome',
29
+ 'rss-mb',
30
+ 'workers',
31
+ 'churn',
32
+ 'churn-at',
33
+ 'compress',
34
+ 'strict-chrome'
35
+ ].includes(k)
36
+ )
37
+ throw Error('Unknown option --' + k);
38
+ const origin = new URL(opts.url ?? 'http://127.0.0.1:8787').origin,
39
+ game = 'crowd-circuit',
40
+ total =
41
+ opts.total === undefined ? Number(opts.n ?? 50) + 2 : Number(opts.total),
42
+ n = total - 2,
43
+ seconds = Number(opts.seconds ?? 150),
44
+ rampMs = Number(opts['ramp-ms'] ?? 50),
45
+ workerCount = Number(opts.workers ?? 4),
46
+ rssLimit = Number(opts['rss-mb'] ?? 1024);
47
+ if (opts.n !== undefined && opts.total !== undefined)
48
+ throw Error('Use --n (simulated) or --total (including Chrome), not both');
49
+ if (
50
+ !Number.isInteger(total) ||
51
+ total < 3 ||
52
+ total > 1000 ||
53
+ !Number.isInteger(workerCount) ||
54
+ workerCount < 1 ||
55
+ workerCount > 16 ||
56
+ !Number.isFinite(seconds) ||
57
+ seconds < 1 ||
58
+ seconds > 3600 ||
59
+ !Number.isFinite(rampMs) ||
60
+ rampMs < 10 ||
61
+ !Number.isFinite(rssLimit) ||
62
+ rssLimit < 128
63
+ )
64
+ throw Error(
65
+ 'n 1..998 simulated or total 3..1000 including Chrome, workers 1..16, seconds 1..3600, ramp-ms >=10, rss-mb >=128'
66
+ );
67
+ if (
68
+ opts.churn !== undefined &&
69
+ (!Number.isInteger(Number(opts.churn)) ||
70
+ Number(opts.churn) < 0 ||
71
+ Number(opts.churn) > n)
72
+ )
73
+ throw Error('churn must be an integer from 0 to n');
74
+ if (
75
+ opts['churn-at'] !== undefined &&
76
+ (!Number.isFinite(Number(opts['churn-at'])) || Number(opts['churn-at']) < 0)
77
+ )
78
+ throw Error('churn-at must be a nonnegative number of seconds');
79
+ const simulated = n,
80
+ room = opts.room ?? `load-${n}-${Date.now().toString(36)}`,
81
+ out = resolve(opts.out ?? `reports/crowd-circuit/${room}`);
82
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(room)) throw Error('Invalid room');
83
+ await mkdir(out, { recursive: true });
84
+ const sources = {};
85
+ for (const name of ['crowd-load.mjs', 'crowd-worker.mjs']) {
86
+ const data = await readFile(new URL(name, import.meta.url));
87
+ await writeFile(`${out}/${name}`, data);
88
+ sources[name] = createHash('sha256').update(data).digest('hex');
89
+ }
90
+ const started = new Date().toISOString();
91
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
92
+ const browsers = [],
93
+ profiles = [],
94
+ windows = [],
95
+ samples = [],
96
+ errors = new Map(),
97
+ workers = [];
98
+ let stopReason = null,
99
+ chromeTrouble = 0,
100
+ measureStart = 0,
101
+ measureEnd = 0,
102
+ maxRss = 0,
103
+ manifest = null,
104
+ finals = [];
105
+ const memoryGuard = setInterval(() => {
106
+ maxRss = Math.max(maxRss, process.memoryUsage.rss() / 1048576);
107
+ if (maxRss > rssLimit) stop('driver RSS exceeded ' + rssLimit + ' MiB');
108
+ }, 200);
109
+ const loop = monitorEventLoopDelay({ resolution: 10 });
110
+ loop.enable();
111
+ function problem(type, text) {
112
+ const key = `${type}: ${String(text).slice(0, 500)}`;
113
+ errors.set(key, (errors.get(key) ?? 0) + 1);
114
+ if (errors.get(key) === 1)
115
+ console.error(JSON.stringify({ phase: 'error', text: key }));
116
+ }
117
+ function stop(reason) {
118
+ stopReason ??= reason;
119
+ }
120
+ process.on('SIGINT', () => stop('SIGINT'));
121
+ process.on('SIGTERM', () => stop('SIGTERM'));
122
+ function makeWorker(index) {
123
+ const thread = new Worker(new URL('./crowd-worker.mjs', import.meta.url), {
124
+ workerData: {
125
+ origin,
126
+ game,
127
+ room,
128
+ index,
129
+ workers: workerCount,
130
+ compress: opts.compress !== 'off'
131
+ }
132
+ });
133
+ let sequence = 0;
134
+ let failure = null;
135
+ const pending = new Map();
136
+ function fail(e) {
137
+ failure = e;
138
+ for (const p of pending.values()) {
139
+ clearTimeout(p.timer);
140
+ p.reject(e);
141
+ }
142
+ pending.clear();
143
+ }
144
+ thread.on('message', (m) => {
145
+ if (m.event) {
146
+ problem('worker' + index, m.text);
147
+ return;
148
+ }
149
+ const p = pending.get(m.id);
150
+ if (p) {
151
+ clearTimeout(p.timer);
152
+ pending.delete(m.id);
153
+ m.error ? p.reject(Error(m.error)) : p.resolve(m.value);
154
+ }
155
+ });
156
+ thread.on('error', (e) => {
157
+ fail(e);
158
+ stop('driver worker failed');
159
+ problem('worker', e.message);
160
+ });
161
+ thread.on('exit', (code) => fail(Error(`worker ${index} exited (${code})`)));
162
+ return {
163
+ thread,
164
+ call(op, ...args) {
165
+ if (failure) return Promise.reject(failure);
166
+ return new Promise((resolve, reject) => {
167
+ const id = ++sequence,
168
+ timer = setTimeout(() => {
169
+ pending.delete(id);
170
+ reject(Error(`worker ${index} ${op} timeout`));
171
+ }, 180000);
172
+ pending.set(id, { resolve, reject, timer });
173
+ thread.postMessage({ id, op, args });
174
+ });
175
+ }
176
+ };
177
+ }
178
+ async function chromeWindow(i) {
179
+ const profile = await mkdtemp(`${tmpdir()}/crowd-load-`);
180
+ profiles.push(profile);
181
+ const browser = await puppeteer.launch({
182
+ executablePath:
183
+ opts.chrome ??
184
+ process.env.CHROME_PATH ??
185
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
186
+ headless: false,
187
+ handleSIGINT: false,
188
+ handleSIGTERM: false,
189
+ handleSIGHUP: false,
190
+ userDataDir: profile,
191
+ defaultViewport: { width: 1000, height: 760 },
192
+ args: [
193
+ '--no-first-run',
194
+ '--no-default-browser-check',
195
+ '--disable-background-timer-throttling',
196
+ '--disable-renderer-backgrounding',
197
+ `--window-position=${i * 550},${i * 80}`,
198
+ '--window-size=1020,840'
199
+ ]
200
+ });
201
+ browsers.push(browser);
202
+ const page = await browser.newPage();
203
+ await page.setUserAgent(`${await browser.userAgent()} HomieCheck crowd-load`);
204
+ await page.evaluateOnNewDocument(() => {
205
+ const Native = window.WebSocket;
206
+ window.__crowdSockets = [];
207
+ window.WebSocket = class extends Native {
208
+ constructor(...args) {
209
+ super(...args);
210
+ const path = new URL(String(args[0]), location.href).pathname;
211
+ const record = (event) => {
212
+ window.__crowdSockets.push({ at: Date.now(), path, ...event });
213
+ if (window.__crowdSockets.length > 100) window.__crowdSockets.shift();
214
+ };
215
+ this.addEventListener('open', () => record({ type: 'open' }));
216
+ this.addEventListener('close', (e) =>
217
+ record({
218
+ type: 'close',
219
+ code: e.code,
220
+ reason: e.reason,
221
+ clean: e.wasClean
222
+ })
223
+ );
224
+ this.addEventListener('error', () => record({ type: 'error' }));
225
+ }
226
+ close(code, reason) {
227
+ const path = new URL(this.url).pathname;
228
+ window.__crowdSockets.push({
229
+ at: Date.now(),
230
+ type: 'client-close',
231
+ path,
232
+ code,
233
+ reason
234
+ });
235
+ super.close(code, reason);
236
+ }
237
+ };
238
+ });
239
+ page.on('pageerror', (e) => problem(`chrome${i}`, e.message));
240
+ page.on('console', (m) => {
241
+ if (m.type() === 'error') problem(`chrome${i}`, m.text());
242
+ });
243
+ await page.goto(`${origin}/${game}/play?room=${room}&name=Chrome-${i + 1}`, {
244
+ waitUntil: 'domcontentloaded',
245
+ timeout: 60000
246
+ });
247
+ await page
248
+ .waitForFunction(
249
+ () =>
250
+ window.__shell?.seat !== null &&
251
+ window.__shell?.stats?.role === 'replica' &&
252
+ window.__shell?.arrival?.phase === 'done',
253
+ { timeout: 60000 }
254
+ )
255
+ .catch(async () => {
256
+ await page.waitForFunction(
257
+ () =>
258
+ window.__shell?.stats?.role === 'replica' &&
259
+ typeof window.__shell?.seat === 'number',
260
+ { timeout: 15000 }
261
+ );
262
+ });
263
+ const frame = page.frames().find((f) => f.url().includes('/__game/'));
264
+ if (frame) await frame.click('canvas');
265
+ windows.push({ page, i, frame });
266
+ return page;
267
+ }
268
+ async function browserFacts(w) {
269
+ return w.page.evaluate(() => {
270
+ const s = window.__shell;
271
+ return {
272
+ room: s?.room,
273
+ seat: s?.seat,
274
+ stats: s?.stats,
275
+ arrival: s?.arrival,
276
+ facts: {
277
+ toolkit: s?.facts?.toolkit,
278
+ transport: s?.facts?.transport,
279
+ gates: s?.facts?.gates,
280
+ counts: s?.facts?.counts,
281
+ hosted: s?.facts?.hosted,
282
+ ticks: s?.facts?.ticks,
283
+ stats: s?.facts?.stats
284
+ },
285
+ results: s?.results?.map((r) => ({
286
+ n: r.n,
287
+ at: r.at,
288
+ own: r.results?.find((p) => p.seat === s.seat)
289
+ }))
290
+ };
291
+ });
292
+ }
293
+ async function sample(phase) {
294
+ const states = await Promise.all(workers.map((w) => w.call('summary'))),
295
+ facts = await Promise.all(windows.map(browserFacts));
296
+ for (const s of states) if (s.stopReason) stop(s.stopReason);
297
+ if (new Set(states.map((s) => s.epoch).filter((e) => e !== null)).size > 1)
298
+ stop('workers observed different room epochs');
299
+ const rss = process.memoryUsage().rss / 1048576;
300
+ maxRss = Math.max(maxRss, rss);
301
+ if (rss > rssLimit) stop('driver RSS exceeded ' + rssLimit + ' MiB');
302
+ for (const [i, f] of facts.entries())
303
+ if (
304
+ f.room !== room ||
305
+ f.stats?.role !== 'replica' ||
306
+ !f.stats?.connected ||
307
+ f.stats?.offline ||
308
+ f.stats?.reconnects > 0 ||
309
+ typeof f.seat !== 'number'
310
+ ) {
311
+ problem(
312
+ 'browser membership',
313
+ JSON.stringify({ i, room: f.room, seat: f.seat, stats: f.stats })
314
+ );
315
+ chromeTrouble++;
316
+ // A real browser on a busy computer losing its link is a finding, and
317
+ // no reason to throw away what a thousand other players measured:
318
+ // the step runs on unless it was asked to stop (--strict-chrome 1).
319
+ if (opts['strict-chrome'] === '1')
320
+ stop('Chrome membership failed or its link reconnected');
321
+ }
322
+ const s = {
323
+ phase,
324
+ seconds: measureStart ? (performance.now() - measureStart) / 1000 : null,
325
+ connected: states.reduce((a, s) => a + s.connected, 0),
326
+ firstState: states.reduce((a, s) => a + s.joined, 0),
327
+ freshState: states.reduce((a, s) => a + s.fresh, 0),
328
+ rssMiB: rss,
329
+ coordinatorLoopP99Ms: loop.percentile(99) / 1e6,
330
+ workers: states,
331
+ browsers: facts
332
+ };
333
+ samples.push(s);
334
+ console.log(
335
+ JSON.stringify({
336
+ ...s,
337
+ browsers: facts.map((f) => ({
338
+ room: f.room,
339
+ seat: f.seat,
340
+ connected: f.stats?.connected,
341
+ reconnects: f.stats?.reconnects
342
+ })),
343
+ workers: states.map((s) => ({
344
+ worker: s.worker,
345
+ loopP99Ms: s.loopP99Ms,
346
+ peakTimerLagMs: s.peakTimerLagMs
347
+ }))
348
+ })
349
+ );
350
+ return s;
351
+ }
352
+ try {
353
+ try {
354
+ const res = await fetch(
355
+ `${origin}/.well-known/homie-studio.json?probe=${Date.now()}`,
356
+ { signal: AbortSignal.timeout(15000) }
357
+ );
358
+ if (res.ok) manifest = await res.json();
359
+ } catch (e) {
360
+ problem('manifest', e.message);
361
+ }
362
+ await writeFile(`${out}/deployment.json`, JSON.stringify(manifest, null, 2));
363
+ console.log(
364
+ JSON.stringify({
365
+ phase: 'start',
366
+ origin,
367
+ room,
368
+ n,
369
+ total,
370
+ simulated,
371
+ chromePlayers: 2,
372
+ seconds,
373
+ workerCount,
374
+ toolkit: manifest?.toolkit ?? null,
375
+ manifestStudioVersion: manifest?.studio ?? null,
376
+ sources
377
+ })
378
+ );
379
+ for (let i = 0; i < 2; i++) await chromeWindow(i);
380
+ for (let i = 0; i < workerCount; i++) workers.push(makeWorker(i));
381
+ let ramped = false;
382
+ const ramp = Promise.all(
383
+ workers.map((w) => w.call('ramp', simulated, rampMs))
384
+ ).finally(() => (ramped = true));
385
+ ramp.catch((e) => {
386
+ problem('ramp', e.message);
387
+ stop('ramp failed');
388
+ });
389
+ while (!ramped && !stopReason) {
390
+ await sample('ramp');
391
+ await sleep(2000);
392
+ }
393
+ if (stopReason) await Promise.all(workers.map((w) => w.call('cancelRamp')));
394
+ await ramp;
395
+ const deadline = performance.now() + 30000;
396
+ while (!stopReason) {
397
+ const s = await sample('joining');
398
+ if (s.firstState === simulated || performance.now() > deadline) break;
399
+ await sleep(1000);
400
+ }
401
+ if (!stopReason) {
402
+ await Promise.all(workers.map((w) => w.call('start')));
403
+ measureStart = performance.now();
404
+ let nextShot = measureStart + 5000,
405
+ churned = false,
406
+ keyRight = true;
407
+ while (!stopReason && performance.now() - measureStart < seconds * 1000) {
408
+ await sample('steady');
409
+ for (const w of windows) {
410
+ await w.page.keyboard.up(keyRight ? 'ArrowLeft' : 'ArrowRight');
411
+ await w.page.keyboard.down(keyRight ? 'ArrowRight' : 'ArrowLeft');
412
+ }
413
+ keyRight = !keyRight;
414
+ if (performance.now() >= nextShot) {
415
+ for (const w of windows)
416
+ await w.page.screenshot({ path: `${out}/chrome-${w.i + 1}.png` });
417
+ nextShot = Infinity;
418
+ }
419
+ if (
420
+ opts.churn &&
421
+ !churned &&
422
+ performance.now() - measureStart > Number(opts['churn-at'] ?? 60) * 1000
423
+ ) {
424
+ churned = true;
425
+ await Promise.all(
426
+ workers.map((w) => w.call('churn', Number(opts.churn)))
427
+ );
428
+ }
429
+ await sleep(
430
+ Math.min(
431
+ 5000,
432
+ Math.max(0, seconds * 1000 - (performance.now() - measureStart))
433
+ )
434
+ );
435
+ }
436
+ }
437
+ } catch (e) {
438
+ problem('fatal', e.stack);
439
+ stop('fatal error');
440
+ } finally {
441
+ measureEnd = performance.now();
442
+ clearInterval(memoryGuard);
443
+ try {
444
+ await sample('final');
445
+ } catch (e) {
446
+ problem('final', e.message);
447
+ }
448
+ const results = await Promise.allSettled(
449
+ workers.map((w) => w.call('finish'))
450
+ );
451
+ finals = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
452
+ if (finals.length !== workers.length) stop('incomplete worker results');
453
+ for (const w of windows) {
454
+ try {
455
+ await w.page.screenshot({ path: `${out}/chrome-${w.i + 1}-end.png` });
456
+ const socketEvents = await Promise.all(
457
+ w.page.frames().map(async (f) => ({
458
+ url: new URL(f.url()).pathname,
459
+ events: await f.evaluate(() => window.__crowdSockets ?? [])
460
+ }))
461
+ );
462
+ await writeFile(
463
+ `${out}/chrome-${w.i + 1}-sockets.json`,
464
+ JSON.stringify(socketEvents, null, 2)
465
+ );
466
+ } catch (e) {
467
+ problem('browser capture', e.message);
468
+ }
469
+ }
470
+ await Promise.allSettled(workers.map((w) => w.thread.terminate()));
471
+ await Promise.allSettled(browsers.map((b) => b.close()));
472
+ await Promise.allSettled(
473
+ profiles.map((p) => rm(p, { recursive: true, force: true }))
474
+ );
475
+ loop.disable();
476
+ const histograms = {};
477
+ for (const key of ['first', 'welcome', 'ack', 'gaps', 'loops']) {
478
+ const h = new Histogram();
479
+ for (const f of finals) h.merge(f.histograms[key]);
480
+ histograms[key] = h.json();
481
+ }
482
+ const players = finals.flatMap((f) => f.players),
483
+ percentiles = (key) => {
484
+ const a = players
485
+ .filter((p) => !p.retired && Number.isFinite(p[key]))
486
+ .map((p) => p[key])
487
+ .sort((a, b) => a - b);
488
+ return {
489
+ count: a.length,
490
+ p50: a[Math.floor(a.length / 2)] ?? null,
491
+ min: a[0] ?? null,
492
+ median: a[Math.floor(a.length / 2)] ?? null,
493
+ max: a.at(-1) ?? null
494
+ };
495
+ },
496
+ sum = (key) => finals.reduce((a, f) => a + (f[key] ?? 0), 0);
497
+ const report = {
498
+ started,
499
+ measurementStarted: measureStart
500
+ ? new Date(performance.timeOrigin + measureStart).toISOString()
501
+ : null,
502
+ ended: new Date().toISOString(),
503
+ origin,
504
+ room,
505
+ n,
506
+ total,
507
+ simulated,
508
+ chromePlayers: 2,
509
+ toolkit: manifest?.toolkit ?? null,
510
+ manifestStudioVersion: manifest?.studio ?? null,
511
+ sources,
512
+ requestedSeconds: seconds,
513
+ measuredSeconds: measureStart ? (measureEnd - measureStart) / 1000 : 0,
514
+ stopReason,
515
+ joined: sum('joined'),
516
+ connectedAtEnd: sum('connected'),
517
+ disconnects: sum('disconnects'),
518
+ firstStateMs: histograms.first,
519
+ welcomeMs: histograms.welcome,
520
+ inputAckMs: histograms.ack,
521
+ receiveGapMs: histograms.gaps,
522
+ inputLoopMs: histograms.loops,
523
+ ackSkipped: sum('ackSkipped'),
524
+ ackExpired: sum('ackExpired'),
525
+ tickHz: percentiles('tickHz'),
526
+ deliveredHz: percentiles('deliveredHz'),
527
+ tickHzMedian: percentiles('tickHz').median,
528
+ deliveredHzMedian: percentiles('deliveredHz').median,
529
+ lifetime: {
530
+ upBytes: sum('totalUp'),
531
+ downBytes: sum('totalDown'),
532
+ peakSampledConnected: Math.max(0, ...samples.map((s) => s.connected))
533
+ },
534
+ payload: {
535
+ upBytes: sum('upBytes'),
536
+ downBytes: sum('downBytes'),
537
+ inputFrames: sum('inputFrames'),
538
+ upFrames: sum('upFrames'),
539
+ downFrames: sum('downFrames'),
540
+ // What the uplink carried: compare with the link's measured capacity.
541
+ wireDownBytes: sum('wireDownBytes'),
542
+ compressedSockets: sum('compressed')
543
+ },
544
+ // Samples in which a Chrome player was not a connected replica of this room.
545
+ chromeTrouble,
546
+ // Whether the server agreed to permessage-deflate on these sockets, as a
547
+ // browser's would be, and what it saved: decoded bytes over wire bytes.
548
+ compression: {
549
+ offered: opts.compress !== 'off',
550
+ sockets: sum('compressed'),
551
+ of: sum('attempted'),
552
+ ratio:
553
+ sum('wireDownBytes') > 0
554
+ ? Math.round((sum('downBytes') / sum('wireDownBytes')) * 100) / 100
555
+ : null
556
+ },
557
+ // The whole ticks ahead each player stamps its input (it starts at 3 and
558
+ // follows what the room reports, as the toolkit's client does), and the
559
+ // share of the room's reports that said an input missed its tick.
560
+ inputLead: (() => {
561
+ const a = finals.flatMap((f) => f.leadTicks ?? []).sort((x, y) => x - y);
562
+ const reports = sum('inputReports');
563
+ return {
564
+ ticksMedian: a[Math.floor(a.length / 2)] ?? null,
565
+ ticksMax: a.at(-1) ?? null,
566
+ lateShare: reports ? Math.round((sum('inputReportsLate') / reports) * 1000) / 1000 : null
567
+ };
568
+ })(),
569
+ driver: {
570
+ workers: workerCount,
571
+ maxRssMiB: maxRss,
572
+ coordinatorLoopP99Ms: loop.percentile(99) / 1e6,
573
+ workerLoopP99Ms: finals.map((f) => f.loopP99Ms),
574
+ peakTimerLagMs: finals.map((f) => f.peakTimerLagMs)
575
+ },
576
+ errors: [...errors].map(([text, count]) => ({ text, count })),
577
+ workerErrors: finals.map((f) => f.errors),
578
+ players,
579
+ samples,
580
+ notes: [
581
+ 'N simulated players plus two Chrome players; --total includes Chrome and prevents exceeding capacity.',
582
+ 'Every received snapshot is fully decoded in a worker thread. No frame sampling or per-frame re-serialization.',
583
+ 'Input ack includes three-tick lead. Tick progression and delivered frame cadence are separate metrics.',
584
+ 'Clients stop input after two seconds without own state; freshness exposes reduced offered load.',
585
+ 'Cloudflare synchronous stage duration may be unavailable; never interpreted as zero CPU.'
586
+ ]
587
+ };
588
+ await writeFile(`${out}/report.json`, JSON.stringify(report, null, 2) + '\n');
589
+ console.log(
590
+ JSON.stringify({ ...report, players: undefined, samples: undefined })
591
+ );
592
+ process.exitCode =
593
+ stopReason ||
594
+ report.joined !== simulated ||
595
+ report.connectedAtEnd !== simulated ||
596
+ report.disconnects ||
597
+ errors.size
598
+ ? 1
599
+ : 0;
600
+ }
601
+ // Owned workers, sockets, Chrome and files are settled; imported browser libraries may retain timers.
602
+ await new Promise((resolve) => process.stdout.write('', resolve));
603
+ process.exit(process.exitCode ?? 0);