@vzn/vx 0.0.650 → 0.0.651
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 +5 -20
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/src/bin.ts +9 -31
- package/src/cache/cache.ts +202 -25
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +3 -0
- package/src/cache/layer.ts +11 -0
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +3 -2
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +11 -1
- package/src/cache/schema.ts +11 -1
- package/src/cli/cache.ts +26 -16
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +12 -23
- package/src/cli/index.ts +4 -9
- package/src/cli/info.ts +15 -7
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +213 -203
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +8 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +38 -42
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +2 -1
- package/src/cli/watch.ts +83 -44
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/exec/executor.ts +2 -1
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +11 -2
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +14 -10
- package/src/graph/task-graph.ts +27 -1
- package/src/handoff.ts +10 -7
- package/src/history/history.ts +5 -0
- package/src/history/metrics.ts +9 -1
- package/src/index.ts +3 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-task.ts +16 -2
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +31 -3
- package/src/orchestrator/persistent-task.ts +3 -1
- package/src/orchestrator/plan.ts +5 -1
- package/src/orchestrator/plugin-host.ts +61 -0
- package/src/orchestrator/plugin.ts +47 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +1 -1
- package/src/orchestrator/run-records.ts +10 -0
- package/src/orchestrator/run.ts +53 -22
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/task-hash.ts +17 -3
- package/src/report/events.ts +3 -11
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +272 -173
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +73 -38
- package/src/report/plain-output.ts +1 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +100 -131
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +65 -10
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +20 -2
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +31 -0
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/config-schema.ts +4 -2
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +28 -0
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +4 -3
- package/src/workspace/workspace.ts +3 -0
- package/src/util/colors.ts +0 -133
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
package/src/exec/runner.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
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
|
|
8
|
-
import { closeSync, existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
9
8
|
import { constants as osConstants } from 'node:os'
|
|
10
9
|
import {
|
|
11
10
|
formatBytes,
|
|
@@ -14,10 +13,12 @@ import {
|
|
|
14
13
|
isOutOfFds,
|
|
15
14
|
killGraceMs,
|
|
16
15
|
OUT_OF_FDS_HINT,
|
|
16
|
+
testTimerMs,
|
|
17
17
|
helperEnv,
|
|
18
18
|
type SecretMask,
|
|
19
19
|
} from '../util/index.js'
|
|
20
20
|
import {
|
|
21
|
+
type Child,
|
|
21
22
|
closeSignalChannel,
|
|
22
23
|
holdGroups,
|
|
23
24
|
killTree,
|
|
@@ -28,6 +29,10 @@ import {
|
|
|
28
29
|
spawnGuarded,
|
|
29
30
|
untilGroupsGone,
|
|
30
31
|
} from './kill-tree.js'
|
|
32
|
+
import { type TreeUsage, watchTree } from './proc-sample.js'
|
|
33
|
+
import { TaskPipes } from './pipes.js'
|
|
34
|
+
import { spawnOff } from './spawn-off.js'
|
|
35
|
+
import { runOnSpare, spareScript, takeSpare } from './warm-shell.js'
|
|
31
36
|
|
|
32
37
|
/** `sh` names itself in its errors. */
|
|
33
38
|
const SH_ARGV0 = { argv0: 'sh' }
|
|
@@ -96,6 +101,8 @@ export interface RunOptions {
|
|
|
96
101
|
* handler can SIGTERM everything still alive mid-run.
|
|
97
102
|
*/
|
|
98
103
|
liveChildren?: Set<ReturnType<typeof Bun.spawn>>
|
|
104
|
+
/** The run's stop (`ExecuteRequest.signal`): one that lands before the child is in `liveChildren` stops it here. */
|
|
105
|
+
signal?: AbortSignal
|
|
99
106
|
/** Told the child's pid once it is spawned (telemetry samples its tree). */
|
|
100
107
|
onSpawn?: (pid: number) => void
|
|
101
108
|
/**
|
|
@@ -113,153 +120,6 @@ function taskStdio(terminal: boolean | undefined, pipes: TaskPipes): [Fd, Fd, Fd
|
|
|
113
120
|
return terminal === true ? ['inherit', 'inherit', 'inherit'] : [pipes.stdin, ...pipes.stdio]
|
|
114
121
|
}
|
|
115
122
|
|
|
116
|
-
const O_NONBLOCK = 0o4000
|
|
117
|
-
const O_CLOEXEC = 0o2000000
|
|
118
|
-
const F_SETFL = 4
|
|
119
|
-
const F_SETPIPE_SZ = 1031
|
|
120
|
-
/**
|
|
121
|
-
* A pipe holds 64 KiB by default, and a 200 MB stdout drained 13% slower
|
|
122
|
-
* through one than through Bun's socketpair (min of 7: 293 vs 259 ms);
|
|
123
|
-
* at 256 KiB it drained in 230. Past the user's pipe quota the kernel
|
|
124
|
-
* refuses the resize and the pipe keeps its 64 KiB.
|
|
125
|
-
*/
|
|
126
|
-
const PIPE_BYTES = 256 * 1024
|
|
127
|
-
|
|
128
|
-
type PipeCalls = {
|
|
129
|
-
pipe2: (fds: Pointer, flags: number) => number
|
|
130
|
-
fcntl: (fd: number, cmd: number, arg: number) => number
|
|
131
|
-
ptr: (view: Int32Array, byteOffset?: number) => Pointer
|
|
132
|
-
}
|
|
133
|
-
let pipeCalls: PipeCalls | null | undefined
|
|
134
|
-
|
|
135
|
-
/** libc's `pipe2` and `fcntl`, bound once; null off Linux or where libc will not load. */
|
|
136
|
-
function linuxPipeCalls(): PipeCalls | null {
|
|
137
|
-
if (pipeCalls !== undefined) return pipeCalls
|
|
138
|
-
pipeCalls = null
|
|
139
|
-
if (process.platform !== 'linux') return null
|
|
140
|
-
try {
|
|
141
|
-
// Loaded at the first spawn, not with the module: a static import cost
|
|
142
|
-
// every vx start 0.3 ms, a warm run that spawns nothing included.
|
|
143
|
-
const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
|
|
144
|
-
// glibc's soname; musl's loader answers any `libc.` name with itself
|
|
145
|
-
// (ldso/dynlink.c's reserved names).
|
|
146
|
-
const { symbols } = dlopen('libc.so.6', {
|
|
147
|
-
pipe2: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
148
|
-
fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
149
|
-
})
|
|
150
|
-
pipeCalls = { pipe2: symbols.pipe2, fcntl: symbols.fcntl, ptr }
|
|
151
|
-
} catch {
|
|
152
|
-
// Bun's socketpair, as before: only `/dev/stdout` by path fails on it.
|
|
153
|
-
}
|
|
154
|
-
return pipeCalls
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Two pipes, `[out read, out write, err read, err write]`, or undefined
|
|
159
|
-
* when libc is not bound or a descriptor is not to be had (Bun's own
|
|
160
|
-
* socketpair then fails the spawn with the reason).
|
|
161
|
-
*/
|
|
162
|
-
function realPipes(): Int32Array | undefined {
|
|
163
|
-
const calls = linuxPipeCalls()
|
|
164
|
-
if (calls === null) return undefined
|
|
165
|
-
const fds = new Int32Array(4)
|
|
166
|
-
// Bun's spawn closes every descriptor it is not handed (probed, Bun
|
|
167
|
-
// 1.4.2); O_CLOEXEC keeps any other exec from holding a write end open.
|
|
168
|
-
if (calls.pipe2(calls.ptr(fds), O_CLOEXEC) !== 0) return undefined
|
|
169
|
-
if (calls.pipe2(calls.ptr(fds, 8), O_CLOEXEC) !== 0) {
|
|
170
|
-
closeSync(fds[0]!)
|
|
171
|
-
closeSync(fds[1]!)
|
|
172
|
-
return undefined
|
|
173
|
-
}
|
|
174
|
-
// vx's ends only: a task whose stdout is non-blocking fails its writes with EAGAIN.
|
|
175
|
-
calls.fcntl(fds[0]!, F_SETFL, O_NONBLOCK)
|
|
176
|
-
calls.fcntl(fds[2]!, F_SETFL, O_NONBLOCK)
|
|
177
|
-
calls.fcntl(fds[0]!, F_SETPIPE_SZ, PIPE_BYTES)
|
|
178
|
-
calls.fcntl(fds[2]!, F_SETPIPE_SZ, PIPE_BYTES)
|
|
179
|
-
return fds
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** One pipe, `[read, write]`, both close-on-exec, or undefined as {@link realPipes}. */
|
|
183
|
-
function heldPipe(): Int32Array | undefined {
|
|
184
|
-
const calls = linuxPipeCalls()
|
|
185
|
-
if (calls === null) return undefined
|
|
186
|
-
const fds = new Int32Array(2)
|
|
187
|
-
return calls.pipe2(calls.ptr(fds), O_CLOEXEC) === 0 ? fds : undefined
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* A task's stdout and stderr. Bun's `'pipe'` is a socketpair, and Linux
|
|
192
|
-
* opens `/dev/stdout` through `/proc/self/fd/1`, which a socket refuses
|
|
193
|
-
* (ENXIO): `echo x > /dev/stdout` and `cmd | tee /dev/stderr` failed in
|
|
194
|
-
* every task, where a terminal or Turbo hands real pipes (X-113). So on
|
|
195
|
-
* Linux vx makes the pipes (`pipe2` through `bun:ffi`) and hands the child
|
|
196
|
-
* the write ends. macOS opens `/dev/fd/N` as a dup of the descriptor,
|
|
197
|
-
* which a socket allows, so Bun's pipe stays there.
|
|
198
|
-
*
|
|
199
|
-
* `held`: stdin is a pipe vx keeps open and never writes (a persistent
|
|
200
|
-
* task's, schema.md), for the same reason: `cat /dev/stdin` on Bun's
|
|
201
|
-
* socketpair failed with ENXIO instead of blocking. Otherwise `'ignore'`.
|
|
202
|
-
*/
|
|
203
|
-
export class TaskPipes {
|
|
204
|
-
/** Descriptor 0 for `Bun.spawn`'s `stdio`. */
|
|
205
|
-
readonly stdin: number | 'pipe' | 'ignore'
|
|
206
|
-
/** Descriptors 1 and 2 for `Bun.spawn`'s `stdio`. */
|
|
207
|
-
readonly stdio: readonly [number | 'pipe', number | 'pipe']
|
|
208
|
-
readonly #read: readonly number[]
|
|
209
|
-
readonly #stdinWrite: number | undefined
|
|
210
|
-
|
|
211
|
-
constructor(terminal?: boolean, held?: boolean) {
|
|
212
|
-
const fds = terminal === true ? undefined : realPipes()
|
|
213
|
-
this.stdio = fds === undefined ? ['pipe', 'pipe'] : [fds[1]!, fds[3]!]
|
|
214
|
-
this.#read = fds === undefined ? [] : [fds[0]!, fds[2]!]
|
|
215
|
-
const inFds = held === true && terminal !== true ? heldPipe() : undefined
|
|
216
|
-
this.stdin = held !== true ? 'ignore' : inFds === undefined ? 'pipe' : inFds[0]!
|
|
217
|
-
this.#stdinWrite = inFds?.[1]
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Close vx's copies of the child's ends once the spawn has returned or
|
|
222
|
-
* thrown: the child holds its own, and a write end left here is a writer
|
|
223
|
-
* the reader's EOF would wait for.
|
|
224
|
-
*/
|
|
225
|
-
spawned(): void {
|
|
226
|
-
for (const fd of [this.stdin, ...this.stdio]) if (typeof fd === 'number') closeSync(fd)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** The child's stdout and stderr. */
|
|
230
|
-
streams(
|
|
231
|
-
child: ReturnType<typeof Bun.spawn>,
|
|
232
|
-
): [
|
|
233
|
-
ReadableStream<Uint8Array> | number | undefined,
|
|
234
|
-
ReadableStream<Uint8Array> | number | undefined,
|
|
235
|
-
] {
|
|
236
|
-
const [out, err] = this.#read
|
|
237
|
-
return out === undefined
|
|
238
|
-
? [child.stdout, child.stderr]
|
|
239
|
-
: [Bun.file(out).stream(), Bun.file(err!).stream()]
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Close the read ends, once both streams have ended or been cancelled.
|
|
244
|
-
* Bun reads a dup of each, but takes it on the stream's first pull: a
|
|
245
|
-
* number closed before then was reused by the next spawn's pipe, and
|
|
246
|
-
* the stream read THAT (probed, Bun 1.4.2).
|
|
247
|
-
*/
|
|
248
|
-
close(): void {
|
|
249
|
-
for (const fd of this.#read) closeSync(fd)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Close the held stdin's write end, once the child has exited and its
|
|
254
|
-
* output has ended. Not with the output alone: a server that sends it
|
|
255
|
-
* to a log (`exec >log 2>&1`) ended its streams at once, read stdin EOF
|
|
256
|
-
* and exited.
|
|
257
|
-
*/
|
|
258
|
-
closeStdin(): void {
|
|
259
|
-
if (this.#stdinWrite !== undefined) closeSync(this.#stdinWrite)
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
123
|
/**
|
|
264
124
|
* Read the terminal's modes before a task that holds it runs, and return
|
|
265
125
|
* what puts them back once it exits: a task killed in raw mode (or one
|
|
@@ -711,10 +571,28 @@ export const POST_EXIT_CUT_LINE =
|
|
|
711
571
|
`still held its stdout/stderr ${POST_EXIT_DRAIN_MS} ms later\n`
|
|
712
572
|
|
|
713
573
|
/**
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
574
|
+
* How long one stream's output waits on the other's open line. dash writes
|
|
575
|
+
* the pieces of `sh: 1: cannot open /dev/tty: …` back to back; a line open
|
|
576
|
+
* longer is a prompt or a progress line, and the other stream's lines are
|
|
577
|
+
* shown live rather than held behind it.
|
|
578
|
+
*/
|
|
579
|
+
export const OPEN_LINE_WAIT_MS = 50
|
|
580
|
+
/** And how much of it waits: the hold never costs more than this per task. */
|
|
581
|
+
export const OPEN_LINE_HOLD_CHARS = 64 * 1024
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* A task's two output callbacks, merged a line at a time. They are two
|
|
585
|
+
* pipes, read in the order they arrive, and dash's stderr line written in
|
|
586
|
+
* pieces took a stdout write inside it: `sh: /dev/ttyNO-TTY\n\n` (CI,
|
|
587
|
+
* 2026-10-29). So a chunk on one stream waits while the other's line is
|
|
588
|
+
* open, and goes once that line ends; what of a chunk follows its last
|
|
589
|
+
* newline goes after the held output. Past `OPEN_LINE_WAIT_MS`,
|
|
590
|
+
* `OPEN_LINE_HOLD_CHARS` or `end` it goes as written, the line unbroken:
|
|
591
|
+
* a newline put there became the stream's own text, and a hit replays
|
|
592
|
+
* each stream to its own descriptor (`2> log` read one the task never
|
|
593
|
+
* wrote). `own` breaks the line before vx's own text only. A stream
|
|
594
|
+
* writing alone is never held, so a task that writes one stream pays a
|
|
595
|
+
* comparison per chunk. An absent callback stays absent (nothing decodes).
|
|
718
596
|
*/
|
|
719
597
|
export function lineEnds(
|
|
720
598
|
onStdout?: (chunk: string) => void,
|
|
@@ -724,18 +602,74 @@ export function lineEnds(
|
|
|
724
602
|
onStderr?: (chunk: string) => void
|
|
725
603
|
/** `text` on a line of its own. */
|
|
726
604
|
own(text: string): string
|
|
605
|
+
/** Lets what waits go: the task's streams ended. */
|
|
606
|
+
end(): void
|
|
727
607
|
} {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
608
|
+
// A test lengthens it to drive the hold itself on a loaded runner, where
|
|
609
|
+
// a `sleep` between two writes outlasted 50 ms (macOS CI, PR #4867).
|
|
610
|
+
const waitMs = testTimerMs('OPEN_LINE_WAIT', OPEN_LINE_WAIT_MS)
|
|
611
|
+
const sinks = [onStdout, onStderr]
|
|
612
|
+
/** The stream whose line is open (0 stdout, 1 stderr). */
|
|
613
|
+
let open: 0 | 1 | undefined
|
|
614
|
+
/** What the other stream wrote while that line was open: one side at most. */
|
|
615
|
+
const held = ['', '']
|
|
616
|
+
/** When what waits began to: the bound counts from there. */
|
|
617
|
+
let heldAt = 0
|
|
618
|
+
// One timer, re-armed only when it fires early: a hold set and cleared
|
|
619
|
+
// its own per line, and a task interleaving short lines paid a timer each
|
|
620
|
+
// (+26 ms of CPU on 20 000 lines, 2026-10-29).
|
|
621
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
622
|
+
const arm = (ms: number): void => {
|
|
623
|
+
timer = setTimeout(due, ms)
|
|
624
|
+
timer.unref()
|
|
625
|
+
}
|
|
626
|
+
const due = (): void => {
|
|
627
|
+
timer = undefined
|
|
628
|
+
const s = held[0]!.length > 0 ? 0 : held[1]!.length > 0 ? 1 : undefined
|
|
629
|
+
if (s === undefined) return
|
|
630
|
+
const left = heldAt + waitMs - performance.now()
|
|
631
|
+
if (left > 0) arm(left)
|
|
632
|
+
else release(s)
|
|
633
|
+
}
|
|
634
|
+
const write = (s: 0 | 1, text: string): void => {
|
|
635
|
+
open = text.endsWith('\n') ? undefined : s
|
|
636
|
+
sinks[s]?.(text)
|
|
637
|
+
}
|
|
638
|
+
const release = (s: 0 | 1): void => {
|
|
639
|
+
const text = held[s]!
|
|
640
|
+
if (text.length === 0) return
|
|
641
|
+
held[s] = ''
|
|
642
|
+
write(s, text)
|
|
643
|
+
}
|
|
644
|
+
const push = (s: 0 | 1, chunk: string): void => {
|
|
645
|
+
if (chunk.length === 0) return
|
|
646
|
+
if (open === undefined || open === s) {
|
|
647
|
+
const other = s === 0 ? 1 : 0
|
|
648
|
+
const nl = held[other]!.length === 0 ? -1 : chunk.lastIndexOf('\n')
|
|
649
|
+
if (nl === -1 || nl === chunk.length - 1) {
|
|
650
|
+
write(s, chunk)
|
|
651
|
+
if (nl !== -1) release(other)
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
write(s, chunk.slice(0, nl + 1))
|
|
655
|
+
release(other)
|
|
656
|
+
return push(s, chunk.slice(nl + 1))
|
|
734
657
|
}
|
|
658
|
+
if (held[s]!.length === 0) heldAt = performance.now()
|
|
659
|
+
held[s] += chunk
|
|
660
|
+
if (held[s]!.length > OPEN_LINE_HOLD_CHARS) return release(s)
|
|
661
|
+
if (timer === undefined) arm(waitMs)
|
|
662
|
+
}
|
|
735
663
|
return {
|
|
736
|
-
...(onStdout && { onStdout:
|
|
737
|
-
...(onStderr && { onStderr:
|
|
738
|
-
own: (text) => (
|
|
664
|
+
...(onStdout && { onStdout: (chunk: string) => push(0, chunk) }),
|
|
665
|
+
...(onStderr && { onStderr: (chunk: string) => push(1, chunk) }),
|
|
666
|
+
own: (text) => (open !== undefined ? `\n${text}` : text),
|
|
667
|
+
end: () => {
|
|
668
|
+
clearTimeout(timer)
|
|
669
|
+
timer = undefined
|
|
670
|
+
release(0)
|
|
671
|
+
release(1)
|
|
672
|
+
},
|
|
739
673
|
}
|
|
740
674
|
}
|
|
741
675
|
|
|
@@ -1151,37 +1085,92 @@ function argTooLongHint(command: string, wrapped: boolean): string {
|
|
|
1151
1085
|
)
|
|
1152
1086
|
}
|
|
1153
1087
|
|
|
1088
|
+
/** A stop's own sweep, for a child it missed: the polite signal, then SIGKILL past the grace. */
|
|
1089
|
+
async function stopLate(proc: Child, signal: 'SIGINT' | 'SIGTERM'): Promise<void> {
|
|
1090
|
+
const letGo = holdGroups([proc])
|
|
1091
|
+
try {
|
|
1092
|
+
killTree(proc, signal)
|
|
1093
|
+
for (const c of await untilGroupsGone([proc], killGraceMs(TIMEOUT_SIGKILL_GRACE_MS)))
|
|
1094
|
+
killTree(c, 'SIGKILL')
|
|
1095
|
+
} finally {
|
|
1096
|
+
letGo()
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1154
1100
|
export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
1155
1101
|
const start = Date.now()
|
|
1156
1102
|
const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
|
|
1157
1103
|
|
|
1158
|
-
let proc:
|
|
1159
|
-
|
|
1104
|
+
let proc: Child
|
|
1105
|
+
// A spare shell takes the task when there is one (warm-shell.ts).
|
|
1106
|
+
const script =
|
|
1107
|
+
opts.terminal === true ? undefined : spareScript(opts.cwd, opts.env, execWrap(fullCommand))
|
|
1108
|
+
const spare = script === undefined ? undefined : takeSpare()
|
|
1109
|
+
const onSpare = spare === undefined ? undefined : await runOnSpare(spare, script!)
|
|
1110
|
+
const pipes = onSpare === undefined ? new TaskPipes(opts.terminal) : spare!.pipes
|
|
1160
1111
|
const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1112
|
+
if (onSpare !== undefined) proc = onSpare as unknown as Child
|
|
1113
|
+
else
|
|
1114
|
+
try {
|
|
1115
|
+
const stdio = taskStdio(opts.terminal, pipes)
|
|
1116
|
+
const argv = (guard: number | undefined): string[] =>
|
|
1117
|
+
shellArgv((guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand))
|
|
1118
|
+
// Its own session and process group, so a kill reaches what it forked
|
|
1119
|
+
// (kill-tree.ts). A terminal handed over is not this session's
|
|
1120
|
+
// controlling one, so no read stops the group.
|
|
1121
|
+
const spawnOpts = {
|
|
1164
1122
|
...SH_ARGV0,
|
|
1165
1123
|
cwd: opts.cwd,
|
|
1166
1124
|
env: opts.env as Record<string, string>,
|
|
1167
|
-
stdio: [...taskStdio(opts.terminal, pipes), ...(guard === undefined ? [] : [guard])],
|
|
1168
|
-
// Its own session and process group, so a kill reaches what it
|
|
1169
|
-
// forked (kill-tree.ts). A terminal handed over is not this
|
|
1170
|
-
// session's controlling one, so no read stops the group.
|
|
1171
1125
|
detached: true,
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1126
|
+
}
|
|
1127
|
+
// Descriptors cross to the spawn thread; Bun's own pipes (macOS) and a
|
|
1128
|
+
// terminal stay on this one.
|
|
1129
|
+
const fds = stdio.filter(
|
|
1130
|
+
(fd): fd is number | 'ignore' => typeof fd === 'number' || fd === 'ignore',
|
|
1131
|
+
)
|
|
1132
|
+
proc =
|
|
1133
|
+
fds.length === stdio.length
|
|
1134
|
+
? // What vx reads of a child (pid, exit, usage, kill) is the same off
|
|
1135
|
+
// the thread; its streams are the pipes vx made (`TaskPipes`).
|
|
1136
|
+
((await spawnGuarded((guard) =>
|
|
1137
|
+
spawnOff({
|
|
1138
|
+
cmd: argv(guard),
|
|
1139
|
+
...spawnOpts,
|
|
1140
|
+
stdio: [...fds, ...(guard === undefined ? [] : [guard])],
|
|
1141
|
+
}),
|
|
1142
|
+
)) as unknown as Child)
|
|
1143
|
+
: spawnGuarded((guard) =>
|
|
1144
|
+
Bun.spawn(argv(guard), {
|
|
1145
|
+
...spawnOpts,
|
|
1146
|
+
stdio: [...stdio, ...(guard === undefined ? [] : [guard])],
|
|
1147
|
+
}),
|
|
1148
|
+
)
|
|
1149
|
+
} catch (err) {
|
|
1150
|
+
pipes.close()
|
|
1151
|
+
const stderr = spawnFailureText(err, opts.cwd, 'task', fullCommand)
|
|
1152
|
+
opts.onStderr?.(stderr)
|
|
1153
|
+
return {
|
|
1154
|
+
exitCode: 127,
|
|
1155
|
+
durationMs: Date.now() - start,
|
|
1156
|
+
stdout: '',
|
|
1157
|
+
stderr,
|
|
1158
|
+
spawnFailed: true,
|
|
1159
|
+
}
|
|
1160
|
+
} finally {
|
|
1161
|
+
pipes.spawned()
|
|
1162
|
+
}
|
|
1182
1163
|
|
|
1183
1164
|
opts.liveChildren?.add(proc)
|
|
1165
|
+
// The spawn is awaited (off the thread, or on a spare shell), and the
|
|
1166
|
+
// command runs meanwhile: a stop that landed then swept a live set
|
|
1167
|
+
// without it, and the run waited out the whole command (CI, 2026-11-04).
|
|
1168
|
+
if (opts.signal?.aborted === true) void stopLate(proc, stopSignal(opts.signal.reason))
|
|
1184
1169
|
opts.onSpawn?.(proc.pid)
|
|
1170
|
+
// Linux: the wait's peak includes vx's own, inherited at exec, so a task
|
|
1171
|
+
// lighter than vx reads none (`rssFloorFor`); the tree looked at while it
|
|
1172
|
+
// runs has the task's own.
|
|
1173
|
+
const watch = process.platform === 'linux' ? watchTree(proc.pid) : undefined
|
|
1185
1174
|
const timeout = armTimeout(proc, opts.timeoutMs)
|
|
1186
1175
|
const ac = new AbortController()
|
|
1187
1176
|
const [out, err] = pipes.streams(proc)
|
|
@@ -1203,12 +1192,30 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
1203
1192
|
if (timeout.timedOut()) ac.abort()
|
|
1204
1193
|
else cut = await drainOrAbort(streams, ac)
|
|
1205
1194
|
const [stdout, streamed] = await streams
|
|
1195
|
+
ends.end()
|
|
1206
1196
|
const cutLine = ends.own(POST_EXIT_CUT_LINE)
|
|
1207
1197
|
if (cut) opts.onStderr?.(cutLine)
|
|
1208
1198
|
const stderr = cut ? streamed + cutLine : streamed
|
|
1209
1199
|
opts.liveChildren?.delete(proc)
|
|
1210
1200
|
releaseGroup(proc)
|
|
1211
1201
|
const exited = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
|
|
1202
|
+
// The spare's `cd` failed: the task never ran, as a spawn into a missing
|
|
1203
|
+
// directory throws. A 127 is the shell's own otherwise.
|
|
1204
|
+
if (onSpare !== undefined && exited === 127 && !existsSync(opts.cwd)) {
|
|
1205
|
+
const text = spawnFailureText(
|
|
1206
|
+
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
|
1207
|
+
opts.cwd,
|
|
1208
|
+
'task',
|
|
1209
|
+
)
|
|
1210
|
+
opts.onStderr?.(text)
|
|
1211
|
+
return {
|
|
1212
|
+
exitCode: 127,
|
|
1213
|
+
durationMs: Date.now() - start,
|
|
1214
|
+
stdout,
|
|
1215
|
+
stderr: stderr + text,
|
|
1216
|
+
spawnFailed: true,
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1212
1219
|
// A timeout is a failure, whatever the child said: one that traps the
|
|
1213
1220
|
// TERM and exits 0 reported success to every executor built on this.
|
|
1214
1221
|
const exitCode = exited === 0 && timeout.timedOut() ? signalExitCode('SIGTERM') : exited
|
|
@@ -1220,10 +1227,20 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
1220
1227
|
...(proc.signalCode != null ? { signal: proc.signalCode } : {}),
|
|
1221
1228
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
1222
1229
|
...(cut ? { leftRunning: true } : {}),
|
|
1223
|
-
...
|
|
1230
|
+
...usageOf(proc, watch?.stop()),
|
|
1224
1231
|
}
|
|
1225
1232
|
}
|
|
1226
1233
|
|
|
1234
|
+
/** The CPU and peak a task used: the wait's, and the peak the looks saw when the wait's is vx's own. */
|
|
1235
|
+
function usageOf(
|
|
1236
|
+
proc: Child,
|
|
1237
|
+
looked: TreeUsage | undefined,
|
|
1238
|
+
): { cpuMs?: number; peakRssBytes?: number } {
|
|
1239
|
+
const usage = resourceUsageToCpuRss(proc.resourceUsage(), rssFloorFor)
|
|
1240
|
+
if (looked === undefined || looked.rssBytes <= 0) return usage
|
|
1241
|
+
return { ...usage, peakRssBytes: Math.max(usage.peakRssBytes ?? 0, looked.rssBytes) }
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1227
1244
|
/**
|
|
1228
1245
|
* This process's own RSS high-water mark, in bytes: the floor under which
|
|
1229
1246
|
* a child's `ru_maxrss` says nothing about the child. Linux folds the
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
unique,
|
|
15
15
|
} from './sandbox-paths.js'
|
|
16
16
|
import type { SandboxedRunArgs } from './sandbox-runtime.js'
|
|
17
|
-
import
|
|
17
|
+
import * as ui from '../ui/index.js'
|
|
18
18
|
|
|
19
19
|
type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
20
20
|
|
|
@@ -58,10 +58,10 @@ import {
|
|
|
58
58
|
spawnFailureText,
|
|
59
59
|
streamToString,
|
|
60
60
|
resourceUsageToCpuRss,
|
|
61
|
-
TaskPipes,
|
|
62
61
|
type CaptureConfig,
|
|
63
62
|
type RunResult,
|
|
64
63
|
} from './runner.js'
|
|
64
|
+
import { TaskPipes } from './pipes.js'
|
|
65
65
|
import { watchTree } from './proc-sample.js'
|
|
66
66
|
import {
|
|
67
67
|
BUN_GLOB_WILDCARDS,
|
|
@@ -73,10 +73,10 @@ import {
|
|
|
73
73
|
isTmpdirRefusal,
|
|
74
74
|
procfsIsOwn,
|
|
75
75
|
TMPDIR_HINT,
|
|
76
|
-
ui,
|
|
77
76
|
UserError,
|
|
78
77
|
xxh3hex,
|
|
79
78
|
} from '../util/index.js'
|
|
79
|
+
import * as ui from '../ui/index.js'
|
|
80
80
|
import {
|
|
81
81
|
bindableReads,
|
|
82
82
|
bindableWrites,
|
|
@@ -2290,6 +2290,7 @@ async function runSandboxedOnce(
|
|
|
2290
2290
|
if (timeout.timedOut()) ac.abort()
|
|
2291
2291
|
else cut = await drainOrAbort(streams, ac)
|
|
2292
2292
|
const [stdout, streamed] = await streams
|
|
2293
|
+
ends.end()
|
|
2293
2294
|
const cutLine = ends.own(POST_EXIT_CUT_LINE)
|
|
2294
2295
|
if (cut) args.onStderr?.(cutLine)
|
|
2295
2296
|
const stderr = cut ? streamed + cutLine : streamed
|
|
@@ -2323,6 +2324,14 @@ async function runSandboxedOnce(
|
|
|
2323
2324
|
// (2026-09-05). A sandboxed task that fails must say what it was denied;
|
|
2324
2325
|
// deciding on the user's behalf that a record was "just traversal" is how
|
|
2325
2326
|
// a failure ends up with an empty violations section and no explanation.
|
|
2327
|
+
// Linux: the write observer's records reach the store over a Unix socket
|
|
2328
|
+
// this process serves, not with the child's exit. The observer sent them
|
|
2329
|
+
// before the task exited, but a task that ends quickly is seen to exit
|
|
2330
|
+
// in the turn its connection is accepted, before it is read: a turn to
|
|
2331
|
+
// accept and a turn to read take them in (40 fast refused mkdirs at
|
|
2332
|
+
// once: 38 of 200 unreported, 33 with one turn, none with two).
|
|
2333
|
+
if (process.platform === 'linux')
|
|
2334
|
+
for (let turn = 0; turn < 2; turn++) await new Promise((r) => setImmediate(r))
|
|
2326
2335
|
const records = takeRecords()
|
|
2327
2336
|
// Read as is on darwin only. Since SRT 0.0.75 the store is fed on Linux
|
|
2328
2337
|
// too, by the seccomp helper's write observer — but SRT judges those
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// A task's spawn on a thread of its own. Bun's spawn holds the calling
|
|
2
|
+
// thread until the child has exec'd (vfork), ~0.6 ms a task, and on a cold
|
|
3
|
+
// run that thread is the scheduler's: 2,000 tasks with 1.8 ms of the
|
|
4
|
+
// scheduler's own work each took 4.96 s spawned there and 3.71 s spawned
|
|
5
|
+
// here, the 3.6 s of that work the floor (2026-10-29). The child's
|
|
6
|
+
// descriptors are numbers in the one table every thread shares, so its
|
|
7
|
+
// pipes are made and read where they were; only the spawn and the wait
|
|
8
|
+
// for its exit move.
|
|
9
|
+
|
|
10
|
+
import type { ResourceUsage } from 'bun'
|
|
11
|
+
|
|
12
|
+
/** What `runCommand` reads of a child spawned here. */
|
|
13
|
+
export interface OffChild {
|
|
14
|
+
readonly pid: number
|
|
15
|
+
readonly exited: Promise<number>
|
|
16
|
+
readonly exitCode: number | null
|
|
17
|
+
readonly signalCode: NodeJS.Signals | null
|
|
18
|
+
resourceUsage(): ResourceUsage | undefined
|
|
19
|
+
kill(signal?: NodeJS.Signals | number): void
|
|
20
|
+
/** Hold the process open from now until the child exits: a spawn with `held: false` does not. */
|
|
21
|
+
hold(): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OffSpawn {
|
|
25
|
+
cmd: string[]
|
|
26
|
+
argv0?: string
|
|
27
|
+
cwd?: string
|
|
28
|
+
env?: Record<string, string>
|
|
29
|
+
/** Each a descriptor number or `'ignore'`: a stream cannot cross threads. */
|
|
30
|
+
stdio: Array<number | 'ignore'>
|
|
31
|
+
detached?: boolean
|
|
32
|
+
/** False: the child keeps no run alive until `hold()` (a spare shell waiting for its task). */
|
|
33
|
+
held?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The worker as source, not a file: a compiled vx carries only its entry
|
|
37
|
+
// point's graph, and a worker file would need an entry point of its own.
|
|
38
|
+
const WORKER = `
|
|
39
|
+
const live = new Map()
|
|
40
|
+
self.onmessage = (e) => {
|
|
41
|
+
const m = e.data
|
|
42
|
+
if (m.kill !== undefined) { live.get(m.kill)?.kill(m.signal); return }
|
|
43
|
+
let p
|
|
44
|
+
try {
|
|
45
|
+
p = Bun.spawn(m.opts.cmd, m.opts)
|
|
46
|
+
} catch (err) {
|
|
47
|
+
postMessage({ id: m.id, error: { message: String(err?.message ?? err), code: err?.code } })
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
live.set(m.id, p)
|
|
51
|
+
postMessage({ id: m.id, pid: p.pid })
|
|
52
|
+
p.exited.then(() => {
|
|
53
|
+
live.delete(m.id)
|
|
54
|
+
const u = p.resourceUsage()
|
|
55
|
+
// Its two fields vx reads: the object itself does not cross threads.
|
|
56
|
+
const usage = u === undefined ? undefined : { cpuTime: { user: u.cpuTime.user, system: u.cpuTime.system, total: u.cpuTime.total }, maxRSS: u.maxRSS }
|
|
57
|
+
postMessage({ id: m.id, exitCode: p.exitCode, signalCode: p.signalCode, usage })
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
`
|
|
61
|
+
|
|
62
|
+
interface Pending {
|
|
63
|
+
held: () => boolean
|
|
64
|
+
spawned: (pid: number) => void
|
|
65
|
+
failed: (err: Error) => void
|
|
66
|
+
exit: (m: {
|
|
67
|
+
exitCode: number | null
|
|
68
|
+
signalCode: NodeJS.Signals | null
|
|
69
|
+
usage?: ResourceUsage
|
|
70
|
+
}) => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let worker: Worker | undefined
|
|
74
|
+
let nextId = 0
|
|
75
|
+
const pending = new Map<number, Pending>()
|
|
76
|
+
let liveCount = 0
|
|
77
|
+
|
|
78
|
+
function thread(): Worker {
|
|
79
|
+
if (worker !== undefined) return worker
|
|
80
|
+
const w = new Worker(URL.createObjectURL(new Blob([WORKER], { type: 'text/javascript' })))
|
|
81
|
+
w.onmessage = (e: MessageEvent) => {
|
|
82
|
+
const m = e.data
|
|
83
|
+
const p = pending.get(m.id)
|
|
84
|
+
if (p === undefined) return
|
|
85
|
+
if (m.error !== undefined) {
|
|
86
|
+
pending.delete(m.id)
|
|
87
|
+
const err = new Error(m.error.message) as NodeJS.ErrnoException
|
|
88
|
+
if (m.error.code !== undefined) err.code = m.error.code
|
|
89
|
+
if (p.held()) settleLive(-1)
|
|
90
|
+
p.failed(err)
|
|
91
|
+
} else if (m.pid !== undefined) p.spawned(m.pid)
|
|
92
|
+
else {
|
|
93
|
+
pending.delete(m.id)
|
|
94
|
+
if (p.held()) settleLive(-1)
|
|
95
|
+
p.exit(m)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// An idle thread keeps no run alive.
|
|
99
|
+
w.unref()
|
|
100
|
+
worker = w
|
|
101
|
+
return w
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Hold the process open while a child spawned here runs, as a spawn on the main thread does. */
|
|
105
|
+
function settleLive(delta: number): void {
|
|
106
|
+
liveCount += delta
|
|
107
|
+
if (liveCount > 0) worker?.ref()
|
|
108
|
+
else worker?.unref()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Spawn `opts` on the spawn thread; resolves once the child is running, rejects when the spawn threw. */
|
|
112
|
+
export function spawnOff(opts: OffSpawn): Promise<OffChild> {
|
|
113
|
+
const w = thread()
|
|
114
|
+
const id = nextId++
|
|
115
|
+
let held = opts.held !== false
|
|
116
|
+
if (held) settleLive(1)
|
|
117
|
+
const { held: _, ...spawn } = opts
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
let exitCode: number | null = null
|
|
120
|
+
let signalCode: NodeJS.Signals | null = null
|
|
121
|
+
let usage: ResourceUsage | undefined
|
|
122
|
+
let onExit!: (code: number) => void
|
|
123
|
+
const exited = new Promise<number>((r) => (onExit = r))
|
|
124
|
+
pending.set(id, {
|
|
125
|
+
held: () => held,
|
|
126
|
+
failed: reject,
|
|
127
|
+
spawned: (pid) =>
|
|
128
|
+
resolve({
|
|
129
|
+
pid,
|
|
130
|
+
exited,
|
|
131
|
+
get exitCode() {
|
|
132
|
+
return exitCode
|
|
133
|
+
},
|
|
134
|
+
get signalCode() {
|
|
135
|
+
return signalCode
|
|
136
|
+
},
|
|
137
|
+
resourceUsage: () => usage,
|
|
138
|
+
kill: (signal) => w.postMessage({ kill: id, signal }),
|
|
139
|
+
hold: () => {
|
|
140
|
+
if (held || exitCode !== null || signalCode !== null) return
|
|
141
|
+
held = true
|
|
142
|
+
settleLive(1)
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
exit: (m) => {
|
|
146
|
+
exitCode = m.exitCode
|
|
147
|
+
signalCode = m.signalCode
|
|
148
|
+
usage = m.usage
|
|
149
|
+
onExit(exitCode ?? 1)
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
w.postMessage({ id, opts: spawn })
|
|
153
|
+
})
|
|
154
|
+
}
|