@a11ign/screenreader-fleet 0.1.0 → 0.3.0

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 (129) hide show
  1. package/dist/capture-client.d.mts +0 -1
  2. package/dist/capture-client.mjs +144 -306
  3. package/dist/check-worker-code.d.mts +0 -1
  4. package/dist/check-worker-code.mjs +42 -141
  5. package/dist/cli-flags.d.mts +0 -1
  6. package/dist/cli-flags.mjs +33 -179
  7. package/dist/code-drift.d.mts +0 -1
  8. package/dist/command-line-census.d.mts +0 -1
  9. package/dist/compare-workers.d.mts +0 -1
  10. package/dist/compare-workers.mjs +383 -255
  11. package/dist/control-plane-isolation.d.mts +0 -1
  12. package/dist/deploy-worker.d.mts +0 -1
  13. package/dist/deploy-worker.mjs +101 -242
  14. package/dist/doctor.d.mts +0 -1
  15. package/dist/doctor.mjs +361 -809
  16. package/dist/fleet-consistency.d.mts +0 -1
  17. package/dist/fleet-consistency.mjs +155 -379
  18. package/dist/fleet-env.d.mts +0 -1
  19. package/dist/fleet-env.mjs +148 -438
  20. package/dist/fleet-scripts.d.mts +0 -1
  21. package/dist/git-safe-env.d.mts +0 -1
  22. package/dist/guest-run.d.mts +0 -1
  23. package/dist/host-address.d.mts +0 -1
  24. package/dist/host-address.mjs +19 -90
  25. package/dist/host-capacity.d.mts +0 -1
  26. package/dist/host-capacity.mjs +22 -136
  27. package/dist/host-metrics.d.mts +0 -1
  28. package/dist/index.d.ts +0 -1
  29. package/dist/index.mjs +231 -0
  30. package/dist/local-vm.d.ts +0 -1
  31. package/dist/measure-guard.d.mts +0 -1
  32. package/dist/normalise-fleet.d.mts +0 -1
  33. package/dist/npm-cli-executable.d.mts +0 -1
  34. package/dist/probe-outcome.d.mts +0 -1
  35. package/dist/probe-outcome.mjs +50 -96
  36. package/dist/protocol-guard.d.mts +0 -1
  37. package/dist/source-walk.d.mts +0 -1
  38. package/dist/src_fleet-scripts_mjs.mjs +16 -0
  39. package/dist/src_git-safe-env_mjs.mjs +9 -0
  40. package/dist/src_utm-deprecated_mjs.mjs +4 -0
  41. package/dist/transient-fault.d.mts +0 -1
  42. package/dist/transient-fault.mjs +21 -81
  43. package/dist/utm-deprecated.d.mts +0 -1
  44. package/dist/worker-code-check.d.mts +0 -1
  45. package/dist/worker-code-check.mjs +127 -76
  46. package/dist/worker-health.d.mts +0 -1
  47. package/dist/worker-health.mjs +16 -61
  48. package/dist/worker-http.d.mts +0 -1
  49. package/dist/worker-http.mjs +42 -234
  50. package/dist/worker-stats.d.mts +0 -1
  51. package/package.json +12 -5
  52. package/dist/capture-client.d.mts.map +0 -1
  53. package/dist/capture-client.mjs.map +0 -1
  54. package/dist/check-worker-code.d.mts.map +0 -1
  55. package/dist/check-worker-code.mjs.map +0 -1
  56. package/dist/cli-flags.d.mts.map +0 -1
  57. package/dist/cli-flags.mjs.map +0 -1
  58. package/dist/code-drift.d.mts.map +0 -1
  59. package/dist/code-drift.mjs +0 -284
  60. package/dist/code-drift.mjs.map +0 -1
  61. package/dist/command-line-census.d.mts.map +0 -1
  62. package/dist/command-line-census.mjs +0 -96
  63. package/dist/command-line-census.mjs.map +0 -1
  64. package/dist/compare-workers.d.mts.map +0 -1
  65. package/dist/compare-workers.mjs.map +0 -1
  66. package/dist/control-plane-isolation.d.mts.map +0 -1
  67. package/dist/control-plane-isolation.mjs +0 -67
  68. package/dist/control-plane-isolation.mjs.map +0 -1
  69. package/dist/deploy-worker.d.mts.map +0 -1
  70. package/dist/deploy-worker.mjs.map +0 -1
  71. package/dist/doctor.d.mts.map +0 -1
  72. package/dist/doctor.mjs.map +0 -1
  73. package/dist/fleet-consistency.d.mts.map +0 -1
  74. package/dist/fleet-consistency.mjs.map +0 -1
  75. package/dist/fleet-env.d.mts.map +0 -1
  76. package/dist/fleet-env.mjs.map +0 -1
  77. package/dist/fleet-scripts.d.mts.map +0 -1
  78. package/dist/fleet-scripts.mjs +0 -41
  79. package/dist/fleet-scripts.mjs.map +0 -1
  80. package/dist/git-safe-env.d.mts.map +0 -1
  81. package/dist/git-safe-env.mjs +0 -44
  82. package/dist/git-safe-env.mjs.map +0 -1
  83. package/dist/guest-run.d.mts.map +0 -1
  84. package/dist/guest-run.mjs +0 -164
  85. package/dist/guest-run.mjs.map +0 -1
  86. package/dist/host-address.d.mts.map +0 -1
  87. package/dist/host-address.mjs.map +0 -1
  88. package/dist/host-capacity.d.mts.map +0 -1
  89. package/dist/host-capacity.mjs.map +0 -1
  90. package/dist/host-metrics.d.mts.map +0 -1
  91. package/dist/host-metrics.mjs +0 -201
  92. package/dist/host-metrics.mjs.map +0 -1
  93. package/dist/index.d.ts.map +0 -1
  94. package/dist/index.js +0 -25
  95. package/dist/index.js.map +0 -1
  96. package/dist/local-vm.d.ts.map +0 -1
  97. package/dist/local-vm.js +0 -360
  98. package/dist/local-vm.js.map +0 -1
  99. package/dist/measure-guard.d.mts.map +0 -1
  100. package/dist/measure-guard.mjs +0 -73
  101. package/dist/measure-guard.mjs.map +0 -1
  102. package/dist/normalise-fleet.d.mts.map +0 -1
  103. package/dist/normalise-fleet.mjs +0 -76
  104. package/dist/normalise-fleet.mjs.map +0 -1
  105. package/dist/npm-cli-executable.d.mts.map +0 -1
  106. package/dist/npm-cli-executable.mjs +0 -159
  107. package/dist/npm-cli-executable.mjs.map +0 -1
  108. package/dist/probe-outcome.d.mts.map +0 -1
  109. package/dist/probe-outcome.mjs.map +0 -1
  110. package/dist/protocol-guard.d.mts.map +0 -1
  111. package/dist/protocol-guard.mjs +0 -121
  112. package/dist/protocol-guard.mjs.map +0 -1
  113. package/dist/source-walk.d.mts.map +0 -1
  114. package/dist/source-walk.mjs +0 -56
  115. package/dist/source-walk.mjs.map +0 -1
  116. package/dist/transient-fault.d.mts.map +0 -1
  117. package/dist/transient-fault.mjs.map +0 -1
  118. package/dist/utm-deprecated.d.mts.map +0 -1
  119. package/dist/utm-deprecated.mjs +0 -23
  120. package/dist/utm-deprecated.mjs.map +0 -1
  121. package/dist/worker-code-check.d.mts.map +0 -1
  122. package/dist/worker-code-check.mjs.map +0 -1
  123. package/dist/worker-health.d.mts.map +0 -1
  124. package/dist/worker-health.mjs.map +0 -1
  125. package/dist/worker-http.d.mts.map +0 -1
  126. package/dist/worker-http.mjs.map +0 -1
  127. package/dist/worker-stats.d.mts.map +0 -1
  128. package/dist/worker-stats.mjs +0 -143
  129. package/dist/worker-stats.mjs.map +0 -1
