@homie-rocks/studio 0.45.3 → 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,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);
@@ -0,0 +1,61 @@
1
+ # Measuring a big room
2
+
3
+ Two tools, both for a trial on your own Cloudflare account. Neither is part of a studio: nothing in a studio's
4
+ Worker imports them.
5
+
6
+ - **From your computer** (`lib/crowd-load.mjs`): simulated players and two real Chrome players over your own
7
+ connection. What a player at home sees, up to what one connection and one computer can carry.
8
+
9
+ ```sh
10
+ CHROME_PATH=/path/to/chrome node node_modules/@homie-rocks/studio/lib/crowd-load.mjs \
11
+ --url https://<studio> --n 300 --seconds 150 --room home-300 --out reports/home-300
12
+ ```
13
+
14
+ - **From inside Cloudflare** (this folder): what the room and its Gates can do, with no home connection in it.
15
+
16
+ `lib/crowd-diagnostics.mjs` records the room's own figures beside either (a row a second), and
17
+ `lib/crowd-summary.mjs <folder>` puts a run's steps in one table. Both players steer as the crowd-circuit
18
+ fixture does; for another game, change the input they send.
19
+
20
+ # The in-Cloudflare crowd probe
21
+
22
+ A Worker that plays simulated players against a studio from inside Cloudflare's network. It separates what a
23
+ studio's room and Gates can do from what one home connection and one busy computer can carry. It is a trial's
24
+ tool: no studio's Worker imports it, and it is deleted when the trial is over.
25
+
26
+ It proves the servers at a given number of players. It does not prove the last hop to a real player: its sockets
27
+ never leave Cloudflare, and they are not compressed.
28
+
29
+ ## Deploy (in the account the studio is in, on a paid Workers plan)
30
+
31
+ ```sh
32
+ cd node_modules/@homie-rocks/studio/lib/crowd-probe
33
+ KEY=$(openssl rand -hex 24)
34
+ npx wrangler deploy --var PROBE_KEY:$KEY
35
+ ```
36
+
37
+ `wrangler` prints the probe's address (`https://homie-crowd-probe.<account>.workers.dev`). The probe answers
38
+ nothing without the key.
39
+
40
+ ## Run one step
41
+
42
+ ```sh
43
+ node node_modules/@homie-rocks/studio/lib/crowd-cloud.mjs \
44
+ --probe https://homie-crowd-probe.<account>.workers.dev --key $KEY \
45
+ --url https://<studio> --n 300 --seconds 150 --room cloud-300 --out reports/cloud-300
46
+ ```
47
+
48
+ It opens one request to the probe for every five players, each held open for the step, adds up what they
49
+ measured, and writes `report.json` in the shape `crowd-load.mjs` writes, so `crowd-summary.mjs` reads both.
50
+
51
+ ## Remove
52
+
53
+ ```sh
54
+ npx wrangler delete --name homie-crowd-probe
55
+ ```
56
+
57
+ ## What it needs of Cloudflare
58
+
59
+ - A paid plan: a request plays five players for minutes (the free plan's 10 ms of CPU a request is not enough).
60
+ - Six connections open at once per request, which is why a request plays five players.
61
+ - `global_fetch_strictly_public`, so its sockets reach the studio by its public address as a player's do.