@joystack/cli 0.0.0-stage → 0.1.43

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +738 -0
  2. package/LICENSE +202 -0
  3. package/README.md +21 -2
  4. package/bin/joystack.mjs +300 -0
  5. package/lib/agent-session.mjs +52 -0
  6. package/lib/agent-surface.mjs +333 -0
  7. package/lib/assets-json.mjs +205 -0
  8. package/lib/assets-page.mjs +184 -0
  9. package/lib/assets-sheet.mjs +365 -0
  10. package/lib/build-id.mjs +130 -0
  11. package/lib/bundle-primitives.mjs +123 -0
  12. package/lib/changelog.mjs +193 -0
  13. package/lib/config.mjs +56 -0
  14. package/lib/create.mjs +241 -0
  15. package/lib/dev.mjs +435 -0
  16. package/lib/engine.mjs +198 -0
  17. package/lib/feedback-sync.mjs +231 -0
  18. package/lib/feedback.mjs +209 -0
  19. package/lib/freeze.mjs +108 -0
  20. package/lib/game-files.mjs +99 -0
  21. package/lib/history.mjs +612 -0
  22. package/lib/inbox-cmd.mjs +57 -0
  23. package/lib/inject.mjs +250 -0
  24. package/lib/joystack-node-doors.mjs +230 -0
  25. package/lib/lane.mjs +141 -0
  26. package/lib/legacy-names.mjs +246 -0
  27. package/lib/lint.mjs +292 -0
  28. package/lib/login.mjs +134 -0
  29. package/lib/model-bake-cli.mjs +17 -0
  30. package/lib/model-bake.mjs +869 -0
  31. package/lib/npm.mjs +38 -0
  32. package/lib/ports.mjs +234 -0
  33. package/lib/primitives.mjs +393 -0
  34. package/lib/printable.mjs +12 -0
  35. package/lib/process-tree.mjs +30 -0
  36. package/lib/project.mjs +84 -0
  37. package/lib/publish.mjs +179 -0
  38. package/lib/replay-cmd.mjs +426 -0
  39. package/lib/replay-export.mjs +1379 -0
  40. package/lib/replay-list.mjs +308 -0
  41. package/lib/replay-profile.mjs +467 -0
  42. package/lib/replay-store.mjs +719 -0
  43. package/lib/room-reload.mjs +168 -0
  44. package/lib/room-server.mjs +539 -0
  45. package/lib/self-update.mjs +162 -0
  46. package/lib/shadow-resolve.mjs +85 -0
  47. package/lib/share.mjs +149 -0
  48. package/lib/smoke-gpu-preload.mjs +91 -0
  49. package/lib/source-path.mjs +33 -0
  50. package/lib/static-server.mjs +1238 -0
  51. package/lib/test-cmd.mjs +697 -0
  52. package/lib/test-policy.mjs +157 -0
  53. package/lib/test-reporter.mjs +177 -0
  54. package/lib/test-selection-cli.mjs +18 -0
  55. package/lib/update.mjs +299 -0
  56. package/package.json +27 -4