@@ -1,123 +1,285 @@
1
1
  #!/usr/bin/env node
2
- // @ts-check
3
- // Why is one worker slower than another? Run the same page on each and diff every phase.
4
- //
5
- // npm run worker:compare -- <page-url> <worker-url> <worker-url> [...] [--rounds=6]
6
- //
7
- // This exists because I spent hours attributing a 2x difference between two guests to the wrong phase.
8
- // `bench-capture.mjs` gives a phase table for ONE worker, so comparing two meant eyeballing two
9
- // printouts, and I compared wall times instead — which said "worker 1 is faster" without saying where.
10
- // The moment the phases were put side by side the answer was immediate: `windowsActivate` was identical
11
- // (7.4s vs 7.3s, so Edge launch was never the difference) and the entire gap was in `sweep`.
12
- //
13
- // It also surfaces the per-sweep detail that capture-core already records and nothing displayed.
14
- // `collectByType` marks every sweep with its type, elapsed ms and round-trip count in each direction,
15
- // and the aggregate hid all of it. Six sweeps collapsed into one number cannot tell you whether a worker
16
- // is doing MORE round trips or SLOWER ones — and those have different causes.
17
- //
18
- // Samples are INTERLEAVED round-robin and reported as medians with an interquartile range, never as
19
- // means. Both choices are corrections to how this went wrong before:
20
- //
21
- // - Sequential sampling (all of worker A, then all of worker B) charges any drift in the host during
22
- // those minutes to the difference between the workers. Round-robin makes drift common to all of them.
23
- // - A single mute-recovery outlier (~86s against a normal ~12s) moves a mean by 5s and makes a healthy
24
- // worker look broken. The median and IQR do not move.
25
- //
26
- // It also refuses to declare a difference the samples do not support -- see `./worker-stats.mjs`.
27
- // "Not distinguishable" is a real answer, and it is the one that was missing.
28
- import { writeFileSync, mkdirSync, realpathSync } from "node:fs";
29
- import { refuseIfBusy, sampleVitals } from "./measure-guard.mjs";
30
- import { pathToFileURL, fileURLToPath } from "node:url";
31
- import { compareWorkers, describe as summarise, recoveryRates } from "./worker-stats.mjs";
32
- import { sampleHost, diffHost } from "./host-metrics.mjs";
33
- import { requestJson, CAPTURE_CLIENT_TIMEOUT_MS } from "./worker-http.mjs";
2
+ import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { execFileSync } from "node:child_process";
34
5
  import { resolve } from "node:path";
6
+ import { describeProbe, probeHealth, WORKER_PROBE_TIMEOUT_MS } from "./probe-outcome.mjs";
7
+ import { CAPTURE_CLIENT_TIMEOUT_MS, requestJson } from "./worker-http.mjs";
35
8
  import { refuseUnknownFlags } from "./cli-flags.mjs";
