@vzn/vx 0.0.21 → 0.0.22
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/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
package/src/exec/runner.ts
CHANGED
|
@@ -4,9 +4,19 @@
|
|
|
4
4
|
// process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
|
|
5
5
|
// folded into the v11 `runs` table by the orchestrator.
|
|
6
6
|
|
|
7
|
-
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
8
|
import { constants as osConstants } from 'node:os'
|
|
9
|
-
import { killGraceMs } from '../util/index.js'
|
|
9
|
+
import { executablePath, isExecutableMissing, killGraceMs } from '../util/index.js'
|
|
10
|
+
import {
|
|
11
|
+
closeSignalChannel,
|
|
12
|
+
holdGroups,
|
|
13
|
+
killTree,
|
|
14
|
+
markGroupIfGone,
|
|
15
|
+
releaseGroup,
|
|
16
|
+
signalThrough,
|
|
17
|
+
spawnGuarded,
|
|
18
|
+
untilGroupsGone,
|
|
19
|
+
} from './kill-tree.js'
|
|
10
20
|
|
|
11
21
|
export interface RunResult {
|
|
12
22
|
exitCode: number
|
|
@@ -126,21 +136,30 @@ const SHELL_BUILTINS = new Set([
|
|
|
126
136
|
/**
|
|
127
137
|
* Prepend `exec ` to a command that is a single EXTERNAL program (no
|
|
128
138
|
* shell control characters, not a builtin, no leading env-assignment).
|
|
129
|
-
* `exec` REPLACES the wrapping `sh` with the program
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* still orphan on a hard programmatic kill — the residual limit every
|
|
137
|
-
* non-cgroup runner shares).
|
|
139
|
+
* `exec` REPLACES the wrapping `sh` with the program: `resourceUsage`
|
|
140
|
+
* measures the program itself rather than the shell, a signal lands on
|
|
141
|
+
* the program directly, and there is one process fewer per task.
|
|
142
|
+
* (Until kill-tree.ts this was also what kept a single command's real
|
|
143
|
+
* process from orphaning on a teardown; the process group covers every
|
|
144
|
+
* shape now.) Compound commands, builtins, and `FOO=bar cmd` forms keep
|
|
145
|
+
* the shell.
|
|
138
146
|
*/
|
|
139
147
|
export function execWrap(command: string): string {
|
|
140
|
-
|
|
148
|
+
const first = execWord(command)
|
|
149
|
+
return first === undefined ? command : `exec ${command}`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The one program a command runs when it is a plain `word args…` — the
|
|
154
|
+
* word `execWrap` execs, and the word the shell names in "not found" when
|
|
155
|
+
* it exits 127. Undefined for a pipeline, a builtin, an assignment: there
|
|
156
|
+
* the 127 could be any segment's.
|
|
157
|
+
*/
|
|
158
|
+
export function execWord(command: string): string | undefined {
|
|
159
|
+
if (SHELL_CONTROL.test(command)) return undefined
|
|
141
160
|
const first = command.trimStart().split(/\s+/)[0] ?? ''
|
|
142
|
-
if (first === '' || first.includes('=') || SHELL_BUILTINS.has(first)) return
|
|
143
|
-
return
|
|
161
|
+
if (first === '' || first.includes('=') || SHELL_BUILTINS.has(first)) return undefined
|
|
162
|
+
return first
|
|
144
163
|
}
|
|
145
164
|
|
|
146
165
|
/**
|
|
@@ -154,6 +173,23 @@ export function signalExitCode(signal: string): number {
|
|
|
154
173
|
return num === undefined ? 130 : 128 + num
|
|
155
174
|
}
|
|
156
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The reverse: the signal an exit above 128 stands for (137 → SIGKILL),
|
|
178
|
+
* by the platform's numbering; undefined for a plain exit. The shell
|
|
179
|
+
* reports 128 + n for a death by signal n, so the read is the shell's
|
|
180
|
+
* convention, not proof — a command may exit 137 on its own.
|
|
181
|
+
*/
|
|
182
|
+
export function exitSignal(code: number): string | undefined {
|
|
183
|
+
if (code <= 128 || code >= 128 + 65) return undefined
|
|
184
|
+
const signals = osConstants.signals as Partial<Record<string, number>>
|
|
185
|
+
const num = code - 128
|
|
186
|
+
return Object.keys(signals).find((k) => signals[k] === num && !SIGNAL_ALIASES.has(k))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Second names for one number (SIGIOT = SIGABRT, SIGPOLL = SIGIO); the
|
|
190
|
+
// canonical one reads.
|
|
191
|
+
const SIGNAL_ALIASES = new Set(['SIGIOT', 'SIGPOLL', 'SIGCLD'])
|
|
192
|
+
|
|
157
193
|
/**
|
|
158
194
|
* Grace after a timeout SIGTERM before escalating to SIGKILL — a child that
|
|
159
195
|
* ignores SIGTERM must still be bounded. Matches the persistent-shutdown grace.
|
|
@@ -164,34 +200,42 @@ const TIMEOUT_SIGKILL_GRACE_MS = 2000
|
|
|
164
200
|
* Arm a SIGTERM timeout on a spawned child. Returns a handle whose
|
|
165
201
|
* `timedOut()` reports whether the timer fired — so the caller can
|
|
166
202
|
* classify the resulting SIGTERM as a real failure rather than a
|
|
167
|
-
* Ctrl-C abort — and `
|
|
168
|
-
*
|
|
169
|
-
* is
|
|
203
|
+
* Ctrl-C abort — and `settle()`, awaited once the child has exited:
|
|
204
|
+
* it cancels the timers and, when the timeout fired, waits out the rest
|
|
205
|
+
* of the grace for the child's GROUP and SIGKILLs whoever is left. The
|
|
206
|
+
* shell dying on the SIGTERM is not the tree dying: a backgrounded
|
|
207
|
+
* process that ignores it lived on under init after vx exited (nx#11782's
|
|
208
|
+
* sibling, reproduced on vx 2026-09-24). A no-op (never fires, nothing
|
|
209
|
+
* to settle) when `timeoutMs` is undefined.
|
|
170
210
|
*/
|
|
171
211
|
export function armTimeout(
|
|
172
212
|
proc: ReturnType<typeof Bun.spawn>,
|
|
173
213
|
timeoutMs: number | undefined,
|
|
174
|
-
): { timedOut: () => boolean;
|
|
175
|
-
if (timeoutMs === undefined) return { timedOut: () => false,
|
|
176
|
-
let
|
|
214
|
+
): { timedOut: () => boolean; settle: () => Promise<void> } {
|
|
215
|
+
if (timeoutMs === undefined) return { timedOut: () => false, settle: async () => {} }
|
|
216
|
+
let firedAt: number | undefined
|
|
177
217
|
let killTimer: ReturnType<typeof setTimeout> | undefined
|
|
218
|
+
const graceMs = killGraceMs(TIMEOUT_SIGKILL_GRACE_MS)
|
|
178
219
|
const timer = setTimeout(() => {
|
|
179
|
-
|
|
180
|
-
proc
|
|
220
|
+
firedAt = Date.now()
|
|
221
|
+
killTree(proc, 'SIGTERM')
|
|
181
222
|
// Escalate to SIGKILL after a grace: a child that TRAPS+IGNORES SIGTERM
|
|
182
223
|
// (`trap '' TERM`) would otherwise defeat the timeout entirely and hang
|
|
183
224
|
// `await proc.exited` until its natural exit — there is no run-level
|
|
184
225
|
// timeout, so a wedged child hangs the whole run forever. Mirrors the
|
|
185
226
|
// end-of-run persistent-shutdown escalation. Unref'd so it never keeps
|
|
186
227
|
// the CLI alive.
|
|
187
|
-
killTimer = setTimeout(() => proc
|
|
228
|
+
killTimer = setTimeout(() => killTree(proc, 'SIGKILL'), graceMs)
|
|
188
229
|
killTimer.unref?.()
|
|
189
230
|
}, timeoutMs)
|
|
190
231
|
return {
|
|
191
|
-
timedOut: () =>
|
|
192
|
-
|
|
232
|
+
timedOut: () => firedAt !== undefined,
|
|
233
|
+
settle: async () => {
|
|
193
234
|
clearTimeout(timer)
|
|
194
235
|
if (killTimer !== undefined) clearTimeout(killTimer)
|
|
236
|
+
if (firedAt === undefined) return
|
|
237
|
+
const left = await untilGroupsGone([proc], Math.max(0, firedAt + graceMs - Date.now()))
|
|
238
|
+
for (const child of left) killTree(child, 'SIGKILL')
|
|
195
239
|
},
|
|
196
240
|
}
|
|
197
241
|
}
|
|
@@ -205,27 +249,44 @@ export function armTimeout(
|
|
|
205
249
|
* at once, so the readers win this race immediately and normal tasks pay
|
|
206
250
|
* nothing; only a stuck reader waits out the grace. Residual buffered output is
|
|
207
251
|
* bounded by the OS pipe buffer (streamToString drains continuously DURING the
|
|
208
|
-
* run), so this is far longer than any real drain needs.
|
|
252
|
+
* run), so this is far longer than any real drain needs. What the leftover
|
|
253
|
+
* process writes after the bound is lost, and `POST_EXIT_CUT_LINE` says so.
|
|
209
254
|
*/
|
|
210
255
|
const POST_EXIT_DRAIN_MS = 250
|
|
211
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The line a task's frame gets when the drain bound cut its pipes. The cut
|
|
259
|
+
* was silent: a backgrounded child's late output vanished from the frame and
|
|
260
|
+
* the cached replay with no word (nx#35302 reproduced on vx, 2026-09-24).
|
|
261
|
+
* The bound stays, since it is what keeps a leftover server from hanging the
|
|
262
|
+
* run; the line makes the loss visible.
|
|
263
|
+
*/
|
|
264
|
+
export const POST_EXIT_CUT_LINE =
|
|
265
|
+
`\n[vx] output after the task's shell exited was cut: a process it left running ` +
|
|
266
|
+
`still held its stdout/stderr ${POST_EXIT_DRAIN_MS} ms later\n`
|
|
267
|
+
|
|
212
268
|
/**
|
|
213
269
|
* After the direct child has exited, wait for the stdout/stderr readers to
|
|
214
270
|
* reach EOF — but bound it: an orphaned grandchild holding the pipe open would
|
|
215
271
|
* hang the run forever (there is no default task timeout). If the grace expires
|
|
216
272
|
* first, abort the reader signal so `streamToString` cancels its read and
|
|
217
|
-
* returns whatever it captured
|
|
218
|
-
*
|
|
273
|
+
* returns whatever it captured, and resolve true: the caller adds
|
|
274
|
+
* `POST_EXIT_CUT_LINE` to the task's stderr. The timer is unref'd so it can
|
|
275
|
+
* never keep the CLI alive after the readers settle (the plugin-flush
|
|
276
|
+
* lesson); it is not cleared, because a late resolve on a race already won
|
|
277
|
+
* by the drain changes nothing (item 636 deleted the clear and nothing
|
|
278
|
+
* reddened).
|
|
219
279
|
*/
|
|
220
|
-
export async function drainOrAbort(
|
|
221
|
-
|
|
280
|
+
export async function drainOrAbort(
|
|
281
|
+
streams: Promise<unknown>,
|
|
282
|
+
ac: AbortController,
|
|
283
|
+
): Promise<boolean> {
|
|
222
284
|
const deadline = new Promise<'timeout'>((resolve) => {
|
|
223
|
-
|
|
224
|
-
timer.unref?.()
|
|
285
|
+
setTimeout(() => resolve('timeout'), POST_EXIT_DRAIN_MS).unref?.()
|
|
225
286
|
})
|
|
226
287
|
const winner = await Promise.race([streams.then(() => 'drained' as const), deadline])
|
|
227
|
-
if (timer !== undefined) clearTimeout(timer)
|
|
228
288
|
if (winner === 'timeout') ac.abort()
|
|
289
|
+
return winner === 'timeout'
|
|
229
290
|
}
|
|
230
291
|
|
|
231
292
|
/**
|
|
@@ -235,6 +296,22 @@ export async function drainOrAbort(streams: Promise<unknown>, ac: AbortControlle
|
|
|
235
296
|
*/
|
|
236
297
|
const READY_MATCH_WINDOW_CHARS = 64 * 1024
|
|
237
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Why a persistent task never became ready — the reason every label and
|
|
301
|
+
* record reads instead of a fabricated exit (item 270). `exitCode` is the
|
|
302
|
+
* child's own when it exited before the pattern matched.
|
|
303
|
+
*/
|
|
304
|
+
export class PersistentReadyError extends Error {
|
|
305
|
+
readonly reason: 'timeout' | 'exited' | 'spawn'
|
|
306
|
+
readonly exitCode: number | undefined
|
|
307
|
+
constructor(message: string, reason: 'timeout' | 'exited' | 'spawn', exitCode?: number) {
|
|
308
|
+
super(message)
|
|
309
|
+
this.name = 'PersistentReadyError'
|
|
310
|
+
this.reason = reason
|
|
311
|
+
this.exitCode = exitCode
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
238
315
|
export interface PersistentSpawn {
|
|
239
316
|
/** Underlying Bun subprocess so the orchestrator can SIGTERM it later. */
|
|
240
317
|
child: ReturnType<typeof Bun.spawn>
|
|
@@ -266,6 +343,12 @@ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs' | 'cap
|
|
|
266
343
|
* resolves before the timer can fire).
|
|
267
344
|
*/
|
|
268
345
|
timeoutMs?: number
|
|
346
|
+
/**
|
|
347
|
+
* Spawn it with fd 3 as its signal channel and route its polite signals
|
|
348
|
+
* there (`signalThrough`): a Linux sandboxed command, whose group signal
|
|
349
|
+
* would kill bwrap instead (`wrapSandboxedCommand`'s `forwardsSignals`).
|
|
350
|
+
*/
|
|
351
|
+
signalChannel?: boolean
|
|
269
352
|
}
|
|
270
353
|
|
|
271
354
|
/**
|
|
@@ -285,18 +368,38 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
285
368
|
|
|
286
369
|
let child: ReturnType<typeof Bun.spawn>
|
|
287
370
|
try {
|
|
288
|
-
child =
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
371
|
+
child = spawnGuarded(() =>
|
|
372
|
+
Bun.spawn([executablePath('sh'), '-c', execWrap(opts.command)], {
|
|
373
|
+
argv0: 'sh',
|
|
374
|
+
cwd: opts.cwd,
|
|
375
|
+
env: opts.env as Record<string, string>,
|
|
376
|
+
// A pipe vx holds and never writes: stdin stays open while vx
|
|
377
|
+
// lives and ends when it does. A dev server that exits on stdin
|
|
378
|
+
// EOF (esbuild --watch, Vite's case in turborepo#8915) became ready
|
|
379
|
+
// and exited 0 under 'ignore'. Not the terminal: several servers
|
|
380
|
+
// would steal each other's keystrokes, and a CI's /dev/null stdin
|
|
381
|
+
// is the same EOF. The one-shot spawn below keeps 'ignore', so a
|
|
382
|
+
// task that reads stdin can never hang CI.
|
|
383
|
+
stdio:
|
|
384
|
+
opts.signalChannel === true ? ['pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
|
|
385
|
+
// Its own session and process group, so a kill reaches what it
|
|
386
|
+
// forked (kill-tree.ts). stdin is a pipe, so a background group
|
|
387
|
+
// never stops on a terminal read.
|
|
388
|
+
detached: true,
|
|
389
|
+
}),
|
|
390
|
+
)
|
|
391
|
+
if (opts.signalChannel === true) {
|
|
392
|
+
signalThrough(child, child.stdio[3] as number)
|
|
393
|
+
const spawned = child
|
|
394
|
+
void spawned.exited.then(() => closeSignalChannel(spawned))
|
|
395
|
+
}
|
|
295
396
|
} catch (err) {
|
|
296
397
|
const message = err instanceof Error ? err.message : String(err)
|
|
297
398
|
return {
|
|
298
399
|
child: undefined as unknown as ReturnType<typeof Bun.spawn>,
|
|
299
|
-
ready: Promise.reject(
|
|
400
|
+
ready: Promise.reject(
|
|
401
|
+
new PersistentReadyError(`failed to spawn persistent task: ${message}`, 'spawn'),
|
|
402
|
+
),
|
|
300
403
|
readyMs: () => Date.now() - start,
|
|
301
404
|
}
|
|
302
405
|
}
|
|
@@ -391,19 +494,25 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
391
494
|
readyTimer = setTimeout(() => {
|
|
392
495
|
if (readyAt === undefined) {
|
|
393
496
|
rejectReady(
|
|
394
|
-
new
|
|
497
|
+
new PersistentReadyError(
|
|
395
498
|
`persistent task not ready within ${opts.timeoutMs}ms — ` +
|
|
396
499
|
`readyWhen pattern never matched; child killed`,
|
|
500
|
+
'timeout',
|
|
397
501
|
),
|
|
398
502
|
)
|
|
399
|
-
|
|
503
|
+
// Listed on the group guard until the SIGKILL: the shell may die
|
|
504
|
+
// on the SIGTERM and let the group go while the server runs out
|
|
505
|
+
// the grace, and a vx that exits inside it (the timer is unref'd)
|
|
506
|
+
// leaves the server to the guard (kill-tree.ts, item 865).
|
|
507
|
+
const letGo = holdGroups([child])
|
|
508
|
+
killTree(child, 'SIGTERM')
|
|
400
509
|
// Same escalation as `armTimeout`: a server that traps TERM and
|
|
401
510
|
// never became ready is not in the persistent registry, so nothing
|
|
402
511
|
// else would ever kill it — it outlived the run under init.
|
|
403
|
-
const killTimer = setTimeout(
|
|
404
|
-
(
|
|
405
|
-
|
|
406
|
-
)
|
|
512
|
+
const killTimer = setTimeout(() => {
|
|
513
|
+
killTree(child, 'SIGKILL')
|
|
514
|
+
letGo()
|
|
515
|
+
}, killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
|
|
407
516
|
killTimer.unref?.()
|
|
408
517
|
}
|
|
409
518
|
}, opts.timeoutMs)
|
|
@@ -413,12 +522,16 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
413
522
|
// — reject the ready promise so the caller can surface it.
|
|
414
523
|
void child.exited.then((code) => {
|
|
415
524
|
opts.liveChildren?.delete(child)
|
|
525
|
+
markGroupIfGone(child)
|
|
526
|
+
releaseGroup(child)
|
|
416
527
|
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
417
528
|
if (readyAt === undefined) {
|
|
418
529
|
rejectReady(
|
|
419
|
-
new
|
|
530
|
+
new PersistentReadyError(
|
|
420
531
|
`persistent task exited before becoming ready (exit ${code ?? '?'})` +
|
|
421
532
|
(readyRe ? ` — readyWhen pattern never matched` : ''),
|
|
533
|
+
'exited',
|
|
534
|
+
code ?? undefined,
|
|
422
535
|
),
|
|
423
536
|
)
|
|
424
537
|
}
|
|
@@ -435,6 +548,22 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
435
548
|
}
|
|
436
549
|
}
|
|
437
550
|
|
|
551
|
+
/**
|
|
552
|
+
* What a task's frame says when `Bun.spawn` itself threw. The text goes
|
|
553
|
+
* through `onStderr` AND onto the result: the orchestrator retains no
|
|
554
|
+
* stderr (execute-task's capture), so a reason that only sat on the result
|
|
555
|
+
* reached nobody — a box without `sh` showed "failed (exit 127)" under a
|
|
556
|
+
* bare `$ <command>` and nothing else (2026-09-16). An ENOENT with the
|
|
557
|
+
* working directory in place is the shell; Bun's own text names the rest.
|
|
558
|
+
*/
|
|
559
|
+
export function spawnFailureText(err: unknown, cwd: string, what = 'task'): string {
|
|
560
|
+
if (isExecutableMissing(err) && existsSync(cwd)) {
|
|
561
|
+
return `\n[vx] vx runs each task with sh -c: failed to spawn 'sh' (working dir: ${cwd}). Install a POSIX sh and re-run.\n`
|
|
562
|
+
}
|
|
563
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
564
|
+
return `\n[vx] failed to spawn ${what}: ${message}\n`
|
|
565
|
+
}
|
|
566
|
+
|
|
438
567
|
export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
439
568
|
const start = Date.now()
|
|
440
569
|
const fullCommand =
|
|
@@ -444,21 +573,24 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
444
573
|
|
|
445
574
|
let proc: ReturnType<typeof Bun.spawn>
|
|
446
575
|
try {
|
|
447
|
-
proc =
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
576
|
+
proc = spawnGuarded(() =>
|
|
577
|
+
Bun.spawn([executablePath('sh'), '-c', execWrap(fullCommand)], {
|
|
578
|
+
argv0: 'sh',
|
|
579
|
+
cwd: opts.cwd,
|
|
580
|
+
env: opts.env as Record<string, string>,
|
|
581
|
+
stdin: 'ignore',
|
|
582
|
+
stdout: 'pipe',
|
|
583
|
+
stderr: 'pipe',
|
|
584
|
+
// Its own session and process group, so a kill reaches what it
|
|
585
|
+
// forked (kill-tree.ts). stdin is ignored, so a background group
|
|
586
|
+
// never stops on a terminal read.
|
|
587
|
+
detached: true,
|
|
588
|
+
}),
|
|
589
|
+
)
|
|
454
590
|
} catch (err) {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
durationMs: Date.now() - start,
|
|
459
|
-
stdout: '',
|
|
460
|
-
stderr: `\n[vx] failed to spawn: ${message}\n`,
|
|
461
|
-
}
|
|
591
|
+
const stderr = spawnFailureText(err, opts.cwd)
|
|
592
|
+
opts.onStderr?.(stderr)
|
|
593
|
+
return { exitCode: 127, durationMs: Date.now() - start, stdout: '', stderr }
|
|
462
594
|
}
|
|
463
595
|
|
|
464
596
|
opts.liveChildren?.add(proc)
|
|
@@ -475,11 +607,15 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
475
607
|
// lets a clean exit EOF immediately and only cuts off a stuck reader after a
|
|
476
608
|
// brief grace — without this the run hangs forever.
|
|
477
609
|
await proc.exited
|
|
478
|
-
timeout.
|
|
610
|
+
await timeout.settle()
|
|
611
|
+
let cut = false
|
|
479
612
|
if (timeout.timedOut()) ac.abort()
|
|
480
|
-
else await drainOrAbort(streams, ac)
|
|
481
|
-
const [stdout,
|
|
613
|
+
else cut = await drainOrAbort(streams, ac)
|
|
614
|
+
const [stdout, streamed] = await streams
|
|
615
|
+
if (cut) opts.onStderr?.(POST_EXIT_CUT_LINE)
|
|
616
|
+
const stderr = cut ? streamed + POST_EXIT_CUT_LINE : streamed
|
|
482
617
|
opts.liveChildren?.delete(proc)
|
|
618
|
+
releaseGroup(proc)
|
|
483
619
|
const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
|
|
484
620
|
return {
|
|
485
621
|
exitCode,
|
|
@@ -516,6 +652,75 @@ export function ownRssHighWater(): number {
|
|
|
516
652
|
return process.memoryUsage.rss()
|
|
517
653
|
}
|
|
518
654
|
|
|
655
|
+
/**
|
|
656
|
+
* What a task's captured output keeps: the first `CAPTURE_HEAD_CHARS` and
|
|
657
|
+
* the last `CAPTURE_TAIL_CHARS` characters, the dropped middle counted and
|
|
658
|
+
* named where it was. The live stream is never bounded — every byte still
|
|
659
|
+
* reaches the terminal as the task writes it — only the copy vx retains
|
|
660
|
+
* for the cache entry and its replay. Unbounded, a task printing 200 MB
|
|
661
|
+
* cost vx 620 MB of RSS on the miss AND on every hit, and its stdout sat
|
|
662
|
+
* whole in `cache.db` (measured 2026-09-16); a realistic chatty suite is
|
|
663
|
+
* 5–20 MB, so the bound is above what a replay is worth reading anyway.
|
|
664
|
+
*/
|
|
665
|
+
export const CAPTURE_HEAD_CHARS = 8 * 1024 * 1024
|
|
666
|
+
export const CAPTURE_TAIL_CHARS = 8 * 1024 * 1024
|
|
667
|
+
|
|
668
|
+
const mib = (n: number): string => `${(n / (1024 * 1024)).toFixed(1)} MiB`
|
|
669
|
+
|
|
670
|
+
/** The line that stands where the dropped middle was. */
|
|
671
|
+
export function droppedOutputLine(dropped: number): string {
|
|
672
|
+
return (
|
|
673
|
+
`\n[vx] ${mib(dropped)} of output not kept — vx keeps the first ${mib(CAPTURE_HEAD_CHARS)} ` +
|
|
674
|
+
`and the last ${mib(CAPTURE_TAIL_CHARS)} of a task's output for its cache entry and replay\n`
|
|
675
|
+
)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* A head-and-tail accumulator: the head fills once, the tail is a ring
|
|
680
|
+
* of chunks trimmed from the front, so memory is bounded by the two
|
|
681
|
+
* limits plus one chunk whatever the task prints.
|
|
682
|
+
*/
|
|
683
|
+
class BoundedCapture {
|
|
684
|
+
private head = ''
|
|
685
|
+
private readonly tail: string[] = []
|
|
686
|
+
private tailLen = 0
|
|
687
|
+
private dropped = 0
|
|
688
|
+
|
|
689
|
+
push(chunk: string): void {
|
|
690
|
+
if (this.head.length < CAPTURE_HEAD_CHARS) {
|
|
691
|
+
const room = CAPTURE_HEAD_CHARS - this.head.length
|
|
692
|
+
if (chunk.length <= room) {
|
|
693
|
+
this.head += chunk
|
|
694
|
+
return
|
|
695
|
+
}
|
|
696
|
+
this.head += chunk.slice(0, room)
|
|
697
|
+
chunk = chunk.slice(room)
|
|
698
|
+
}
|
|
699
|
+
this.tail.push(chunk)
|
|
700
|
+
this.tailLen += chunk.length
|
|
701
|
+
while (this.tailLen > CAPTURE_TAIL_CHARS) {
|
|
702
|
+
const first = this.tail[0]!
|
|
703
|
+
const excess = this.tailLen - CAPTURE_TAIL_CHARS
|
|
704
|
+
if (first.length <= excess) {
|
|
705
|
+
this.tail.shift()
|
|
706
|
+
this.tailLen -= first.length
|
|
707
|
+
this.dropped += first.length
|
|
708
|
+
} else {
|
|
709
|
+
this.tail[0] = first.slice(excess)
|
|
710
|
+
this.tailLen -= excess
|
|
711
|
+
this.dropped += excess
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
text(): string {
|
|
717
|
+
const tail = this.tail.join('')
|
|
718
|
+
return this.dropped === 0
|
|
719
|
+
? this.head + tail
|
|
720
|
+
: this.head + droppedOutputLine(this.dropped) + tail
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
519
724
|
/**
|
|
520
725
|
* Drain a `Bun.spawn` stdout/stderr stream while invoking the live
|
|
521
726
|
* callback per UTF-8 chunk. Returns the accumulated string, or `''` when
|
|
@@ -535,7 +740,7 @@ export async function streamToString(
|
|
|
535
740
|
// so the runtime value is always a ReadableStream; the `number` branch
|
|
536
741
|
// is unreachable but typed.
|
|
537
742
|
if (!stream || typeof stream === 'number') return ''
|
|
538
|
-
|
|
743
|
+
const full = new BoundedCapture()
|
|
539
744
|
const reader = stream.getReader()
|
|
540
745
|
const decoder = new TextDecoder()
|
|
541
746
|
// On abort, cancel the read so a pending `reader.read()` resolves
|
|
@@ -551,19 +756,63 @@ export async function streamToString(
|
|
|
551
756
|
const { value, done } = await reader.read()
|
|
552
757
|
if (done) break
|
|
553
758
|
const chunk = decoder.decode(value, { stream: true })
|
|
554
|
-
if (retain) full
|
|
759
|
+
if (retain) full.push(chunk)
|
|
555
760
|
onChunk?.(chunk)
|
|
556
761
|
}
|
|
557
762
|
const tail = decoder.decode()
|
|
558
763
|
if (tail.length > 0) {
|
|
559
|
-
if (retain) full
|
|
764
|
+
if (retain) full.push(tail)
|
|
560
765
|
onChunk?.(tail)
|
|
561
766
|
}
|
|
562
767
|
} finally {
|
|
563
768
|
signal?.removeEventListener('abort', onAbort)
|
|
564
769
|
reader.releaseLock()
|
|
565
770
|
}
|
|
566
|
-
return full
|
|
771
|
+
return full.text()
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* How far above the parent's own mark a child's `ru_maxrss` must read to
|
|
776
|
+
* count as the child's. A light child inherits the parent's footprint at
|
|
777
|
+
* exec, so its reading sits ON the floor by construction, and the kernel's
|
|
778
|
+
* per-thread RSS counters lag by up to 64 pages between syncs — a `true`
|
|
779
|
+
* spawned from a 300 MB parent read 376 MB against a floor a few pages
|
|
780
|
+
* lower on one CI run in twelve (2026-09-16). Four MiB is above any
|
|
781
|
+
* accounting jitter and below what any reservation resolves (64 MB steps).
|
|
782
|
+
*/
|
|
783
|
+
export const RSS_FLOOR_SLACK_BYTES = 4 * 1024 * 1024
|
|
784
|
+
|
|
785
|
+
const MIN_PLAUSIBLE_PEAK_BYTES = 1024 * 1024
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* A child's `maxRSS` in BYTES, whatever unit the runtime reported it in.
|
|
789
|
+
*
|
|
790
|
+
* Bun >= 1.4 — vx's declared floor — normalizes the kernel's `ru_maxrss`
|
|
791
|
+
* (kilobytes on Linux, bytes on macOS) before handing it over, and this file
|
|
792
|
+
* used to state that as "BYTES on every platform". It is not true of every
|
|
793
|
+
* Bun: 1.3.11 passes the raw kilobytes through, so every peak read 1024×
|
|
794
|
+
* SMALL, fell under the parent floor and recorded nothing. The opposite
|
|
795
|
+
* mistake is in this file's own history — an unconditional ×1024 on Linux
|
|
796
|
+
* made a 64 MB suite read as 64 GB and, once reservations were learned from
|
|
797
|
+
* that history, ran every task alone (2026-09-12). Both directions come from
|
|
798
|
+
* asserting a platform unit instead of deciding it from the number in hand.
|
|
799
|
+
*
|
|
800
|
+
* The number decides it. No process peaks under a megabyte — a bare `true`
|
|
801
|
+
* costs a couple — so a reading below that is not bytes, it is the kernel's
|
|
802
|
+
* kilobytes, and multiplying is right by the same margin that makes the test
|
|
803
|
+
* safe. A real byte figure is never near the threshold, and neither is a real
|
|
804
|
+
* kilobyte one: the two live 1024× apart.
|
|
805
|
+
*
|
|
806
|
+
* Calibrating against this process's OWN `process.resourceUsage()` was the
|
|
807
|
+
* first attempt and it was wrong: that is the node-compatible API, which
|
|
808
|
+
* reports kilobytes even where `Subprocess.resourceUsage()` reports bytes, so
|
|
809
|
+
* the ratio read 1024 on a runtime already handing over bytes and doubled the
|
|
810
|
+
* old 64 GB defect. It passed here, where both APIs use kilobytes, and turned
|
|
811
|
+
* CI red on both platforms (2026-09-20). Compare like with like, or do not
|
|
812
|
+
* compare at all.
|
|
813
|
+
*/
|
|
814
|
+
export function peakRssBytes(maxRSS: number): number {
|
|
815
|
+
return maxRSS > 0 && maxRSS < MIN_PLAUSIBLE_PEAK_BYTES ? maxRSS * 1024 : maxRSS
|
|
567
816
|
}
|
|
568
817
|
|
|
569
818
|
/**
|
|
@@ -580,17 +829,13 @@ export function resourceUsageToCpuRss(
|
|
|
580
829
|
if (!usage) return {}
|
|
581
830
|
// cpuTime.total is microseconds as a bigint → ms.
|
|
582
831
|
const cpuMs = Number(usage.cpuTime.total) / 1000
|
|
583
|
-
// `maxRSS`
|
|
584
|
-
// `
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
// and reads the peak back within a bounded factor of it.
|
|
593
|
-
// A reading at or under the parent's own mark is the parent's (see
|
|
594
|
-
// `ownRssHighWater`): the child's peak is unknown, bounded by it.
|
|
595
|
-
return usage.maxRSS > floorBytes ? { cpuMs, peakRssBytes: usage.maxRSS } : { cpuMs }
|
|
832
|
+
// `maxRSS` in whatever unit this runtime reports, in bytes by the rule
|
|
833
|
+
// above. `tests/runner.test.ts` allocates a known number of bytes and
|
|
834
|
+
// reads the peak back within a bounded factor of it, which is the
|
|
835
|
+
// end-to-end check on both the unit and the floor.
|
|
836
|
+
// A reading at or within the slack of the parent's own mark is the
|
|
837
|
+
// parent's (see `ownRssHighWater`, `RSS_FLOOR_SLACK_BYTES`): the child's
|
|
838
|
+
// peak is unknown, bounded by it.
|
|
839
|
+
const peak = peakRssBytes(usage.maxRSS)
|
|
840
|
+
return peak > floorBytes + RSS_FLOOR_SLACK_BYTES ? { cpuMs, peakRssBytes: peak } : { cpuMs }
|
|
596
841
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { lstatSync, readdirSync, statSync } from 'node:fs'
|
|
7
7
|
import path from 'node:path'
|
|
8
|
-
import { localBindingOn, unique } from './sandbox-paths.js'
|
|
8
|
+
import { isMountableLiteral, localBindingOn, unique } from './sandbox-paths.js'
|
|
9
9
|
import type { SandboxedRunArgs } from './sandbox-runtime.js'
|
|
10
10
|
|
|
11
11
|
type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
@@ -26,11 +26,11 @@ type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
|
26
26
|
* the task cannot produce. macOS needs none of this (seatbelt matches
|
|
27
27
|
* paths, it does not mount), so the grant stays exact there.
|
|
28
28
|
*/
|
|
29
|
-
function bindableWrites(paths: readonly string[]): string[] {
|
|
29
|
+
export function bindableWrites(paths: readonly string[]): string[] {
|
|
30
30
|
if (process.platform !== 'linux') return [...paths]
|
|
31
31
|
return unique(
|
|
32
32
|
paths.map((p) => {
|
|
33
|
-
if (
|
|
33
|
+
if (!isMountableLiteral(p)) return p
|
|
34
34
|
try {
|
|
35
35
|
if (statSync(p).isDirectory()) return p
|
|
36
36
|
} catch {
|
|
@@ -42,6 +42,36 @@ function bindableWrites(paths: readonly string[]): string[] {
|
|
|
42
42
|
)
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A read grant with the WALLS inside it cut out: the directories of the
|
|
47
|
+
* projects nested in this one, the repository and vx's own directory. A
|
|
48
|
+
* root project's `read: ['.']` bound every nested project, `.git` and the
|
|
49
|
+
* cache readable, and the key, which excludes nested projects, replayed a
|
|
50
|
+
* nested file's old bytes (item 1010). The cut is the punch above, a wall
|
|
51
|
+
* dropped where a write path is bound: its siblings are granted, the wall
|
|
52
|
+
* is not. A grant that IS a wall, or lies inside one, names it on purpose
|
|
53
|
+
* and stays. Linux only, as the punch is; a grant with no wall under it
|
|
54
|
+
* costs nothing.
|
|
55
|
+
*/
|
|
56
|
+
export function punchWalls(readPath: string, walls: readonly string[]): string[] {
|
|
57
|
+
if (process.platform !== 'linux') return [readPath]
|
|
58
|
+
const under = walls.filter((w) => w !== readPath && w.startsWith(readPath + path.sep))
|
|
59
|
+
if (under.length === 0) return [readPath]
|
|
60
|
+
let entries: string[]
|
|
61
|
+
try {
|
|
62
|
+
entries = readdirSync(readPath)
|
|
63
|
+
} catch {
|
|
64
|
+
return [readPath]
|
|
65
|
+
}
|
|
66
|
+
const out: string[] = []
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const child = path.join(readPath, entry)
|
|
69
|
+
if (under.includes(child)) continue
|
|
70
|
+
out.push(...punchWalls(child, under))
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
45
75
|
/** Directories already warned about below — once per process, not per spawn. */
|
|
46
76
|
const warnedSymlinkPunch = new Set<string>()
|
|
47
77
|
|
|
@@ -135,13 +165,12 @@ export function buildCustomConfig(
|
|
|
135
165
|
args: Pick<SandboxedRunArgs, 'config'>,
|
|
136
166
|
baselines: {
|
|
137
167
|
allowRead: readonly string[]
|
|
138
|
-
allowWrite: readonly string[]
|
|
139
168
|
denyRead: readonly string[]
|
|
140
169
|
},
|
|
141
170
|
): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
|
|
142
171
|
const c = args.config
|
|
143
172
|
const denyRead = unique([...baselines.denyRead])
|
|
144
|
-
const allowWrite = bindableWrites(unique([...
|
|
173
|
+
const allowWrite = bindableWrites(unique([...c.allowWrite]))
|
|
145
174
|
const allowRead = unique(
|
|
146
175
|
[...baselines.allowRead, ...c.allowRead].flatMap((r) => punchWritePaths(r, allowWrite)),
|
|
147
176
|
)
|