@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.
- package/CHANGELOG.md +44 -0
- package/README.md +3 -2
- package/docs/rooms-milestone-2-notes.md +345 -0
- package/guides/game/RULES.md +11 -2
- package/lib/build-identity.mjs +43 -0
- package/lib/cloudflare.mjs +13 -11
- package/lib/crowd-cloud.mjs +88 -0
- package/lib/crowd-diagnostics.mjs +73 -0
- package/lib/crowd-load.mjs +603 -0
- package/lib/crowd-probe/README.md +61 -0
- package/lib/crowd-probe/client.mjs +96 -0
- package/lib/crowd-probe/worker.mjs +72 -0
- package/lib/crowd-probe/wrangler.jsonc +12 -0
- package/lib/crowd-summary.mjs +140 -0
- package/lib/crowd-worker.mjs +496 -0
- package/lib/scaffold.mjs +5 -5
- package/lib/template-history.json +42 -0
- package/netplay/netplay.ts +18 -5
- package/package.json +4 -2
- package/rules/host.ts +34 -8
- package/rules/interest.mjs +320 -51
- package/rules/rules.ts +13 -3
- package/rules/timing.mjs +20 -0
- package/worker/build-id.mjs +2 -0
- package/worker/gate.mjs +436 -53
- package/worker/index.mjs +90 -10
- package/worker/office-page.mjs +11 -0
- package/worker/office.mjs +27 -0
- package/worker/room.mjs +85 -16
- package/worker/version.mjs +1 -1
- package/worker/world-wire.mjs +110 -0
|
@@ -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);
|