@vzn/vx 0.0.597 → 0.0.598
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/bin.ts +9 -5
- package/src/cache/cache.ts +32 -19
- package/src/cache/layered-cache.ts +14 -9
- package/src/cache/output-index.ts +12 -7
- package/src/cache/run-history.ts +16 -8
- package/src/cli/plan-format.ts +23 -17
- package/src/cli/select.ts +13 -2
- package/src/cli/show.ts +13 -25
- package/src/cli/watch-cycle.ts +43 -0
- package/src/cli/watch.ts +37 -5
- package/src/exec/kill-tree.ts +34 -4
- package/src/exec/runner.ts +150 -12
- package/src/exec/sandbox-runtime.ts +23 -9
- package/src/graph/priorities.ts +1 -2
- package/src/index.ts +3 -1
- package/src/orchestrator/doctor.ts +3 -1
- package/src/orchestrator/execute-task.ts +21 -2
- package/src/orchestrator/hit-restore.ts +1 -1
- package/src/orchestrator/lockfile-claim.ts +14 -7
- package/src/orchestrator/logger.ts +7 -4
- package/src/orchestrator/plugin-host.ts +7 -2
- package/src/orchestrator/plugin.ts +5 -2
- package/src/orchestrator/prepare.ts +4 -2
- package/src/orchestrator/projects.ts +1 -1
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-lock.ts +10 -1
- package/src/workspace/config-cache.ts +15 -1
- package/src/workspace/config-eval.ts +20 -1
- package/src/workspace/project-loader.ts +18 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.598",
|
|
4
4
|
"description": "The fastest task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.598",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.598",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.598",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.598"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/bin.ts
CHANGED
|
@@ -30,8 +30,12 @@ async function main(): Promise<void> {
|
|
|
30
30
|
// is set, and `exitOnceFlushed` exits after both streams have ended.
|
|
31
31
|
process.exitCode = code
|
|
32
32
|
} catch (err) {
|
|
33
|
-
const { fsRefusalHint, isFsRefusal, isOutOfFds, isUserError, OUT_OF_FDS_HINT } =
|
|
33
|
+
const { fsRefusalHint, isFsRefusal, isOutOfFds, isUserError, maskedLine, OUT_OF_FDS_HINT } =
|
|
34
34
|
await import('./util/index.js')
|
|
35
|
+
// A plugin's failure quotes what it was told (a remote's reply, a
|
|
36
|
+
// header), and a config's own throw is its author's text: a secret in
|
|
37
|
+
// either reached stderr whole (L-11).
|
|
38
|
+
const say = (line: string): void => void process.stderr.write(maskedLine(line))
|
|
35
39
|
// UserError (workspace not found, cycle, config invalid, ...) —
|
|
36
40
|
// print the message only; the stack is noise the user can't act
|
|
37
41
|
// on. Everything else gets the full stack so internal bugs are
|
|
@@ -41,16 +45,16 @@ async function main(): Promise<void> {
|
|
|
41
45
|
// that wants its own name in the line) is printed as it is; prefixing
|
|
42
46
|
// it produced `vx: vx why: …` (walkthrough, 2026-09-04).
|
|
43
47
|
const m = err.message
|
|
44
|
-
|
|
48
|
+
say(m.startsWith('vx ') ? `${m}\n` : `vx: ${m}\n`)
|
|
45
49
|
} else if (isOutOfFds(err)) {
|
|
46
|
-
|
|
50
|
+
say(`vx: ${err.message} — ${OUT_OF_FDS_HINT}\n`)
|
|
47
51
|
} else if (isFsRefusal(err)) {
|
|
48
52
|
// The file system's refusal names the path; the stack would name
|
|
49
53
|
// the verb's write, which the reader cannot act on either.
|
|
50
|
-
|
|
54
|
+
say(`vx: ${err.message} — ${fsRefusalHint(err)}\n`)
|
|
51
55
|
} else {
|
|
52
56
|
const message = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
|
53
|
-
|
|
57
|
+
say(`vx: ${message}\n`)
|
|
54
58
|
}
|
|
55
59
|
// Same reason as the success path above: a large stderr is truncated by
|
|
56
60
|
// `process.exit` too, and an error message cut in half is the one a
|
package/src/cache/cache.ts
CHANGED
|
@@ -660,15 +660,6 @@ export class Cache implements CacheLayer {
|
|
|
660
660
|
/** The shared store this index attached, or undefined when it holds its entries itself. */
|
|
661
661
|
readonly storeDir: string | undefined
|
|
662
662
|
|
|
663
|
-
/**
|
|
664
|
-
* The store this open was asked for and could not use, with why: the
|
|
665
|
-
* entries went to a store inside `cacheDir` instead. Never printed.
|
|
666
|
-
*/
|
|
667
|
-
readonly storeFallback: string | null = null
|
|
668
|
-
|
|
669
|
-
/** Set when this open moved a workspace index's own entries out for a shared store. */
|
|
670
|
-
readonly storeMoved: { from: string; to: string } | null = null
|
|
671
|
-
|
|
672
663
|
/**
|
|
673
664
|
* Set when THIS open found an index written by another `SCHEMA_VERSION`
|
|
674
665
|
* and dropped every table. The next open sees the current version and
|
|
@@ -913,9 +904,6 @@ export class Cache implements CacheLayer {
|
|
|
913
904
|
// the entries stay in this workspace rather than fail the run.
|
|
914
905
|
const fallback = cacheDir
|
|
915
906
|
if (fallback !== storeDir) {
|
|
916
|
-
// Set by the open that falls back, never printed; the ones after it
|
|
917
|
-
// find it recorded.
|
|
918
|
-
if (recorded !== fallback) this.storeFallback = `${storeDir} (${blocked})`
|
|
919
907
|
storeDir = fallback
|
|
920
908
|
openCacheDir(fallback)
|
|
921
909
|
}
|
|
@@ -932,13 +920,11 @@ export class Cache implements CacheLayer {
|
|
|
932
920
|
.get() != null,
|
|
933
921
|
)
|
|
934
922
|
if (holds) {
|
|
935
|
-
const had = this.db.prepare('SELECT 1 FROM main.entries LIMIT 1').get() != null
|
|
936
923
|
this.db
|
|
937
924
|
.transaction(() => {
|
|
938
925
|
for (const t of STORE_TABLES) this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
|
|
939
926
|
})
|
|
940
927
|
.immediate()
|
|
941
|
-
if (had) this.storeMoved = { from: cacheDir, to: storeDir }
|
|
942
928
|
}
|
|
943
929
|
}
|
|
944
930
|
this.storeDir = storeDir
|
|
@@ -1050,13 +1036,25 @@ export class Cache implements CacheLayer {
|
|
|
1050
1036
|
* Attach the shared store as `store`, with the pragmas the index takes:
|
|
1051
1037
|
* WAL for both, so a workspace's run and another's share the store as
|
|
1052
1038
|
* two runs on one `--cache-dir` share an index. A reading verb over a
|
|
1053
|
-
* store with no file yet reads an empty one in memory.
|
|
1039
|
+
* store with no file or no tables yet reads an empty one in memory.
|
|
1054
1040
|
*/
|
|
1055
1041
|
private attachStore(storeDir: string, inspecting: boolean): void {
|
|
1056
1042
|
const storeFile = path.join(storeDir, 'store.db')
|
|
1057
|
-
|
|
1043
|
+
let absent = inspecting && !existsSync(storeFile)
|
|
1058
1044
|
try {
|
|
1059
1045
|
this.db.prepare('ATTACH DATABASE ? AS store').run(absent ? ':memory:' : storeFile)
|
|
1046
|
+
// One with no tables yet is read empty in memory too: the journal
|
|
1047
|
+
// switch and the table creation below are writes, under no write
|
|
1048
|
+
// lock, racing an opener making that store.
|
|
1049
|
+
if (
|
|
1050
|
+
inspecting &&
|
|
1051
|
+
!absent &&
|
|
1052
|
+
this.db.prepare("SELECT 1 FROM store.sqlite_master WHERE name = 'entries'").get() == null
|
|
1053
|
+
) {
|
|
1054
|
+
this.db.exec('DETACH DATABASE store')
|
|
1055
|
+
this.db.prepare('ATTACH DATABASE ? AS store').run(':memory:')
|
|
1056
|
+
absent = true
|
|
1057
|
+
}
|
|
1060
1058
|
this.db.exec('PRAGMA store.journal_mode = WAL')
|
|
1061
1059
|
if (!absent) this.db.fileControl('store', SQLITE_FCNTL_PERSIST_WAL, 1)
|
|
1062
1060
|
this.db.exec('PRAGMA store.journal_size_limit = 67108864')
|
|
@@ -1760,6 +1758,21 @@ export class Cache implements CacheLayer {
|
|
|
1760
1758
|
return this.packArtifact(args)
|
|
1761
1759
|
}
|
|
1762
1760
|
|
|
1761
|
+
/**
|
|
1762
|
+
* The artifact under a private second name, for a body read more than
|
|
1763
|
+
* once (a digest pass then an upload, a retry). The live name is not
|
|
1764
|
+
* stable: a re-save of the key renames other bytes over it, and a body
|
|
1765
|
+
* opened by path read those mid-upload (a digest of one artifact over
|
|
1766
|
+
* the bytes of another). A `Bun.file` over an fd is no answer — its
|
|
1767
|
+
* second read starts where the first ended. Throws when the artifact is
|
|
1768
|
+
* gone; `release` unlinks the name.
|
|
1769
|
+
*/
|
|
1770
|
+
pinArtifact(hash: string): { body: Blob; release: () => Promise<void> } {
|
|
1771
|
+
const pinned = this.tempPath(hash)
|
|
1772
|
+
linkSync(this.tarPath(hash), pinned)
|
|
1773
|
+
return { body: Bun.file(pinned), release: () => unlink(pinned).catch(() => undefined) }
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1763
1776
|
/**
|
|
1764
1777
|
* The remote body goes to the temp by `Bun.write`, which streams a
|
|
1765
1778
|
* `Response` and copies a file `Blob` without collecting either, so a
|
|
@@ -2310,9 +2323,9 @@ export class Cache implements CacheLayer {
|
|
|
2310
2323
|
// Delete DB rows in a single transaction (one fsync; ON DELETE
|
|
2311
2324
|
// CASCADE clears `output_files`) and unlink artifacts in parallel.
|
|
2312
2325
|
// Replaces N round-trips + serialized rm with one transaction + a
|
|
2313
|
-
// Promise.all over the unlinks. The
|
|
2314
|
-
//
|
|
2315
|
-
//
|
|
2326
|
+
// Promise.all over the unlinks. The hash list binds as one `json_each`
|
|
2327
|
+
// parameter, so a huge eviction stays under any build's bound-parameter
|
|
2328
|
+
// ceiling.
|
|
2316
2329
|
if (dryRun) {
|
|
2317
2330
|
return { evicted: victims.size, bytesFreed, orphans: picked.files.length, orphanBytes }
|
|
2318
2331
|
}
|
|
@@ -82,7 +82,8 @@ export interface RemoteCacheLayer {
|
|
|
82
82
|
/**
|
|
83
83
|
* Store an artifact (fire-and-forget from LayeredCache's perspective).
|
|
84
84
|
* `body` is file-backed (`Bun.file`) when the local store holds the
|
|
85
|
-
* artifact, so a plugin that streams it never holds it whole
|
|
85
|
+
* artifact, so a plugin that streams it never holds it whole, and every
|
|
86
|
+
* read of it sees the same bytes.
|
|
86
87
|
*/
|
|
87
88
|
put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void>
|
|
88
89
|
}
|
|
@@ -549,12 +550,12 @@ export class LayeredCache implements CacheLayer {
|
|
|
549
550
|
// succeeded; we don't fail it on cache-server issues.
|
|
550
551
|
//
|
|
551
552
|
// The job hands the plugin a file-backed Blob over the local artifact,
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
// contract.
|
|
553
|
+
// pinned under a private name when the job starts (`pinArtifact`): a
|
|
554
|
+
// queued job holds a path, not a buffer, a plugin that streams the Blob
|
|
555
|
+
// never holds it whole, and every read of it sees the same bytes though
|
|
556
|
+
// a re-save of the key renames others over the live name. If a
|
|
557
|
+
// concurrent `vx cache prune` removed the artifact first the pin throws
|
|
558
|
+
// and this upload is skipped — the never-fail contract.
|
|
558
559
|
const hash = args.hash
|
|
559
560
|
const durationMs = args.entry.durationMs
|
|
560
561
|
// Local writes disabled (`--cache=local:,remote:rw`): there is no
|
|
@@ -575,9 +576,11 @@ export class LayeredCache implements CacheLayer {
|
|
|
575
576
|
this.enqueueUpload(async () => {
|
|
576
577
|
const tally = this.local.uploads
|
|
577
578
|
const start = performance.now()
|
|
579
|
+
let release: (() => Promise<void>) | undefined
|
|
578
580
|
try {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
+
let body: Blob
|
|
582
|
+
if (packed !== undefined) body = new Blob([packed])
|
|
583
|
+
else ({ body, release } = this.local.pinArtifact(hash))
|
|
581
584
|
await this.wire.put(hash, body, { durationMs })
|
|
582
585
|
tally.count++
|
|
583
586
|
tally.bytes += body.size
|
|
@@ -585,6 +588,8 @@ export class LayeredCache implements CacheLayer {
|
|
|
585
588
|
} catch (err) {
|
|
586
589
|
tally.failed++
|
|
587
590
|
this.reportRemoteError('upload', hash, err)
|
|
591
|
+
} finally {
|
|
592
|
+
await release?.()
|
|
588
593
|
}
|
|
589
594
|
})
|
|
590
595
|
}
|
|
@@ -336,13 +336,18 @@ export class OutputIndex {
|
|
|
336
336
|
if (this.pendingStamps.size > 0) {
|
|
337
337
|
const stamps = [...this.pendingStamps]
|
|
338
338
|
this.pendingStamps.clear()
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
339
|
+
// IMMEDIATE: the body reads `entries` before it writes, and a
|
|
340
|
+
// deferred transaction that did could not wait for the lock
|
|
341
|
+
// (run-history.ts).
|
|
342
|
+
this.db
|
|
343
|
+
.transaction(() => {
|
|
344
|
+
for (const [hash, rows] of stamps) {
|
|
345
|
+
// A stamp outlives nothing: an entry pruned since has no rows to vouch for.
|
|
346
|
+
if (this.entryExists.get(hash) === null) continue
|
|
347
|
+
for (const [rel, ino, ctime] of rows) this.stampOutputFile.run(hash, rel, ino, ctime)
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
.immediate()
|
|
346
351
|
}
|
|
347
352
|
if (this.pendingDirs.size === 0) return
|
|
348
353
|
const pending = [...this.pendingDirs]
|
package/src/cache/run-history.ts
CHANGED
|
@@ -69,10 +69,9 @@ export class RunHistory {
|
|
|
69
69
|
this.insertRun.run(...bindRun(runs[0]!, this.digestValue))
|
|
70
70
|
return
|
|
71
71
|
}
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
this.db.transaction(() => this.insertRuns(runs))()
|
|
72
|
+
// One transaction, one fsync: for a 200-task run, one instead of 200.
|
|
73
|
+
// IMMEDIATE, as `recordRunBundle` explains.
|
|
74
|
+
this.db.transaction(() => this.insertRuns(runs)).immediate()
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
/** `runs` as few INSERTs; each distinct forward-args list digested once. */
|
|
@@ -99,10 +98,19 @@ export class RunHistory {
|
|
|
99
98
|
// the entry-save transaction (`save`/`ingest`) so a warm
|
|
100
99
|
// all-cache-hit run — which writes no `runs`-vs-`entry_inputs`
|
|
101
100
|
// mismatch — pays nothing for the moat it isn't refreshing.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
//
|
|
102
|
+
// IMMEDIATE: the write lock is taken at BEGIN, where the busy timeout
|
|
103
|
+
// waits for it. A run's rows digest their forward args under a salt
|
|
104
|
+
// the body READS first (`[]` from every CLI run), and a deferred
|
|
105
|
+
// transaction that read before it wrote could not wait: 13 of 48 runs
|
|
106
|
+
// on one shared cache dir lost their history to `database is locked`
|
|
107
|
+
// in 2–60 ms (BUSY_SNAPSHOT, a retry passing at once).
|
|
108
|
+
this.db
|
|
109
|
+
.transaction(() => {
|
|
110
|
+
this.insertRuns(bundle.runs)
|
|
111
|
+
this.insertInvocation.run(...bindInvocation(bundle.invocation))
|
|
112
|
+
})
|
|
113
|
+
.immediate()
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
/**
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Formatters for `--dry-run` (human / JSON) and `--graph` (DOT).
|
|
2
2
|
|
|
3
3
|
import type { CacheStatus, RunPlan } from '../orchestrator/index.js'
|
|
4
|
-
import { formatElapsed } from '../util/index.js'
|
|
4
|
+
import { formatElapsed, maskedCommand } from '../util/index.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Human-readable preview. One line per real task (groups hidden, same
|
|
@@ -48,7 +48,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
48
48
|
)
|
|
49
49
|
// Optional one-line description from the task config, indented
|
|
50
50
|
// under the id so the eye picks up the task → blurb mapping.
|
|
51
|
-
const taskDesc = t
|
|
51
|
+
const taskDesc = shownDescription(t)
|
|
52
52
|
if (taskDesc) {
|
|
53
53
|
lines.push(` ${' '.repeat(idWidth)} ${taskDesc}`)
|
|
54
54
|
}
|
|
@@ -104,6 +104,12 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
104
104
|
return lines.join('\n') + '\n'
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** A TS config may build the description from `process.env` (L-11). */
|
|
108
|
+
function shownDescription(t: RunPlan['tasks'][number]): string | undefined {
|
|
109
|
+
const { description, exec } = t.node.config
|
|
110
|
+
return description === undefined ? undefined : maskedCommand(description, exec?.env)
|
|
111
|
+
}
|
|
112
|
+
|
|
107
113
|
/** One task of `vx run --dry=json`: the wire, stated by `schemas/plan.json`. */
|
|
108
114
|
export interface PlanTaskJson {
|
|
109
115
|
id: string
|
|
@@ -122,21 +128,21 @@ export function formatPlanJson(plan: RunPlan): string {
|
|
|
122
128
|
return (
|
|
123
129
|
JSON.stringify(
|
|
124
130
|
{
|
|
125
|
-
tasks: plan.tasks.map((t): PlanTaskJson =>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
? { description:
|
|
138
|
-
|
|
139
|
-
})
|
|
131
|
+
tasks: plan.tasks.map((t): PlanTaskJson => {
|
|
132
|
+
const description = shownDescription(t)
|
|
133
|
+
return {
|
|
134
|
+
id: t.node.id,
|
|
135
|
+
project: t.node.projectName,
|
|
136
|
+
task: t.node.taskName,
|
|
137
|
+
hash: t.hash,
|
|
138
|
+
cacheStatus: t.cacheStatus,
|
|
139
|
+
deps: t.deps,
|
|
140
|
+
...(t.p50Ms !== undefined ? { p50Ms: t.p50Ms } : {}),
|
|
141
|
+
...(t.executor !== undefined ? { executor: t.executor } : {}),
|
|
142
|
+
...(t.download !== undefined ? { download: t.download } : {}),
|
|
143
|
+
...(description !== undefined ? { description } : {}),
|
|
144
|
+
}
|
|
145
|
+
}),
|
|
140
146
|
...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
|
|
141
147
|
// The gate's refusals belong on the SCRIPTING surface too: a CI job
|
|
142
148
|
// asking "did --download=none actually defer anything, and if not
|
package/src/cli/select.ts
CHANGED
|
@@ -27,7 +27,14 @@ import type { ProjectConfig } from '../config.js'
|
|
|
27
27
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
28
28
|
import { parseDependencySpec } from '../graph/index.js'
|
|
29
29
|
import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
isUserError,
|
|
32
|
+
listed,
|
|
33
|
+
maskedCommand,
|
|
34
|
+
maskedLine,
|
|
35
|
+
nearest,
|
|
36
|
+
UserError,
|
|
37
|
+
} from '../util/index.js'
|
|
31
38
|
import {
|
|
32
39
|
claimedAffected,
|
|
33
40
|
fingerprintClaims,
|
|
@@ -483,7 +490,11 @@ export async function pickTask(
|
|
|
483
490
|
.filter((t) => !isDefaultBuild(config.tasks?.[t]))
|
|
484
491
|
.sort()
|
|
485
492
|
for (const t of taskNames) {
|
|
486
|
-
const
|
|
493
|
+
const task = config.tasks?.[t]
|
|
494
|
+
const desc =
|
|
495
|
+
task?.description === undefined
|
|
496
|
+
? undefined
|
|
497
|
+
: maskedCommand(task.description, task.exec?.env)
|
|
487
498
|
entries.push({ project: meta.name, task: t, ...(desc ? { description: desc } : {}) })
|
|
488
499
|
}
|
|
489
500
|
}
|
package/src/cli/show.ts
CHANGED
|
@@ -221,33 +221,21 @@ function projectDir(root: string, meta: ProjectMeta): string {
|
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
/**
|
|
224
|
-
* A task as `vx show` prints it: a secret variable's value masked
|
|
225
|
-
*
|
|
226
|
-
*
|
|
224
|
+
* A task as `vx show` prints it: a secret variable's value masked in every
|
|
225
|
+
* string it holds, where a TS config that builds one from `process.env`
|
|
226
|
+
* put it (the command, `env.define`, a runtime probe, `readyWhen`, the
|
|
227
|
+
* description) (L-11).
|
|
227
228
|
*/
|
|
228
229
|
function shownTask(task: TaskConfig): TaskConfig {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
return
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
...(exec.command !== undefined ? { command: secrets.mask(exec.command) } : {}),
|
|
239
|
-
...(define !== undefined
|
|
240
|
-
? {
|
|
241
|
-
env: {
|
|
242
|
-
...exec.env,
|
|
243
|
-
define: Object.fromEntries(
|
|
244
|
-
Object.entries(define).map(([k, v]) => [k, secrets.mask(v)]),
|
|
245
|
-
),
|
|
246
|
-
},
|
|
247
|
-
}
|
|
248
|
-
: {}),
|
|
249
|
-
},
|
|
250
|
-
}
|
|
230
|
+
const secrets = secretMask([process.env, task.exec?.env?.define], task.exec?.env?.secret)
|
|
231
|
+
return secrets === null ? task : (maskStrings(task, secrets.mask) as TaskConfig)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function maskStrings(value: unknown, mask: (text: string) => string): unknown {
|
|
235
|
+
if (typeof value === 'string') return mask(value)
|
|
236
|
+
if (Array.isArray(value)) return value.map((v) => maskStrings(v, mask))
|
|
237
|
+
if (typeof value !== 'object' || value === null) return value
|
|
238
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskStrings(v, mask)]))
|
|
251
239
|
}
|
|
252
240
|
|
|
253
241
|
/** A project config with each task as `shownTask` prints it. */
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// One watch cycle's run, stoppable on its own. A cycle whose dev server
|
|
2
|
+
// never prints its `readyWhen` line and has no `exec.timeout` never ended,
|
|
3
|
+
// so every later edit (the fix included) waited behind it for good (WD-15).
|
|
4
|
+
// While nothing but persistent tasks is in flight, an edit stops the cycle
|
|
5
|
+
// as Ctrl-C would and the loop starts the next one; a cycle running any
|
|
6
|
+
// other work is never stopped.
|
|
7
|
+
|
|
8
|
+
import { createEventBus, type RunOptions } from '../orchestrator/index.js'
|
|
9
|
+
|
|
10
|
+
export interface WatchCycle {
|
|
11
|
+
/** The options for this cycle's run: its own bus and a stop of its own beside watch's. */
|
|
12
|
+
readonly opts: RunOptions
|
|
13
|
+
/** Every task in flight is a persistent one not yet ready. */
|
|
14
|
+
waitsOnReadiness(): boolean
|
|
15
|
+
/** Stop the run as a SIGTERM would; watch keeps going. */
|
|
16
|
+
interrupt(): void
|
|
17
|
+
readonly interrupted: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `onWaiting` hears each task event after which the cycle waits on readiness alone. */
|
|
21
|
+
export function watchCycle(opts: RunOptions, stop: AbortSignal, onWaiting: () => void): WatchCycle {
|
|
22
|
+
const own = new AbortController()
|
|
23
|
+
const bus = createEventBus()
|
|
24
|
+
// id → persistent; a group task has no process and holds nothing up.
|
|
25
|
+
const active = new Map<string, boolean>()
|
|
26
|
+
const waitsOnReadiness = (): boolean =>
|
|
27
|
+
active.size > 0 && [...active.values()].every((persistent) => persistent)
|
|
28
|
+
bus.subscribe((e) => {
|
|
29
|
+
if (e.kind === 'task:start' && e.node.config.exec !== undefined)
|
|
30
|
+
active.set(e.node.id, e.node.config.exec.persistent !== undefined)
|
|
31
|
+
else if (e.kind === 'task:complete') active.delete(e.node.id)
|
|
32
|
+
else return
|
|
33
|
+
if (waitsOnReadiness()) onWaiting()
|
|
34
|
+
})
|
|
35
|
+
return {
|
|
36
|
+
opts: { ...opts, bus, signal: AbortSignal.any([stop, own.signal]) },
|
|
37
|
+
waitsOnReadiness,
|
|
38
|
+
interrupt: () => own.abort('SIGTERM'),
|
|
39
|
+
get interrupted() {
|
|
40
|
+
return own.signal.aborted
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/cli/watch.ts
CHANGED
|
@@ -45,7 +45,8 @@ 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 {
|
|
48
|
+
import { type WatchCycle, watchCycle } from './watch-cycle.js'
|
|
49
|
+
import { hangupIgnored, maskedLine, restartTimings } from '../util/index.js'
|
|
49
50
|
import { memberEntries, sameMembers, sweepConfigs, watchedProjects } from './watch-set.js'
|
|
50
51
|
|
|
51
52
|
/** One line for a watcher or re-read the OS refused; the loop goes on without it. */
|
|
@@ -376,11 +377,31 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
376
377
|
/** The cycle in flight, so the stop path can wait for its teardown before resolving. */
|
|
377
378
|
let inFlight: Promise<void> = Promise.resolve()
|
|
378
379
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
380
|
+
/** The run in flight, while one is. */
|
|
381
|
+
let current: WatchCycle | undefined
|
|
382
|
+
/** What stopped a cycle that waited on a server's readiness; the loop runs it next. */
|
|
383
|
+
let interruptedBy: string | undefined
|
|
384
|
+
let interruptTimer: ReturnType<typeof setTimeout> | null = null
|
|
385
|
+
// Judged on settled state one window later, as an idle edit is, and only
|
|
386
|
+
// if the cycle still waits on readiness alone by then.
|
|
387
|
+
const interruptIfWaiting = (): void => {
|
|
388
|
+
if (changes.pending.size === 0 || current?.waitsOnReadiness() !== true) return
|
|
389
|
+
if (interruptTimer) clearTimeout(interruptTimer)
|
|
390
|
+
interruptTimer = setTimeout(() => {
|
|
391
|
+
interruptTimer = null
|
|
392
|
+
const c = current
|
|
393
|
+
if (c === undefined || c.interrupted || !c.waitsOnReadiness() || stop.aborted) return
|
|
394
|
+
const label = changes.judge()
|
|
395
|
+
if (label === undefined) return
|
|
396
|
+
interruptedBy = label
|
|
397
|
+
c.interrupt()
|
|
398
|
+
}, DEBOUNCE_MS)
|
|
399
|
+
}
|
|
379
400
|
let windowOpened = 0
|
|
380
401
|
|
|
381
402
|
const trigger = (label: string, abs: string): void => {
|
|
382
403
|
if (!changes.pending.has(abs)) changes.pending.set(abs, label)
|
|
383
|
-
if (running) return
|
|
404
|
+
if (running) return interruptIfWaiting()
|
|
384
405
|
if (debounceTimer) {
|
|
385
406
|
if (Date.now() - windowOpened >= DEBOUNCE_MAX_MS) return
|
|
386
407
|
clearTimeout(debounceTimer)
|
|
@@ -415,7 +436,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
415
436
|
// run made within a tick of it carried an earlier mtime and read as
|
|
416
437
|
// an edit (WD-20). The end needs no stamp: an mtime never leads it.
|
|
417
438
|
const start = fsClockNow(cacheDir)
|
|
418
|
-
|
|
439
|
+
current = watchCycle(opts, stop, interruptIfWaiting)
|
|
440
|
+
const cycle = await runOrchestrator(current.opts)
|
|
441
|
+
current = undefined
|
|
419
442
|
held = cycle.persistent
|
|
420
443
|
if (cycle.refused !== undefined) process.stderr.write(`vx watch: ${cycle.refused}\n`)
|
|
421
444
|
changes.lastCycle = { start, end: Date.now() }
|
|
@@ -427,7 +450,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
427
450
|
// message but DON'T let it crash the watch loop; the next
|
|
428
451
|
// FS event (if any) will retry. The dispose() on SIGINT
|
|
429
452
|
// is the canonical exit; we don't unilaterally abort here.
|
|
430
|
-
|
|
453
|
+
// A config's own throw or a plugin's failure may quote a secret (L-11).
|
|
454
|
+
const message = maskedLine(err instanceof Error ? err.message : String(err))
|
|
431
455
|
process.stderr.write(`vx watch: cycle failed: ${message}\n`)
|
|
432
456
|
}
|
|
433
457
|
// After a failed cycle too: a package added with a config that does
|
|
@@ -439,7 +463,14 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
439
463
|
}
|
|
440
464
|
// What landed mid-run is judged on settled state, one window
|
|
441
465
|
// after the run, under the label of what actually arrived.
|
|
442
|
-
|
|
466
|
+
current = undefined
|
|
467
|
+
if (stop.aborted) break
|
|
468
|
+
if (interruptedBy !== undefined) {
|
|
469
|
+
label = interruptedBy
|
|
470
|
+
interruptedBy = undefined
|
|
471
|
+
continue
|
|
472
|
+
}
|
|
473
|
+
if (changes.pending.size === 0) break
|
|
443
474
|
await Bun.sleep(DEBOUNCE_MS)
|
|
444
475
|
label = changes.judge()
|
|
445
476
|
}
|
|
@@ -856,6 +887,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
856
887
|
const cleanup = async (): Promise<void> => {
|
|
857
888
|
pool.closeAll()
|
|
858
889
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
890
|
+
if (interruptTimer) clearTimeout(interruptTimer)
|
|
859
891
|
// The aborted cycle is tearing its children down; resolve only once
|
|
860
892
|
// it has returned, so the process never exits over a live child.
|
|
861
893
|
await inFlight
|
package/src/exec/kill-tree.ts
CHANGED
|
@@ -65,13 +65,16 @@ export function closeSignalChannel(child: Child): void {
|
|
|
65
65
|
let guardFd: number | null | undefined
|
|
66
66
|
|
|
67
67
|
// Three lists: groups (`+`/`-`), single pids (`=`/`_`) and paths to
|
|
68
|
-
// remove (`@`/`!`), killed and removed in that order at EOF.
|
|
68
|
+
// remove (`@`/`!`), killed and removed in that order at EOF. A
|
|
69
|
+
// group is listed once however often its line arrives: a `-` strikes
|
|
70
|
+
// one entry, and a shell that keeps a failed printf buffered (bash as
|
|
71
|
+
// macOS's sh, B-10) sends the line again with its retry.
|
|
69
72
|
const GUARD_SCRIPT = [
|
|
70
73
|
"g=' ' k=' ' f=' '",
|
|
71
74
|
'while IFS= read -r l; do',
|
|
72
75
|
' p=${l#?}',
|
|
73
76
|
' case $l in',
|
|
74
|
-
' +*) g="$g$p " ;;',
|
|
77
|
+
' +*) case $g in *" $p "*) ;; *) g="$g$p " ;; esac ;;',
|
|
75
78
|
' -*) case $g in *" $p "*) g="${g%% $p *} ${g#* $p }" ;; esac ;;',
|
|
76
79
|
' =*) k="$k$p " ;;',
|
|
77
80
|
' _*) case $k in *" $p "*) k="${k%% $p *} ${k#* $p }" ;; esac ;;',
|
|
@@ -168,18 +171,45 @@ function guardWrite(line: string): void {
|
|
|
168
171
|
*/
|
|
169
172
|
export function spawnGuarded(spawn: (guard: number | undefined) => Child): Child {
|
|
170
173
|
startGuard()
|
|
171
|
-
|
|
174
|
+
if (typeof guardFd !== 'number') return spawn(undefined)
|
|
175
|
+
handingTo = guardProc?.pid
|
|
176
|
+
try {
|
|
177
|
+
return spawn(guardFd)
|
|
178
|
+
} finally {
|
|
179
|
+
handingTo = undefined
|
|
180
|
+
}
|
|
172
181
|
}
|
|
173
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The guard whose pipe `spawnGuarded` is handing over right now. Only a
|
|
185
|
+
* line built for that hand-over retries: its pipe is broken only once
|
|
186
|
+
* the guard has exited, so `kill -0` ends the wait. A line for any other
|
|
187
|
+
* pipe would wait on a guard that has nothing to do with it.
|
|
188
|
+
*/
|
|
189
|
+
let handingTo: number | undefined
|
|
190
|
+
|
|
174
191
|
/**
|
|
175
192
|
* The shell line that lists `$$`'s group on the guard's pipe at `fd` and
|
|
176
193
|
* closes it, so the task never holds the guard open. `$$` is the spawned
|
|
177
194
|
* shell, which leads its group (every guarded spawn is `detached`); a
|
|
178
195
|
* program that is not a shell is `exec`'d after it. A write to a guard
|
|
179
196
|
* that has died is ignored rather than a SIGPIPE that kills the task.
|
|
197
|
+
* The pipe is vx's own nonblocking one, so a full queue fails the write
|
|
198
|
+
* (EAGAIN) and the group went unlisted: the write is retried while the
|
|
199
|
+
* guard lives. The shell cannot tell EAGAIN from EPIPE, so the guard's
|
|
200
|
+
* pid ends the wait, and vx SIGKILLs a guard it gives up on.
|
|
180
201
|
*/
|
|
181
202
|
export function guardLine(fd: number): string {
|
|
182
|
-
|
|
203
|
+
const add = `printf '+%s\\n' $$ >&${fd} 2>/dev/null`
|
|
204
|
+
// `kill -0` counts a zombie, and a guard vx has not reaped (its event
|
|
205
|
+
// loop busy, or an init that never reaps an orphan after vx's kill -9)
|
|
206
|
+
// kept the shell spinning. Where /proc is ours, its state says dead;
|
|
207
|
+
// `##*) ` cuts at the LAST ')', as comm may hold one.
|
|
208
|
+
const alive = procfsIsOwn()
|
|
209
|
+
? `{ read -r vx_g </proc/${handingTo}/stat; } 2>/dev/null && case "\${vx_g##*) }" in [ZX]*) false ;; esac`
|
|
210
|
+
: `kill -0 ${handingTo} 2>/dev/null`
|
|
211
|
+
const retry = handingTo === undefined ? '' : ` || until ${add}; do ${alive} || break; done`
|
|
212
|
+
return `trap '' PIPE; ${add}${retry}; trap - PIPE; exec ${fd}>&-; `
|
|
183
213
|
}
|
|
184
214
|
|
|
185
215
|
/**
|