@vzn/vx 0.0.595 → 0.0.597

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 (82) hide show
  1. package/README.md +1 -1
  2. package/launcher.cjs +7 -3
  3. package/package.json +5 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +170 -99
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/git-inputs.ts +23 -0
  8. package/src/cache/index.ts +3 -0
  9. package/src/cache/inputs.ts +99 -44
  10. package/src/cache/key-fold.ts +1 -1
  11. package/src/cache/layer.ts +9 -6
  12. package/src/cache/policy.ts +5 -0
  13. package/src/cache/zstd.ts +10 -4
  14. package/src/cli/cache.ts +12 -6
  15. package/src/cli/foreign-flags.ts +57 -3
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +10 -4
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +4 -4
  21. package/src/cli/run.ts +73 -30
  22. package/src/cli/select.ts +6 -3
  23. package/src/cli/show.ts +8 -2
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-filter.ts +63 -12
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch-judge.ts +15 -8
  28. package/src/cli/watch.ts +18 -11
  29. package/src/cli/workspace-config.ts +3 -1
  30. package/src/config.ts +42 -21
  31. package/src/exec/executor.ts +1 -1
  32. package/src/exec/kill-tree.ts +89 -18
  33. package/src/exec/proc-sample.ts +1 -1
  34. package/src/exec/runner.ts +74 -36
  35. package/src/exec/sandbox-paths.ts +12 -1
  36. package/src/exec/sandbox-runtime.ts +283 -88
  37. package/src/exec/sandbox-violations.ts +115 -15
  38. package/src/graph/scheduler.ts +30 -14
  39. package/src/graph/task-graph.ts +265 -44
  40. package/src/orchestrator/admission.ts +12 -3
  41. package/src/orchestrator/affected-tasks.ts +6 -2
  42. package/src/orchestrator/deferred-outputs.ts +3 -2
  43. package/src/orchestrator/doctor.ts +2 -2
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/excluded-keys.ts +2 -13
  46. package/src/orchestrator/execute-task.ts +85 -39
  47. package/src/orchestrator/failure-mode.ts +43 -28
  48. package/src/orchestrator/framed-output.ts +8 -8
  49. package/src/orchestrator/index.ts +0 -1
  50. package/src/orchestrator/keyed-projects.ts +6 -3
  51. package/src/orchestrator/logger.ts +21 -9
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +9 -13
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +7 -23
  56. package/src/orchestrator/plugin.ts +37 -16
  57. package/src/orchestrator/prepare.ts +13 -4
  58. package/src/orchestrator/run-artifacts.ts +4 -3
  59. package/src/orchestrator/run-lock.ts +25 -24
  60. package/src/orchestrator/run.ts +22 -5
  61. package/src/orchestrator/sandbox-request.ts +96 -18
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +7 -13
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/telemetry.ts +16 -7
  68. package/src/orchestrator/upstream.ts +11 -0
  69. package/src/util/hangup.ts +42 -0
  70. package/src/util/index.ts +2 -0
  71. package/src/util/paths.ts +24 -4
  72. package/src/workspace/affected.ts +79 -42
  73. package/src/workspace/config-eval.ts +27 -6
  74. package/src/workspace/config-imports.ts +8 -53
  75. package/src/workspace/config-schema.ts +56 -9
  76. package/src/workspace/filter.ts +19 -0
  77. package/src/workspace/lockfile.ts +2 -1
  78. package/src/workspace/migrate-scripts.ts +5 -3
  79. package/src/workspace/migration.ts +1 -1
  80. package/src/workspace/project-loader.ts +19 -4
  81. package/src/workspace/repo-id.ts +42 -4
  82. package/src/workspace/workspace.ts +53 -24
package/src/cli/watch.ts CHANGED
@@ -45,7 +45,7 @@ import {
45
45
  } from './watch-filter.js'
46
46
  import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
47
47
  import { ChangeJudge } from './watch-judge.js'
48
- import { restartTimings } from '../util/index.js'
48
+ import { hangupIgnored, restartTimings } from '../util/index.js'
49
49
  import { memberEntries, sameMembers, sweepConfigs, watchedProjects } from './watch-set.js'
50
50
 
51
51
  /** One line for a watcher or re-read the OS refused; the loop goes on without it. */
@@ -103,7 +103,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
103
103
  }
104
104
 
105
105
  const cwd = process.cwd()