36
- /**
37
- * takes a page and two workers POSITIONALLY. `--runs=` is a documented alias of `--rounds=`, so both
38
- * are accepted — a guard listing only one would refuse a spelling the code deliberately supports.
39
- *
40
- * An unrecognised flag is otherwise IGNORED, so it runs the default and reports success.
41
- */
42
- refuseUnknownFlags(["--rounds=", "--runs="], { entry: import.meta.url, command: "npm run worker:compare" });
43
- // `requestJson`, not `fetch`: undici stops waiting for response HEADERS at 300 s whatever the
44
- // AbortSignal says, and the worker writes its status and body together at the END of a capture.
45
- // See worker-http.mjs -- this budget sits at or above that cap, so it never applied.
46
- // Resolved from THIS module, never the cwd -- same reason as `doctor.mjs`'s `DATASET`: this package
47
- // cannot import `@a11ign/lab`'s canonical `runs/` resolution without a dependency cycle.
9
+ async function refuseIfBusy(workers, { what, timeoutMs = WORKER_PROBE_TIMEOUT_MS, request }) {
10
+ const states = await Promise.all(workers.map(async (worker)=>{
11
+ const probe = await probeHealth(worker, {
12
+ timeoutMs,
13
+ request
14
+ });
15
+ if ("ready" === probe.outcome) return {
16
+ worker,
17
+ free: true,
18
+ why: ""
19
+ };
20
+ return {
21
+ worker,
22
+ free: false,
23
+ why: describeProbe(probe, {
24
+ worker,
25
+ timeoutMs
26
+ })
27
+ };
28
+ }));
29
+ const busy = states.filter((s)=>!s.free);
30
+ if (!busy.length) return;
31
+ throw new Error(`REFUSING to measure ${what}: ${busy.length} of ${workers.length} worker(s) are not free — ${busy.map((b)=>b.why).join(" ")} A measurement taken against a busy box samples its 429s, not its behaviour, and returns a number that looks real. Wait, or pass a worker that is free.`);
32
+ }
33
+ const VITALS_TIMEOUT_MS = 20000;
34
+ async function sampleVitals(worker, { timeoutMs = VITALS_TIMEOUT_MS, request, warn = ()=>{} } = {}) {
35
+ const probe = await probeHealth(worker, {
36
+ timeoutMs,
37
+ request
38
+ });
39
+ if ("refused" === probe.outcome || "no-answer" === probe.outcome) {
40
+ warn(` no vitals sampled: ${describeProbe(probe, {
41
+ worker,
42
+ timeoutMs
43
+ })}\n`);
44
+ return null;
45
+ }
46
+ return probe.health?.vitals ?? null;
47
+ }
48
+ const MIN_ROUNDS_FOR_A_VERDICT = 5;
49
+ const sorted = (values)=>[
50
+ ...values
51
+ ].sort((a, b)=>a - b);
52
+ function quantile(values, q) {
53
+ if (!values.length) return null;
54
+ const s = sorted(values);
55
+ const position = (s.length - 1) * q;
56
+ const low = Math.floor(position), high = Math.ceil(position);
57
+ return low === high ? s[low] : s[low] + (s[high] - s[low]) * (position - low);
58
+ }
59
+ function describe(values) {
60
+ if (!values.length) return null;
61
+ const q1 = quantile(values, 0.25);
62
+ const q3 = quantile(values, 0.75);
63
+ return {
64
+ n: values.length,
65
+ median: quantile(values, 0.5),
66
+ q1,
67
+ q3,
68
+ iqr: q3 - q1,
69
+ min: Math.min(...values),
70
+ max: Math.max(...values)
71
+ };
72
+ }
73
+ function overlaps(a, b) {
74
+ return a.q1 <= b.q3 && b.q1 <= a.q3;
75
+ }
76
+ function compareWorkers(samplesByWorker) {
77
+ const stats = {};
78
+ for (const [worker, values] of Object.entries(samplesByWorker)){
79
+ const description = describe(values);
80
+ if (description) stats[worker] = description;
81
+ }
82
+ const names = Object.keys(stats);
83
+ if (names.length < 2) return {
84
+ stats,
85
+ slowest: null,
86
+ fastest: null,
87
+ distinguishable: false,
88
+ verdict: "need at least two workers with samples"
89
+ };
90
+ const rounds = Math.min(...names.map((n)=>stats[n].n));
91
+ const byMedian = [
92
+ ...names
93
+ ].sort((a, b)=>stats[a].median - stats[b].median);
94
+ const fastest = byMedian[0], slowest = byMedian[byMedian.length - 1];
95
+ const gap = stats[slowest].median - stats[fastest].median;
96
+ const widest = Math.max(stats[fastest].iqr, stats[slowest].iqr);
97
+ if (rounds < MIN_ROUNDS_FOR_A_VERDICT) return {
98
+ stats,
99
+ slowest,
100
+ fastest,
101
+ distinguishable: false,
102
+ verdict: `only ${rounds} round(s) — too few to claim anything. Run at least ${MIN_ROUNDS_FOR_A_VERDICT}.`
103
+ };
104
+ if (gap <= widest || overlaps(stats[fastest], stats[slowest])) return {
105
+ stats,
106
+ slowest,
107
+ fastest,
108
+ distinguishable: false,
109
+ verdict: `NOT DISTINGUISHABLE: medians differ by ${gap.toFixed(1)} but the spread is ${widest.toFixed(1)} and the interquartile ranges overlap. These samples do not support a difference between the workers.`
110
+ };
111
+ return {
112
+ stats,
113
+ slowest,
114
+ fastest,
115
+ distinguishable: true,
116
+ verdict: `${slowest} is slower than ${fastest} by ${gap.toFixed(1)} (medians), with non-overlapping interquartile ranges over ${rounds} interleaved rounds.`
117
+ };
118
+ }
119
+ function recoveryRates(deltas) {
120
+ const rates = {};
121
+ for (const [worker, d] of Object.entries(deltas))rates[worker] = d.captures > 0 ? d.recoveries / d.captures : null;
122
+ return rates;
123
+ }
124
+ const BYTES_PER_MB = 1048576;
125
+ function parseVmStat(output) {
126
+ const pageSize = Number(/page size of (\d+) bytes/.exec(output)?.[1]);
127
+ if (!Number.isFinite(pageSize)) return null;
128
+ const pages = (label)=>Number(new RegExp(`${label}:\\s+(\\d+)`).exec(output)?.[1] ?? 0);
129
+ const mb = (label)=>Math.round(pages(label) * pageSize / BYTES_PER_MB);
130
+ return {
131
+ freeMb: mb("Pages free"),
132
+ activeMb: mb("Pages active"),
133
+ inactiveMb: mb("Pages inactive"),
134
+ wiredMb: mb("Pages wired down"),
135
+ compressorMb: mb("Pages occupied by compressor"),
136
+ pageins: pages("Pageins"),
137
+ pageouts: pages("Pageouts")
138
+ };
139
+ }
140
+ function parseIostat(output) {
141
+ const lines = String(output).trim().split(/\r?\n/);
142
+ const header = lines.findIndex((l)=>/^\s*disk/.test(l));
143
+ if (-1 === header) return [];
144
+ const devices = lines[header].trim().split(/\s+/);
145
+ const samples = lines.slice(header + 2).filter((l)=>/\d/.test(l));
146
+ const last = samples.at(-1);
147
+ if (!last) return [];
148
+ const numbers = last.trim().split(/\s+/).map(Number);
149
+ return devices.map((device, i)=>({
150
+ device,
151
+ kbPerTransfer: numbers[3 * i] ?? 0,
152
+ transfersPerSecond: numbers[3 * i + 1] ?? 0,
153
+ mbPerSecond: numbers[3 * i + 2] ?? 0
154
+ })).filter((d)=>Number.isFinite(d.mbPerSecond));
155
+ }
156
+ function parseLoadAverage(output) {
157
+ const m = /([\d.]+)\s+([\d.]+)\s+([\d.]+)/.exec(String(output));
158
+ return m ? {
159
+ one: Number(m[1]),
160
+ five: Number(m[2]),
161
+ fifteen: Number(m[3])
162
+ } : null;
163
+ }
164
+ function parseProcessMemory(psOutput, match) {
165
+ return String(psOutput).trim().split(/\r?\n/).flatMap((line)=>{
166
+ const fields = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
167
+ if (!fields || !fields[3].includes(match)) return [];
168
+ return [
169
+ {
170
+ pid: Number(fields[1]),
171
+ residentMb: Math.round(Number(fields[2]) / 1024),
172
+ command: fields[3]
173
+ }
174
+ ];
175
+ });
176
+ }
177
+ const run = (cmd, args)=>{
178
+ try {
179
+ return execFileSync(cmd, args, {
180
+ encoding: "utf8",
181
+ timeout: 15000
182
+ });
183
+ } catch {
184
+ return "";
185
+ }
186
+ };
187
+ function sampleHost({ processMatch = "QEMU" } = {}) {
188
+ const memory = parseVmStat(run("vm_stat", []));
189
+ return {
190
+ at: new Date().toISOString(),
191
+ memory,
192
+ disk: parseIostat(run("iostat", [
193
+ "-d",
194
+ "-c",
195
+ "2",
196
+ "-w",
197
+ "1"
198
+ ])),
199
+ load: parseLoadAverage(run("sysctl", [
200
+ "-n",
201
+ "vm.loadavg"
202
+ ])),
203
+ processes: parseProcessMemory(run("ps", [
204
+ "-Ao",
205
+ "pid=,rss=,comm="
206
+ ]), processMatch)
207
+ };
208
+ }
209
+ const reading = (snapshot, field, fallback)=>snapshot?.memory?.[field] ?? fallback;
210
+ function diffHost(before, after) {
211
+ const pageouts = (reading(after, "pageouts", 0) ?? 0) - (reading(before, "pageouts", 0) ?? 0);
212
+ const pageins = (reading(after, "pageins", 0) ?? 0) - (reading(before, "pageins", 0) ?? 0);
213
+ return {
214
+ pageoutsDelta: pageouts,
215
+ pageinsDelta: pageins,
216
+ swappingDuringRun: pageouts > 0,
217
+ compressorMb: reading(after, "compressorMb", null),
218
+ freeMb: reading(after, "freeMb", null),
219
+ residentMbTotal: (after?.processes ?? []).reduce((sum, p)=>sum + p.residentMb, 0)
220
+ };
221
+ }
222
+ refuseUnknownFlags([
223
+ "--rounds=",
224
+ "--runs="
225
+ ], {
226
+ entry: import.meta.url,
227
+ command: "npm run worker:compare"
228
+ });
48
229
  const OUT = resolve(fileURLToPath(new URL("../../../", import.meta.url)), "runs/worker-compare");
