@ultimat3/cli 22.6.1 → 22.7.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.
@@ -2,6 +2,11 @@
2
2
  // reproduces it exactly. Split out of cmd-test.ts because a printed reproduction is only true if
3
3
  // it carries every input to the run — that rule is this file's, and argv parsing is that one's.
4
4
  //
5
+ // ONE PROCESS PER BATCH, N WORKERS. Until 2026-09-27 it was one process for the whole selection;
6
+ // a worker's heap grows with every file it runs, so that process's peak grew with the corpus, and
7
+ // `test-batches.ts` now spends each pass as a few `--parallel=N` processes in sequence — the
8
+ // measurement is its header. Everything below still holds inside a batch.
9
+ //
5
10
  // ONE PROCESS, N WORKERS, `As of 2026-08-27`. This file used to pack the files into N bins itself
6
11
  // (largest-first greedy over file SIZE) and `Promise.all` one `bun test` per bin. Bun 1.4 runs the
7
12
  // pool itself — `--parallel=N`, which starts each file on the next free worker — so the packer is
@@ -21,10 +26,11 @@
21
26
  // file's packer and its `Shard` type gone, one process instead of eight, and Bun's own summary
22
27
  // instead of eight merged ones (issue #342).
23
28
  //
24
- // `--timings` IS REFUSED FOR THE SAME REASON. Bun will start the slowest files first from a
29
+ // `--timings` WAS REFUSED FOR THE SAME REASON (until 22.7, below). Bun will start the slowest files first from a
25
30
  // recorded timings file, but there is at most the ~5s between the measured 59.8s median and the
26
31
  // 54.6s floor in it — against a committed JSON that goes stale on every test edit and that nothing
27
- // in the gate would notice had gone stale.
32
+ // in the gate would notice had gone stale. 22.7 uses it differently: a LOCAL cache under `.x/`
33
+ // (`TEST_TIMINGS_FILE`), rewritten by every run, so it cannot go stale and nothing is committed.
28
34
  //
29
35
  // TWO THINGS THE OLD SPLIT OWNED AND BUN NOW OWNS. `--parallel` implies `--isolate`, so the
30
36
  // per-FILE module registry that made an arbitrary partition safe at all is unchanged. Isolation is
@@ -36,10 +42,11 @@
36
42
  // process (probed on 1.4.0). `ULTIMATE_TEST_WORKER` stays the first key and is what `--worker`
37
43
  // still sets, so a single-shard rerun keeps naming its own database.
38
44
 
45
+ // why: Bun ships no path-join primitive; the timings cache lives under the run's root.
46
+ import { join } from 'node:path';
39
47
  import { ERROR_DOCS_URL } from '@ultimat3/core';
40
48
  import type { AffectedSelection } from './affected';
41
49
  import type { Runner } from './exec';
42
- import { execOutput } from './exec';
43
50
  import { msg } from './messages';
44
51
  import type { CommandResult, Finding, JsonValue, StepResult } from './output';
45
52
  import { quoteArg } from './shell-quote';
@@ -48,8 +55,13 @@ import { testEnvOverrides } from './test-dotenv';
48
55
  /** `@ultimat3/testing`'s `ISOLATED_ENV`, restated: `cli → testing` is a runtime edge, kept to fixtures. */
49
56
  export const ISOLATED_TEST_ENV = 'ULTIMATE_TEST_ISOLATED';
50
57
 
58
+ /** Bun's per-file durations, cached under the run's root and refreshed by every parallel run. */
59
+ export const TEST_TIMINGS_FILE = '.x/test-timings.json';
60
+
61
+ import { runBatches, testBatches } from './test-batches';
51
62
  import { testPasses } from './test-passes';
52
63
  import type { TestFile } from './test-select';
64
+ import { BATCH_FILES_PER_WORKER, SHARED_BATCH_FILES_PER_WORKER } from './test-workers';
53
65
  import type { TestType } from './verify-tests';
54
66
 