106
- const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks)
106
+ const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks, 'watch')
107
107
  if ('error' in resolved) {
108
108
  process.stderr.write(`vx watch: ${resolved.error}\n`)
109
109
  return 1
@@ -146,8 +146,9 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
146
146
  }
147
147
  process.once('SIGTERM', () => stop.abort('SIGTERM'))
148
148
  // A task runs in its own session (exec/kill-tree.ts): the terminal
149
- // closing reaches the loop alone, and the loop passes it on.
150
- process.once('SIGHUP', () => stop.abort('SIGHUP'))
149
+ // closing reaches the loop alone, and the loop passes it on — unless vx
150
+ // was started with it ignored (nohup).
151
+ if (!hangupIgnored()) process.once('SIGHUP', () => stop.abort('SIGHUP'))
151
152
 
152
153
  // Enumerate projects-in-scope so we know what dirs to watch: the bare
153
154
  // tasks' scope (`opts.projects`; undefined means "every project", or
@@ -406,6 +407,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
406
407
  try {
407
408
  await held?.stop()
408
409
  held = undefined
410
+ // A Ctrl-C while the old server shut down ran a cycle anyway: a
411
+ // `not run` row and a footer printed above `stopped` (WD-22).
412
+ if (stop.aborted) break
409
413
  restartTimings()
410
414
  // On the mtime clock, as the arm is: from `Date.now()` a write the
411
415
  // run made within a tick of it carried an earlier mtime and read as
@@ -415,10 +419,6 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
415
419
  held = cycle.persistent
416
420
  if (cycle.refused !== undefined) process.stderr.write(`vx watch: ${cycle.refused}\n`)
417
421
  changes.lastCycle = { start, end: Date.now() }
418
- if (reread && !stop.aborted) {
419
- reread = false
420
- await rearm()
421
- }
422
422
  } catch (err) {
423
423
  // A re-run can fail catastrophically when the workspace
424
424
  // itself moved out from under us — e.g. the user deleted
@@ -430,6 +430,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
430
430
  const message = err instanceof Error ? err.message : String(err)
431
431
  process.stderr.write(`vx watch: cycle failed: ${message}\n`)
432
432
  }
433
+ // After a failed cycle too: a package added with a config that does
434
+ // not load yet fails its cycle, and unarmed, the fix to that config
435
+ // was never an event.
436
+ if (reread && !stop.aborted) {
437
+ reread = false
438
+ await rearm()
439
+ }
433
440
  // What landed mid-run is judged on settled state, one window
434
441
  // after the run, under the label of what actually arrived.
435
442
  if (changes.pending.size === 0 || stop.aborted) break
@@ -471,8 +478,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
471
478
 
472
479
  /** The instant the watchers go live, on the mtime clock (see `fsClockNow`): a path last modified before it is the initial run's, not an edit. */
473
480
  const armedAt = fsClockNow(cacheDir)
474
- /** What existed at the arm, so a file born and gone since is no deletion (watch-judge.ts). */
475
- const existedAtArm = gitFiles(workspaceRoot)
481
+ /** What existed and was tracked at the arm, so a file born and gone since is no deletion and a tracked one is the user's edit (watch-judge.ts). */
482
+ const atArm = gitFiles(workspaceRoot)
476
483
  /** Which settled paths are changes (watch-judge.ts); `pending` holds what fired since. */
477
484
  const changes = new ChangeJudge({
478
485
  workspaceRoot,
@@ -480,7 +487,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
480
487
  held: () => held !== undefined,
481
488
  uncached: () => uncached,
482
489
  fenced: (ownDir, abs) => fenced(ownDir, abs),
483
- ...(existedAtArm !== undefined ? { existedAtArm } : {}),
490
+ ...(atArm !== undefined ? { existedAtArm: atArm.listed, trackedAtArm: atArm.tracked } : {}),
484
491
  })
485
492
  /** Per-project arms by directory, so `rearm` can add and drop them. */
486
493
  const perProject = new Map<string, WatchHandle>()
@@ -71,7 +71,9 @@ export function parseCacheDirFlag(
71
71
  const v = a === '--cache-dir' ? args[i + 1] : a.slice('--cache-dir='.length)
72
72
  if (v === undefined || v === '') return { error: '--cache-dir requires a path' }
73
73
  if (a === '--cache-dir' && v.startsWith('-')) {
74
- return { error: `--cache-dir requires a path, got flag: ${v}` }
74
+ return {
75
+ error: `--cache-dir requires a path (got flag ${v}; a path that starts with - needs --cache-dir=${v})`,
76
+ }
75
77
  }
76
78
  return { cacheDir: v, next: a === '--cache-dir' ? i + 1 : i }
77
79
  }
