@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.
- 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 -3
- package/src/cmd-test.ts +25 -2
- package/src/cmd-verify-spec.ts +37 -7
- 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 +3 -0
- package/src/output.ts +26 -0
- package/src/templates/scaffold-docs.ts +3 -1
- package/src/test-batches.ts +145 -0
- package/src/test-shards.ts +111 -23
- package/src/test-slots.ts +168 -0
- package/src/test-workers.ts +175 -63
- 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 +63 -11
- package/src/verify-tests.ts +39 -4
package/src/test-shards.ts
CHANGED
|
@@ -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`
|
|
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
|
-
? [
|
|
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
|
-
...
|
|
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
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
-
//
|
|
242
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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:
|
|
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;
|