@@ -0,0 +1,467 @@
1
+ // `joystack replay profile` — which functions a stretch of a recorded session
2
+ // spends its time in, written down as a table.
3
+ //
4
+ // The performance track says what a stretch cost on the device that played it
5
+ // and how that split between simulating, presenting and drawing. It cannot say
6
+ // which functions inside the simulation spent the time: no per-frame counter
7
+ // can carry that, and no phone can be asked for it twice. The replay can: it
8
+ // runs the same simulation on the same inputs, so re-running the ticks under a
9
+ // sampling profiler names the functions.
10
+ //
11
+ // What a profile here can NOT say is what any of it cost on the device. This
12
+ // machine has a different CPU, a different JIT state and no thermal throttle,
13
+ // so the table holds shares of this machine's samples, never the device's
14
+ // milliseconds. It covers the simulation only — the room and the traits it
15
+ // ticks — because a replay draws nothing.
16
+ import { mkdirSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { loadOpenReplay, rowSinkFor } from './replay-export.mjs';
20
+ import { streamDir } from './replay-store.mjs';
21
+ import { sourcePath } from './source-path.mjs';
22
+ import { STATE_DIR_NAMES } from './legacy-names.mjs';
23
+
24
+ // The sampling interval, in microseconds. A second of simulation on a
25
+ // developer machine runs in tens of milliseconds, and V8's default of 1 ms
26
+ // would give it a few dozen samples — too few for one sample to be less than
27
+ // several percent.
28
+ export const PROFILE_SAMPLE_US = 100;
29
+
30
+ // How many ticks one profile covers before it is stopped, reduced to the
31
+ // functions that ran, and started again. A profile holds every sample until it
32
+ // is stopped, and a range is as long as the session a recording claims, so a
33
+ // whole long session in one profile is one very large object.
34
+ const PROFILE_PIECE_TICKS = 600;
35
+
36
+ /**
37
+ * `--ticks 1195-1228` (or one tick) as `{ from, to }`. Throws, naming the flag,
38
+ * on anything else.
39
+ */
40
+ export function parseTickRange(text) {
41
+ const m = /^\s*(\d+)\s*(?:[-–]\s*(\d+))?\s*$/.exec(String(text ?? ''));
42
+ const from = m ? Number(m[1]) : NaN;
43
+ const to = m ? Number(m[2] ?? m[1]) : NaN;
44
+ if (!m || !Number.isSafeInteger(from) || !Number.isSafeInteger(to) || to < from) {
45
+ throw new Error(`--ticks takes a tick or a range of ticks, like 1195-1228, and ${JSON.stringify(String(text ?? ''))} is not one`);
46
+ }
47
+ return { from, to };
48
+ }
49
+
50
+ // Samples that are the profiler or the process rather than the game: stopping
51
+ // a profile posts through the inspector, and `(idle)` is the process waiting.
52
+ // Counting either would rank the measurement among the things it measures.
53
+ const notTheGame = (frame) => String(frame?.url ?? '').startsWith('node:inspector') || frame?.functionName === '(idle)';
54
+
55
+ // ── which tick or channel handler a sample ran under ─────────────────────────
56
+ //
57
+ // A function's name and file say what ran, not why: the same query helper runs
58
+ // under every trait that reads a query, and a trait's tick is usually an
59
+ // anonymous arrow. So while the profiler runs, each kind's ticks and channel
60
+ // handlers are swapped for stand-ins with names of their own —
61
+ // `joystack_run_<n>`, numbered into a list of what each one is — and a sample is
62
+ // credited to the nearest stand-in above it in the call stack. A stand-in is a
63
+ // function built with `new Function`, because V8 names a profile's frames by
64
+ // the function's source, and closures made by one source text share one name.
65
+ // Samples under no stand-in are the engine's own work around the traits:
66
+ // draining inputs, the sync phase, reading the world back.
67
+ const RUN_PREFIX = 'joystack_run_';
68
+
69
+ /**
70
+ * Swap every tick and channel handler of a replayer's runtime for a named
71
+ * stand-in. Returns what each stand-in is, and a function that puts the
72
+ * originals back. A channel handler's trait comes from the lowered game's
73
+ * `channelOrigins`, since the runtime's handler table is keyed by channel name
74
+ * alone.
75
+ */
76
+ function instrumentRuns(runtime, channelOrigins) {
77
+ const runs = [];
78
+ const undo = [];
79
+ const code = runtime?.code;
80
+ if (!code || typeof code !== 'object') return { runs, restore: () => {} };
81
+ const name = (v) => (typeof v === 'string' ? v : null);
82
+ const standIn = (inner, n) => new Function('inner', `return function ${RUN_PREFIX}${n}(a, b, c) { return inner(a, b, c); };`)(inner);
83
+ for (const [kind, impl] of Object.entries(code)) {
84
+ const ticks = Array.isArray(impl?.ticks) ? impl.ticks : [];
85
+ ticks.forEach((t, position) => {
86
+ if (typeof t?.fn !== 'function') return;
87
+ const original = t.fn;
88
+ // A tick's place in the list is its place in the kind's run order.
89
+ runs.push({ kind, trait: name(t.trait), instance: name(t.instance), tick_index: position, channel: null });
90
+ t.fn = standIn(original, runs.length - 1);
91
+ undo.push(() => { t.fn = original; });
92
+ });
93
+ const handlers = impl?.handlers;
94
+ if (handlers && typeof handlers === 'object') {
95
+ for (const [channel, original] of Object.entries(handlers)) {
96
+ if (typeof original !== 'function') continue;
97
+ const origin = channelOrigins?.[kind] && Object.hasOwn(channelOrigins[kind], channel) ? channelOrigins[kind][channel] : null;
98
+ runs.push({ kind, trait: name(origin?.trait), instance: name(origin?.instance), tick_index: null, channel });
99
+ handlers[channel] = standIn(original, runs.length - 1);
100
+ undo.push(() => { handlers[channel] = original; });
101
+ }
102
+ }
103
+ }
104
+ return { runs, restore: () => { for (const u of undo) u(); } };
105
+ }
106
+
107
+ /**
108
+ * Where a frame's code lives, as a person would look for it: relative to the
109
+ * game folder, or inside the engine's package, with a one-based line.
110
+ */
111
+ export function sourceLabel(frame, { dir = null, sharedDir = null } = {}) {
112
+ const url = frame?.url ?? '';
113
+ if (!url) return '(native)';
114
+ // V8 line numbers are zero-based; every editor and stack trace is not.
115
+ const line = (frame.lineNumber ?? -1) + 1;
116
+ let where = url;
117
+ if (url.startsWith('file:')) {
118
+ let path;
119
+ try { path = fileURLToPath(url); } catch { path = null; }
120
+ where = sourcePath(path, { dir, sharedDir }) ?? url;
121
+ }
122
+ return `${where}:${line}`;
123
+ }
124
+
125
+ // A frame's file, as a path, when it is a file on disk.
126
+ function framePath(frame) {
127
+ const url = frame?.url ?? '';
128
+ if (!url.startsWith('file:')) return null;
129
+ try { return fileURLToPath(url); } catch { return null; }
130
+ }
131
+
132
+ // Whether a frame is the game's own code: a file inside the game folder that
133
+ // is not installed there — the engine, this CLI and every other package live
134
+ // under `node_modules/` — and not the tooling's own `.joystack/` folder.
135
+ const gameFrameTest = ({ dir, sharedDir }) => {
136
+ const memo = new Map();
137
+ return (frame) => {
138
+ const url = frame?.url ?? '';
139
+ if (!dir || !url) return false;
140
+ if (!memo.has(url)) {
141
+ const at = sourcePath(framePath(frame), { dir, sharedDir });
142
+ const first = at?.split('/')[0];
143
+ memo.set(url, at !== null && !at.startsWith('@joystack/engine/') && first !== 'node_modules' && !STATE_DIR_NAMES.includes(first));
144
+ }
145
+ return memo.get(url);
146
+ };
147
+ };
148
+
149
+ // Every sampled node of one profile, with the stand-in it ran under (or null),
150
+ // and the innermost frame of the game's own code at or above it (or null).
151
+ function* sampledNodes(profile, runs, isGame = () => false) {
152
+ const nodes = profile?.nodes ?? [];
153
+ const byId = new Map(nodes.map((n) => [n.id, n]));
154
+ const parent = new Map();
155
+ for (const n of nodes) for (const c of n.children ?? []) parent.set(c, n.id);
156
+ // Both walk up the call tree from a node; memoised, so each node is visited
157
+ // once however many samples sit below it.
158
+ const upward = (test) => {
159
+ const memo = new Map();
160
+ const find = (id) => {
161
+ if (memo.has(id)) return memo.get(id);
162
+ const n = byId.get(id);
163
+ let found = test(n);
164
+ if (found === undefined) found = parent.has(id) ? find(parent.get(id)) : null;
165
+ memo.set(id, found);
166
+ return found;
167
+ };
168
+ return find;
169
+ };
170
+ const runOf = upward((n) => {
171
+ const name = n?.callFrame?.functionName ?? '';
172
+ return name.startsWith(RUN_PREFIX) ? (runs[Number(name.slice(RUN_PREFIX.length))] ?? null) : undefined;
173
+ });
174
+ const gameOf = upward((n) => (n && isGame(n.callFrame) ? n.callFrame : undefined));
175
+ for (const n of nodes) {
176
+ const hits = n.hitCount ?? 0;
177
+ if (!hits || notTheGame(n.callFrame)) continue;
178
+ yield { node: n, hits, run: n.id != null ? runOf(n.id) : null, game: n.id != null ? gameOf(n.id) : null };
179
+ }
180
+ }
181
+
182
+ const EMPTY_RUN = { kind: null, trait: null, instance: null, tick_index: null, channel: null };
183
+
184
+ // Which of a tick handler, a channel handler, or the engine around them a run is.
185
+ const whatOf = (r) => (r.channel ? 'channel' : r.kind ? 'tick' : 'engine');
186
+
187
+ /**
188
+ * V8 CPU profiles, reduced to the functions that spent the time, most first,
189
+ * each credited to the tick or channel handler it ran under, and to the
190
+ * innermost function of the game's own code it ran under (`game_fn`,
191
+ * `game_source`) — itself, when it is the game's. Engine code a game function
192
+ * called is that game function's doing; summing by `game_source` says so.
193
+ *
194
+ * Self time, from `hitCount`: a sample lands on the function actually running.
195
+ * One function under two different ticks, or two different game functions, is
196
+ * two rows; reached by two callers under the same ones, one. Several profiles
197
+ * sum as one.
198
+ *
199
+ * @param {object|object[]} profiles - Profiler.stop()'s profile, or several
200
+ * @param {{ runs?: object[], dir?: string, sharedDir?: string }} [opts]
201
+ * @returns {{ fn: string, source: string, game_fn: string|null, game_source: string|null,
202
+ * kind: string|null, trait: string|null, instance: string|null,
203
+ * tick_index: number|null, channel: string|null, samples: number, share: number }[]}
204
+ */
205
+ export function tallyProfiles(profiles, { runs = [], dir = null, sharedDir = null } = {}) {
206
+ const byFn = new Map();
207
+ const isGame = gameFrameTest({ dir, sharedDir });
208
+ let total = 0;
209
+ for (const profile of Array.isArray(profiles) ? profiles : [profiles]) {
210
+ for (const { node, hits, run, game } of sampledNodes(profile, runs, isGame)) {
211
+ const frame = node.callFrame ?? {};
212
+ const fn = frame.functionName || '(anonymous)';
213
+ const source = sourceLabel(frame, { dir, sharedDir });
214
+ const game_fn = game ? (game.functionName || '(anonymous)') : null;
215
+ const game_source = game ? sourceLabel(game, { dir, sharedDir }) : null;
216
+ const r = run ?? EMPTY_RUN;
217
+ const key = [fn, source, game_fn, game_source, r.kind, r.instance, r.tick_index, r.channel].join('\x1f');
218
+ const cur = byFn.get(key) ?? { fn, source, game_fn, game_source, ...r, samples: 0 };
219
+ cur.samples += hits;
220
+ byFn.set(key, cur);
221
+ total += hits;
222
+ }
223
+ }
224
+ return [...byFn.values()]
225
+ .map((r) => ({ ...r, share: r.samples / total }))
226
+ .sort((a, b) => b.samples - a.samples);
227
+ }
228
+
229
+ /**
230
+ * The lines samples landed on, from V8's per-line counts of each function:
231
+ * one row per line per tick or channel handler, most first. `source` is the
232
+ * line itself, not the line the function starts on. Shares are of every
233
+ * sample, as the function rows' are; a function V8 kept no line counts for
234
+ * has no line rows.
235
+ *
236
+ * @returns {{ fn: string, source: string, kind: string|null, trait: string|null, instance: string|null,
237
+ * tick_index: number|null, channel: string|null, samples: number, share: number }[]}
238
+ */
239
+ export function tallyLines(profiles, { runs = [], dir = null, sharedDir = null } = {}) {
240
+ const byLine = new Map();
241
+ // One label per file and line: naming a source resolves real paths.
242
+ const labels = new Map();
243
+ const label = (url, line) => {
244
+ const key = `${line}\x1f${url}`;
245
+ if (!labels.has(key)) labels.set(key, sourceLabel({ url, lineNumber: line - 1 }, { dir, sharedDir }));
246
+ return labels.get(key);
247
+ };
248
+ let total = 0;
249
+ for (const profile of Array.isArray(profiles) ? profiles : [profiles]) {
250
+ for (const { node, hits, run } of sampledNodes(profile, runs)) {
251
+ total += hits;
252
+ const frame = node.callFrame ?? {};
253
+ if (!frame.url || !Array.isArray(node.positionTicks)) continue;
254
+ const fn = frame.functionName || '(anonymous)';
255
+ const r = run ?? EMPTY_RUN;
256
+ for (const { line, ticks } of node.positionTicks) {
257
+ if (!Number.isSafeInteger(line) || !(ticks > 0)) continue;
258
+ // `positionTicks` lines are one-based already; sourceLabel adds one.
259
+ const source = label(frame.url, line);
260
+ const key = [fn, source, r.kind, r.instance, r.tick_index, r.channel].join('\x1f');
261
+ const cur = byLine.get(key) ?? { fn, source, ...r, samples: 0 };
262
+ cur.samples += ticks;
263
+ byLine.set(key, cur);
264
+ }
265
+ }
266
+ }
267
+ return [...byLine.values()]
268
+ .map((r) => ({ ...r, share: r.samples / total }))
269
+ .sort((a, b) => b.samples - a.samples);
270
+ }
271
+
272
+ /**
273
+ * The samples each tick and channel handler ran, and the engine's own work
274
+ * around them, as shares of all the samples.
275
+ *
276
+ * @returns {{ what: 'tick'|'channel'|'engine', kind: string|null, trait: string|null,
277
+ * instance: string|null, tick_index: number|null, channel: string|null, samples: number, share: number }[]}
278
+ */
279
+ export function tallyRuns(profiles, { runs = [] } = {}) {
280
+ // Keyed by what a run IS, not by its object: `--repeat` wraps every handler
281
+ // again on each run, and the same handler must add up to one row.
282
+ const byRun = new Map();
283
+ let total = 0;
284
+ for (const profile of Array.isArray(profiles) ? profiles : [profiles]) {
285
+ for (const { hits, run } of sampledNodes(profile, runs)) {
286
+ const r = run ?? EMPTY_RUN;
287
+ const key = [r.kind, r.instance, r.tick_index, r.channel].join('\x1f');
288
+ const cur = byRun.get(key) ?? { what: whatOf(r), ...r, samples: 0 };
289
+ cur.samples += hits;
290
+ byRun.set(key, cur);
291
+ total += hits;
292
+ }
293
+ }
294
+ return [...byRun.values()]
295
+ .map((r) => ({ ...r, share: r.samples / total }))
296
+ .sort((a, b) => b.samples - a.samples);
297
+ }
298
+
299
+ // A V8 profiler over this process, or null where one cannot be opened (an old
300
+ // runtime, an environment with the inspector disabled).
301
+ async function nodeProfiler() {
302
+ try {
303
+ const { Session } = await import('node:inspector/promises');
304
+ const session = new Session();
305
+ session.connect();
306
+ await session.post('Profiler.enable');
307
+ await session.post('Profiler.setSamplingInterval', { interval: PROFILE_SAMPLE_US });
308
+ return {
309
+ start: () => session.post('Profiler.start'),
310
+ stop: async () => (await session.post('Profiler.stop'))?.profile ?? null,
311
+ dispose: async () => {
312
+ try { await session.post('Profiler.disable'); } finally { session.disconnect(); }
313
+ },
314
+ };
315
+ } catch {
316
+ return null;
317
+ }
318
+ }
319
+
320
+ // `row` is `function` (one per function, credited to the tick or channel
321
+ // handler it ran under and to the innermost game function above it), `line`
322
+ // (one per line samples landed on, per handler) or `trait` (one per tick and
323
+ // channel handler, plus one `engine` row for the work around them). `what`
324
+ // says which of tick, channel or engine a row's run is. `tick_index` is which
325
+ // of its kind's ticks a tick handler is, counted from 0 in the order the kind
326
+ // runs them; it is not a sim tick, which `tick_from` and `tick_to` are.
327
+ const PROFILE_COLUMNS = [
328
+ { name: 'row', type: 'STRING' },
329
+ { name: 'tick_from', type: 'DOUBLE' },
330
+ { name: 'tick_to', type: 'DOUBLE' },
331
+ { name: 'fn', type: 'STRING' },
332
+ { name: 'source', type: 'STRING' },
333
+ { name: 'game_fn', type: 'STRING' },
334
+ { name: 'game_source', type: 'STRING' },
335
+ { name: 'what', type: 'STRING' },
336
+ { name: 'kind', type: 'STRING' },
337
+ { name: 'trait', type: 'STRING' },
338
+ { name: 'instance', type: 'STRING' },
339
+ { name: 'tick_index', type: 'DOUBLE' },
340
+ { name: 'channel', type: 'STRING' },
341
+ { name: 'samples', type: 'DOUBLE' },
342
+ { name: 'share', type: 'DOUBLE' },
343
+ ];
344
+
345
+ /**
346
+ * Re-run ticks `from`–`to` of a recorded session under a sampling profiler and
347
+ * write one row per function, line and handler that ran, with its share of the
348
+ * samples.
349
+ *
350
+ * @param {object} opts
351
+ * @param {string} opts.dir - the game folder
352
+ * @param {string} [opts.stream] - which recording; defaults to the most recent
353
+ * @param {{ from: number, to: number }} opts.ticks
354
+ * @param {string} opts.outDir
355
+ * @param {string} [opts.sharedDir] - the resolved engine bundle's shared/ tree
356
+ * @param {number} [opts.repeat] - how many times to run the ticks, each from a
357
+ * fresh replay; the samples of every run sum
358
+ * @param {Function} [opts.openReplayImpl] - seam for tests
359
+ * @param {Function} [opts.profilerImpl] - seam for tests
360
+ * @param {(msg: string) => void} [opts.log]
361
+ * @returns {Promise<{ stream: string|null, file: string, fromTick: number, toTick: number, samples: number,
362
+ * rows: number, repeat: number }>}
363
+ */
364
+ export async function profileSession({
365
+ dir, stream, ticks, outDir, sharedDir, repeat = 1, openReplayImpl, profilerImpl, log = () => {},
366
+ }) {
367
+ if (!ticks) {
368
+ throw new Error('profile needs --ticks: the ticks to re-run under the profiler, like 1195-1228. '
369
+ + 'Find them in the exported perf table.');
370
+ }
371
+ if (!Number.isSafeInteger(repeat) || repeat < 1) throw new Error(`--repeat takes a whole number of runs, and ${repeat} is not one`);
372
+ const openReplay = openReplayImpl ?? await loadOpenReplay(sharedDir, 'profile');
373
+ // Every run opens the recording afresh, and opening one says what it found
374
+ // — an engine or build that changed since, a drift. Said once, not per run.
375
+ const said = new Set();
376
+ const logOnce = (msg) => {
377
+ if (said.has(msg)) return;
378
+ said.add(msg);
379
+ log(msg);
380
+ };
381
+ const pieces = [];
382
+ const runsAll = [];
383
+ let first = null;
384
+ for (let i = 0; i < repeat; i++) {
385
+ const got = await profileOnce({ dir, stream, ticks, openReplay, profilerImpl, log: logOnce, runsOffset: runsAll.length });
386
+ // Every run numbers its stand-ins from where the last left off, so the
387
+ // pieces of all of them read against one list.
388
+ runsAll.push(...got.runs);
389
+ pieces.push(...got.pieces);
390
+ first ??= got;
391
+ }
392
+ const { chosen, fromTick, toTick } = first;
393
+ const fns = tallyProfiles(pieces, { runs: runsAll, dir, sharedDir });
394
+ const lines = tallyLines(pieces, { runs: runsAll, dir, sharedDir });
395
+ const traits = tallyRuns(pieces, { runs: runsAll });
396
+ const samples = fns.reduce((n, f) => n + f.samples, 0);
397
+
398
+ mkdirSync(outDir, { recursive: true });
399
+ const file = join(outDir, `${chosen ?? 'session'}.profile.${fromTick}-${toTick}.parquet`);
400
+ const sink = rowSinkFor(file, PROFILE_COLUMNS);
401
+ let rows;
402
+ try {
403
+ for (const f of fns) sink.push({ row: 'function', tick_from: fromTick, tick_to: toTick, what: whatOf(f), ...f });
404
+ for (const l of lines) sink.push({ row: 'line', tick_from: fromTick, tick_to: toTick, what: whatOf(l), ...l });
405
+ for (const t of traits) sink.push({ row: 'trait', tick_from: fromTick, tick_to: toTick, ...t });
406
+ rows = sink.finish();
407
+ } catch (e) {
408
+ sink.abandon();
409
+ throw e;
410
+ }
411
+ return { stream: chosen, file, fromTick, toTick, samples, rows, repeat };
412
+ }
413
+
414
+ // One run of the ticks from a freshly opened replay, profiled in pieces.
415
+ async function profileOnce({ dir, stream, ticks, openReplay, profilerImpl, log, runsOffset }) {
416
+ const opened = await openReplay({ dir, stream, log });
417
+ const { replayer } = opened;
418
+ const chosen = opened.stream ?? stream ?? null;
419
+ // Both become part of a file name: the session's name when none was given
420
+ // is a directory listing's, and playback's position is the recording's
421
+ // keyframe tick. Neither is first-party, so each is checked, not trusted.
422
+ if (chosen !== null) streamDir(dir, chosen);
423
+ const position = () => {
424
+ const t = replayer.tick;
425
+ if (!Number.isSafeInteger(t)) throw new Error(`this recording's playback is at tick ${JSON.stringify(t)}, which is not a tick`);
426
+ return t;
427
+ };
428
+ const endTick = replayer.endTick;
429
+ if (ticks.from > endTick) {
430
+ throw new Error(`this session ends at tick ${endTick}, so there is no tick ${ticks.from} to profile`);
431
+ }
432
+ // Stepping to a tick runs it, so playback stops one short of the first tick
433
+ // the profile holds. A session entered at a keyframe past `from` has already
434
+ // run those ticks, and the profile starts where playback does.
435
+ while (position() < ticks.from - 1 && !replayer.atEnd) replayer.step();
436
+ const fromTick = position() + 1;
437
+ if (fromTick > ticks.to) {
438
+ throw new Error(`playback of this session starts at tick ${fromTick}, after the ticks asked for`);
439
+ }
440
+
441
+ const profiler = await (profilerImpl ?? nodeProfiler)();
442
+ if (!profiler) throw new Error('this Node cannot open its own profiler (node:inspector), so there is nothing to profile with');
443
+ const { runs, restore } = instrumentRuns(replayer.runtime, opened.bundle?.channelOrigins);
444
+ // Stand-ins are numbered from zero in each run; renumbered here into the
445
+ // list every run shares.
446
+ const renumber = (name) => (name?.startsWith(RUN_PREFIX) ? `${RUN_PREFIX}${Number(name.slice(RUN_PREFIX.length)) + runsOffset}` : name);
447
+ // Each piece keeps only what the tally reads: the call tree's shape and the
448
+ // sampled frames. The timeline goes.
449
+ const pieces = [];
450
+ try {
451
+ while (position() < ticks.to && !replayer.atEnd) {
452
+ const pieceEnd = Math.min(ticks.to, position() + PROFILE_PIECE_TICKS);
453
+ await profiler.start();
454
+ while (position() < pieceEnd && !replayer.atEnd) replayer.step();
455
+ const raw = await profiler.stop();
456
+ pieces.push({
457
+ nodes: (raw?.nodes ?? []).map(({ id, children, hitCount, positionTicks, callFrame }) => ({
458
+ id, children, hitCount, positionTicks, callFrame: { ...callFrame, functionName: renumber(callFrame?.functionName) },
459
+ })),
460
+ });
461
+ }
462
+ } finally {
463
+ restore();
464
+ await profiler.dispose();
465
+ }
466
+ return { chosen, fromTick, toTick: position(), runs, pieces };
467
+ }