@vzn/vx 0.0.57 → 0.0.59

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.57",
3
+ "version": "0.0.59",
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.57",
21
- "@vzn/vx-linux-arm64": "0.0.57",
22
- "@vzn/vx-darwin-x64": "0.0.57",
23
- "@vzn/vx-darwin-arm64": "0.0.57"
20
+ "@vzn/vx-linux-x64": "0.0.59",
21
+ "@vzn/vx-linux-arm64": "0.0.59",
22
+ "@vzn/vx-darwin-x64": "0.0.59",
23
+ "@vzn/vx-darwin-arm64": "0.0.59"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -116,6 +116,15 @@ function unreadableIndex(err: unknown): boolean {
116
116
  return typeof code === 'string' && (code.startsWith('SQLITE_CORRUPT') || code === 'SQLITE_NOTADB')
117
117
  }
118
118
 
119
+ /** The refusal for an index SQLite cannot read, at the open or at any later read or write (A-8). */
120
+ function unreadableIndexError(dbFile: string, err: unknown): UserError {
121
+ return new UserError(
122
+ `the cache index ${dbFile} is not a database SQLite can read (${(err as Error).message}) — ` +
123
+ `it holds nothing a run cannot rebuild: remove it with its -wal and -shm files and ` +
124
+ `re-run, and \`vx cache prune\` reclaims the artifacts it indexed`,
125
+ )
126
+ }
127
+
119
128
  /** Say once, on the channel the opener has, that an upgrade emptied the index. */
