@vzn/vx 0.0.58 → 0.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.58",
3
+ "version": "0.0.60",
4
4
  "description": "An open, extensible monorepo task runner.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.58",
21
- "@vzn/vx-linux-arm64": "0.0.58",
22
- "@vzn/vx-darwin-x64": "0.0.58",
23
- "@vzn/vx-darwin-arm64": "0.0.58"
20
+ "@vzn/vx-linux-x64": "0.0.60",
21
+ "@vzn/vx-linux-arm64": "0.0.60",
22
+ "@vzn/vx-darwin-x64": "0.0.60",
23
+ "@vzn/vx-darwin-arm64": "0.0.60"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -103,18 +103,31 @@ function guardWrite(line: string): void {
103
103
  }
104
104
 
105
105
  /**
106
- * Spawn a task child and list its group for the guard to kill if vx dies
107
- * holding it. The guard starts BEFORE the spawn: started after it, the
108
- * guard's own spawn was a window in which the task ran and a `kill -9`
109
- * of vx found its group unlisted — under a traced sandbox, a third of
110
- * the kills landed there. What is left is the step from the spawn's
111
- * return to one pipe write.
106
+ * Spawn a task child whose group the guard kills if vx dies holding it.
107
+ * The CHILD lists its own group, before it runs anything: vx wrote the
108
+ * line after the spawn returned, and under load the child ran first, so a
109
+ * `kill -9` of vx in between left its group unlisted and alive (4 of 40
110
+ * runs at tenfold load; B-9). `spawn` gets the guard's pipe, to hand the
111
+ * child at some descriptor `n`, and runs `guardLine(n)` in the child's
112
+ * shell first. The child holds the pipe until it has written, so the
113
+ * guard cannot reach EOF before the line: vx's death alone no longer
114
+ * races it. The guard starts BEFORE the spawn, so there is a pipe to hand.
115
+ * `undefined` when the guard could not start: nothing lists anything.
112
116
  */