55
67
  /**
@@ -83,17 +95,43 @@ export function testArgs(input: {
83
95
  * same in silence: they refuse the `--` instead.
84
96
  */
85
97
  readonly passthrough?: readonly string[];
98
+ /**
99
+ * A fresh global per FILE. Off by default since 22.7 — `--parallel` implies it, so the default
100
+ * run says `--no-isolate` — and on for a repository whose `x.verify.json` says `"isolate": true`
101
+ * or a caller's `--isolate`. Measured, 4 workers: the framework's unit corpus 168s isolated,
102
+ * 77s not; a notificado.co third 85-107s isolated, 20s not (`test-workers.ts`).
103
+ */
104
+ readonly isolate?: boolean;
105
+ /**
106
+ * A per-file durations cache (`TEST_TIMINGS_FILE`): Bun starts the slowest files first from it
107
+ * and rewrites it after the run, so the last straggler is never the file that started last.
108
+ * Parallel form only; a caller's own `--timings` wins.
109
+ */
110
+ readonly timings?: string;
86
111
  }): readonly string[] {
87
112
  const files = [...input.files].sort();
88
113
  const extra = input.passthrough ?? [];
114
+ const isolate =
115
+ extra.includes('--isolate') || (input.isolate === true && !extra.includes('--no-isolate'));
116
+ const bare = extra.filter((arg) => arg !== '--isolate' && arg !== '--no-isolate');
89
117
  return input.shard === undefined
90
- ? ['bun', 'test', `--parallel=${String(input.workers)}`, ...extra, ...files]
118
+ ? [
119
+ 'bun',
120
+ 'test',
121
+ `--parallel=${String(input.workers)}`,
122
+ ...(isolate ? [] : ['--no-isolate']),
123
+ ...(input.timings === undefined || bare.some((arg) => arg.startsWith('--timings'))
124
+ ? []
125
+ : [`--timings=${input.timings}`, '--update-timings']),
126
+ ...bare,
127
+ ...files,
128
+ ]
91
129
  : [
92
130
  'bun',
93
131
  'test',
94
- '--isolate',
132
+ ...(isolate ? ['--isolate'] : []),
95
133
  `--shard=${String(input.shard + 1)}/${String(input.workers)}`,
96
- ...extra,
134
+ ...bare,
97
135
  ...files,
98
136
  ];
99
137
  }
@@ -124,6 +162,8 @@ export interface ReproduceOptions {
124
162
  readonly shard?: number;
125
163
  /** What the caller put after `--`. It reaches `bun test`, so a rerun without it runs differently. */
126
164
  readonly passthrough?: readonly string[];
165
+ /** The run was isolated per file; the rerun must be too. */
166
+ readonly isolate?: boolean;
127
167
  }
128
168
 
129
169
  /**
@@ -146,6 +186,7 @@ export function reproduceFor(options: ReproduceOptions): string {
146
186
  '--workers',
147
187
  String(options.workers),
148
188
  ...(options.shard === undefined ? [] : ['--worker', String(options.shard)]),
189
+ ...(options.isolate === true ? ['--isolate'] : []),
149
190
  // Last, and after a `--` of its own, because that is where the caller typed it and where the
150
191
  // parser will find it again. Quoted for `shell-quote.ts`'s reason: a reproduce line is pasted.
151
192
  ...(options.passthrough === undefined || options.passthrough.length === 0
@@ -180,8 +221,19 @@ export interface RunShardsOptions {
180
221
  * fallback so much as a seam this file's own tests use to hand it a fixture instead.
181
222
  */
182
223
  readonly env?: Readonly<Record<string, string | undefined>>;
224
+ /** A fresh global per file (`x.verify.json` `"isolate": true`, or `--isolate`). Default off. */
225
+ readonly isolate?: boolean;
226
+ /** The machine-pool lease (`test-slots.ts`), for a default-width run. */
227
+ readonly lease?: (want: number) => Promise<{ readonly count: number; release(): void }>;
183
228
  }
184
229
 
