@vzn/vx 0.0.596 → 0.0.598
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/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/bin.ts +9 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +60 -31
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +87 -32
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +2 -1
- package/src/cache/layered-cache.ts +14 -9
- package/src/cache/output-index.ts +12 -7
- package/src/cache/run-history.ts +16 -8
- package/src/cli/cache.ts +2 -2
- package/src/cli/foreign-flags.ts +56 -2
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +6 -3
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +26 -20
- package/src/cli/run.ts +42 -15
- package/src/cli/select.ts +19 -5
- package/src/cli/show.ts +21 -27
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-cycle.ts +43 -0
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch.ts +50 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +21 -0
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +121 -20
- package/src/exec/runner.ts +224 -48
- package/src/exec/sandbox-paths.ts +11 -0
- package/src/exec/sandbox-runtime.ts +187 -54
- package/src/exec/sandbox-violations.ts +59 -6
- package/src/graph/priorities.ts +1 -2
- package/src/graph/scheduler.ts +2 -2
- package/src/graph/task-graph.ts +145 -33
- package/src/index.ts +3 -1
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +5 -3
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +70 -28
- package/src/orchestrator/failure-mode.ts +38 -29
- package/src/orchestrator/framed-output.ts +5 -5
- package/src/orchestrator/hit-restore.ts +1 -1
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/lockfile-claim.ts +14 -7
- package/src/orchestrator/logger.ts +17 -7
- package/src/orchestrator/miss-save.ts +7 -11
- package/src/orchestrator/plan.ts +4 -20
- package/src/orchestrator/plugin-host.ts +7 -2
- package/src/orchestrator/plugin.ts +5 -2
- package/src/orchestrator/prepare.ts +4 -2
- package/src/orchestrator/projects.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +2 -2
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-lock.ts +35 -25
- package/src/orchestrator/run.ts +2 -1
- package/src/orchestrator/sandbox-request.ts +31 -9
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +5 -9
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +1 -1
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-cache.ts +15 -1
- package/src/workspace/config-eval.ts +47 -7
- package/src/workspace/config-imports.ts +8 -55
- package/src/workspace/config-schema.ts +30 -6
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/project-loader.ts +33 -6
- package/src/workspace/workspace.ts +36 -14
package/src/exec/runner.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
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 {
|
|
7
|
+
import type { Pointer } from 'bun:ffi'
|
|
8
|
+
import { closeSync, existsSync, readFileSync } from 'node:fs'
|
|
8
9
|
import { constants as osConstants } from 'node:os'
|
|
9
10
|
import {
|
|
10
11
|
shellArgv,
|
|
@@ -18,8 +19,8 @@ import {
|
|
|
18
19
|
closeSignalChannel,
|
|
19
20
|
holdGroups,
|
|
20
21
|
killTree,
|
|
21
|
-
markGroupIfGone,
|
|
22
22
|
releaseGroup,
|
|
23
|
+
releaseServerGroup,
|
|
23
24
|
signalThrough,
|
|
24
25
|
guardLine,
|
|
25
26
|
spawnGuarded,
|
|
@@ -98,11 +99,164 @@ export interface RunOptions {
|
|
|
98
99
|
terminal?: boolean
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
type Fd = 'inherit' | 'ignore' | 'pipe' | number
|
|
103
|
+
|
|
101
104
|
/** A child's descriptors 0–2: vx's own for a task that holds the terminal. */
|
|
102
|
-
function taskStdio(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
function taskStdio(
|
|
106
|
+
terminal: boolean | undefined,
|
|
107
|
+
stdin: 'ignore' | 'pipe',
|
|
108
|
+
pipes: TaskPipes,
|
|
109
|
+
): [Fd, Fd, Fd] {
|
|
110
|
+
return terminal === true ? ['inherit', 'inherit', 'inherit'] : [stdin, ...pipes.stdio]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const O_NONBLOCK = 0o4000
|
|
114
|
+
const O_CLOEXEC = 0o2000000
|
|
115
|
+
const F_SETFL = 4
|
|
116
|
+
const F_SETPIPE_SZ = 1031
|
|
117
|
+
/**
|
|
118
|
+
* A pipe holds 64 KiB by default, and a 200 MB stdout drained 13% slower
|
|
119
|
+
* through one than through Bun's socketpair (min of 7: 293 vs 259 ms);
|
|
120
|
+
* at 256 KiB it drained in 230. Past the user's pipe quota the kernel
|
|
121
|
+
* refuses the resize and the pipe keeps its 64 KiB.
|
|
122
|
+
*/
|
|
123
|
+
const PIPE_BYTES = 256 * 1024
|
|
124
|
+
|
|
125
|
+
type PipeCalls = {
|
|
126
|
+
pipe2: (fds: Pointer, flags: number) => number
|
|
127
|
+
fcntl: (fd: number, cmd: number, arg: number) => number
|
|
128
|
+
ptr: (view: Int32Array, byteOffset?: number) => Pointer
|
|
129
|
+
}
|
|
130
|
+
let pipeCalls: PipeCalls | null | undefined
|
|
131
|
+
|
|
132
|
+
/** libc's `pipe2` and `fcntl`, bound once; null off Linux or where libc will not load. */
|
|
133
|
+
function linuxPipeCalls(): PipeCalls | null {
|
|
134
|
+
if (pipeCalls !== undefined) return pipeCalls
|
|
135
|
+
pipeCalls = null
|
|
136
|
+
if (process.platform !== 'linux') return null
|
|
137
|
+
try {
|
|
138
|
+
// Loaded at the first spawn, not with the module: a static import cost
|
|
139
|
+
// every vx start 0.3 ms, a warm run that spawns nothing included.
|
|
140
|
+
const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
|
|
141
|
+
// glibc's soname; musl's loader answers any `libc.` name with itself
|
|
142
|
+
// (ldso/dynlink.c's reserved names).
|
|
143
|
+
const { symbols } = dlopen('libc.so.6', {
|
|
144
|
+
pipe2: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
145
|
+
fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
146
|
+
})
|
|
147
|
+
pipeCalls = { pipe2: symbols.pipe2, fcntl: symbols.fcntl, ptr }
|
|
148
|
+
} catch {
|
|
149
|
+
// Bun's socketpair, as before: only `/dev/stdout` by path fails on it.
|
|
150
|
+
}
|
|
151
|
+
return pipeCalls
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Two pipes, `[out read, out write, err read, err write]`, or undefined
|
|
156
|
+
* when libc is not bound or a descriptor is not to be had (Bun's own
|
|
157
|
+
* socketpair then fails the spawn with the reason).
|
|
158
|
+
*/
|
|
159
|
+
function realPipes(): Int32Array | undefined {
|
|
160
|
+
const calls = linuxPipeCalls()
|
|
161
|
+
if (calls === null) return undefined
|
|
162
|
+
const fds = new Int32Array(4)
|
|
163
|
+
// Bun's spawn closes every descriptor it is not handed (probed, Bun
|
|
164
|
+
// 1.4.2); O_CLOEXEC keeps any other exec from holding a write end open.
|
|
165
|
+
if (calls.pipe2(calls.ptr(fds), O_CLOEXEC) !== 0) return undefined
|
|
166
|
+
if (calls.pipe2(calls.ptr(fds, 8), O_CLOEXEC) !== 0) {
|
|
167
|
+
closeSync(fds[0]!)
|
|
168
|
+
closeSync(fds[1]!)
|
|
169
|
+
return undefined
|
|
170
|
+
}
|
|
171
|
+
// vx's ends only: a task whose stdout is non-blocking fails its writes with EAGAIN.
|
|
172
|
+
calls.fcntl(fds[0]!, F_SETFL, O_NONBLOCK)
|
|
173
|
+
calls.fcntl(fds[2]!, F_SETFL, O_NONBLOCK)
|
|
174
|
+
calls.fcntl(fds[0]!, F_SETPIPE_SZ, PIPE_BYTES)
|
|
175
|
+
calls.fcntl(fds[2]!, F_SETPIPE_SZ, PIPE_BYTES)
|
|
176
|
+
return fds
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A task's stdout and stderr. Bun's `'pipe'` is a socketpair, and Linux
|
|
181
|
+
* opens `/dev/stdout` through `/proc/self/fd/1`, which a socket refuses
|
|
182
|
+
* (ENXIO): `echo x > /dev/stdout` and `cmd | tee /dev/stderr` failed in
|
|
183
|
+
* every task, where a terminal or Turbo hands real pipes (X-113). So on
|
|
184
|
+
* Linux vx makes the pipes (`pipe2` through `bun:ffi`) and hands the child
|
|
185
|
+
* the write ends. macOS opens `/dev/fd/N` as a dup of the descriptor,
|
|
186
|
+
* which a socket allows, so Bun's pipe stays there.
|
|
187
|
+
*/
|
|
188
|
+
export class TaskPipes {
|
|
189
|
+
/** Descriptors 1 and 2 for `Bun.spawn`'s `stdio`. */
|
|
190
|
+
readonly stdio: readonly [number | 'pipe', number | 'pipe']
|
|
191
|
+
readonly #read: readonly number[]
|
|
192
|
+
|
|
193
|
+
constructor(terminal?: boolean) {
|
|
194
|
+
const fds = terminal === true ? undefined : realPipes()
|
|
195
|
+
this.stdio = fds === undefined ? ['pipe', 'pipe'] : [fds[1]!, fds[3]!]
|
|
196
|
+
this.#read = fds === undefined ? [] : [fds[0]!, fds[2]!]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Close vx's copies of the write ends once the spawn has returned or
|
|
201
|
+
* thrown: the child holds its own, and one left here is a writer the
|
|
202
|
+
* reader's EOF would wait for.
|
|
203
|
+
*/
|
|
204
|
+
spawned(): void {
|
|
205
|
+
for (const fd of this.stdio) if (typeof fd === 'number') closeSync(fd)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The child's stdout and stderr. */
|
|
209
|
+
streams(
|
|
210
|
+
child: ReturnType<typeof Bun.spawn>,
|
|
211
|
+
): [
|
|
212
|
+
ReadableStream<Uint8Array> | number | undefined,
|
|
213
|
+
ReadableStream<Uint8Array> | number | undefined,
|
|
214
|
+
] {
|
|
215
|
+
const [out, err] = this.#read
|
|
216
|
+
return out === undefined
|
|
217
|
+
? [child.stdout, child.stderr]
|
|
218
|
+
: [Bun.file(out).stream(), Bun.file(err!).stream()]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Close the read ends, once both streams have ended or been cancelled.
|
|
223
|
+
* Bun reads a dup of each, but takes it on the stream's first pull: a
|
|
224
|
+
* number closed before then was reused by the next spawn's pipe, and
|
|
225
|
+
* the stream read THAT (probed, Bun 1.4.2).
|
|
226
|
+
*/
|
|
227
|
+
close(): void {
|
|
228
|
+
for (const fd of this.#read) closeSync(fd)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Read the terminal's modes before a task that holds it runs, and return
|
|
234
|
+
* what puts them back once it exits: a task killed in raw mode (or one
|
|
235
|
+
* that never undid its `stty raw -echo`) left the user's terminal with no
|
|
236
|
+
* echo and no Ctrl-C. Bun restores them at exit only when vx's stdout is
|
|
237
|
+
* the terminal, so `vx run … > log` kept them, and the rest of a run
|
|
238
|
+
* printed in them. `detached`, as the task is: a vx in a background job
|
|
239
|
+
* would be stopped by SIGTTOU on the set.
|
|
240
|
+
*/
|
|
241
|
+
function keepTerminalModes(): (() => void) | undefined {
|
|
242
|
+
const saved = stty('-g')
|
|
243
|
+
if (saved === undefined) return undefined
|
|
244
|
+
return () => {
|
|
245
|
+
if (stty('-g') !== saved) stty(saved)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function stty(arg: string): string | undefined {
|
|
250
|
+
try {
|
|
251
|
+
const out = Bun.spawnSync(['stty', arg], {
|
|
252
|
+
stdio: ['inherit', 'pipe', 'ignore'],
|
|
253
|
+
detached: true,
|
|
254
|
+
})
|
|
255
|
+
return out.success ? out.stdout.toString().trim() : undefined
|
|
256
|
+
} catch {
|
|
257
|
+
// No stty: the modes stay the task's, as before.
|
|
258
|
+
return undefined
|
|
259
|
+
}
|
|
106
260
|
}
|
|
107
261
|
|
|
108
262
|
export function shellQuote(arg: string): string {
|
|
@@ -485,12 +639,14 @@ const READY_MATCH_WINDOW_CHARS = 64 * 1024
|
|
|
485
639
|
|
|
486
640
|
/**
|
|
487
641
|
* Terminal escapes a `readyWhen` pattern is matched without: CSI (colour,
|
|
488
|
-
* cursor), OSC (titles, links)
|
|
642
|
+
* cursor), OSC (titles, links), charset picks (`tput sgr0` writes
|
|
643
|
+
* `\x1b(B\x1b[m`) and the two-byte forms. Vite under
|
|
489
644
|
* `FORCE_COLOR` prints `\x1b[1mLocal\x1b[22m:`, which `Local:` never matched
|
|
490
645
|
* (item 1059). The streamed bytes keep them; only the tested text drops them.
|
|
491
646
|
*/
|
|
492
|
-
|
|
493
|
-
|
|
647
|
+
const TERMINAL_ESCAPE_RE =
|
|
648
|
+
// eslint-disable-next-line no-control-regex -- ESC is the point
|
|
649
|
+
/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[ -/]+[0-~]|[@-Z\\-_])/g
|
|
494
650
|
|
|
495
651
|
/**
|
|
496
652
|
* Why a persistent task never became ready — the reason every label and
|
|
@@ -566,6 +722,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
566
722
|
const readyRe = opts.readyWhen !== undefined ? new RegExp(opts.readyWhen) : undefined
|
|
567
723
|
|
|
568
724
|
let child: ReturnType<typeof Bun.spawn>
|
|
725
|
+
const pipes = new TaskPipes(opts.terminal)
|
|
726
|
+
const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
|
|
569
727
|
try {
|
|
570
728
|
const signalFd = opts.signalChannel === true
|
|
571
729
|
child = spawnGuarded((guard) =>
|
|
@@ -586,7 +744,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
586
744
|
// The one-shot spawn below keeps 'ignore', so a task that reads
|
|
587
745
|
// stdin can never hang CI.
|
|
588
746
|
stdio: [
|
|
589
|
-
...taskStdio(opts.terminal, 'pipe'),
|
|
747
|
+
...taskStdio(opts.terminal, 'pipe', pipes),
|
|
590
748
|
...(signalFd ? ['pipe' as const] : []),
|
|
591
749
|
...(guard === undefined ? [] : [guard]),
|
|
592
750
|
],
|
|
@@ -597,12 +755,14 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
597
755
|
},
|
|
598
756
|
),
|
|
599
757
|
)
|
|
758
|
+
if (restoreModes !== undefined) void child.exited.then(restoreModes)
|
|
600
759
|
if (signalFd) {
|
|
601
760
|
signalThrough(child, child.stdio[3] as number)
|
|
602
761
|
const spawned = child
|
|
603
762
|
void spawned.exited.then(() => closeSignalChannel(spawned))
|
|
604
763
|
}
|
|
605
764
|
} catch (err) {
|
|
765
|
+
pipes.close()
|
|
606
766
|
const message = err instanceof Error ? err.message : String(err)
|
|
607
767
|
return {
|
|
608
768
|
child: undefined as unknown as ReturnType<typeof Bun.spawn>,
|
|
@@ -611,6 +771,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
611
771
|
),
|
|
612
772
|
readyMs: () => Date.now() - start,
|
|
613
773
|
}
|
|
774
|
+
} finally {
|
|
775
|
+
pipes.spawned()
|
|
614
776
|
}
|
|
615
777
|
|
|
616
778
|
let resolveReady!: () => void
|
|
@@ -621,7 +783,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
621
783
|
})
|
|
622
784
|
|
|
623
785
|
const markReady = (): void => {
|
|
624
|
-
|
|
786
|
+
// A marker printed on the way down, once the wait gave up, is not ready.
|
|
787
|
+
if (readyAt === undefined && gaveUpAt === undefined) {
|
|
625
788
|
readyAt = Date.now()
|
|
626
789
|
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
627
790
|
resolveReady()
|
|
@@ -703,46 +866,50 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
703
866
|
|
|
704
867
|
// Wire up readers. We deliberately don't await them — they run for
|
|
705
868
|
// the child's lifetime. The `ready` promise resolves out-of-band.
|
|
706
|
-
|
|
707
|
-
void consumeChunks(
|
|
869
|
+
const [stdout, stderr] = pipes.streams(child)
|
|
870
|
+
void Promise.all([consumeChunks(stdout, false), consumeChunks(stderr, true)]).finally(() =>
|
|
871
|
+
pipes.close(),
|
|
872
|
+
)
|
|
708
873
|
|
|
709
874
|
opts.liveChildren?.add(child)
|
|
710
875
|
opts.onSpawn?.(child.pid)
|
|
711
876
|
|
|
712
|
-
// Readiness deadline
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
877
|
+
// Readiness deadline: SIGTERM, wait the grace for the GROUP, SIGKILL
|
|
878
|
+
// what is left, and only then reject. The caller's teardown used to
|
|
879
|
+
// signal the group again a turn after this SIGTERM, and a server whose
|
|
880
|
+
// handler is a one-shot (`process.once('SIGTERM')`, a trap that resets
|
|
881
|
+
// itself) died on the second mid-cleanup. Cleared the moment ready
|
|
882
|
+
// fires so a healthy server is never killed by a stale timer.
|
|
716
883
|
let readyTimer: ReturnType<typeof setTimeout> | undefined
|
|
717
884
|
if (readyRe && opts.timeoutMs !== undefined) {
|
|
718
|
-
const giveUp = (): void => {
|
|
719
|
-
if (readyAt
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
)
|
|
728
|
-
// Listed on the group guard until the SIGKILL: the shell may die
|
|
729
|
-
// on the SIGTERM and let the group go while the server runs out
|
|
730
|
-
// the grace, and a vx that exits inside it (the timer is unref'd)
|
|
731
|
-
// leaves the server to the guard (kill-tree.ts, item 865).
|
|
732
|
-
const letGo = holdGroups([child])
|
|
885
|
+
const giveUp = async (): Promise<void> => {
|
|
886
|
+
if (readyAt !== undefined) return
|
|
887
|
+
gaveUpAt = Date.now()
|
|
888
|
+
// Listed on the group guard until the SIGKILL: the shell may die on
|
|
889
|
+
// the SIGTERM and let the group go while the server runs out the
|
|
890
|
+
// grace, and a vx killed inside it leaves the server to the guard
|
|
891
|
+
// (kill-tree.ts, item 865).
|
|
892
|
+
const letGo = holdGroups([child])
|
|
893
|
+
try {
|
|
733
894
|
killTree(child, 'SIGTERM')
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
killTimer.unref?.()
|
|
895
|
+
// A server that traps TERM and never became ready is not in the
|
|
896
|
+
// persistent registry, so nothing else would ever kill it.
|
|
897
|
+
const left = await untilGroupsGone([child], killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
|
|
898
|
+
for (const c of left) killTree(c, 'SIGKILL')
|
|
899
|
+
await child.exited
|
|
900
|
+
} finally {
|
|
901
|
+
letGo()
|
|
742
902
|
}
|
|
903
|
+
rejectReady(
|
|
904
|
+
new PersistentReadyError(
|
|
905
|
+
`persistent task not ready within ${opts.timeoutMs}ms — ` +
|
|
906
|
+
`readyWhen pattern never matched; child killed`,
|
|
907
|
+
'timeout',
|
|
908
|
+
),
|
|
909
|
+
)
|
|
743
910
|
}
|
|
744
911
|
readyTimer = setTimeout(() => {
|
|
745
|
-
readyTimer = afterPendingExits(giveUp)
|
|
912
|
+
readyTimer = afterPendingExits(() => void giveUp())
|
|
746
913
|
}, opts.timeoutMs)
|
|
747
914
|
}
|
|
748
915
|
|
|
@@ -750,10 +917,9 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
750
917
|
// — reject the ready promise so the caller can surface it.
|
|
751
918
|
void child.exited.then((code) => {
|
|
752
919
|
opts.liveChildren?.delete(child)
|
|
753
|
-
|
|
754
|
-
releaseGroup(child)
|
|
920
|
+
releaseServerGroup(child)
|
|
755
921
|
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
756
|
-
if (readyAt === undefined) {
|
|
922
|
+
if (readyAt === undefined && gaveUpAt === undefined) {
|
|
757
923
|
rejectReady(
|
|
758
924
|
new PersistentReadyError(
|
|
759
925
|
`persistent task exited before becoming ready (exit ${code ?? '?'})` +
|
|
@@ -798,13 +964,18 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
798
964
|
const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
|
|
799
965
|
|
|
800
966
|
let proc: ReturnType<typeof Bun.spawn>
|
|
967
|
+
const pipes = new TaskPipes(opts.terminal)
|
|
968
|
+
const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
|
|
801
969
|
try {
|
|
802
970
|
proc = spawnGuarded((guard) =>
|
|
803
971
|
Bun.spawn(shellArgv((guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand)), {
|
|
804
972
|
...SH_ARGV0,
|
|
805
973
|
cwd: opts.cwd,
|
|
806
974
|
env: opts.env as Record<string, string>,
|
|
807
|
-
stdio: [
|
|
975
|
+
stdio: [
|
|
976
|
+
...taskStdio(opts.terminal, 'ignore', pipes),
|
|
977
|
+
...(guard === undefined ? [] : [guard]),
|
|
978
|
+
],
|
|
808
979
|
// Its own session and process group, so a kill reaches what it
|
|
809
980
|
// forked (kill-tree.ts). A terminal handed over is not this
|
|
810
981
|
// session's controlling one, so no read stops the group.
|
|
@@ -812,19 +983,23 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
812
983
|
}),
|
|
813
984
|
)
|
|
814
985
|
} catch (err) {
|
|
986
|
+
pipes.close()
|
|
815
987
|
const stderr = spawnFailureText(err, opts.cwd)
|
|
816
988
|
opts.onStderr?.(stderr)
|
|
817
989
|
return { exitCode: 127, durationMs: Date.now() - start, stdout: '', stderr, spawnFailed: true }
|
|
990
|
+
} finally {
|
|
991
|
+
pipes.spawned()
|
|
818
992
|
}
|
|
819
993
|
|
|
820
994
|
opts.liveChildren?.add(proc)
|
|
821
995
|
opts.onSpawn?.(proc.pid)
|
|
822
996
|
const timeout = armTimeout(proc, opts.timeoutMs)
|
|
823
997
|
const ac = new AbortController()
|
|
998
|
+
const [out, err] = pipes.streams(proc)
|
|
824
999
|
const streams = Promise.all([
|
|
825
|
-
streamToString(
|
|
826
|
-
streamToString(
|
|
827
|
-
])
|
|
1000
|
+
streamToString(out, opts.onStdout, ac.signal, opts.capture?.stdout ?? true),
|
|
1001
|
+
streamToString(err, opts.onStderr, ac.signal, opts.capture?.stderr ?? true),
|
|
1002
|
+
]).finally(() => pipes.close())
|
|
828
1003
|
// Gate on the child's own exit, not on stream EOF: an orphaned grandchild
|
|
829
1004
|
// can keep the pipe open past the child's exit (a timeout SIGTERM leaves the
|
|
830
1005
|
// grandchild alive; a NORMAL exit of `server & echo up` does too), so EOF may
|
|
@@ -832,6 +1007,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
832
1007
|
// lets a clean exit EOF immediately and only cuts off a stuck reader after a
|
|
833
1008
|
// brief grace — without this the run hangs forever.
|
|
834
1009
|
await proc.exited
|
|
1010
|
+
restoreModes?.()
|
|
835
1011
|
await timeout.settle()
|
|
836
1012
|
let cut = false
|
|
837
1013
|
if (timeout.timedOut()) ac.abort()
|
|
@@ -27,6 +27,17 @@ export function isMountableLiteral(grant: string): boolean {
|
|
|
27
27
|
return !MOUNT_WILDCARDS.test(grant)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The one path a grant names, or undefined for a pattern. A grant whose
|
|
32
|
+
* only brackets are escaped (`out/\[id\]`) names `out/[id]` as surely as a
|
|
33
|
+
* literal does, though it is still scanned and matched as a glob.
|
|
34
|
+
*/
|
|
35
|
+
export function namedPath(grant: string): string | undefined {
|
|
36
|
+
if (isMountableLiteral(grant)) return grant
|
|
37
|
+
const escaped = /\\([[\]])/g
|
|
38
|
+
return isMountableLiteral(grant.replace(escaped, '')) ? grant.replace(escaped, '$1') : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
/**
|
|
31
42
|
* Canonicalize a path with realpath, tolerating paths that don't exist
|
|
32
43
|
* yet: resolve the longest existing ancestor and re-append the rest.
|