113
- export function spawnGuarded(spawn: () => Child): Child {
117
+ export function spawnGuarded(spawn: (guard: number | undefined) => Child): Child {
114
118
  startGuard()
115
- const child = spawn()
116
- if (child.pid > 0) guardWrite(`+${child.pid}\n`)
117
- return child
119
+ return spawn(typeof guardFd === 'number' ? guardFd : undefined)
120
+ }
121
+
122
+ /**
123
+ * The shell line that lists `$$`'s group on the guard's pipe at `fd` and
124
+ * closes it, so the task never holds the guard open. `$$` is the spawned
125
+ * shell, which leads its group (every guarded spawn is `detached`); a
126
+ * program that is not a shell is `exec`'d after it. A write to a guard
127
+ * that has died is ignored rather than a SIGPIPE that kills the task.
128
+ */
129
+ export function guardLine(fd: number): string {
130
+ return `trap '' PIPE; printf '+%s\\n' $$ >&${fd} 2>/dev/null; trap - PIPE; exec ${fd}>&-; `
118
131
  }
119
132
 
120
133
  /**
@@ -14,6 +14,7 @@ import {
14
14
  markGroupIfGone,
15
15
  releaseGroup,
16
16
  signalThrough,
17
+ guardLine,
17
18
  spawnGuarded,
18
19
  untilGroupsGone,
19
20
  } from './kill-tree.js'
@@ -435,27 +436,40 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
435
436
 
436
437
  let child: ReturnType<typeof Bun.spawn>
437
438
  try {
438
- child = spawnGuarded(() =>
439
- Bun.spawn([executablePath('sh'), '-c', execWrap(opts.command)], {
440
- argv0: 'sh',
441
- cwd: opts.cwd,
442
- env: opts.env as Record<string, string>,
443
- // A pipe vx holds and never writes: stdin stays open while vx
444
- // lives and ends when it does. A dev server that exits on stdin
445
- // EOF (esbuild --watch, Vite's case in turborepo#8915) became ready
446
- // and exited 0 under 'ignore'. Not the terminal: several servers
447
- // would steal each other's keystrokes, and a CI's /dev/null stdin
448
- // is the same EOF. The one-shot spawn below keeps 'ignore', so a
449
- // task that reads stdin can never hang CI.
450
- stdio:
451
- opts.signalChannel === true ? ['pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
452
- // Its own session and process group, so a kill reaches what it
453
- // forked (kill-tree.ts). stdin is a pipe, so a background group
454
- // never stops on a terminal read.
455
- detached: true,
456
- }),
439
+ const signalFd = opts.signalChannel === true
440
+ child = spawnGuarded((guard) =>
441
+ Bun.spawn(
442
+ [
443
+ executablePath('sh'),
444
+ '-c',
445
+ (guard === undefined ? '' : guardLine(signalFd ? 4 : 3)) + execWrap(opts.command),
446
+ ],
447
+ {
448
+ argv0: 'sh',
449
+ cwd: opts.cwd,
450
+ env: opts.env as Record<string, string>,
451
+ // A pipe vx holds and never writes: stdin stays open while vx
452
+ // lives and ends when it does. A dev server that exits on stdin
453
+ // EOF (esbuild --watch, Vite's case in turborepo#8915) became ready
454
+ // and exited 0 under 'ignore'. Not the terminal: several servers
455
+ // would steal each other's keystrokes, and a CI's /dev/null stdin
456
+ // is the same EOF. The one-shot spawn below keeps 'ignore', so a
457
+ // task that reads stdin can never hang CI.
458
+ stdio: [
459
+ 'pipe',
460
+ 'pipe',
461
+ 'pipe',
462
+ ...(signalFd ? ['pipe' as const] : []),
463
+ ...(guard === undefined ? [] : [guard]),
464
+ ],
465
+ // Its own session and process group, so a kill reaches what it
466
+ // forked (kill-tree.ts). stdin is a pipe, so a background group
467
+ // never stops on a terminal read.
468
+ detached: true,
469
+ },
470
+ ),
457
471
  )
458
- if (opts.signalChannel === true) {
472
+ if (signalFd) {
459
473
  signalThrough(child, child.stdio[3] as number)
460
474
  const spawned = child
461
475
  void spawned.exited.then(() => closeSignalChannel(spawned))
@@ -651,19 +665,24 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
651
665
 
652
666
  let proc: ReturnType<typeof Bun.spawn>
653
667
  try {
654
- proc = spawnGuarded(() =>
655
- Bun.spawn([executablePath('sh'), '-c', execWrap(fullCommand)], {
656
- argv0: 'sh',
657
- cwd: opts.cwd,
658
- env: opts.env as Record<string, string>,
659
- stdin: 'ignore',
660
- stdout: 'pipe',
661
- stderr: 'pipe',
662
- // Its own session and process group, so a kill reaches what it
663
- // forked (kill-tree.ts). stdin is ignored, so a background group
664
- // never stops on a terminal read.
665
- detached: true,
666
- }),
668
+ proc = spawnGuarded((guard) =>
669
+ Bun.spawn(
670
+ [
671
+ executablePath('sh'),
672
+ '-c',
673
+ (guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand),
674
+ ],
675
+ {
676
+ argv0: 'sh',
677
+ cwd: opts.cwd,
678
+ env: opts.env as Record<string, string>,
679
+ stdio: ['ignore', 'pipe', 'pipe', ...(guard === undefined ? [] : [guard])],
680
+ // Its own session and process group, so a kill reaches what it
681
+ // forked (kill-tree.ts). stdin is ignored, so a background group
682
+ // never stops on a terminal read.
683
+ detached: true,
684
+ },
685
+ ),
667
686
  )
668
687
  } catch (err) {
669
688
  const stderr = spawnFailureText(err, opts.cwd)
@@ -64,6 +64,7 @@ import {
64
64
  killTree,
65
65
  releaseGroup,
66
66
  signalThrough,
67
+ guardLine,
67
68
  spawnGuarded,
68
69
  } from './kill-tree.js'
69
70
 
@@ -902,13 +903,20 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
902
903
  // listening on the port under init, where the next run's bridge
903
904
  // could not bind it (item 873).
904
905
  procs.push(
905
- spawnGuarded(() =>
906
- Bun.spawn(portBridgeHostArgv(tag, p), {
907
- stdin: 'ignore',
908
- stdout: 'ignore',
909
- stderr: 'ignore',
910
- detached: true,
911
- }),
906
+ spawnGuarded((guard) =>
907
+ guard === undefined
908
+ ? Bun.spawn(portBridgeHostArgv(tag, p), {
909
+ stdio: ['ignore', 'ignore', 'ignore'],
910
+ detached: true,
911
+ })
912
+ : Bun.spawn(
913
+ [
914
+ executablePath('sh'),
915
+ '-c',
916
+ `${guardLine(3)}exec ${portBridgeHostArgv(tag, p).map(shellQuote).join(' ')}`,
917
+ ],
918
+ { stdio: ['ignore', 'ignore', 'ignore', guard], detached: true },
919
+ ),
912
920
  ),
913
921
  )
914
922
  } catch {
@@ -1086,16 +1094,35 @@ async function runSandboxedOnce(
1086
1094
  wrapped,
1087
1095
  ]
1088
1096
  : [sh, '-c', wrapped]
1089
- proc = spawnGuarded(() =>
1090
- Bun.spawn(spawnArgv, {
1091
- argv0: straceLog ? 'strace' : 'sh',
1092
- cwd: args.cwd,
1093
- env: args.env as Record<string, string>,
1094
- // fd 3 is the signal channel the in-sandbox watcher reads.
1095
- stdio: forwardsSignals ? ['ignore', 'pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
1096
- // As the unsandboxed spawn: its own process group (kill-tree.ts).
1097
- detached: true,
1098
- }),
1097
+ // With the guard, a shell lists the group first and `exec`s the
1098
+ // spawn: strace itself would hold the guard's pipe for its lifetime.
1099
+ const guardAt = forwardsSignals ? 4 : 3
1100
+ proc = spawnGuarded((guard) =>
1101
+ Bun.spawn(
1102
+ guard === undefined
1103
+ ? spawnArgv
1104
+ : [
1105
+ sh,
1106
+ '-c',
1107
+ guardLine(guardAt) +
1108
+ (straceLog ? `exec ${spawnArgv.map(shellQuote).join(' ')}` : wrapped),
1109
+ ],
1110
+ {
1111
+ argv0: straceLog && guard === undefined ? 'strace' : 'sh',
1112
+ cwd: args.cwd,
1113
+ env: args.env as Record<string, string>,
1114
+ // fd 3 is the signal channel the in-sandbox watcher reads.
1115
+ stdio: [
1116
+ 'ignore',
1117
+ 'pipe',
1118
+ 'pipe',
1119
+ ...(forwardsSignals ? ['pipe' as const] : []),
1120
+ ...(guard === undefined ? [] : [guard]),
1121
+ ],
1122
+ // As the unsandboxed spawn: its own process group (kill-tree.ts).
1123
+ detached: true,
1124
+ },
1125
+ ),
1099
1126
  )
1100
1127
  if (forwardsSignals) signalThrough(proc, proc.stdio[3] as number)
1101
1128
  } catch (err) {
@@ -91,6 +91,9 @@ export function createEventBus(): EventBus {
91
91
  * renderer subscribes via `terminalSubscriber`.
92
92
  */
93
93
  export function busLogger(bus: EventBus): Logger {
94
+ // run() reaches runEnd on its success path, in its finally and from a
95
+ // signal; a plugin's onRunEnd hears the first only.
96
+ let ended = false
94
97
  return {
95
98
  status: (line) => bus.emit({ kind: 'run:status', line }),
96
99
  taskStdout: (node, chunk) => bus.emit({ kind: 'task:stdout', node, chunk }),
@@ -98,7 +101,11 @@ export function busLogger(bus: EventBus): Logger {
98
101
  taskComplete: (node, outcome) => bus.emit({ kind: 'task:complete', node, outcome }),
99
102
  runStart: (info) => bus.emit({ kind: 'run:start', info }),
100
103
  taskStart: (node) => bus.emit({ kind: 'task:start', node }),
101
- runEnd: () => bus.emit({ kind: 'run:end' }),
104
+ runEnd: () => {
105
+ if (ended) return
106
+ ended = true
107
+ bus.emit({ kind: 'run:end' })
108
+ },
102
109
  }
103
110
  }
104
111
 
@@ -142,19 +149,12 @@ export function terminalSubscriber(sink: Logger): RunEventSubscriber {
142
149
  * it to `send` — the shared run→consumer forwarding path for whatever an
143
150
  * embedder builds on it (an NDJSON stream, an enveloped protocol; core
144
151
  * ships neither). Group-task start/complete events are dropped (no command,
145
- * pure scheduling noise). `run()` emits run:end twice (normal + finally)
146
- * plus trailing summary status lines; we forward the run once and then go
147
- * quiet, so a consumer sees exactly one terminal frame per run. `send`
152
+ * pure scheduling noise). Summary status lines that follow run:end are
153
+ * forwarded too. `send`
148
154
  * must be fire-and-forget and tolerant of its own failures (a dead
149
155
  * consumer can never break the run); the bus already isolates throws.
150
156
  */
151
157
  export function wireForwarder(send: (event: WireEvent) => void): RunEventSubscriber {
152
- // run() emits run:end TWICE (normal + finally), with the summary footer
153
- // (run:status lines) emitted in BETWEEN. Drop the duplicate run:end, but
154
- // keep forwarding everything else — including those post-run:end status
155
- // lines, so a consumer that renders them gets the footer, and one that
156
- // ignores run:status is unaffected.
157
- let endForwarded = false
158
158
  // Which tasks the consumer has been told about. A skipped task never
159
159
  // reaches the scheduler's onStart, so its completion arrives with no
160
160
  // preceding task:start — and `createWireRenderer` resolves a completion's
@@ -164,10 +164,7 @@ export function wireForwarder(send: (event: WireEvent) => void): RunEventSubscri
164
164
  // fidelity (real requested/surfaced/command) instead of a stand-in.
165
165
  const started = new Set<string>()
166
166
  return (event) => {
167
- if (event.kind === 'run:end') {
168
- if (endForwarded) return
169
- endForwarded = true
170
- } else if (
167
+ if (
171
168
  (event.kind === 'task:start' || event.kind === 'task:complete') &&
172
169
  isGroupTask(event.node)
173
170
  ) {
@@ -413,7 +413,6 @@ export function createTelemetrySource(args: {
413
413
  }
414
414
  }
415
415
 
416
- let endEmitted = false
417
416
  const subscriber: RunEventSubscriber = (event: RunEvent) => {
418
417
  const ts = Date.now()
419
418
  switch (event.kind) {
@@ -472,9 +471,6 @@ export function createTelemetrySource(args: {
472
471
  case 'run:status':
473
472
  return // status lines are terminal-rendering noise, not telemetry
474
473
  case 'run:end':
475
- // run() emits run:end twice (normal + finally); emit one record.
476
- if (endEmitted) return
477
- endEmitted = true
478
474
  deliver({ v: TELEMETRY_SCHEMA_VERSION, kind: 'run.end', runId, ts })
479
475
  return
480
476
  }