@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.597",
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.597",
25
- "@vzn/vx-linux-arm64": "0.0.597",
26
- "@vzn/vx-darwin-x64": "0.0.597",
27
- "@vzn/vx-darwin-arm64": "0.0.597"
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
- process.stderr.write(m.startsWith('vx ') ? `${m}\n` : `vx: ${m}\n`)
48
+ say(m.startsWith('vx ') ? `${m}\n` : `vx: ${m}\n`)
45
49
  } else if (isOutOfFds(err)) {
46
- process.stderr.write(`vx: ${err.message} — ${OUT_OF_FDS_HINT}\n`)
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
- process.stderr.write(`vx: ${err.message} — ${fsRefusalHint(err)}\n`)
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
- process.stderr.write(`vx: ${message}\n`)
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
@@ -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
- const absent = inspecting && !existsSync(storeFile)
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 IN-list is chunked at 900 like
2314
- // flushAccessed so a huge eviction stays under any build's
2315
- // bound-parameter ceiling.
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
- // opened when the plugin reads it: a queued job holds a path, not a
553
- // buffer, and a plugin that streams the Blob never holds it whole. The
554
- // artifact is content-addressed and immutable, so a deferred read sees
555
- // the same bytes; if a concurrent `vx cache prune` removed it first the
556
- // plugin's read throws and this upload is skipped — the never-fail
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
- const body =
580
- packed !== undefined ? new Blob([packed]) : Bun.file(this.local.outputsPath(hash))
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
- this.db.transaction(() => {
340
- for (const [hash, rows] of stamps) {
341
- // A stamp outlives nothing: an entry pruned since has no rows to vouch for.
342
- if (this.entryExists.get(hash) === null) continue
343
- for (const [rel, ino, ctime] of rows) this.stampOutputFile.run(hash, rel, ino, ctime)
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]
@@ -69,10 +69,9 @@ export class RunHistory {
69
69
  this.insertRun.run(...bindRun(runs[0]!, this.digestValue))
70
70
  return
71
71
  }
72
- // `bun:sqlite`'s `transaction()` returns a callable that wraps the
73
- // body in BEGIN/COMMIT, fsyncing once at the end. For a 200-task
74
- // run that's one fsync instead of 200.
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
- this.db.transaction(() => {
103
- this.insertRuns(bundle.runs)
104
- this.insertInvocation.run(...bindInvocation(bundle.invocation))
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
  /**
@@ -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.node.config.description
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
- id: t.node.id,
127
- project: t.node.projectName,
128
- task: t.node.taskName,
129
- hash: t.hash,
130
- cacheStatus: t.cacheStatus,
131
- deps: t.deps,
132
- ...(t.p50Ms !== undefined ? { p50Ms: t.p50Ms } : {}),
133
- ...(t.executor !== undefined ? { executor: t.executor } : {}),
134
- ...(t.download !== undefined ? { download: t.download } : {}),
135
-
136
- ...(t.node.config.description !== undefined
137
- ? { description: t.node.config.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 { isUserError, listed, maskedLine, nearest, UserError } from '../util/index.js'
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 desc = config.tasks?.[t]?.description
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
- * wherever it appears, in `env.define` and in the command a TS config
226
- * built from `process.env` (L-11).
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 exec = task.exec
230
- if (exec === undefined) return task
231
- const define = exec.env?.define
232
- const secrets = secretMask([process.env, define], exec.env?.secret)
233
- if (secrets === null) return task
234
- return {
235
- ...task,
236
- exec: {
237
- ...exec,
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 { hangupIgnored, restartTimings } from '../util/index.js'
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
- const cycle = await runOrchestrator(opts)
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
- const message = err instanceof Error ? err.message : String(err)
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
- if (changes.pending.size === 0 || stop.aborted) break
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
@@ -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
- return spawn(typeof guardFd === 'number' ? guardFd : undefined)
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
- return `trap '' PIPE; printf '+%s\\n' $$ >&${fd} 2>/dev/null; trap - PIPE; exec ${fd}>&-; `
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
  /**