@ultimat3/cli 22.6.2 → 22.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +28 -28
- package/src/app-load.ts +88 -47
- package/src/app-reload-graph.ts +228 -0
- package/src/cmd-build-spec.ts +8 -1
- package/src/cmd-build.ts +23 -8
- package/src/cmd-dev-fixture.ts +26 -0
- package/src/cmd-dev.ts +10 -0
- package/src/cmd-test-spec.ts +9 -10
- package/src/cmd-test.ts +25 -2
- package/src/cmd-verify-spec.ts +37 -13
- package/src/cmd-verify.ts +81 -5
- package/src/dev-route-table.ts +7 -0
- package/src/error-codes.ts +8 -0
- package/src/mcp-errors.ts +5 -0
- package/src/messages.ts +1 -0
- package/src/output.ts +26 -0
- package/src/templates/scaffold-docs.ts +3 -1
- package/src/test-batches.ts +35 -4
- package/src/test-shards.ts +74 -10
- package/src/test-slots.ts +168 -0
- package/src/test-workers.ts +169 -80
- package/src/verify-errors.ts +49 -0
- package/src/verify-floor.ts +28 -0
- package/src/verify-merge.ts +235 -0
- package/src/verify-run.ts +16 -9
- package/src/verify-shard.ts +145 -0
- package/src/verify-step.ts +20 -1
- package/src/verify-test-run.ts +47 -6
- package/src/verify-tests.ts +39 -4
package/src/test-shards.ts
CHANGED
|
@@ -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`
|
|
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
|
-
? [
|
|
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
|
-
...
|
|
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
|
-
//
|
|
247
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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;
|
package/src/test-workers.ts
CHANGED
|
@@ -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
|
|
7
|
-
// answer, and `
|
|
8
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
31
|
-
* the
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
38
|
+
export const GATE_BUDGET_CAP = 4 * 1024 * 1024 * 1024;
|
|
47
39
|
|
|
48
|
-
/**
|
|
49
|
-
export const
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
|
78
|
+
export const BATCH_FILES_PER_WORKER = 24;
|
|
83
79
|
|
|
84
80
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
99
|
-
export const
|
|
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
|
-
* `
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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`)
|
|
119
|
-
*
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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.
|