@ultimat3/cli 22.6.2 → 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.
@@ -26,10 +26,11 @@
26
26
  // file's packer and its `Shard` type gone, one process instead of eight, and Bun's own summary
27
27
  // instead of eight merged ones (issue #342).
28
28
  //
29
- // `--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
30
30
  // recorded timings file, but there is at most the ~5s between the measured 59.8s median and the
31
31
  // 54.6s floor in it — against a committed JSON that goes stale on every test edit and that nothing
32
- // 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.
33
34
  //
34
35
  // TWO THINGS THE OLD SPLIT OWNED AND BUN NOW OWNS. `--parallel` implies `--isolate`, so the
35
36
  // per-FILE module registry that made an arbitrary partition safe at all is unchanged. Isolation is
@@ -41,6 +42,8 @@
41
42
  // process (probed on 1.4.0). `ULTIMATE_TEST_WORKER` stays the first key and is what `--worker`
42
43
  // still sets, so a single-shard rerun keeps naming its own database.
43
44
 
45
+ // why: Bun ships no path-join primitive; the timings cache lives under the run's root.
46
+ import { join } from 'node:path';
44
47
  import { ERROR_DOCS_URL } from '@ultimat3/core';
45
48
  import type { AffectedSelection } from './affected';
46
49
  import type { Runner } from './exec';
@@ -52,9 +55,13 @@ import { testEnvOverrides } from './test-dotenv';
52
55
  /** `@ultimat3/testing`'s `ISOLATED_ENV`, restated: `cli → testing` is a runtime edge, kept to fixtures. */
53
56
  export const ISOLATED_TEST_ENV = 'ULTIMATE_TEST_ISOLATED';
54
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
+
55
61
  import { runBatches, testBatches } from './test-batches';
56
62
  import { testPasses } from './test-passes';
57
63
  import type { TestFile } from './test-select';
64
+ import { BATCH_FILES_PER_WORKER, SHARED_BATCH_FILES_PER_WORKER } from './test-workers';
58
65
  import type { TestType } from './verify-tests';
59
66
 
60
67
  /**
@@ -88,17 +95,43 @@ export function testArgs(input: {
88
95
  * same in silence: they refuse the `--` instead.
89
96
  */
90
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;
91
111
  }): readonly string[] {
92
112
  const files = [...input.files].sort();
93
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');
94
117
  return input.shard === undefined
95
- ? ['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
+ ]
96
129
  : [
97
130
  'bun',
98
131
  'test',
99
- '--isolate',
132
+ ...(isolate ? ['--isolate'] : []),
100
133
  `--shard=${String(input.shard + 1)}/${String(input.workers)}`,
101
- ...extra,
134
+ ...bare,
102
135
  ...files,
103
136
  ];
104
137
  }
@@ -129,6 +162,8 @@ export interface ReproduceOptions {
129
162
  readonly shard?: number;
130
163
  /** What the caller put after `--`. It reaches `bun test`, so a rerun without it runs differently. */
131
164
  readonly passthrough?: readonly string[];
165
+ /** The run was isolated per file; the rerun must be too. */
166
+ readonly isolate?: boolean;
132
167
  }
133
168
 
134
169
  /**
@@ -151,6 +186,7 @@ export function reproduceFor(options: ReproduceOptions): string {
151
186
  '--workers',
152
187
  String(options.workers),
153
188
  ...(options.shard === undefined ? [] : ['--worker', String(options.shard)]),
189
+ ...(options.isolate === true ? ['--isolate'] : []),
154
190
  // Last, and after a `--` of its own, because that is where the caller typed it and where the
155
191
  // parser will find it again. Quoted for `shell-quote.ts`'s reason: a reproduce line is pasted.
156
192
  ...(options.passthrough === undefined || options.passthrough.length === 0
@@ -185,8 +221,19 @@ export interface RunShardsOptions {
185
221
  * fallback so much as a seam this file's own tests use to hand it a fixture instead.
186
222
  */
187
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 }>;
188
228
  }