49
230
  const MS_PER_S = 1000;
50
- /**
51
- * `dataset-paths.mjs`'s `refuseIfRunsReadonly`, duplicated rather than imported -- same reason `OUT`
52
- * above resolves from this module's own location instead of importing `runsRoot()`: this package cannot
53
- * import `@a11ign/lab` without a dependency cycle (see `dataset-paths.test.ts`'s own EXEMPT entry
54
- * for this file). `A11Y_RUNS_READONLY=1 npm run worker:compare ...` must refuse and name `OUT` exactly
55
- * like every other runs/ writer, so a peer asking "is this safe to run" gets one answer regardless of
56
- * which package the script happens to live in.
57
- */
58
231
  function refuseIfRunsReadonly() {
59
- if (process.env.A11Y_RUNS_READONLY !== "1")
60
- return;
232
+ if ("1" !== process.env.A11Y_RUNS_READONLY) return;
61
233
  console.error("REFUSING to write runs/worker-compare/compare.json — A11Y_RUNS_READONLY=1 is set.");
62
234
  console.error(" Unset A11Y_RUNS_READONLY to run this for real.");
63
235
  process.exit(3);
64
236
  }
65
- const args = process.argv.slice(2);
66
- const runs = Number(args.find((a) => a.startsWith("--rounds="))?.slice("--rounds=".length)
67
- ?? args.find((a) => a.startsWith("--runs="))?.slice("--runs=".length) ?? 6);
68
- const [page, ...workers] = args.filter((a) => !a.startsWith("--"));
69
- const short = (/** @type {any} */ worker) => new URL(worker).hostname.split(".").pop();
70
- async function capture(/** @type {any} */ worker) {
237
+ const compare_workers_args = process.argv.slice(2);
238
+ const runs = Number(compare_workers_args.find((a)=>a.startsWith("--rounds="))?.slice(9) ?? compare_workers_args.find((a)=>a.startsWith("--runs="))?.slice(7) ?? 6);
239
+ const [page, ...compare_workers_workers] = compare_workers_args.filter((a)=>!a.startsWith("--"));
240
+ const compare_workers_short = (worker)=>new URL(worker).hostname.split(".").pop();
241
+ async function capture(worker) {
71
242
  const response = await requestJson(`${worker.replace(/\/$/, "")}/capture`, {
72
243
  method: "POST",
73
- body: { url: page, probeForms: true },
74
- timeoutMs: CAPTURE_CLIENT_TIMEOUT_MS,
244
+ body: {
245
+ url: page,
246
+ probeForms: true
247
+ },
248
+ timeoutMs: CAPTURE_CLIENT_TIMEOUT_MS
75
249
  });
76
250
  const body = response.json ?? {};
77
- if (!response.ok)
78
- throw new Error(`HTTP ${response.status}: ${JSON.stringify(body).slice(0, 160)}`);
251
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${JSON.stringify(body).slice(0, 160)}`);
79
252
  return body;
80
253
  }
81
- async function diagnostics(/** @type {any} */ worker) {
254
+ async function diagnostics(worker) {
82
255
  try {
83
- // `requestJson`, not `fetch`: the same worker JSON client every other probe in this file uses (see
84
- // `capture`, above), rather than a second hand-rolled timeout mechanism for this one endpoint.
85
- const response = await requestJson(`${worker.replace(/\/$/, "")}/diagnostics`, { timeoutMs: 180_000 });
86
- // `?? null`: `response.json` is `undefined` on unparseable JSON (requestJson's own contract) rather
87
- // than a throw, so this still folds into the same "an older worker has no /diagnostics" outcome.
256
+ const response = await requestJson(`${worker.replace(/\/$/, "")}/diagnostics`, {
257
+ timeoutMs: 180000
258
+ });
88
259
  return response.ok ? response.json ?? null : null;
89
- }
90
- catch {
91
- return null; // an older worker has no /diagnostics; the phase comparison still works
260
+ } catch {
261
+ return null;
92
262
  }
93
263
  }
94
- /**
95
- * Phase costs from one capture's diagnostics.
96
- *
97
- * Diagnostics carry a cumulative `atMs`, so a phase's own cost is the gap from the previous mark — the
98
- * same arithmetic bench-capture does. Repeated events (there are six `sweep` marks) accumulate, which is
99
- * what makes `sweep` look like one large phase.
100
- */
101
- function phaseCosts(/** @type {any} */ entries) {
102
- /** @type {Record<string, any>} */
264
+ function phaseCosts(entries) {
103
265
  const costs = {};
104
266
  let previous = 0;
105
- for (const entry of entries ?? []) {
106
- if (typeof entry.atMs !== "number")
107
- continue;
267
+ for (const entry of entries ?? [])if ("number" == typeof entry.atMs) {
108
268
  costs[entry.event] = (costs[entry.event] ?? 0) + (entry.atMs - previous);
109
269
  previous = entry.atMs;
110
270
  }
111
271
  return costs;
112
272
  }
113
- /** Per-sweep detail, keyed by sweep type, which the aggregate throws away. */
114
- function sweepDetail(/** @type {any} */ entries) {
115
- /** @type {Record<string, any>} */
273
+ function sweepDetail(entries) {
116
274
  const byType = {};
117
- for (const entry of entries ?? []) {
118
- if (entry.event !== "sweep" || !entry.type)
119
- continue;
120
- const to = byType[entry.type] ??= { found: 0, ms: 0, trips: 0, n: 0 };
275
+ for (const entry of entries ?? []){
276
+ if ("sweep" !== entry.event || !entry.type) continue;
277
+ const to = byType[entry.type] ??= {
278
+ found: 0,
279
+ ms: 0,
280
+ trips: 0,
281
+ n: 0
282
+ };
121
283
  to.found += entry.found ?? 0;
122
284
  to.ms += (entry.prevMs ?? 0) + (entry.nextMs ?? 0);
123
285
  to.trips += (entry.prevTrips ?? 0) + (entry.nextTrips ?? 0);
@@ -125,208 +287,174 @@ function sweepDetail(/** @type {any} */ entries) {
125
287
  }
126
288
  return byType;
127
289
  }
128
- /** `sampleVitals` says on stderr why a worker has none, so a blank column in the report has a reason. */
129
- async function vitals(/** @type {string} */ worker) {
130
- return sampleVitals(worker, { warn: (line) => process.stderr.write(line) });
290
+ async function vitals(worker) {
291
+ return sampleVitals(worker, {
292
+ warn: (line)=>process.stderr.write(line)
293
+ });
131
294
  }
132
- /**
133
- * Only when RUN, never on import.
134
- *
135
- * This drove REAL captures against two workers at module scope -- so importing it to check the file still
136
- * loads issued `runs * workers` captures, each up to 560 s, and wrote a report under `runs/worker-compare`.
137
- * The usage guard moved in with it: a missing page argument is a mistake made by a CALLER, and there is no
138
- * caller when a file is merely imported.
139
- */
140
295
  async function main() {
141
- if (!page || workers.length < 2) {
296
+ if (!page || compare_workers_workers.length < 2) {
142
297
  process.stderr.write("usage: npm run worker:compare -- <page-url> <worker> <worker> [--rounds=6]\n");
143
298
  process.exit(2);
144
299
  }
145
300
  refuseIfRunsReadonly();
146
- // REFUSE A BUSY BOX BEFORE MEASURING IT. This whole tool exists to answer "which worker is the
147
- // problem", and four separate measurement errors came from sampling a box that was doing something
148
- // else — including 12 straight 429s read as timings. `refuseIfBusy` was written for exactly this and
149
- // was wired to nothing, which is the `scorer:verify` shape: a guard that exists and never runs.
150
- await refuseIfBusy(workers, { what: `worker:compare against ${page}` });
151
- // Per worker: the three series this comparison exists to print, plus the last run's diagnostics.
152
- // Declared because every array here is empty at construction and inferred `never[]`, so each push --
153
- // which IS the measurement -- reads as an error while the shape that discards it reads as fine.
154
- /** @type {Record<string, { phases: any[], sweeps: any[], walls: number[], diagnostics: any }>} */
155
- const results = Object.fromEntries(workers.map((/** @type {string} */ w) => [w, { phases: [], sweeps: [], walls: [], diagnostics: null }]));
156
- /** @type {Record<string, any>} */
157
- /** @type {Record<string, any>} */
301
+ await refuseIfBusy(compare_workers_workers, {
302
+ what: `worker:compare against ${page}`
303
+ });
304
+ const results = Object.fromEntries(compare_workers_workers.map((w)=>[
305
+ w,
306
+ {
307
+ phases: [],
308
+ sweeps: [],
309
+ walls: [],
310
+ diagnostics: null
311
+ }
312
+ ]));
158
313
  const vitalsBefore = {};
159
- /** @type {Record<string, any>} */
160
314
  const vitalsAfter = {};
161
- for (const worker of workers)
162
- vitalsBefore[worker] = await vitals(worker);
163
- // The foundations, sampled around the run. Timings alone say something got slower; they never say
164
- // which resource ran out. Not sampling disk I/O is why three guests contending on one SSD was
165
- // misdiagnosed as a memory problem, then as a guest problem, before anyone looked at the disk.
315
+ for (const worker of compare_workers_workers)vitalsBefore[worker] = await vitals(worker);
166
316
  const hostBefore = sampleHost();
167
- // INTERLEAVED, round-robin. Measuring one worker for five minutes and then the next attributes any drift
168
- // in the host during those ten minutes to the difference between the workers -- which is how a shared
169
- // Mac running builds and browsers produces a confident wrong answer. Round-robin makes drift common.
170
- process.stdout.write(`\nInterleaving ${runs} round(s) across ${workers.length} worker(s)\n`);
171
- for (let round = 1; round <= runs; round++) {
172
- for (const worker of workers) {
173
- try {
174
- const started = Date.now();
175
- const result = await capture(worker);
176
- const wall = Date.now() - started;
177
- results[worker].walls.push(wall);
178
- results[worker].phases.push(phaseCosts(result.diagnostics));
179
- results[worker].sweeps.push(sweepDetail(result.diagnostics));
180
- process.stdout.write(` round ${round} ${short(worker)}: ${(wall / MS_PER_S).toFixed(1)}s ` +
181
- `${result.transcript?.length ?? 0} phrases\n`);
182
- }
183
- catch (error) {
184
- process.stdout.write(` round ${round} ${short(worker)}: FAILED ${ /** @type {any} */(error).message}\n`);
185
- }
186
- }
317
+ process.stdout.write(`\nInterleaving ${runs} round(s) across ${compare_workers_workers.length} worker(s)\n`);
318
+ for(let round = 1; round <= runs; round++)for (const worker of compare_workers_workers)try {
319
+ const started = Date.now();
320
+ const result = await capture(worker);
321
+ const wall = Date.now() - started;
322
+ results[worker].walls.push(wall);
323
+ results[worker].phases.push(phaseCosts(result.diagnostics));
324
+ results[worker].sweeps.push(sweepDetail(result.diagnostics));
325
+ process.stdout.write(` round ${round} ${compare_workers_short(worker)}: ${(wall / MS_PER_S).toFixed(1)}s ${result.transcript?.length ?? 0} phrases\n`);
326
+ } catch (error) {
327
+ process.stdout.write(` round ${round} ${compare_workers_short(worker)}: FAILED ${error.message}\n`);
187
328
  }
188
- for (const worker of workers) {
329
+ for (const worker of compare_workers_workers){
189
330
  vitalsAfter[worker] = await vitals(worker);
190
331
  results[worker].diagnostics = await diagnostics(worker);
191
332
  }
192
- report({ results, vitalsBefore, vitalsAfter, hostBefore });
333
+ report({
334
+ results,
335
+ vitalsBefore,
336
+ vitalsAfter,
337
+ hostBefore
338
+ });
193
339
  }
194
- /**
195
- * The comparison, and the verdict. Split from the MEASUREMENT half so neither exceeds the lint gate's
196
- * 70 lines and complexity 15 -- and because they are genuinely different jobs: one drives the workers,
197
- * the other decides what the numbers mean. `compare-workers` exists because reading two `bench-capture`
198
- * printouts side by side attributed a 2x difference to the wrong phase for hours.
199
- */
200
- function report(/** @type {any} */ { results, vitalsBefore, vitalsAfter, hostBefore }) {
201
- // Shared by both tables below: which phases exist at all, and how to pull one worker's samples.
202
- const allPhases = [...new Set(Object.values(results).flatMap((r) => r.phases.flatMap(Object.keys)))];
203
- const phaseSamples = (/** @type {any} */ worker, /** @type {any} */ phase) => results[worker].phases.map((/** @type {any} */ p) => (p[phase] ?? 0) / MS_PER_S);
204
- const { verdict, deltas } = reportWallTime({ results, vitalsBefore, vitalsAfter });
205
- reportPhases({ results, allPhases, phaseSamples });
206
- const { foundations, hostAfter } = reportFoundations({ hostBefore });
207
- mkdirSync(OUT, { recursive: true });
208
- writeFileSync(resolve(OUT, "compare.json"), JSON.stringify({ page, rounds: runs, results, verdict, recoveryDeltas: deltas,
209
- foundations, hostBefore, hostAfter }, null, 2) + "\n", "utf8");
340
+ function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
341
+ const allPhases = [
342
+ ...new Set(Object.values(results).flatMap((r)=>r.phases.flatMap(Object.keys)))
343
+ ];
344
+ const phaseSamples = (worker, phase)=>results[worker].phases.map((p)=>(p[phase] ?? 0) / MS_PER_S);
345
+ const { verdict, deltas } = reportWallTime({
346
+ results,
347
+ vitalsBefore,
348
+ vitalsAfter
349
+ });
350
+ reportPhases({
351
+ results,
352
+ allPhases,
353
+ phaseSamples
354
+ });
355
+ const { foundations, hostAfter } = reportFoundations({
356
+ hostBefore
357
+ });
358
+ mkdirSync(OUT, {
359
+ recursive: true
360
+ });
361
+ writeFileSync(resolve(OUT, "compare.json"), JSON.stringify({
362
+ page: page,
363
+ rounds: runs,
364
+ results,
365
+ verdict,
366
+ recoveryDeltas: deltas,
367
+ foundations,
368
+ hostBefore,
369
+ hostAfter
370
+ }, null, 2) + "\n", "utf8");
210
371
  process.stdout.write(`\nReport: ${resolve(OUT, "compare.json")}\n`);
211
372
  }
212
- /** Wall time and the verdict, first, because that is the question being asked. */
213
- function reportWallTime(/** @type {any} */ { results, vitalsBefore, vitalsAfter }) {
214
- // Wall time first, with the verdict, because that is the question being asked.
215
- const wallSeconds = Object.fromEntries(workers.map((w) => [short(w), results[w].walls.map((/** @type {any} */ v) => v / MS_PER_S)]));
373
+ function reportWallTime({ results, vitalsBefore, vitalsAfter }) {
374
+ const wallSeconds = Object.fromEntries(compare_workers_workers.map((w)=>[
375
+ compare_workers_short(w),
376
+ results[w].walls.map((v)=>v / MS_PER_S)
377
+ ]));
216
378
  const verdict = compareWorkers(wallSeconds);
217
379
  process.stdout.write(`\n\nWALL TIME (seconds), ${runs} interleaved round(s)\n`);
218
- process.stdout.write(` ${"worker".padEnd(10)}${"n".padStart(4)}${"median".padStart(9)}${"IQR".padStart(9)}` +
219
- `${"min".padStart(8)}${"max".padStart(8)} recoveries\n`);
220
- const deltas = Object.fromEntries(workers.map((w) => [short(w), {
221
- recoveries: (vitalsAfter[w]?.recoveries ?? 0) - (vitalsBefore[w]?.recoveries ?? 0),
222
- captures: (vitalsAfter[w]?.captures ?? 0) - (vitalsBefore[w]?.captures ?? 0),
223
- }]));
224
- const rates = /** @type {Record<string, any>} */ (recoveryRates(deltas));
225
- for (const w of workers) {
226
- const name = String(short(w)), d = summarise(wallSeconds[name]);
227
- if (!d)
228
- continue;
229
- const rate = rates[name] === null ? "n/a" : `${deltas[name].recoveries}/${deltas[name].captures}`;
230
- process.stdout.write(` ${name.padEnd(10)}${String(d.n).padStart(4)}${d.median.toFixed(1).padStart(9)}` +
231
- `${d.iqr.toFixed(1).padStart(9)}${d.min.toFixed(1).padStart(8)}${d.max.toFixed(1).padStart(8)} ${rate}\n`);
380
+ process.stdout.write(` ${"worker".padEnd(10)}${"n".padStart(4)}${"median".padStart(9)}${"IQR".padStart(9)}${"min".padStart(8)}${"max".padStart(8)} recoveries\n`);
381
+ const deltas = Object.fromEntries(compare_workers_workers.map((w)=>[
382
+ compare_workers_short(w),
383
+ {
384
+ recoveries: (vitalsAfter[w]?.recoveries ?? 0) - (vitalsBefore[w]?.recoveries ?? 0),
385
+ captures: (vitalsAfter[w]?.captures ?? 0) - (vitalsBefore[w]?.captures ?? 0)
386
+ }
387
+ ]));
388
+ const rates = recoveryRates(deltas);
389
+ for (const w of compare_workers_workers){
390
+ const name = String(compare_workers_short(w)), d = describe(wallSeconds[name]);
391
+ if (!d) continue;
392
+ const rate = null === rates[name] ? "n/a" : `${deltas[name].recoveries}/${deltas[name].captures}`;
393
+ process.stdout.write(` ${name.padEnd(10)}${String(d.n).padStart(4)}${d.median.toFixed(1).padStart(9)}${d.iqr.toFixed(1).padStart(9)}${d.min.toFixed(1).padStart(8)}${d.max.toFixed(1).padStart(8)} ${rate}\n`);
232
394
  }
233
395
  process.stdout.write(`\n ${verdict.verdict}\n`);
234
- // Returned rather than written here: the JSON report is assembled once, by `report`, so one place
235
- // decides what a comparison record contains.
236
- return { verdict, deltas };
396
+ return {
397
+ verdict,
398
+ deltas
399
+ };
237
400
  }
238
- /** Where a real difference actually LIVES: per-phase medians, then the per-sweep detail. */
239
- function reportPhases(/** @type {any} */ { results, allPhases, phaseSamples }) {
240
- // Per-phase medians, so a real difference can be located rather than guessed at.
401
+ function reportPhases({ results, allPhases, phaseSamples }) {
241
402
  process.stdout.write(`\nPHASE MEDIANS (seconds)\n`);
242
- process.stdout.write(` ${"phase".padEnd(20)}${workers.map((/** @type {string} */ w) => String(short(w)).padStart(9)).join("")} spread\n`);
243
- const rows = allPhases
244
- .map((/** @type {any} */ phase) => {
245
- const medians = workers.map((w) => summarise(phaseSamples(w, phase))?.median ?? 0);
246
- return { phase, medians, spread: Math.max(...medians) - Math.min(...medians) };
247
- })
248
- .filter((/** @type {any} */ row) => row.medians.some((/** @type {any} */ v) => v >= 0.05))
249
- .sort((/** @type {any} */ a, /** @type {any} */ b) => b.spread - a.spread);
250
- for (const { phase, medians, spread } of rows) {
251
- process.stdout.write(` ${phase.padEnd(20)}${medians.map((/** @type {any} */ v) => v.toFixed(1).padStart(9)).join("")}` +
252
- `${spread.toFixed(1).padStart(10)}${spread >= 1 ? " <-- diverges" : ""}\n`);
253
- }
254
- // The per-sweep view. `found` and `trips` separate "doing more work" from "doing work more slowly",
255
- // which is the distinction the aggregate destroys and the one that identifies a cause.
256
- const sweepTypes = [...new Set(Object.values(results).flatMap((r) => r.sweeps.flatMap(Object.keys)))];
403
+ process.stdout.write(` ${"phase".padEnd(20)}${compare_workers_workers.map((w)=>String(compare_workers_short(w)).padStart(9)).join("")} spread\n`);
404
+ const rows = allPhases.map((phase)=>{
405
+ const medians = compare_workers_workers.map((w)=>describe(phaseSamples(w, phase))?.median ?? 0);
406
+ return {
407
+ phase,
408
+ medians,
409
+ spread: Math.max(...medians) - Math.min(...medians)
410
+ };
411
+ }).filter((row)=>row.medians.some((v)=>v >= 0.05)).sort((a, b)=>b.spread - a.spread);
412
+ for (const { phase, medians, spread } of rows)process.stdout.write(` ${phase.padEnd(20)}${medians.map((v)=>v.toFixed(1).padStart(9)).join("")}${spread.toFixed(1).padStart(10)}${spread >= 1 ? " <-- diverges" : ""}\n`);
413
+ const sweepTypes = [
414
+ ...new Set(Object.values(results).flatMap((r)=>r.sweeps.flatMap(Object.keys)))
415
+ ];
257
416
  if (sweepTypes.length) {
258
417
  process.stdout.write("\nSWEEP DETAIL (mean ms / round trips / elements found)\n");
259
- for (const type of sweepTypes) {
418
+ for (const type of sweepTypes){
260
419
  process.stdout.write(` ${type.padEnd(14)}`);
261
- for (const w of workers) {
262
- const runsWith = results[w].sweeps.map((/** @type {any} */ s) => s[type]).filter(Boolean);
263
- // Medians here too: one mute recovery inside a sweep skews a mean the same way it skews wall time.
264
- const ms = (summarise(runsWith.map((/** @type {any} */ x) => x.ms))?.median ?? 0).toFixed(0);
265
- const trips = (summarise(runsWith.map((/** @type {any} */ x) => x.trips))?.median ?? 0).toFixed(1);
266
- const found = (summarise(runsWith.map((/** @type {any} */ x) => x.found))?.median ?? 0).toFixed(1);
267
- process.stdout.write(`${short(w)}: ${ms}ms/${trips}t/${found}f `);
420
+ for (const w of compare_workers_workers){
421
+ const runsWith = results[w].sweeps.map((s)=>s[type]).filter(Boolean);
422
+ const ms = (describe(runsWith.map((x)=>x.ms))?.median ?? 0).toFixed(0);
423
+ const trips = (describe(runsWith.map((x)=>x.trips))?.median ?? 0).toFixed(1);
424
+ const found = (describe(runsWith.map((x)=>x.found))?.median ?? 0).toFixed(1);
425
+ process.stdout.write(`${compare_workers_short(w)}: ${ms}ms/${trips}t/${found}f `);
268
426
  }
269
427
  process.stdout.write("\n");
270
428
  }
271
429
  process.stdout.write("\n ms up but trips flat => each round trip is slower (the guest or NVDA is the cause).\n");
272
430
  process.stdout.write(" trips up => the sweep is genuinely walking more (the page or dedupe differs).\n");
273
431
  }
274
- for (const w of workers) {
432
+ for (const w of compare_workers_workers){
275
433
  const d = results[w].diagnostics;
276
- if (!d)
277
- continue;
278
- process.stdout.write(`\n ${short(w)} guest: profile=${d.edgeProfile?.megabytes}MB ` +
279
- `policy=${JSON.stringify(d.edgePolicy)} procs=${JSON.stringify(d.processes)}\n`);
434
+ if (d) process.stdout.write(`\n ${compare_workers_short(w)} guest: profile=${d.edgeProfile?.megabytes}MB policy=${JSON.stringify(d.edgePolicy)} procs=${JSON.stringify(d.processes)}\n`);
280
435
  }
281
436
  }
282
- /**
283
- * The foundations. Timings alone say something got slower; they never say which resource ran out --
284
- * which is why three guests contending on one SSD was misdiagnosed as memory, then as the guests.
285
- */
286
- function reportFoundations(/** @type {any} */ { hostBefore }) {
437
+ function reportFoundations({ hostBefore }) {
287
438
  const hostAfter = sampleHost();
288
- const foundations = diffHost(/** @type {any} */ (hostBefore), /** @type {any} */ (hostAfter));
289
- // These describe THE MACHINE THIS COMMAND RAN ON, which is the workers' machine only while the workers
290
- // are local UTM guests. A bare-metal fleet is four separate computers, and this host's swap state then
291
- // says nothing whatever about their capture times.
292
- //
293
- // It printed `352 pageouts <-- THE HOST WAS SWAPPING; these timings describe a constrained machine`
294
- // during a four-box bare-metal run, next to `guest resident 0 MB across 0 process(es)` — the second line
295
- // already saying there were no guests here to constrain. Attributing worker timings to the wrong
296
- // computer is the exact failure this tool was written to stop; it should not commit it itself.
439
+ const foundations = diffHost(hostBefore, hostAfter);
297
440
  const localGuests = hostAfter.processes.length;
298
- process.stdout.write(localGuests
299
- ? "\nFOUNDATIONS (the host, during this run)\n"
300
- : "\nFOUNDATIONS (this CONTROL host only — no local guests, so none of this describes the workers)\n");
301
- process.stdout.write(` load ${hostAfter.load?.one ?? "?"} (1m), ` +
302
- `${hostAfter.load?.five ?? "?"} (5m)\n`);
303
- const busiest = [...(hostAfter.disk ?? [])].sort((a, b) => b.mbPerSecond - a.mbPerSecond)[0];
304
- process.stdout.write(` disk ${busiest ? `${busiest.device} ${busiest.mbPerSecond} MB/s, ` +
305
- `${busiest.transfersPerSecond} tps` : "unavailable"}\n`);
306
- process.stdout.write(` guest resident ${foundations.residentMbTotal} MB across ` +
307
- `${hostAfter.processes.length} process(es) <- RSS, not phys_footprint\n`);
308
- process.stdout.write(` free / compressed ${foundations.freeMb} MB free, ` +
309
- `${foundations.compressorMb} MB compressed\n`);
310
- // The pageout DELTA is still worth printing either way — it is true about this machine. What is gated is
311
- // the INFERENCE, because "these timings describe a constrained machine" is only true when the timings
312
- // were produced here.
313
- process.stdout.write(` paging ${foundations.pageoutsDelta} pageouts during the run` +
314
- `${foundations.swappingDuringRun
315
- ? (localGuests
316
- ? " <-- THE HOST WAS SWAPPING; these timings describe a constrained machine"
317
- : " (on this control host; the workers are elsewhere, so this did not affect them)")
318
- : " (none)"}\n`);
441
+ process.stdout.write(localGuests ? "\nFOUNDATIONS (the host, during this run)\n" : "\nFOUNDATIONS (this CONTROL host only — no local guests, so none of this describes the workers)\n");
442
+ process.stdout.write(` load ${hostAfter.load?.one ?? "?"} (1m), ${hostAfter.load?.five ?? "?"} (5m)\n`);
443
+ const busiest = [
444
+ ...hostAfter.disk ?? []
445
+ ].sort((a, b)=>b.mbPerSecond - a.mbPerSecond)[0];
446
+ process.stdout.write(` disk ${busiest ? `${busiest.device} ${busiest.mbPerSecond} MB/s, ${busiest.transfersPerSecond} tps` : "unavailable"}\n`);
447
+ process.stdout.write(` guest resident ${foundations.residentMbTotal} MB across ${hostAfter.processes.length} process(es) <- RSS, not phys_footprint\n`);
448
+ process.stdout.write(` free / compressed ${foundations.freeMb} MB free, ${foundations.compressorMb} MB compressed\n`);
449
+ process.stdout.write(` paging ${foundations.pageoutsDelta} pageouts during the run${foundations.swappingDuringRun ? localGuests ? " <-- THE HOST WAS SWAPPING; these timings describe a constrained machine" : " (on this control host; the workers are elsewhere, so this did not affect them)" : " (none)"}\n`);
319
450
  if (!localGuests) {
320
451
  process.stdout.write("\n No local guest processes were found, so these workers are REMOTE machines and nothing\n");
321
452
  process.stdout.write(" above describes them. For a bare-metal fleet, each worker's own /diagnostics is the\n");
322
453
  process.stdout.write(" equivalent: curl http://<worker>:8765/diagnostics | jq .\n");
323
454
  }
324
- return { foundations, hostAfter };
455
+ return {
456
+ foundations,
457
+ hostAfter
458
+ };
325
459
  }
326
- // REALPATH'D: `import.meta.url` is resolved through symlinks by Node's ESM loader and `process.argv[1]`
327
- // is not, so a bin reached via its `.bin` symlink (which is how npm always installs one) mismatched here
328
- // and this guard silently read false — the tool loaded, did nothing, and exited 0. `/var` and `/tmp` are
329
- // themselves symlinks on macOS, so this fired every time. Same defect, same fix, as `cli.ts`'s `isProgram`.
330
- if (import.meta.url === pathToFileURL(process.argv[1] ? realpathSync(process.argv[1]) : "").href)
331
- await main();
332
- //# sourceMappingURL=compare-workers.mjs.map
460
+ if (import.meta.url === pathToFileURL(process.argv[1] ? realpathSync(process.argv[1]) : "").href) await main();