@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 +5 -5
- package/src/cache/cache.ts +84 -32
- package/src/exec/kill-tree.ts +23 -10
- package/src/exec/runner.ts +52 -33
- package/src/exec/sandbox-runtime.ts +44 -17
- package/src/orchestrator/execute-task.ts +4 -7
- package/src/util/edit-distance.ts +11 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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"
|
package/src/cache/cache.ts
CHANGED
|
@@ -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
|
|
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
|
-
--
|
|
631
|
-
--
|
|
632
|
-
--
|
|
633
|
-
--
|
|
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.
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
package/src/exec/kill-tree.ts
CHANGED
|
@@ -103,18 +103,31 @@ function guardWrite(line: string): void {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
|
-
* Spawn a task child
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* of vx
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
/**
|
package/src/exec/runner.ts
CHANGED
|
@@ -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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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 (
|
|
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(
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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,
|
|
453
|
-
// `
|
|
454
|
-
// — the HashCache memos
|
|
455
|
-
//
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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)
|
|
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]!,
|
|
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
|
-
|
|
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
|