@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.
Files changed (79) hide show
  1. package/launcher.cjs +7 -3
  2. package/package.json +5 -5
  3. package/src/bin.ts +9 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +60 -31
  6. package/src/cache/git-inputs.ts +23 -0
  7. package/src/cache/index.ts +2 -0
  8. package/src/cache/inputs.ts +87 -32
  9. package/src/cache/key-fold.ts +1 -1
  10. package/src/cache/layer.ts +2 -1
  11. package/src/cache/layered-cache.ts +14 -9
  12. package/src/cache/output-index.ts +12 -7
  13. package/src/cache/run-history.ts +16 -8
  14. package/src/cli/cache.ts +2 -2
  15. package/src/cli/foreign-flags.ts +56 -2
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +6 -3
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +26 -20
  21. package/src/cli/run.ts +42 -15
  22. package/src/cli/select.ts +19 -5
  23. package/src/cli/show.ts +21 -27
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-cycle.ts +43 -0
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch.ts +50 -11
  28. package/src/cli/workspace-config.ts +3 -1
  29. package/src/config.ts +21 -0
  30. package/src/exec/executor.ts +1 -1
  31. package/src/exec/kill-tree.ts +121 -20
  32. package/src/exec/runner.ts +224 -48
  33. package/src/exec/sandbox-paths.ts +11 -0
  34. package/src/exec/sandbox-runtime.ts +187 -54
  35. package/src/exec/sandbox-violations.ts +59 -6
  36. package/src/graph/priorities.ts +1 -2
  37. package/src/graph/scheduler.ts +2 -2
  38. package/src/graph/task-graph.ts +145 -33
  39. package/src/index.ts +3 -1
  40. package/src/orchestrator/affected-tasks.ts +6 -2
  41. package/src/orchestrator/deferred-outputs.ts +3 -2
  42. package/src/orchestrator/doctor.ts +5 -3
  43. package/src/orchestrator/excluded-keys.ts +2 -13
  44. package/src/orchestrator/execute-task.ts +70 -28
  45. package/src/orchestrator/failure-mode.ts +38 -29
  46. package/src/orchestrator/framed-output.ts +5 -5
  47. package/src/orchestrator/hit-restore.ts +1 -1
  48. package/src/orchestrator/index.ts +0 -1
  49. package/src/orchestrator/keyed-projects.ts +6 -3
  50. package/src/orchestrator/lockfile-claim.ts +14 -7
  51. package/src/orchestrator/logger.ts +17 -7
  52. package/src/orchestrator/miss-save.ts +7 -11
  53. package/src/orchestrator/plan.ts +4 -20
  54. package/src/orchestrator/plugin-host.ts +7 -2
  55. package/src/orchestrator/plugin.ts +5 -2
  56. package/src/orchestrator/prepare.ts +4 -2
  57. package/src/orchestrator/projects.ts +1 -1
  58. package/src/orchestrator/run-artifacts.ts +2 -2
  59. package/src/orchestrator/run-context.ts +1 -1
  60. package/src/orchestrator/run-lock.ts +35 -25
  61. package/src/orchestrator/run.ts +2 -1
  62. package/src/orchestrator/sandbox-request.ts +31 -9
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +5 -9
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/upstream.ts +11 -0
  68. package/src/util/hangup.ts +42 -0
  69. package/src/util/index.ts +2 -0
  70. package/src/util/paths.ts +1 -1
  71. package/src/workspace/affected.ts +79 -42
  72. package/src/workspace/config-cache.ts +15 -1
  73. package/src/workspace/config-eval.ts +47 -7
  74. package/src/workspace/config-imports.ts +8 -55
  75. package/src/workspace/config-schema.ts +30 -6
  76. package/src/workspace/lockfile.ts +2 -1
  77. package/src/workspace/migrate-scripts.ts +1 -1
  78. package/src/workspace/project-loader.ts +33 -6
  79. package/src/workspace/workspace.ts +36 -14
@@ -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 { existsSync, readFileSync } from 'node:fs'
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(terminal: boolean | undefined, stdin: 'ignore' | 'pipe') {
103
- return terminal === true
104
- ? (['inherit', 'inherit', 'inherit'] as const)
105
- : ([stdin, 'pipe', 'pipe'] as const)
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) and the two-byte forms. Vite under
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
- // eslint-disable-next-line no-control-regex -- ESC is the point
493
- const TERMINAL_ESCAPE_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g
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
- if (readyAt === undefined) {
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
- void consumeChunks(child.stdout, false)
707
- void consumeChunks(child.stderr, true)
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. Reject FIRST so the failure reads as a
713
- // timeout, then SIGTERM — the exit handler's later reject is a
714
- // no-op on the settled promise. Cleared the moment ready fires so
715
- // a healthy server is never killed by a stale timer.
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 === undefined) {
720
- gaveUpAt = Date.now()
721
- rejectReady(
722
- new PersistentReadyError(
723
- `persistent task not ready within ${opts.timeoutMs}ms — ` +
724
- `readyWhen pattern never matched; child killed`,
725
- 'timeout',
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
- // Same escalation as `armTimeout`: a server that traps TERM and
735
- // never became ready is not in the persistent registry, so nothing
736
- // else would ever kill it — it outlived the run under init.
737
- const killTimer = setTimeout(() => {
738
- killTree(child, 'SIGKILL')
739
- letGo()
740
- }, killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
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
- markGroupIfGone(child)
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: [...taskStdio(opts.terminal, 'ignore'), ...(guard === undefined ? [] : [guard])],
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(proc.stdout, opts.onStdout, ac.signal, opts.capture?.stdout ?? true),
826
- streamToString(proc.stderr, opts.onStderr, ac.signal, opts.capture?.stderr ?? true),
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.