230
+ const isolatedRun = (options: RunShardsOptions): boolean => {
231
+ const extra = options.passthrough ?? [];
232
+ return (
233
+ extra.includes('--isolate') || (options.isolate === true && !extra.includes('--no-isolate'))
234
+ );
235
+ };
236
+
185
237
  /**
186
238
  * The reproduction's inputs for ONE pass: `workers` is that pass's real width, not the ask, and
187
239
  * `type` is the pass's own when the split gave it one — `x test live --workers 1` reruns exactly
@@ -197,6 +249,7 @@ const planOf = (
197
249
  ...(options.sample === undefined ? {} : { sample: options.sample.kept }),
198
250
  ...(options.affected === undefined ? {} : { affected: options.affected }),
199
251
  ...(options.only === undefined ? {} : { shard: options.only }),
252
+ ...(options.isolate === true ? { isolate: true } : {}),
200
253
  ...(options.passthrough === undefined || options.passthrough.length === 0
201
254
  ? {}
202
255
  : { passthrough: options.passthrough }),
@@ -219,10 +272,10 @@ export const failureOf = (code: number, files: number, plan: ReproduceOptions):
219
272
  });
220
273
 
221
274
  /**
222
- * ONE `bun test` PER PASS, and one pass unless the selection mixes serial files with the rest —
223
- * `test-passes.ts` decides that, and this spends it. Bun owns the pool inside a pass and hands
224
- * each free worker the next file, so nothing here decides which file runs where; see this file's
225
- * header for what that measured.
275
+ * ONE `bun test` PER BATCH, a pass being as many batches as `test-batches.ts` cuts it into, and
276
+ * one pass unless the selection mixes serial files with the rest — `test-passes.ts` decides that,
277
+ * and this spends it. Bun owns the pool inside a batch and hands each free worker the next file, so
278
+ * nothing here decides which file runs where; see this file's header for what that measured.
226
279
  *
227
280
  * Sequential, never `Promise.all`: the whole point of a serial pass is that nothing runs beside
228
281
  * it. And every pass runs even after one fails — the caller asked for a suite, and a report that
@@ -238,9 +291,13 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
238
291
  // same parent env, so the leaked-key set cannot differ pass to pass.
239
292
  const envOverrides: Record<string, string | undefined> = {
240
293
  ...testEnvOverrides(options.root, options.env ?? Bun.env),
241
- // Every run this spawns is isolated (`--parallel` or `--isolate`) unless the caller opted out.
242
- ...((options.passthrough ?? []).includes('--no-isolate') ? {} : { [ISOLATED_TEST_ENV]: '1' }),
294
+ // Only an isolated run: the preload clears `Bun.plugin` handlers after each file, which in a
295
+ // shared global would leave the next `.tsx` compiling with Bun's classic factory.
296
+ ...(isolatedRun(options) ? { [ISOLATED_TEST_ENV]: '1' } : {}),
243
297
  };
298
+ const passthrough = options.passthrough ?? [];
299
+ const watching = passthrough.includes('--watch');
300
+ const bailing = passthrough.some((arg) => arg === '--bail' || arg.startsWith('--bail='));
244
301
  const passes = testPasses({
245
302
  files: options.files,
246
303
  workers: options.workers,
@@ -250,18 +307,43 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
250
307
  const started = performance.now();
251
308
  const steps: StepResult[] = [];
252
309
  const spent: JsonValue[] = [];
310
+ let batchCount = 0;
253
311
  let ok = true;
254
312
  let exitCode = 0;
255
313
  for (const pass of passes) {
256
314
  const files = pass.files.map((file) => file.path);
257
- const result = await options.runner(
258
- testArgs({
259
- files,
260
- workers: pass.workers,
261
- ...(only === undefined ? {} : { shard: only }),
262
- ...(options.passthrough === undefined ? {} : { passthrough: options.passthrough }),
263
- }),
264
- {
315
+ // A `--worker` rerun is one process over one shard and is never split: shard i of the rerun
316
+ // must be the same files as shard i of the run it reproduces. Nor is `-- --watch`, which never
317
+ // exits, so a second batch would never start. Every other pass is spent in `test-batches.ts`'
318
+ // batches, one `bun test` after another.
319
+ const batches =
320
+ only === undefined && !watching
321
+ ? testBatches(
322
+ files,
323
+ pass.workers,
324
+ isolatedRun(options) ? BATCH_FILES_PER_WORKER : SHARED_BATCH_FILES_PER_WORKER,
325
+ )
326
+ : [files];
327
+ const result = await runBatches({
328
+ runner: options.runner,
329
+ batches,
330
+ stopOnFailure: bailing,
331
+ workers: pass.workers,
332
+ // A `--worker` rerun is one process whatever its N says, and a serial pass is one worker:
333
+ // only a default-width parallel pass leases from the machine pool.
334
+ ...(only === undefined && pass.workers > 1 && options.lease !== undefined
335
+ ? { lease: options.lease }
336
+ : {}),
337
+ argsFor: (batch, width) =>
338
+ testArgs({
339
+ files: batch,
340
+ workers: only === undefined ? width : pass.workers,
341
+ ...(only === undefined ? {} : { shard: only }),
342
+ ...(options.passthrough === undefined ? {} : { passthrough: options.passthrough }),
343
+ ...(options.isolate === undefined ? {} : { isolate: options.isolate }),
344
+ timings: join(options.root, TEST_TIMINGS_FILE),
345
+ }),
346
+ options: {
265
347
  cwd: options.root,
266
348
  ...(Object.keys(envOverrides).length === 0 && only === undefined
267
349
  ? {}
@@ -272,24 +354,28 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
272
354
  },
273
355
  }),
274
356
  },
275
- );
357
+ });
358
+ batchCount += batches.length;
276
359
  const plan = planOf(options, pass);
277
360
  const label =
278
361
  only === undefined ? `${pass.workers} worker(s)` : `shard ${only} of ${pass.workers}`;
279
362
  steps.push({
280
- name: `${pass.type === undefined ? label : `${pass.type} · ${label}`} · ${files.length} files`,
363
+ name: `${pass.type === undefined ? label : `${pass.type} · ${label}`} · ${files.length} files${
364
+ batches.length > 1 ? msg('cli.test.batches', { batches: batches.length }) : ''
365
+ }`,
281
366
  ok: result.ok,
282
367
  durationMs: result.durationMs,
283
368
  // `output.ts` documents this field as absent for a NON-test step, so omitting it here made
284
369
  // `renderJson` describe the test step as one — recoverable only by parsing `name`.
285
370
  workers: pass.workers,
286
371
  findings: result.ok ? [] : [failureOf(result.code, files.length, plan)],
287
- output: execOutput(result),
372
+ output: result.output,
288
373
  });
289
374
  spent.push({
290
375
  ...(pass.type === undefined ? {} : { type: pass.type }),
291
376
  files: files.length,
292
377
  workers: pass.workers,
378
+ ...(batches.length > 1 ? { batches: batches.length } : {}),
293
379
  ok: result.ok,
294
380
  exitCode: result.code,
295
381
  reproduce: reproduceFor(plan),
@@ -321,6 +407,8 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
321
407
  // Only when the split made more than one, so a single-pass run's JSON is byte-identical to
322
408
  // what it has always been — and a mixed one can never be read as if it were a single run.
323
409
  ...(spent.length > 1 ? { passes: spent } : {}),
410
+ // Only when some pass was split, for the same reason: an unbatched run's JSON is unchanged.
411
+ ...(batchCount > spent.length ? { batches: batchCount } : {}),
324
412
  ok,
325
413
  exitCode,
326
414
  reproduce: reproduceFor(plan),
@@ -0,0 +1,168 @@
1
+ // The MACHINE's test budget, shared by every `x test` and `x verify` running on it at once.
2
+ //
3
+ // A run's own width (`test-workers.ts`) bounds one run. It cannot bound two: on 2026-09-27 two
4
+ // agents' gates on one 45 GB box each planned 14-18 workers off the same free-memory reading and
5
+ // the kernel OOM-killed the machine with 36 `bun` processes holding ~40 GB. So each batch of
6
+ // workers first LEASES that many slots from a pool of lock files — one file per worker the machine
7
+ // budget allows — and a second run on the same box gets what is left: it runs narrower, or waits
8
+ // for one slot, instead of doubling the machine's memory.
9
+ //
10
+ // A slot is a file created with O_EXCL holding the leasing process's pid. A holder that died
11
+ // without releasing (SIGKILL, OOM, a closed terminal) is detected by its pid no longer existing,
12
+ // and its slot is taken over. Nothing here needs a daemon, and nothing survives a reboot that
13
+ // matters: the directory is under the OS temp dir.
14
+ //
15
+ // Bun ships no O_EXCL create and no pid probe: `openSync(…, 'wx')` and `process.kill(pid, 0)` are
16
+ // the node:fs / process primitives for exactly those two.
17
+ // why: Bun ships no O_EXCL create (writeFileSync flag 'wx') and no sync unlink/mkdir.
18
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
19
+ // why: Bun exposes no tmpdir(); only node:os answers the platform temp root.
20
+ import { tmpdir } from 'node:os';
21
+ // why: Bun ships no path-join primitive.
22
+ import { join } from 'node:path';
23
+
24
+ /** Where the pool lives. Per user, so two accounts on one box never read each other's pids. */
25
+ export const SLOTS_DIR_ENV = 'ULTIMATE_TEST_SLOTS_DIR';
26
+
27
+ /** `0` turns the machine pool off — a CI runner that runs one job per VM has nothing to share. */
28
+ export const SLOTS_ENV = 'ULTIMATE_TEST_SLOTS';
29
+
30
+ /**
31
+ * Set on every `bun test` a leased batch spawns: the slots are already held for that process
32
+ * tree, so an `x test` / `x verify` a test runs inside it (the framework's own suites do) must
33
+ * not lease again — it would wait on its own parent's slots.
34
+ */
35
+ export const SLOT_HELD_ENV = 'ULTIMATE_TEST_SLOT_HELD';
36
+
37
+ /** How long a run waits for its first slot before it runs one worker anyway, and says so. */
38
+ export const SLOT_WAIT_MS = 10 * 60 * 1000;
39
+
40
+ const POLL_MS = 500;
41
+
42
+ type Env = Readonly<Record<string, string | undefined>>;
43
+
44
+ export const slotsDir = (env: Env = Bun.env): string =>
45
+ env[SLOTS_DIR_ENV] ??
46
+ join(tmpdir(), `ultimate-test-slots-${String(process.getuid?.() ?? 'user')}`);
47
+
48
+ export const slotsEnabled = (env: Env = Bun.env): boolean =>
49
+ env[SLOTS_ENV] !== '0' && env[SLOT_HELD_ENV] !== '1' && Bun.env[SLOT_HELD_ENV] !== '1';
50
+
51
+ /** A lease on `count` workers' worth of the machine. `count` is at least 1. */
52
+ export interface SlotLease {
53
+ readonly count: number;
54
+ /** Slots actually held — 0 when the wait ran out and the run went ahead unleased. */
55
+ readonly held: number;
56
+ /** How long the lease waited for its first slot. */
57
+ readonly waitedMs: number;
58
+ release(): void;
59
+ }
60
+
61
+ const pidAlive = (pid: number): boolean => {
62
+ if (!Number.isInteger(pid) || pid <= 0) return false;
63
+ try {
64
+ process.kill(pid, 0);
65
+ return true;
66
+ } catch (error) {
67
+ // EPERM is a live process another user owns — alive, and not ours to take.
68
+ return (error as { code?: string } | null)?.code === 'EPERM';
69
+ }
70
+ };
71
+
72
+ export interface AcquireInput {
73
+ /** Workers this batch wants. */
74
+ readonly want: number;
75
+ /** Slots the machine budget allows in all — `defaultWorkers()` for this box. */
76
+ readonly capacity: number;
77
+ readonly dir?: string;
78
+ readonly pid?: number;
79
+ readonly alive?: (pid: number) => boolean;
80
+ readonly waitMs?: number;
81
+ readonly sleep?: (ms: number) => Promise<void>;
82
+ readonly now?: () => number;
83
+ }
84
+
85
+ /** Take one slot file, or take it over from a dead holder. */
86
+ function tryTake(path: string, pid: number, alive: (pid: number) => boolean): boolean {
87
+ for (let attempt = 0; attempt < 2; attempt += 1) {
88
+ try {
89
+ writeFileSync(path, String(pid), { flag: 'wx' });
90
+ return true;
91
+ } catch (error) {
92
+ if ((error as { code?: string } | null)?.code !== 'EEXIST') return false;
93
+ }
94
+ let holder: number;
95
+ try {
96
+ holder = Number.parseInt(readFileSync(path, 'utf8'), 10);
97
+ } catch {
98
+ // Released between the create and the read: try the create once more.
99
+ continue;
100
+ }
101
+ if (alive(holder)) return false;
102
+ try {
103
+ unlinkSync(path);
104
+ } catch {
105
+ // Another run took it over first; the second create decides.
106
+ }
107
+ }
108
+ return false;
109
+ }
110
+
111
+ /**
112
+ * Lease up to `want` slots out of `capacity`. Never zero: with every slot held, it polls until one
113
+ * frees, and after `waitMs` it runs one worker unleased rather than hang a gate forever.
114
+ */
115
+ export async function acquireSlots(input: AcquireInput): Promise<SlotLease> {
116
+ const dir = input.dir ?? slotsDir();
117
+ const pid = input.pid ?? process.pid;
118
+ const alive = input.alive ?? pidAlive;
119
+ const sleep = input.sleep ?? ((ms: number) => Bun.sleep(ms));
120
+ const now = input.now ?? (() => performance.now());
121
+ const want = Math.max(1, Math.trunc(input.want));
122
+ const capacity = Math.max(1, Math.trunc(input.capacity));
123
+ mkdirSync(dir, { recursive: true });
124
+ const started = now();
125
+ const waitMs =
126
+ input.waitMs !== undefined && Number.isFinite(input.waitMs) && input.waitMs >= 0
127
+ ? input.waitMs
128
+ : SLOT_WAIT_MS;
129
+ const deadline = started + waitMs;
130
+ for (;;) {
131
+ const held: string[] = [];
132
+ for (let index = 0; index < capacity && held.length < want; index += 1) {
133
+ const path = join(dir, `slot-${String(index)}`);
134
+ if (tryTake(path, pid, alive)) held.push(path);
135
+ }
136
+ if (held.length > 0 || now() >= deadline) {
137
+ const waitedMs = Math.round(now() - started);
138
+ let released = false;
139
+ return {
140
+ count: Math.max(1, held.length),
141
+ held: held.length,
142
+ waitedMs,
143
+ release: () => {
144
+ if (released) return;
145
+ released = true;
146
+ for (const path of held) {
147
+ try {
148
+ if (readFileSync(path, 'utf8') === String(pid)) unlinkSync(path);
149
+ } catch {
150
+ // Already gone — a crashed-holder sweep by another run took it.
151
+ }
152
+ }
153
+ },
154
+ };
155
+ }
156
+ await sleep(POLL_MS);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The lease a default-width run takes per batch, or none: an explicit `--workers` is the caller's
162
+ * call (as it is over the budget), and `ULTIMATE_TEST_SLOTS=0` turns the pool off.
163
+ */
164
+ export const machineLease = (
165
+ capacity: number,
166
+ env: Env = Bun.env,
167
+ ): ((want: number) => Promise<SlotLease>) | undefined =>
168
+ slotsEnabled(env) ? (want) => acquireSlots({ want, capacity, dir: slotsDir(env) }) : undefined;