120
129
  export function noteSchemaReset(cache: Cache, warn: (message: string) => void): void {
121
130
  if (cache.formatChange !== null) {
@@ -417,6 +426,7 @@ export class Cache implements CacheLayer {
417
426
  private readonly inspecting: boolean
418
427
  /** Why this process cannot write into `cacheDir`, or `null`; decided at open. */
419
428
  private readonly writeBlocked: string | null
429
+ private readonly dbFile: string
420
430
 
421
431
  /**
422
432
  * Set when THIS open found an index written by another `SCHEMA_VERSION`
@@ -508,6 +518,7 @@ export class Cache implements CacheLayer {
508
518
  // created the typo's directory, a `.gitignore` and a database, then
509
519
  // said "no recorded runs yet" (item 900).
510
520
  const dbFile = path.join(cacheDir, 'cache.db')
521
+ this.dbFile = dbFile
511
522
  const absent = mode === 'inspect' && !existsSync(dbFile)
512
523
  this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir)
513
524
  this.write = localPolicy.write && this.writeBlocked === null
@@ -530,11 +541,7 @@ export class Cache implements CacheLayer {
530
541
  } catch (err) {
531
542
  if (!unreadableIndex(err)) throw err
532
543
  this.db.close()
533
- throw new UserError(
534
- `the cache index ${dbFile} is not a database SQLite can read (${(err as Error).message}) — ` +
535
- `it holds nothing a run cannot rebuild: remove it with its -wal and -shm files and ` +
536
- `re-run, and \`vx cache prune\` reclaims the artifacts it indexed`,
537
- )
544
+ throw unreadableIndexError(dbFile, err)
538
545
  }
539
546
  }
540
547
  readable(() => {
@@ -627,10 +634,10 @@ export class Cache implements CacheLayer {
627
634
  `)
628
635
 
629
636
  this.db.exec(`
630
- -- stdout/stderr live in the <hash>.tar.zst artifact, not here
631
- -- (v14+) — so they survive remote round-trips. The entries
632
- -- table is the queryable index: command, exit_code, duration,
633
- -- size, timestamps.
637
+ -- The queryable index: command, exit_code, duration, size,
638
+ -- timestamps, and stdout, which the <hash>.tar.zst artifact also
639
+ -- carries so it survives a remote round trip; a local hit
640
+ -- replays it from here.
634
641
  CREATE TABLE IF NOT EXISTS entries (
635
642
  hash TEXT PRIMARY KEY,
636
643
  project TEXT NOT NULL,
@@ -873,37 +880,57 @@ export class Cache implements CacheLayer {
873
880
  }
874
881
  }
875
882
 
883
+ /**
884
+ * Run `op` against the index, mapping SQLite's "not a database" — a page
885
+ * corrupt past the ones the open reads — to the open's own refusal. The
886
+ * open reads the header and `schema_meta` alone (item 1005), so a file
887
+ * corrupt deeper answered the first lookup that reached the bad page: every
888
+ * task of a run failed as an "internal error", and `vx cache prune` printed
889
+ * a stack (A-8). Every other error passes through as it was.
890
+ */
891
+ private guard<T>(op: () => T): T {
892
+ const refuse = (err: unknown): never => {
893
+ throw unreadableIndex(err) ? unreadableIndexError(this.dbFile, err) : err
894
+ }
895
+ try {
896
+ const out = op()
897
+ return (out instanceof Promise ? out.catch(refuse) : out) as T
898
+ } catch (err) {
899
+ return refuse(err)
900
+ }
901
+ }
902
+
876
903
  // --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
877
904
  getConfigEval(key: string): string | null {
878
- return this.configEvals.getConfigEval(key)
905
+ return this.guard(() => this.configEvals.getConfigEval(key))
879
906
  }
880
907
  getConfigClosures(configPaths: readonly string[]): Map<string, string[]> {
881
- return this.configEvals.getConfigClosures(configPaths)
908
+ return this.guard(() => this.configEvals.getConfigClosures(configPaths))
882
909
  }
883
910
  putConfigClosure(configPath: string, files: readonly string[]): void {
884
- this.configEvals.putConfigClosure(configPath, files)
911
+ return this.guard(() => this.configEvals.putConfigClosure(configPath, files))
885
912
  }
886
913
  getConfigEvals(keys: readonly string[]): Map<string, string> {
887
- return this.configEvals.getConfigEvals(keys)
914
+ return this.guard(() => this.configEvals.getConfigEvals(keys))
888
915
  }
889
916
  putConfigEval(key: string, json: string): void {
890
- this.configEvals.putConfigEval(key, json)
917
+ return this.guard(() => this.configEvals.putConfigEval(key, json))
891
918
  }
892
919
  putConfigEvals(entries: ReadonlyArray<readonly [string, string]>): void {
893
- this.configEvals.putConfigEvals(entries)
920
+ return this.guard(() => this.configEvals.putConfigEvals(entries))
894
921
  }
895
922
  putConfigClosures(entries: ReadonlyArray<readonly [string, readonly string[]]>): void {
896
- this.configEvals.putConfigClosures(entries)
923
+ return this.guard(() => this.configEvals.putConfigClosures(entries))
897
924
  }
898
925
  // --- input file hashes: delegated to `FileHashStore` (see file-hashes.ts) ---
899
926
  hashFile(filePath: string): Promise<string> {
900
- return this.files.hashFile(filePath)
927
+ return this.guard(() => this.files.hashFile(filePath))
901
928
  }
902
929
  hashBytes(bytes: Uint8Array, nearPath: string): string {
903
930
  return this.files.hashBytes(bytes, nearPath)
904
931
  }
905
932
  hashFiles(paths: readonly string[]): Promise<Map<string, string>> {
906
- return this.files.hashFiles(paths)
933
+ return this.guard(() => this.files.hashFiles(paths))
907
934
  }
908
935
  /**
909
936
  * `relPosix` against the run's workspace root, memoized: the same three
@@ -939,7 +966,7 @@ export class Cache implements CacheLayer {
939
966
  // Local reads disabled (e.g. `--force` / `--cache=local:w`): report a
940
967
  // miss so the task re-executes. The artifact + index are untouched.
941
968
  if (!this.read) return null
942
- return this.readEntry(hash)
969
+ return this.guard(() => this.readEntry(hash))
943
970
  }
944
971
 
945
972
  /**
@@ -952,7 +979,7 @@ export class Cache implements CacheLayer {
952
979
  * every single run.
953
980
  */
954
981
  getIngested(hash: string): Promise<CacheEntry | null> {
955
- return this.readEntry(hash)
982
+ return this.guard(() => this.readEntry(hash))
956
983
  }
957
984
 
958
985
  private async readEntry(hash: string): Promise<CacheEntry | null> {
@@ -989,6 +1016,10 @@ export class Cache implements CacheLayer {
989
1016
  * the lazy path.
990
1017
  */
991
1018
  async getMany(hashes: readonly string[]): Promise<Map<string, CacheEntry>> {
1019
+ return this.guard(() => this.getManyEntries(hashes))
1020
+ }
1021
+
1022
+ private async getManyEntries(hashes: readonly string[]): Promise<Map<string, CacheEntry>> {
992
1023
  const out = new Map<string, CacheEntry>()
993
1024
  if (!this.read || hashes.length === 0) return out
994
1025
  const rows: EntryRow[] = []
@@ -1019,6 +1050,10 @@ export class Cache implements CacheLayer {
1019
1050
  // Existence probe: SQL row + artifact-on-disk check, no byte reads
1020
1051
  // and no accessed_at bump (the plan path must stay read-only).
1021
1052
  async has(hash: string): Promise<'local' | 'remote' | null> {
1053
+ return this.guard(() => this.hasEntry(hash))
1054
+ }
1055
+
1056
+ private async hasEntry(hash: string): Promise<'local' | 'remote' | null> {
1022
1057
  if (!this.read) return null
1023
1058
  const row = this.selectEntry.get(hash) as EntryRow | undefined
1024
1059
  if (!row) return null
@@ -1038,7 +1073,7 @@ export class Cache implements CacheLayer {
1038
1073
  // a microtask hop per call, and a 1,000-hit warm run pays the proofs
1039
1074
  // 2,000 times (measured 2026-09-10: +3 ms on `classify + probe`).
1040
1075
  loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
1041
- return this.outputs.loadOutputFilesBatch(hashes)
1076
+ return this.guard(() => this.outputs.loadOutputFilesBatch(hashes))
1042
1077
  }
1043
1078
  isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
1044
1079
  return this.outputs.isOutputsCurrent(projectDir, expected)
@@ -1059,7 +1094,7 @@ export class Cache implements CacheLayer {
1059
1094
  this.outputs.recordOutputStamps(hash, projectDir, workspaceRoot)
1060
1095
  }
1061
1096
  loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
1062
- return this.outputs.loadOutputDirsBatch(hashes)
1097
+ return this.guard(() => this.outputs.loadOutputDirsBatch(hashes))
1063
1098
  }
1064
1099
  outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
1065
1100
  return this.outputs.outputDirsCurrent(projectDir, rows)
@@ -1258,12 +1293,14 @@ export class Cache implements CacheLayer {
1258
1293
  const endPack = span('save: pack')
1259
1294
  const compressed = await this.packArtifactToTemp(this.tempPath(args.hash), args)
1260
1295
  endPack()
1261
- await this.writeArtifactAndIndex(args.hash, compressed, {
1262
- taskId: args.entry.taskId,
1263
- command: args.entry.command,
1264
- durationMs: args.entry.durationMs,
1265
- ...(args.inputComponents !== undefined ? { inputComponents: args.inputComponents } : {}),
1266
- })
1296
+ await this.guard(() =>
1297
+ this.writeArtifactAndIndex(args.hash, compressed, {
1298
+ taskId: args.entry.taskId,
1299
+ command: args.entry.command,
1300
+ durationMs: args.entry.durationMs,
1301
+ ...(args.inputComponents !== undefined ? { inputComponents: args.inputComponents } : {}),
1302
+ }),
1303
+ )
1267
1304
  }
1268
1305
 
1269
1306
  /**
@@ -1301,7 +1338,7 @@ export class Cache implements CacheLayer {
1301
1338
  await unlink(tmpPath).catch(() => undefined)
1302
1339
  throw err
1303
1340
  }
1304
- await this.writeArtifactAndIndex(hash, { tmpPath }, meta)
1341
+ await this.guard(() => this.writeArtifactAndIndex(hash, { tmpPath }, meta))
1305
1342
  }
1306
1343
 
1307
1344
  /** Archive name → absolute source path for every declared output. */
@@ -1612,15 +1649,19 @@ export class Cache implements CacheLayer {
1612
1649
 
1613
1650
  // --- run history: delegated to `RunHistory` (see run-history.ts) ---
1614
1651
  recordRun(run: RunRecord): void {
1615
- this.history.recordRun(run)
1652
+ return this.guard(() => this.history.recordRun(run))
1616
1653
  }
1617
1654
  recordRuns(runs: readonly RunRecord[]): void {
1618
- this.history.recordRuns(runs)
1655
+ return this.guard(() => this.history.recordRuns(runs))
1619
1656
  }
1620
1657
  recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
1621
- this.history.recordRunBundle(bundle)
1658
+ return this.guard(() => this.history.recordRunBundle(bundle))
1622
1659
  }
1623
1660
  stats(opts: CacheStatsOptions = {}): CacheStats {
1661
+ return this.guard(() => this.statsOf(opts))
1662
+ }
1663
+
1664
+ private statsOf(opts: CacheStatsOptions): CacheStats {
1624
1665
  this.flushAccessed()
1625
1666
  this.outputs.flushOutputDirs()
1626
1667
  const project = opts.project
@@ -1664,6 +1705,13 @@ export class Cache implements CacheLayer {
1664
1705
  async evictIfDue(
1665
1706
  policy: { maxAgeMs?: number; maxBytes?: number },
1666
1707
  now: number = Date.now(),
1708
+ ): Promise<PruneResult | null> {
1709
+ return this.guard(() => this.evictIfDueNow(policy, now))
1710
+ }
1711
+
1712
+ private async evictIfDueNow(
1713
+ policy: { maxAgeMs?: number; maxBytes?: number },
1714
+ now: number,
1667
1715
  ): Promise<PruneResult | null> {
1668
1716
  if (this.writeBlocked !== null || this.inspecting) return null
1669
1717
  // First: an entry this run restored still carries its old `accessed_at`
@@ -1698,6 +1746,10 @@ export class Cache implements CacheLayer {
1698
1746
  }
1699
1747
 
1700
1748
  async prune(options: PruneOptions): Promise<PruneResult> {
1749
+ return this.guard(() => this.pruneIndex(options))
1750
+ }
1751
+
1752
+ private async pruneIndex(options: PruneOptions): Promise<PruneResult> {
1701
1753
  this.flushAccessed()
1702
1754
  // Before any entry is deleted, so a kept entry's pending snapshot is on
1703
1755
  // disk whatever the eviction does next. A pruned hash's snapshot cannot
@@ -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) {
@@ -449,13 +449,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
449
449
  // The PROBE hash is computed WITHOUT capture — a warm all-cache-hit
450
450
  // run allocates no component array and pushes nothing (the warm path
451
451
  // does zero extra Tier-3 work). The cache-key components for the
452
- // Tier-3 input fingerprint are captured only on a MISS, right before
453
- // `cache.save`, by a second `computeTaskHash` with `captureInto` set
454
- // — the HashCache memos (package.json bytes, task config, runtime
455
- // command output) plus the gitFilesCache OID map make that second
456
- // pass a fold + array pushes, no re-stat / re-hash I/O. It runs on
457
- // the miss path only, where the task is about to spawn a subprocess
458
- // anyway, so its cost is in the noise.
452
+ // Tier-3 input fingerprint are captured only on a MISS, by
453
+ // `describeTaskInputs` just before the command spawns (with
454
+ // `captureInto` set) — the HashCache memos plus the gitFilesCache OID
455
+ // map make that pass a fold + array pushes, no re-stat / re-hash I/O.
459
456
  //
460
457
  // Local short-circuit reuse: when the classify phase already derived
461
458
  // this task's stable key + probed it, reuse the up-front hash verbatim
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Levenshtein distance, capped: anything past two edits reads as 3. Used
3
- * by the "did you mean" hints for task names and flags, where a hint
4
- * beyond two edits would guess rather than help.
2
+ * Levenshtein distance, capped: anything at or past `cap` edits reads as
3
+ * `cap` (3 by default: past two edits a hint would guess rather than
4
+ * help). A caller that allows three edits needs a cap of four — under a
5
+ * cap of 3, a candidate nine edits away read as 3 and was offered.
5
6
  */
6
- export function editDistance(a: string, b: string): number {
7
- if (Math.abs(a.length - b.length) > 2) return 3
7
+ export function editDistance(a: string, b: string, cap = 3): number {
8
+ if (Math.abs(a.length - b.length) >= cap) return cap
8
9
  let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
9
10
  for (let i = 1; i <= a.length; i++) {
10
11
  const cur = [i]
@@ -17,7 +18,7 @@ export function editDistance(a: string, b: string): number {
17
18
  }
18
19
  prev = cur
19
20
  }
20
- return Math.min(prev[b.length]!, 3)
21
+ return Math.min(prev[b.length]!, cap)
21
22
  }
22
23
 
23
24
  /**
@@ -35,7 +36,10 @@ export function nearest(
35
36
  let best: string | undefined
36
37
  let bestD = maxEdits + 1
37
38
  for (const c of candidates) {
38
- const d = editDistance(name, c)
39
+ // Capped one past the budget, so "past it" never reads as within it:
40
+ // `--continue-on-error` was nine edits from `--concurrency`, capped to
41
+ // the three `vx run`'s same-prefix hint allows, and offered.
42
+ const d = editDistance(name, c, maxEdits + 1)
39
43
  if (d < bestD) {
40
44
  bestD = d
41
45
  best = c