189
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
+
190
237
  /**
191
238
  * The reproduction's inputs for ONE pass: `workers` is that pass's real width, not the ask, and
192
239
  * `type` is the pass's own when the split gave it one — `x test live --workers 1` reruns exactly
@@ -202,6 +249,7 @@ const planOf = (
202
249
  ...(options.sample === undefined ? {} : { sample: options.sample.kept }),
203
250
  ...(options.affected === undefined ? {} : { affected: options.affected }),
204
251
  ...(options.only === undefined ? {} : { shard: options.only }),
252
+ ...(options.isolate === true ? { isolate: true } : {}),
205
253
  ...(options.passthrough === undefined || options.passthrough.length === 0
206
254
  ? {}
207
255
  : { passthrough: options.passthrough }),
@@ -243,8 +291,9 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
243
291
  // same parent env, so the leaked-key set cannot differ pass to pass.
244
292
  const envOverrides: Record<string, string | undefined> = {
245
293
  ...testEnvOverrides(options.root, options.env ?? Bun.env),
246
- // Every run this spawns is isolated (`--parallel` or `--isolate`) unless the caller opted out.
247
- ...((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' } : {}),
248
297
  };
249
298
  const passthrough = options.passthrough ?? [];
250
299
  const watching = passthrough.includes('--watch');
@@ -267,17 +316,32 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
267
316
  // must be the same files as shard i of the run it reproduces. Nor is `-- --watch`, which never
268
317
  // exits, so a second batch would never start. Every other pass is spent in `test-batches.ts`'
269
318
  // batches, one `bun test` after another.
270
- const batches = only === undefined && !watching ? testBatches(files, pass.workers) : [files];
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];
271
327
  const result = await runBatches({
272
328
  runner: options.runner,
273
329
  batches,
274
330
  stopOnFailure: bailing,
275
- argsFor: (batch) =>
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) =>
276
338
  testArgs({
277
339
  files: batch,
278
- workers: Math.min(pass.workers, batch.length),
340
+ workers: only === undefined ? width : pass.workers,
279
341
  ...(only === undefined ? {} : { shard: only }),
280
342
  ...(options.passthrough === undefined ? {} : { passthrough: options.passthrough }),
343
+ ...(options.isolate === undefined ? {} : { isolate: options.isolate }),
344
+ timings: join(options.root, TEST_TIMINGS_FILE),
281
345
  }),
282
346
  options: {
283
347
  cwd: options.root,
@@ -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;
@@ -3,10 +3,12 @@
3
3
  // suite two different ways, and the `--worker N` reproduction a shard failure prints would then
4
4
  // name a shard the gate never ran.
5
5
 
6
- // Bun ships no CPU-count or free-memory primitive: `cpus()` is the fallback when navigator cannot
7
- // answer, and `freemem()` is the only reader of available memory.
8
- import { cpus, freemem } from 'node:os';
6
+ // Bun ships no CPU-count or memory primitive: `cpus()` is the fallback when navigator cannot
7
+ // answer, and `totalmem()` is the only reader of the machine's RAM.
8
+ // why: Bun ships no CPU-count or total-memory primitive.
9
+ import { cpus, totalmem } from 'node:os';
9
10
  import type { TestType } from '@ultimat3/testing';
11
+ import { TestBudgetInvalidError } from './verify-errors';
10
12
 
11
13
  /** navigator first: it is the runtime's own answer, and it respects a container's CPU limit. */