package/src/config.ts CHANGED
@@ -292,10 +292,10 @@ export interface SandboxConfig {
292
292
 
293
293
  /** The denials `sandbox.ignore` can leave out of the report: patterns per class. */
294
294
  export interface SandboxIgnore {
295
- read?: string[]
296
- write?: string[]
297
- systemInfo?: string[]
298
- network?: string[]
295
+ read?: readonly string[]
296
+ write?: readonly string[]
297
+ systemInfo?: readonly string[]
298
+ network?: readonly string[]
299
299
  }
300
300
 
301
301
  export interface SandboxGrants {
@@ -304,29 +304,29 @@ export interface SandboxGrants {
304
304
  * `node_modules`, NOT `cache.inputs.files`. A task that reads its own
305
305
  * source in the sandbox says so here.
306
306
  */
307
- read?: string[]
307
+ read?: readonly string[]
308
308
  /**
309
309
  * Writable paths or globs. The task can write NOTHING without these:
310
310
  * declaring `cache.outputs.files` grants no write, and a task whose
311
311
  * only declaration is its outputs writes into a tmpfs that evaporates
312
312
  * (vx then warns that `cache.outputs` matched no files).
313
313
  */
314
- write?: string[]
314
+ write?: readonly string[]
315
315
  /**
316
316
  * Reachable domains, `['*.example.com']` a pattern. The proxy is the
317
317
  * run's: it filters every sandboxed task against the union of the lists
318
318
  * the run's tasks declare, so `true` adds no domain and reaches only
319
319
  * those (none in a run with no list). Omitted adds none either.
320
320
  */
321
- network?: true | string[]
321
+ network?: true | readonly string[]
322
322
  /**
323
323
  * Kernel information the task may query, by info type — the name that
324
324
  * appears in a denial, e.g. `vfs.disk-space` (every Bun and git process
325
325
  * probes free space at startup).
326
326
  */
327
- systemInfo?: string[]
327
+ systemInfo?: readonly string[]
328
328
  /** Unix socket paths the task may connect to. `true` allows any. */
329
- unixSockets?: true | string[]
329
+ unixSockets?: true | readonly string[]
330
330
  /**
331
331
  * Bind and reach localhost ports — a test that boots a server for
332
332
  * itself. A port LIST additionally makes those ports reachable from
@@ -335,9 +335,9 @@ export interface SandboxGrants {
335
335
  * namespace, each listed port is bridged out to the host's loopback;
336
336
  * on macOS the host already sees them and the list means `true`.
337
337
  */
338
- localBinding?: boolean | number[]
338
+ localBinding?: boolean | readonly number[]
339
339
  /** macOS: Mach service names the task may look up. */
340
- machLookup?: string[]
340
+ machLookup?: readonly string[]
341
341
  /** Acquire a TTY. Rare outside interactive tools. */
342
342
  pty?: boolean
343
343
  /** Write to `.git/config`. Most build tools should not. */
@@ -346,7 +346,7 @@ export interface SandboxGrants {
346
346
 
347
347
  export interface SandboxDenials {
348
348
  /** Domains blocked even if `allow.network` would admit them. */
349
- network?: string[]
349
+ network?: readonly string[]
350
350
  }
351
351
 
352
352
  export interface ExecConfig {
@@ -458,7 +458,7 @@ export interface ExecEnv {
458
458
  * folded into the cache key — for things like CI flags, regional
459
459
  * vars, secrets that change between machines without affecting output.
460
460
  */
461
- passThrough?: string[]
461
+ passThrough?: readonly string[]
462
462
  /**
463
463
  * Explicit `name: value` pairs. The values are set on the child AND
464
464
  * folded into the cache key (they are literal in your config, captured
@@ -471,7 +471,7 @@ export interface ExecEnv {
471
471
  * hold `TOKEN`, `SECRET`, `KEY`, `PASSWORD`, `PASSWD` or `CREDENTIAL`
472
472
  * are masked without it; this reaches `GH_PAT`, `NPM_AUTH`.
473
473
  */
474
- secret?: string[]
474
+ secret?: readonly string[]
475
475
  }
476
476
 
477
477
  export interface CacheConfig {
@@ -488,7 +488,7 @@ export interface CacheOutputs {
488
488
  * Outputs are not filtered through gitignore — typical artifact dirs
489
489
  * like `dist/` and `coverage/` are captured normally even when ignored.
490
490
  */
491
- files: string[]
491
+ files: readonly string[]
492
492
  /**
493
493
  * Files produced OUTSIDE the project dir, as workspace-root-relative
494
494
  * globs (the Turbo `$TURBO_ROOT$` / Nx `{workspaceRoot}` escape
@@ -500,7 +500,7 @@ export interface CacheOutputs {
500
500
  * like overlapping `files` (vx cleans declared outputs before a run
501
501
  * and before a restore, so the second would delete the first's).
502
502
  */
503
- workspaceFiles?: string[]
503
+ workspaceFiles?: readonly string[]
504
504
  }
505
505
 
506
506
  export interface CacheInputs {
@@ -514,7 +514,7 @@ export interface CacheInputs {
514
514
  * files are excluded automatically — a task cannot invalidate itself,
515
515
  * and cannot read across project boundaries.
516
516
  */
517
- files: string[]
517
+ files: readonly string[]
518
518
  /**
519
519
  * Workspace-root-relative globs (the Turbo `$TURBO_ROOT$` / Nx
520
520
  * `{workspaceRoot}` equivalent), for inputs that live outside the
@@ -528,7 +528,7 @@ export interface CacheInputs {
528
528
  * Declared outputs (both `outputs` fields) are excluded
529
529
  * automatically (a task cannot invalidate itself).
530
530
  */
531
- workspaceFiles?: string[]
531
+ workspaceFiles?: readonly string[]
532
532
  /**
533
533
  * Env var names whose runtime values from parent `process.env` are
534
534
  * folded into the cache key. **Independent of `exec.env`** — declaring
@@ -536,7 +536,7 @@ export interface CacheInputs {
536
536
  * invalidation. To both forward AND track, list the name in both
537
537
  * `exec.env.passThrough` and here.
538
538
  */
539
- env?: string[]
539
+ env?: readonly string[]
540
540
  /**
541
541
  * Which upstream tasks' cache keys participate in this task's key.
542
542
  * Uses the same Turbo/Nx micro-syntax as `dependsOn`, plus
@@ -565,7 +565,7 @@ export interface CacheInputs {
565
565
  * lock, the OUTPUT is resolved live every run — correct under
566
566
  * `--frozen`. Use for project-specific runtime probes.
567
567
  */
568
- runtime?: string[]
568
+ runtime?: readonly string[]
569
569
  /**
570
570
  * Like `runtime`, but commands run at the WORKSPACE ROOT and are
571
571
  * deduped GLOBALLY per command across the whole run — a `node -v`
@@ -573,7 +573,7 @@ export interface CacheInputs {
573
573
  * analog of `workspaceFiles`: per-task, root-anchored. Use for global
574
574
  * tool versions, OS info, etc.
575
575
  */
576
- workspaceRuntime?: string[]
576
+ workspaceRuntime?: readonly string[]
577
577
  }
578
578
 
579
579
  /**
@@ -608,6 +608,25 @@ type Known<T, Shape> = { [P in keyof T]: P extends keyof Shape ? unknown : never
608
608
  /** `T[P]` when `T` has it; `unknown` (no keys to check) when it does not. */
609
609
  type At<T, P extends PropertyKey> = T extends { readonly [Q in P]?: infer V } ? V : unknown
610
610
 
611
+ /** The objects inside each task's `exec`, held to their keys as `exec` is (D-156). */
612
+ type KnownExecs<T extends ProjectConfig> = {
613
+ tasks?: {
614
+ [K in keyof NonNullable<T['tasks']>]?: {
615
+ exec?: KnownExec<At<NonNullable<T['tasks']>[K], 'exec'>>
616
+ }
617
+ }
618
+ }
619
+
620
+ type KnownExec<E> = {
621
+ env?: Known<At<E, 'env'>, ExecEnv>
622
+ persistent?: Known<At<E, 'persistent'>, PersistentConfig>
623
+ sandbox?: Known<At<E, 'sandbox'>, SandboxConfig> & {
624
+ allow?: Known<At<At<E, 'sandbox'>, 'allow'>, SandboxGrants>
625
+ deny?: Known<At<At<E, 'sandbox'>, 'deny'>, SandboxDenials>
626
+ ignore?: Known<At<At<E, 'sandbox'>, 'ignore'>, SandboxIgnore>
627
+ }
628
+ }
629
+
611
630
  /**
612
631
  * Identity function — exists only so TypeScript narrows literal types
613
632
  * and, crucially, **validates `dependsOn` against this project's own
@@ -617,6 +636,7 @@ type At<T, P extends PropertyKey> = T extends { readonly [Q in P]?: infer V } ?
617
636
  */
618
637
  export function defineProject<const T extends ProjectConfig>(
619
638
  config: T &
639
+ KnownExecs<T> &
620
640
  Known<T, ProjectConfig> & {
621
641
  tasks?: {
622
642
  [K in keyof NonNullable<T['tasks']>]?: Known<NonNullable<T['tasks']>[K], TaskConfig> & {
@@ -640,6 +660,7 @@ export function defineWorkspace<T extends WorkspaceConfig>(
640
660
  At<T, 'cacheRetention'>,
641
661
  NonNullable<WorkspaceConfig['cacheRetention']>
642
662
  >
663
+ rules?: Known<At<T, 'rules'>, WorkspaceRules>
643
664
  },
644
665
  ): T {
645
666
  return config
@@ -145,7 +145,7 @@ export interface ExecuteRequest {
145
145
  * `RunOptions.signal`) or when `timeoutMs` elapses. An executor ends its
146
146
  * work and returns: a child an executor spawned is not one core can
147
147
  * reach (H-10, H-12). One already aborted is work not to start (B-55).
148
- * A non-zero exit after the timeout's abort is recorded `timedOut`. Core
148
+ * Any exit after the timeout's abort, a 0 included, is a timeout. Core
149
149
  * waits the kill grace (`VX_KILL_GRACE_MS`, 2 s) after the abort, then
150
150
  * settles the attempt without `execute` and abandons it (H-14).
151
151
  */
@@ -64,17 +64,37 @@ export function closeSignalChannel(child: Child): void {
64
64
  */
65
65
  let guardFd: number | null | undefined
66
66
 
67
+ // Three lists: groups (`+`/`-`), single pids (`=`/`_`) and paths to
68
+ // remove (`@`/`!`), killed and removed in that order at EOF.
67
69
  const GUARD_SCRIPT = [
68
- "g=' '",
70
+ "g=' ' k=' ' f=' '",
69
71
  'while IFS= read -r l; do',
72
+ ' p=${l#?}',
70
73
  ' case $l in',
71
- ' +*) g="$g${l#+} " ;;',
72
- ' -*) p=${l#-}; case $g in *" $p "*) g="${g%% $p *} ${g#* $p }" ;; esac ;;',
74
+ ' +*) g="$g$p " ;;',
75
+ ' -*) case $g in *" $p "*) g="${g%% $p *} ${g#* $p }" ;; esac ;;',
76
+ ' =*) k="$k$p " ;;',
77
+ ' _*) case $k in *" $p "*) k="${k%% $p *} ${k#* $p }" ;; esac ;;',
78
+ ' @*) f="$f$p " ;;',
79
+ ' !*) case $f in *" $p "*) f="${f%%" $p "*} ${f#*" $p "}" ;; esac ;;',
73
80
  ' esac',
74
81
  'done <&3',
75
82
  'for p in $g; do kill -s KILL -- "-$p"; done 2>/dev/null',
83
+ 'for p in $k; do kill -s KILL "$p"; done 2>/dev/null',
84
+ 'set -f; for p in $f; do rm -rf -- "$p"; done 2>/dev/null',
76
85
  ].join('\n')
77
86
 
87
+ /** The guard's process, so a guard vx stops writing to is stopped too. */
88
+ let guardProc: Child | undefined
89
+
90
+ /**
91
+ * How long a write waits on a guard that is behind. Bun opens the pipe
92
+ * nonblocking, and the kernel queues ~280 unread writes whatever their
93
+ * size: a burst of releases, or a guard the scheduler has not yet run,
94
+ * makes a write EAGAIN while the guard lives.
95
+ */
96
+ const GUARD_STALL_MS = 1_000
97
+
78
98
  function startGuard(): void {
79
99
  if (guardFd !== undefined) return
80
100
  guardFd = null
@@ -89,31 +109,49 @@ function startGuard(): void {
89
109
  guard.unref()
90
110
  const fd = guard.stdio[3] as number
91
111
  guardFd = fd
112
+ guardProc = guard
92
113
  // A guard that has died (killed, the OOM killer) is handed to no later
93
114
  // spawn: its pipe is broken, and a shell whose printf buffers (bash as
94
115
  // macOS's sh) flushed the failed `+<pgid>` line into the task's own
95
116
  // stdout, cached replay included (B-10).
96
117
  void guard.exited.then(() => {
97
- if (guardFd !== fd) return
98
- guardFd = null
99
- try {
100
- closeSync(fd)
101
- } catch {
102
- // already closed
103
- }
118
+ if (guardFd === fd) stopGuard()
104
119
  })
105
120
  } catch {
106
121
  // The limit as it was: nothing takes the groups down.
107
122
  }
108
123
  }
109
124
 
110
- function guardWrite(line: string): void {
111
- if (typeof guardFd !== 'number') return
125
+ /**
126
+ * Stop guarding for the rest of the process, and stop the guard with it:
127
+ * a guard that lived on would keep every group whose release it never got
128
+ * and SIGKILL them at vx's clean exit.
129
+ */
130
+ function stopGuard(): void {
131
+ const fd = guardFd
132
+ guardFd = null
133
+ guardProc?.kill('SIGKILL')
134
+ if (typeof fd !== 'number') return
112
135
  try {
113
- writeSync(guardFd, line)
136
+ closeSync(fd)
114
137
  } catch {
115
- guardFd = null
138
+ // already closed
139
+ }
140
+ }
141
+
142
+ function guardWrite(line: string): void {
143
+ if (typeof guardFd !== 'number') return
144
+ const deadline = Date.now() + GUARD_STALL_MS
145
+ for (;;) {
146
+ try {
147
+ writeSync(guardFd, line)
148
+ return
149
+ } catch (err) {
150
+ if ((err as NodeJS.ErrnoException).code !== 'EAGAIN' || Date.now() >= deadline) break
151
+ Bun.sleepSync(1)
152
+ }
116
153
  }
154
+ stopGuard()
117
155
  }
118
156
 
119
157
  /**
@@ -157,6 +195,25 @@ export function releaseGroup(child: Child): void {
157
195
  else guardWrite(`-${child.pid}\n`)
158
196
  }
159
197
 
198
+ /**
199
+ * List what the guard takes down besides groups if vx dies: the sandbox
200
+ * runtime's session. The runtime spawns its bridge socat as a plain child
201
+ * in vx's OWN group, which the guard must never kill, and a `kill -9`
202
+ * leaves its sockets in the temp directory. So each pid is killed alone
203
+ * and each path removed, by a name only that session carries. The
204
+ * returned function strikes them once the session is down, so a pid the
205
+ * kernel reuses is never killed on its account. Starts the guard: a spawn
206
+ * follows. A path holding whitespace is not listed (the lists split on it).
207
+ */
208
+ export function guardSession(pids: readonly number[], paths: readonly string[]): () => void {
209
+ startGuard()
210
+ const listed = paths.filter((p) => !/\s/.test(p))
211
+ guardWrite(pids.map((p) => `=${p}\n`).join('') + listed.map((p) => `@${p}\n`).join(''))
212
+ return () => {
213
+ guardWrite(pids.map((p) => `_${p}\n`).join('') + listed.map((p) => `!${p}\n`).join(''))
214
+ }
215
+ }
216
+
160
217
  /** Groups a teardown is still taking down: how many hold each, and whether its runner let it go. */
161
218
  const holds = new Map<number, { count: number; released: boolean }>()
162
219
 
@@ -181,7 +238,7 @@ export function holdGroups(children: readonly Child[]): () => void {
181
238
  if (hold === undefined) continue
182
239
  if (--hold.count > 0) continue
183
240
  holds.delete(pid)
184
- if (hold.released) guardWrite(`-${pid}\n`)
241
+ if (owed.delete(pid) || hold.released) guardWrite(`-${pid}\n`)
185
242
  }
186
243
  }
187
244
  }
@@ -197,9 +254,23 @@ export function holdGroups(children: readonly Child[]): () => void {
197
254
  */
198
255
  const goneGroups = new WeakSet<Child>()
199
256
 
200
- /** Note, as `child`'s leader exits, whether its group went with it. */
201
- export function markGroupIfGone(child: Child): void {
202
- if (!groupAlive(child)) goneGroups.add(child)
257
+ /** Server groups that outlived their leader: struck when a teardown next lets them go. */
258
+ const owed = new Set<number>()
259
+
260
+ /**
261
+ * A persistent child's leader has exited. An empty group is struck and
262
+ * never signalled again. One with a member left stays listed until a
263
+ * teardown lets it go: the run's registry still owns a ready server after
264
+ * its shell exits and stops its group at the end, and struck here, the
265
+ * `server` of a `server & echo up` was left to nobody by a `kill -9` of vx.
266
+ */
267
+ export function releaseServerGroup(child: Child): void {
268
+ if (groupAlive(child)) {
269
+ owed.add(child.pid)
270
+ return
271
+ }
272
+ goneGroups.add(child)
273
+ releaseGroup(child)
203
274
  }
204
275
 
205
276
  export function killTree(child: Child, signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL'): void {
@@ -26,7 +26,7 @@ interface Proc {
26
26
 
27
27
  /**
28
28
  * Linux's `/proc/<pid>/stat` counts CPU in USER_HZ ticks, fixed at 100 by
29
- * the ABI on every architecture; `tests/proc-sample.test.ts` pins it by
29
+ * the ABI on every architecture; `tests/proc-sample.unsafe.test.ts` pins it by
30
30
  * burning a known span and reading it back.
31
31
  */
32
32
  const TICK_MS = 10
@@ -18,8 +18,8 @@ import {
18
18
  closeSignalChannel,
19
19
  holdGroups,
20
20
  killTree,
21
- markGroupIfGone,
22
21
  releaseGroup,
22
+ releaseServerGroup,
23
23
  signalThrough,
24
24
  guardLine,
25
25
  spawnGuarded,
@@ -105,6 +105,36 @@ function taskStdio(terminal: boolean | undefined, stdin: 'ignore' | 'pipe') {
105
105
  : ([stdin, 'pipe', 'pipe'] as const)
106
106
  }
107
107
 
108
+ /**
109
+ * Read the terminal's modes before a task that holds it runs, and return
110
+ * what puts them back once it exits: a task killed in raw mode (or one
111
+ * that never undid its `stty raw -echo`) left the user's terminal with no
112
+ * echo and no Ctrl-C. Bun restores them at exit only when vx's stdout is
113
+ * the terminal, so `vx run … > log` kept them, and the rest of a run
114
+ * printed in them. `detached`, as the task is: a vx in a background job
115
+ * would be stopped by SIGTTOU on the set.
116
+ */
117
+ function keepTerminalModes(): (() => void) | undefined {
118
+ const saved = stty('-g')
119
+ if (saved === undefined) return undefined
120
+ return () => {
121
+ if (stty('-g') !== saved) stty(saved)
122
+ }
123
+ }
124
+
125
+ function stty(arg: string): string | undefined {
126
+ try {
127
+ const out = Bun.spawnSync(['stty', arg], {
128
+ stdio: ['inherit', 'pipe', 'ignore'],
129
+ detached: true,
130
+ })
131
+ return out.success ? out.stdout.toString().trim() : undefined
132
+ } catch {
133
+ // No stty: the modes stay the task's, as before.
134
+ return undefined
135
+ }
136
+ }
137
+
108
138
  export function shellQuote(arg: string): string {
109
139
  if (arg === '') return `''`
110
140
  // A `#` opens a comment only at a word's start, so `app#build` stays bare.
@@ -485,12 +515,14 @@ const READY_MATCH_WINDOW_CHARS = 64 * 1024
485
515
 
486
516
  /**
487
517
  * Terminal escapes a `readyWhen` pattern is matched without: CSI (colour,
488
- * cursor), OSC (titles, links) and the two-byte forms. Vite under
518
+ * cursor), OSC (titles, links), charset picks (`tput sgr0` writes
519
+ * `\x1b(B\x1b[m`) and the two-byte forms. Vite under
489
520
  * `FORCE_COLOR` prints `\x1b[1mLocal\x1b[22m:`, which `Local:` never matched
490
521
  * (item 1059). The streamed bytes keep them; only the tested text drops them.
491
522
  */
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
523
+ const TERMINAL_ESCAPE_RE =
524
+ // eslint-disable-next-line no-control-regex -- ESC is the point
525
+ /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[ -/]+[0-~]|[@-Z\\-_])/g
494
526
 
495
527
  /**
496
528
  * Why a persistent task never became ready — the reason every label and
@@ -566,6 +598,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
566
598
  const readyRe = opts.readyWhen !== undefined ? new RegExp(opts.readyWhen) : undefined
567
599
 
568
600
  let child: ReturnType<typeof Bun.spawn>
601
+ const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
569
602
  try {
570
603
  const signalFd = opts.signalChannel === true
571
604
  child = spawnGuarded((guard) =>
@@ -597,6 +630,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
597
630
  },
598
631
  ),
599
632
  )
633
+ if (restoreModes !== undefined) void child.exited.then(restoreModes)
600
634
  if (signalFd) {
601
635
  signalThrough(child, child.stdio[3] as number)
602
636
  const spawned = child
@@ -621,7 +655,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
621
655
  })
622
656
 
623
657
  const markReady = (): void => {
624
- if (readyAt === undefined) {
658
+ // A marker printed on the way down, once the wait gave up, is not ready.
659
+ if (readyAt === undefined && gaveUpAt === undefined) {
625
660
  readyAt = Date.now()
626
661
  if (readyTimer !== undefined) clearTimeout(readyTimer)
627
662
  resolveReady()
@@ -709,40 +744,42 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
709
744
  opts.liveChildren?.add(child)
710
745
  opts.onSpawn?.(child.pid)
711
746
 
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.
747
+ // Readiness deadline: SIGTERM, wait the grace for the GROUP, SIGKILL
748
+ // what is left, and only then reject. The caller's teardown used to
749
+ // signal the group again a turn after this SIGTERM, and a server whose
750
+ // handler is a one-shot (`process.once('SIGTERM')`, a trap that resets
751
+ // itself) died on the second mid-cleanup. Cleared the moment ready
752
+ // fires so a healthy server is never killed by a stale timer.
716
753
  let readyTimer: ReturnType<typeof setTimeout> | undefined
717
754
  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])
755
+ const giveUp = async (): Promise<void> => {
756
+ if (readyAt !== undefined) return
757
+ gaveUpAt = Date.now()
758
+ // Listed on the group guard until the SIGKILL: the shell may die on
759
+ // the SIGTERM and let the group go while the server runs out the
760
+ // grace, and a vx killed inside it leaves the server to the guard
761
+ // (kill-tree.ts, item 865).
762
+ const letGo = holdGroups([child])
763
+ try {
733
764
  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?.()
765
+ // A server that traps TERM and never became ready is not in the
766
+ // persistent registry, so nothing else would ever kill it.
767
+ const left = await untilGroupsGone([child], killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
768
+ for (const c of left) killTree(c, 'SIGKILL')
769
+ await child.exited
770
+ } finally {
771
+ letGo()
742
772
  }
773
+ rejectReady(
774
+ new PersistentReadyError(
775
+ `persistent task not ready within ${opts.timeoutMs}ms — ` +
776
+ `readyWhen pattern never matched; child killed`,
777
+ 'timeout',
778
+ ),
779
+ )
743
780
  }
744
781
  readyTimer = setTimeout(() => {
745
- readyTimer = afterPendingExits(giveUp)
782
+ readyTimer = afterPendingExits(() => void giveUp())
746
783
  }, opts.timeoutMs)
747
784
  }
748
785
 
@@ -750,10 +787,9 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
750
787
  // — reject the ready promise so the caller can surface it.
751
788
  void child.exited.then((code) => {
752
789
  opts.liveChildren?.delete(child)
753
- markGroupIfGone(child)
754
- releaseGroup(child)
790
+ releaseServerGroup(child)
755
791
  if (readyTimer !== undefined) clearTimeout(readyTimer)
756
- if (readyAt === undefined) {
792
+ if (readyAt === undefined && gaveUpAt === undefined) {
757
793
  rejectReady(
758
794
  new PersistentReadyError(
759
795
  `persistent task exited before becoming ready (exit ${code ?? '?'})` +
@@ -798,6 +834,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
798
834
  const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
799
835
 
800
836
  let proc: ReturnType<typeof Bun.spawn>
837
+ const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
801
838
  try {
802
839
  proc = spawnGuarded((guard) =>
803
840
  Bun.spawn(shellArgv((guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand)), {
@@ -832,6 +869,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
832
869
  // lets a clean exit EOF immediately and only cuts off a stuck reader after a
833
870
  // brief grace — without this the run hangs forever.
834
871
  await proc.exited
872
+ restoreModes?.()
835
873
  await timeout.settle()
836
874
  let cut = false
837
875
  if (timeout.timedOut()) ac.abort()