12
14
  export function availableCpus(): number {
@@ -15,78 +17,75 @@ export function availableCpus(): number {
15
17
  }
16
18
 
17
19
  /**
18
- * Deliberately MORE workers than cores, bounded by memory rather than by a fixed count.
20
+ * The width of a default run is a MEMORY question first and a CPU question second, and the memory
21
+ * it plans on is a BUDGET, not "whatever looks free".
19
22
  *
20
- * `cpus - 1` is the intuitive default and it was measured to be worthless exactly where it has to
21
- * pay off. On a 4-core `ubuntu-latest` — the runner this repo commits to — the `unit` step:
23
+ * Until 22.7 the default was `ceil(cpus x 1.5)` held to 60% of `os.freemem()`. Both halves failed
24
+ * on the machines Ultimate is for (8-16 GB, 2-8 cores, often two agents' gates at once):
22
25
  *
23
- * | workers | wall |
24
- * |---------|-------|
25
- * | serial | 43.2s |
26
- * | 3 (cpus - 1) | 44.8s | <- the old default: slower than not sharding at all
27
- * | 4 | 41.6s |
28
- * | 6 | 34.8s |
26
+ * - `freemem()` is MemAvailable, which counts reclaimable page cache, so a box that had just built
27
+ * read as mostly free. Two gates planned independently, each took 14-18 workers, and on
28
+ * 2026-09-27 a 45 GB box with no swap was OOM-killed with 36 `bun` processes holding ~40 GB.
29
+ * - 1.5x the cores only paid when every worker was stalled on `--isolate` rebuilding a module
30
+ * registry per file. Without isolation (the default since 22.7, below) a worker is CPU-bound,
31
+ * and oversubscribing only multiplies memory.
29
32
  *
30
- * Three workers on four cores loses to serial because sharding is not free — each worker reloads
31
- * the framework's module graph — and three of them cannot cover that cost. The reason more-than-
32
- * cores wins is that a test worker is not CPU-bound end to end: it spends real time on module
33
- * resolution, on `--isolate` rebuilding a registry per file, and on waiting for its database.
34
- * Oversubscribing fills those stalls.
35
- *
36
- * The bound is memory, not cores. A worker is a whole Bun process with the framework's module
37
- * graph loaded and — in the typed suites — its own cloned Postgres or an in-process PGlite. Until
38
- * 22.3 that was a FIXED ceiling of 8, which held a 12-core box to 8 workers with 30 GB free. The
39
- * ceiling is now what the machine can actually hold — `MEMORY_SHARE` of `os.freemem()`
40
- * (MemAvailable on Linux, so reclaimable page cache counts as free) divided by `WORKER_BYTES` —
41
- * which binds on a small CI runner, on a loaded laptop and on a box shared with other sessions,
42
- * the places an unbounded count swaps or, with no swap, is OOM-killed.
43
- *
44
- * The floor of 2 keeps a 1-core box sharding rather than silently reverting to serial.
33
+ * So: `min(GATE_BUDGET_CAP, max(GATE_BUDGET_FLOOR, GATE_BUDGET_SHARE x TOTAL memory))` — total, never free, so the plan
34
+ * is the same on every run of the same machine — divided by `WORKER_BYTES`, clamped to 1..cpus.
35
+ * `ULTIMATE_TEST_MEMORY_BUDGET` (e.g. `3g`) replaces the budget, `ULTIMATE_TEST_MAX_WORKERS` caps
36
+ * the width, and an explicit `--workers` wins over both.
45
37
  */
46
- export const WORKER_OVERSUBSCRIBE = 1.5;
38
+ export const GATE_BUDGET_CAP = 4 * 1024 * 1024 * 1024;
47
39
 
48
- /** The floor the paragraph above names: a 1-core box shards rather than reverting to serial. */
49
- export const WORKER_FLOOR = 2;
40
+ /** A quarter of the machine: an editor, a language server and another agent's gate need the rest. */
41
+ export const GATE_BUDGET_SHARE = 0.25;
50
42
 
51
43
  /**
52
- * What one test worker is budgeted at, for the memory bound above. MEASURED — whole-tree RSS of
53
- * `x test unit` on the notificado.co corpus (768 files, PGlite per file), 12-core box, Bun 1.4.0,
54
- * `As of 2026-09-27`, batched by `BATCH_FILES_PER_WORKER`:
55
- *
56
- * | workers | batches | peak tree RSS | largest single worker |
57
- * |---------|---------|---------------|-----------------------|
58
- * | 12 | 3 | 10.8-11.4 GB | 1.38-1.41 GB |
59
- * | 14 | 3 | 12.2 GB | 1.39 GB |
60
- * | 18 | 2 | 15.9 GB | 1.47 GB |
44
+ * The budget never plans below this: enough for TWO workers at `WORKER_BYTES`. A quarter of an
45
+ * 8 GB laptop (2 GiB) planned one worker, ~4.7 min for notificado.co's unit tier; two stay inside
46
+ * the owner's 1-4 GB gate target (~2.6 GB measured).
47
+ */
48
+ export const GATE_BUDGET_FLOOR = 2.75 * 1024 * 1024 * 1024;
49
+
50
+ /**
51
+ * What one test worker is planned at. MEASURED, whole-tree RSS sampled every 200 ms, Bun 1.4.0,
52
+ * 12-core box, `As of 2026-09-27`, NO isolation:
61
53
  *
62
- * Unbatched, the same box at 22.6.1's default of 18 workers peaked at 17.1-18.2 GB, and the same
63
- * run's wall clock was 132-167s against 152-163s for this default (14 workers, 3 batches) — within
64
- * the noise of a box at load 30-40.
54
+ * | corpus | workers | batch | peak tree | tree / worker |
55
+ * |-----------------------------------------|---------|-------|-----------|---------------|
56
+ * | framework unit (1620 files) | 4 | 24 | 3.61-3.78 GB | 0.90-0.95 GB |
57
+ * | notificado.co unit (768 files) | 4 | 24 | 4.26-4.58 GB | 1.07-1.15 GB |
58
+ * | notificado.co unit (768 files) | 4 | 48 | 4.35 GB | 1.09 GB |
59
+ * | notificado.co unit (768 files) | 3 | 24 | 3.28-3.61 GB | 1.09-1.20 GB |
65
60
  *
66
- * The constant is the LARGEST single worker rounded up, not the average (~0.88 GB), so the bound
67
- * holds even on the run where every worker peaks at the same moment. The 2026-09-25 figure it
68
- * replaces (1 GiB, from a "~0.45 GB slope over a ~17 GB intercept") budgeted the slope and waved
69
- * the intercept away as "the corpus"; that intercept was per-worker growth over an unbatched run,
70
- * and an 18-worker default on 35 GB free took a 45 GB box with no swap down (2026-09-27).
61
+ * The planning figure is what the TREE costs per worker at its peak — the `x` parent and Bun's
62
+ * coordinator included — rounded up from the heaviest corpus, not the largest single worker:
63
+ * workers do not all peak at the same instant. The floor of a worker is not the runner: a Bun
64
+ * process that has booted ONE PGlite sits at 0.9-1.1 GB RSS even after `close()` and a full GC
65
+ * (measured standalone), so a suite on the embedded database cannot plan below ~1 GB a worker.
71
66
  */
72
- export const WORKER_BYTES = 1.5 * 1024 * 1024 * 1024;
67
+ export const WORKER_BYTES = 1.25 * 1024 * 1024 * 1024;
68
+
69
+ /** The fewest workers `--workers` accepts and the fewest a default plans: one is a legal width. */
70
+ export const WORKER_FLOOR = 1;
73
71
 
74
72
  /**
75
- * The share of available memory a default-width run may plan to use. Never all of it, because free
76
- * memory is read ONCE, before a single worker has started, and a dev box is never only running the
77
- * tests: a type-checker, an editor's language server, another agent's gate all grow after that
78
- * read. A run that plans on every free byte is one neighbour away from the OOM killer. 0.6 and not
79
- * less because the plan is made at the LARGEST worker's peak: measured, the whole tree peaks at
80
- * ~0.9 GB a worker, so the real peak lands near 35-40% of what was available at the start.
73
+ * Files each ISOLATED worker is handed before its `bun test` process is thrown away and a fresh one
74
+ * takes the next batch (`test-batches.ts`, which carries the measurement): under `--isolate` every
75
+ * file re-evaluates the module graph and the heap grows per file, so the recycle point is what
76
+ * makes the peak a function of the width rather than of the corpus.
81
77
  */
82
- export const MEMORY_SHARE = 0.6;
78
+ export const BATCH_FILES_PER_WORKER = 24;
83
79
 
84
80
  /**
85
- * Files each worker is handed before its `bun test` process is thrown away and a fresh one takes
86
- * the next batch (`test-batches.ts`, which carries the measurement). What makes the peak a function
87
- * of the width rather than of the corpus.
81
+ * The recycle point WITHOUT isolation (the default since 22.7). A shared worker's peak is set by
82
+ * its live PGlite, not by how many files it has run, and a batch boundary is pure cost: every
83
+ * worker restarts, re-imports the app and re-boots its database, and the batch waits on its
84
+ * slowest file. Measured on notificado.co's unit tier (768 files, 281 s of file time, 3 workers,
85
+ * no isolation): 6 batches of 48/worker 150-172 s, ONE batch 92-94 s at 3.9 GB peak — the work
86
+ * floor (281 s / 3). 256 a worker keeps a recycle point for a corpus far past that size.
88
87
  */
89
- export const BATCH_FILES_PER_WORKER = 24;
88
+ export const SHARED_BATCH_FILES_PER_WORKER = 256;
90
89
 
91
90
  /**
92
91
  * The most `--workers` accepts, on either command. Not a default and not a memory rule — a sanity
@@ -95,36 +94,126 @@ export const BATCH_FILES_PER_WORKER = 24;
95
94
  */
96
95
  export const WORKER_CEILING = 64;
97
96
 
98
- /** Bun ships no memory primitive; `freemem()` is libuv's MemAvailable on Linux. */
99
- export const availableMemory = (): number => freemem();
97
+ /** Replaces the budget: `3g`, `512m`, `4GiB`, or a byte count. */
98
+ export const MEMORY_BUDGET_ENV = 'ULTIMATE_TEST_MEMORY_BUDGET';
99
+
100
+ /** Caps the default width: a positive integer. */
101
+ export const MAX_WORKERS_ENV = 'ULTIMATE_TEST_MAX_WORKERS';
102
+
103
+ /** Bun ships no memory primitive; `totalmem()` is the machine's (or the cgroup-blind host's) RAM. */
104
+ export const totalMemory = (): number => totalmem();
105
+
106
+ type Env = Readonly<Record<string, string | undefined>>;
107
+
108
+ const UNITS: Readonly<Record<string, number>> = {
109
+ '': 1,
110
+ b: 1,
111
+ k: 1024,
112
+ kb: 1024,
113
+ kib: 1024,
114
+ m: 1024 ** 2,
115
+ mb: 1024 ** 2,
116
+ mib: 1024 ** 2,
117
+ g: 1024 ** 3,
118
+ gb: 1024 ** 3,
119
+ gib: 1024 ** 3,
120
+ };
121
+
122
+ /**
123
+ * `3g` → 3 GiB. Binary units whatever the spelling, because the reader is sizing against RSS and
124
+ * RSS is pages. `undefined` for anything else — the caller refuses it with the value in the cause,
125
+ * rather than a typo silently meaning "the default".
126
+ */
127
+ export const parseBytes = (raw: string): number | undefined => {
128
+ const match = /^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$/i.exec(raw);
129
+ if (match === null) return undefined;
130
+ const unit = (match[2] ?? '').toLowerCase();
131
+ if (!Object.hasOwn(UNITS, unit)) return undefined;
132
+ const bytes = Math.floor(Number(match[1]) * (UNITS[unit] as number));
133
+ return Number.isFinite(bytes) && bytes > 0 ? bytes : undefined;
134
+ };
135
+
136
+ /** Why the width is what it is, in words a step line can print. */
137
+ export interface WorkerPlan {
138
+ readonly workers: number;
139
+ readonly budgetBytes: number;
140
+ /** Which bound decided the width. */
141
+ readonly boundBy: 'budget' | 'cpus' | 'max-workers';
142
+ /** `4 workers (budget 4.0 GB)` — the step line's suffix. */
143
+ readonly reason: string;
144
+ }
145
+
146
+ const gb = (bytes: number): string => (bytes / 1024 ** 3).toFixed(1);
147
+
148
+ /** The budget a default run plans on: the env override, or `min(4 GiB, max(2.75 GiB, 25% of RAM))`. */
149
+ export function memoryBudget(total: number = totalMemory(), env: Env = Bun.env): number {
150
+ const raw = env[MEMORY_BUDGET_ENV];
151
+ if (raw !== undefined && raw.trim() !== '') {
152
+ const bytes = parseBytes(raw);
153
+ if (bytes === undefined) throw new TestBudgetInvalidError(MEMORY_BUDGET_ENV, raw);
154
+ return bytes;
155
+ }
156
+ return Math.min(
157
+ GATE_BUDGET_CAP,
158
+ Math.max(GATE_BUDGET_FLOOR, Math.floor(Math.max(0, total) * GATE_BUDGET_SHARE)),
159
+ );
160
+ }
161
+
162
+ const maxWorkersOf = (env: Env): number | undefined => {
163
+ const raw = env[MAX_WORKERS_ENV];
164
+ if (raw === undefined || raw.trim() === '') return undefined;
165
+ const value = Number(raw.trim());
166
+ if (!Number.isInteger(value) || value < 1) throw new TestBudgetInvalidError(MAX_WORKERS_ENV, raw);
167
+ return value;
168
+ };
100
169
 
101
170
  /**
102
- * `ceil(cpus x 1.5)`, held to `MEMORY_SHARE` of free memory at `WORKER_BYTES` a worker, and never
103
- * below the floor. The file-count
104
- * clamp is `test-passes.ts`'s (every pass is clamped to its own file list), because only the
105
- * caller knows the selection.
171
+ * `clamp(1..cpus, floor(budget / WORKER_BYTES))`, then `ULTIMATE_TEST_MAX_WORKERS`. Never more
172
+ * workers than cores, never fewer than one. The file-count clamp is `test-passes.ts`'s, because
173
+ * only the caller knows the selection.
106
174
  */
175
+ export function workerPlan(
176
+ cpus: number = availableCpus(),
177
+ total: number = totalMemory(),
178
+ env: Env = Bun.env,
179
+ ): WorkerPlan {
180
+ const budgetBytes = memoryBudget(total, env);
181
+ const cores = Math.max(1, Math.trunc(cpus));
182
+ const byBudget = Math.max(WORKER_FLOOR, Math.floor(budgetBytes / WORKER_BYTES));
183
+ const cap = maxWorkersOf(env);
184
+ let workers = Math.min(cores, byBudget, WORKER_CEILING);
185
+ let boundBy: WorkerPlan['boundBy'] = byBudget <= cores ? 'budget' : 'cpus';
186
+ if (cap !== undefined && cap < workers) {
187
+ workers = cap;
188
+ boundBy = 'max-workers';
189
+ }
190
+ const noun = workers === 1 ? 'worker' : 'workers';
191
+ const why =
192
+ boundBy === 'max-workers'
193
+ ? `${MAX_WORKERS_ENV}=${String(cap)}`
194
+ : boundBy === 'cpus'
195
+ ? `${String(cores)} cores, budget ${gb(budgetBytes)} GB`
196
+ : `budget ${gb(budgetBytes)} GB`;
197
+ return { workers, budgetBytes, boundBy, reason: `${String(workers)} ${noun} (${why})` };
198
+ }
199
+
200
+ /** The width alone — `workerPlan(...).workers`. */
107
201
  export const defaultWorkers = (
108
- available: number = availableCpus(),
109
- freeBytes: number = availableMemory(),
110
- ): number => {
111
- const byCpu = Math.ceil(available * WORKER_OVERSUBSCRIBE);
112
- const byMemory = Math.floor((freeBytes * MEMORY_SHARE) / WORKER_BYTES);
113
- return Math.max(WORKER_FLOOR, Math.min(byCpu, byMemory, WORKER_CEILING));
114
- };
202
+ cpus: number = availableCpus(),
203
+ total: number = totalMemory(),
204
+ env: Env = Bun.env,
205
+ ): number => workerPlan(cpus, total, env).workers;
115
206
 
116
207
  /**
117
208
  * The width for a parallel suite that SHARES the machine — `x verify` runs the static steps beside
118
- * `live`, `job`, `e2e` and `eval` (`verify-run.ts`), and each of those scans is a CPU-bound process
119
- * of its own. `defaultWorkers()`' oversubscription fills a worker's own stalls when nothing else
120
- * wants the cores; beside six other processes it only multiplies the contention. Measured on
121
- * notificado.co, 8 vCPU (#537): at 1.5x, `errors` went 1.8s alone → 7.9s in the gate and
122
- * `boundaries` 2.3s → 9.7s. So: one worker per core, still held to what memory can carry.
209
+ * `live`, `job`, `e2e` and `eval` (`verify-run.ts`). The default is already one worker per core at
210
+ * most, so this is the same plan; it stays a name of its own so the overlap window has one reader.
123
211
  */
124
212
  export const sharedWorkers = (
125
- available: number = availableCpus(),
126
- freeBytes: number = availableMemory(),
127
- ): number => Math.min(defaultWorkers(available, freeBytes), Math.max(WORKER_FLOOR, available));
213
+ cpus: number = availableCpus(),
214
+ total: number = totalMemory(),
215
+ env: Env = Bun.env,
216
+ ): number => defaultWorkers(cpus, total, env);
128
217
 
129
218
  /**
130
219
  * Which types run across worker processes, and why the other two cannot.