@vzn/vx 0.0.650 → 0.0.651

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +5 -20
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/src/bin.ts +9 -31
  5. package/src/cache/cache.ts +202 -25
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/git-inputs.ts +188 -39
  8. package/src/cache/index.ts +1 -0
  9. package/src/cache/inputs.ts +3 -0
  10. package/src/cache/layer.ts +11 -0
  11. package/src/cache/layered-cache.ts +4 -0
  12. package/src/cache/linked-inputs.ts +3 -0
  13. package/src/cache/lockfile-memo.ts +3 -2
  14. package/src/cache/output-index.ts +5 -2
  15. package/src/cache/remote-lane.ts +2 -1
  16. package/src/cache/run-history.ts +11 -1
  17. package/src/cache/schema.ts +11 -1
  18. package/src/cli/cache.ts +26 -16
  19. package/src/cli/completions.ts +4 -2
  20. package/src/cli/docs.ts +10 -11
  21. package/src/cli/help.ts +12 -23
  22. package/src/cli/index.ts +4 -9
  23. package/src/cli/info.ts +15 -7
  24. package/src/cli/init.ts +23 -15
  25. package/src/cli/last.ts +213 -203
  26. package/src/cli/lock.ts +55 -5
  27. package/src/cli/plan-format.ts +100 -118
  28. package/src/cli/plugin-commands.ts +8 -2
  29. package/src/cli/plugin-templates.ts +2 -2
  30. package/src/cli/run.ts +46 -27
  31. package/src/cli/select.ts +43 -5
  32. package/src/cli/show.ts +38 -42
  33. package/src/cli/task-verb.ts +22 -0
  34. package/src/cli/upgrade.ts +3 -1
  35. package/src/cli/watch-fs.ts +6 -1
  36. package/src/cli/watch-judge.ts +2 -1
  37. package/src/cli/watch.ts +83 -44
  38. package/src/cli/why.ts +123 -89
  39. package/src/cli/workspace-config.ts +2 -1
  40. package/src/exec/executor.ts +2 -1
  41. package/src/exec/kill-tree.ts +58 -5
  42. package/src/exec/local-executor.ts +1 -1
  43. package/src/exec/pipes.ts +152 -0
  44. package/src/exec/runner.ts +199 -182
  45. package/src/exec/sandbox-binds.ts +1 -1
  46. package/src/exec/sandbox-runtime.ts +11 -2
  47. package/src/exec/spawn-off.ts +154 -0
  48. package/src/exec/warm-shell.ts +146 -0
  49. package/src/graph/critical-path.ts +40 -28
  50. package/src/graph/priorities.ts +98 -44
  51. package/src/graph/scheduler.ts +14 -10
  52. package/src/graph/task-graph.ts +27 -1
  53. package/src/handoff.ts +10 -7
  54. package/src/history/history.ts +5 -0
  55. package/src/history/metrics.ts +9 -1
  56. package/src/index.ts +3 -1
  57. package/src/orchestrator/doctor.ts +1 -1
  58. package/src/orchestrator/execute-task.ts +16 -2
  59. package/src/orchestrator/graph-memo.ts +50 -1
  60. package/src/orchestrator/graph-stage.ts +6 -0
  61. package/src/orchestrator/index.ts +3 -0
  62. package/src/orchestrator/live-usage.ts +16 -11
  63. package/src/orchestrator/locked-digests.ts +63 -0
  64. package/src/orchestrator/lockfile-claim.ts +108 -29
  65. package/src/orchestrator/miss-save.ts +31 -3
  66. package/src/orchestrator/persistent-task.ts +3 -1
  67. package/src/orchestrator/plan.ts +5 -1
  68. package/src/orchestrator/plugin-host.ts +61 -0
  69. package/src/orchestrator/plugin.ts +47 -2
  70. package/src/orchestrator/prepare.ts +16 -1
  71. package/src/orchestrator/projects.ts +4 -1
  72. package/src/orchestrator/run-context.ts +29 -10
  73. package/src/orchestrator/run-failures.ts +1 -1
  74. package/src/orchestrator/run-records.ts +10 -0
  75. package/src/orchestrator/run.ts +53 -22
  76. package/src/orchestrator/sandbox-request.ts +2 -2
  77. package/src/orchestrator/signals.ts +8 -5
  78. package/src/orchestrator/task-hash.ts +17 -3
  79. package/src/report/events.ts +3 -11
  80. package/src/report/forecast.ts +10 -32
  81. package/src/report/framed-output.ts +272 -173
  82. package/src/report/index.ts +8 -3
  83. package/src/report/logger.ts +73 -38
  84. package/src/report/plain-output.ts +1 -1
  85. package/src/report/status-line.ts +49 -357
  86. package/src/report/summary.ts +100 -131
  87. package/src/ui/ansi.ts +48 -0
  88. package/src/ui/components.ts +506 -0
  89. package/src/ui/index.ts +79 -0
  90. package/src/ui/kit.ts +37 -0
  91. package/src/ui/live-region.ts +363 -0
  92. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  93. package/src/ui/terminal.ts +51 -0
  94. package/src/ui/theme.ts +224 -0
  95. package/src/ui/version-line.ts +28 -0
  96. package/src/util/config-frame.ts +65 -10
  97. package/src/util/edit-distance.ts +6 -1
  98. package/src/util/index.ts +2 -15
  99. package/src/util/paths.ts +20 -2
  100. package/src/util/secret-mask.ts +39 -3
  101. package/src/util/settle.ts +31 -0
  102. package/src/util/timing.ts +4 -16
  103. package/src/workspace/affected.ts +89 -30
  104. package/src/workspace/config-schema.ts +4 -2
  105. package/src/workspace/dependencies-lock.ts +191 -0
  106. package/src/workspace/filter.ts +28 -0
  107. package/src/workspace/index.ts +9 -0
  108. package/src/workspace/lockfile.ts +159 -6
  109. package/src/workspace/migration.ts +4 -3
  110. package/src/workspace/workspace.ts +3 -0
  111. package/src/util/colors.ts +0 -133
  112. package/src/util/report.ts +0 -44
  113. package/src/util/ui.ts +0 -258
  114. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  115. /package/src/{report → ui}/terminal-signals.ts +0 -0
package/src/cli/why.ts CHANGED
@@ -18,19 +18,9 @@ import {
18
18
  whyDidThisRerun,
19
19
  } from '../history/index.js'
20
20
  import { resolveRunId } from '../orchestrator/index.js'
21
- import {
22
- detectColors,
23
- ERROR,
24
- MASKED,
25
- nearMatches,
26
- paint,
27
- printable,
28
- refusal,
29
- SUCCESS,
30
- ui,
31
- UserError,
32
- WARN,
33
- } from '../util/index.js'
21
+ import { MASKED, nearMatches, printable, refusal, UserError } from '../util/index.js'
22
+ import * as ui from '../ui/index.js'
23
+ import type { Style } from '../ui/index.js'
34
24
  import { runRules } from '../settings.js'
35
25
  import { buildPackageGraph, findWorkspaceRoot } from '../workspace/index.js'
36
26
  import { findCwdProject } from './select.js'
@@ -165,6 +155,7 @@ async function resolveTarget(
165
155
  cache: Cache,
166
156
  target: string,
167
157
  cwdProject: () => Promise<string | null>,
158
+ declared: () => Promise<boolean>,
168
159
  ): Promise<string> {
169
160
  const db = indexDb(cache)
170
161
  if (target.includes('#')) {
@@ -202,11 +193,21 @@ async function resolveTarget(
202
193
  const ids = pairs.map((p) => `${p.project}#${p.task}`)
203
194
  const invoked = ids.length === 0 && db.query('SELECT 1 FROM invocations LIMIT 1').get() !== null
204
195
  if (ids.length === 0 && !invoked) {
196
+ // `vx run` of a task no project declares is refused: its advice was that.
197
+ const known = await declared()
205
198
  throw refusal(
206
- { tone: 'info', headline: `no runs of ${target} yet`, fix: `vx run ${target}` },
199
+ known
200
+ ? { tone: 'info', headline: `no runs of ${target} yet`, fix: `vx run ${target}` }
201
+ : {
202
+ tone: 'info',
203
+ headline: `no project declares ${target}`,
204
+ fix: 'vx show lists the tasks',
205
+ },
207
206
  {
208
207
  code: 'VX_E_NO_HISTORY',
209
- message: `vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`,
208
+ message: known
209
+ ? `vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`
210
+ : `vx why: no project declares "${target}" (vx show lists the tasks)`,
210
211
  },
211
212
  )
212
213
  }
@@ -264,8 +265,12 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
264
265
  const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
265
266
  try {
266
267
  const db = indexDb(cache)
267
- const taskId = await resolveTarget(cache, await rootSpelled(root, parsed.target), () =>
268
- findCwdProject(process.cwd()),
268
+ const target = await rootSpelled(root, parsed.target)
269
+ const taskId = await resolveTarget(
270
+ cache,
271
+ target,
272
+ () => findCwdProject(process.cwd()),
273
+ () => declares(root, target),
269
274
  )
270
275
  let runId: string | null
271
276
  if (parsed.runId !== undefined) {
@@ -290,14 +295,23 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
290
295
  // the persisted entry metadata so the verb still says something useful.
291
296
  const explanation = explainCacheKey(db, taskId)
292
297
  if (parsed.format === 'json') {
293
- process.stdout.write(`${JSON.stringify({ taskId, why: null, diff: null, explanation })}\n`)
298
+ ui.data('out', `${JSON.stringify({ taskId, why: null, diff: null, explanation })}\n`)
294
299
  } else {
295
- const c = detectColors(process.stdout)
296
- process.stdout.write(
297
- `${ui.inline(ui.id(taskId), c)}: recorded runs carry no run id — showing the latest cache entry instead\n` +
298
- (explanation.latestEntry !== null
299
- ? ` hash ${explanation.latestEntry.hash} · $ ${explanation.latestEntry.command}\n`
300
- : ' (no cache entry either)\n'),
300
+ ui.print(
301
+ 'out',
302
+ ui.section(
303
+ 'Why',
304
+ taskId,
305
+ ui.text('recorded runs carry no run id: the latest cache entry instead'),
306
+ ui.fields(
307
+ explanation.latestEntry !== null
308
+ ? [
309
+ ['key', explanation.latestEntry.hash],
310
+ ['command', ui.cmd(explanation.latestEntry.command)],
311
+ ]
312
+ : [['key', 'no cache entry either']],
313
+ ),
314
+ ),
301
315
  )
302
316
  }
303
317
  return 0
@@ -326,27 +340,22 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
326
340
  const writers = inputWriters(db, root, taskId, diff.entries, roots)
327
341
 
328
342
  if (parsed.format === 'json') {
329
- process.stdout.write(`${JSON.stringify({ taskId, runId, why, diff, roots, writers })}\n`)
343
+ ui.data('out', `${JSON.stringify({ taskId, runId, why, diff, roots, writers })}\n`)
330
344
  return 0
331
345
  }
332
346
 
333
- const lines: string[] = []
334
- const c = detectColors(process.stdout)
335
- const dim = (text: string): string => paint('', text, c, { dim: true })
336
- const head = (text: string): string => paint('', text, c, { bold: true })
337
347
  const t = why.thisRun!
338
- lines.push(`${ui.inline(ui.id(taskId), c)}${dim(` — run ${runId}`)}`)
339
- lines.push(` ${dim('this run')} ${runFacts(t)}`)
348
+ const head: ui.FieldRow[] = [
349
+ ['run', runId],
350
+ ['this run', runFacts(t)],
351
+ ]
340
352
  if (why.previousRun == null && t.hash === '') {
341
353
  // Only keyed runs are compared: a persistent task's tenth run read
342
354
  // as its first, and the note saying why went unprinted.
343
- lines.push(` ${dim('previous')} (none with a cache key)`)
344
- lines.push(` ${dim('verdict')} ${why.note}`)
355
+ head.push(['previous', 'none with a cache key'], ['verdict', why.note])
345
356
  } else if (why.previousRun == null) {
346
- lines.push(` ${dim('previous')} (none — first recorded run of this task)`)
357
+ head.push(['previous', 'none: first recorded run of this task'])
347
358
  } else {
348
- const p = why.previousRun
349
- lines.push(` ${dim('previous')} ${runFacts(p)}`)
350
359
  // The key moved and the diff says by what: the verdict names it, so
351
360
  // an env-only change reads as one line, not "inputs differ" above a
352
361
  // table to scan.
@@ -354,56 +363,71 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
354
363
  const moved = diff.entries.map((e) =>
355
364
  e.name === e.kind ? e.kind : `${e.kind} ${printable(e.name)}`,
356
365
  )
357
- lines.push(
358
- ` ${dim('verdict')} ${
366
+ head.push(
367
+ ['previous', runFacts(why.previousRun)],
368
+ [
369
+ 'verdict',
359
370
  why.hashChanged === true && moved.length > 0
360
- ? paint(
361
- WARN,
371
+ ? ui.tone(
372
+ 'warn',
362
373
  `cache key changed: ${moved.slice(0, 3).join(', ')}${moved.length > 3 ? ` and ${moved.length - 3} more` : ''}`,
363
- c,
364
374
  )
365
- : why.note
366
- }`,
375
+ : why.note,
376
+ ],
367
377
  )
368
378
  }
379
+ if (diff.entries.length === 0 && why.hashChanged === true) head.push(['detail', diff.note])
380
+ const blocks: ui.Block[] = [ui.section('Why', taskId, ui.fields(head))]
369
381
 
370
382
  if (diff.entries.length > 0) {
371
- lines.push('')
372
- lines.push(
373
- ` ${head('what changed')}${dim(` (${diff.entries.length} component${diff.entries.length === 1 ? '' : 's'}, ${diff.unchangedCount} unchanged)`)}:`,
383
+ blocks.push(
384
+ ui.section(
385
+ 'What changed',
386
+ `${diff.entries.length} of ${diff.entries.length + diff.unchangedCount}`,
387
+ ui.table(
388
+ ['change', 'kind', 'name', 'before → after'],
389
+ diff.entries.map((e) => [
390
+ ui.tone(CHANGE_HUE[e.change] ?? 'warn', e.change),
391
+ e.kind,
392
+ ui.strong(printable(e.name)),
393
+ // A secret-named env input shows its name and its change, no hash.
394
+ e.before === MASKED || e.after === MASKED
395
+ ? ''
396
+ : e.change === 'added'
397
+ ? `+ ${e.after}`
398
+ : e.change === 'removed'
399
+ ? `- ${e.before}`
400
+ : `${e.before} → ${e.after}`,
401
+ ]),
402
+ ),
403
+ ),
374
404
  )
375
- const kindW = Math.max(...diff.entries.map((e) => e.kind.length), 4)
376
- for (const e of diff.entries) {
377
- // A secret-named env input shows its name and its change, no hash.
378
- const beforeAfter =
379
- e.before === MASKED || e.after === MASKED
380
- ? ''
381
- : e.change === 'added'
382
- ? `+ ${e.after}`
383
- : e.change === 'removed'
384
- ? `- ${e.before}`
385
- : `${e.before} → ${e.after}`
386
- lines.push(
387
- ` ${paint(CHANGE_HUE[e.change] ?? WARN, e.change.padEnd(7), c)} ${dim(e.kind.padEnd(kindW))} ${printable(e.name)} ${dim(beforeAfter)}`.trimEnd(),
388
- )
389
- }
390
405
  if (roots.length > 0) {
391
- lines.push('', ` ${head('root cause')}:`)
392
- for (const r of roots.slice(0, ROOTS_SHOWN)) {
406
+ const lines: ui.Inline[] = roots.slice(0, ROOTS_SHOWN).map((r) => {
393
407
  const what = r.entries.map((e) =>
394
408
  e.name === e.kind ? e.kind : `${e.kind} ${printable(e.name)}`,
395
409
  )
396
- lines.push(
397
- ` ${r.chain.join(' \u2190 ')} \u2190 ${what.slice(0, 3).join(', ')}${what.length > 3 ? ` and ${what.length - 3} more` : ''}`,
398
- )
399
- }
400
- if (roots.length > ROOTS_SHOWN) lines.push(` and ${roots.length - ROOTS_SHOWN} more`)
410
+ return [
411
+ ...r.chain.flatMap((id, i) => [...(i > 0 ? [ui.dim(' \u2190 ')] : []), ui.id(id)]),
412
+ ui.dim(' \u2190 '),
413
+ ui.strong(
414
+ `${what.slice(0, 3).join(', ')}${what.length > 3 ? ` and ${what.length - 3} more` : ''}`,
415
+ ),
416
+ ]
417
+ })
418
+ if (roots.length > ROOTS_SHOWN) lines.push(`and ${roots.length - ROOTS_SHOWN} more`)
419
+ blocks.push(ui.section('Root cause', ui.text(...lines)))
401
420
  }
402
421
  if (writers.length > 0) {
403
- lines.push('', ` ${head('changed during a run')}:`)
404
- for (const w of writers) {
405
- lines.push(` ${printable(w.file)} ${dim(`while ${w.writers.join(', ')} ran`)}`)
406
- }
422
+ blocks.push(
423
+ ui.section(
424
+ 'Changed during a run',
425
+ ui.table(
426
+ ['file', 'written while'],
427
+ writers.map((w) => [ui.path(printable(w.file)), w.writers.join(', ')]),
428
+ ),
429
+ ),
430
+ )
407
431
  }
408
432
  const rootKinds = roots.flatMap((r) => r.entries.map((e) => e.kind))
409
433
  // An upstream row names the task to ask next: the command, not a
@@ -418,31 +442,26 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
418
442
  k in WHAT_TO_DO && !(k === 'upstream' && (roots.length > 0 || upstream.length === 0)),
419
443
  )
420
444
  if (kinds.length > 0) {
421
- lines.push('', ` ${head('what to do')}:`)
422
- // Its own column: a root cause's kind and the `written` row are
423
- // not in the table above, and overflowed its width.
424
- const todoW = Math.max(kindW, ...kinds.map((k) => k.length), writers.length > 0 ? 7 : 0)
445
+ const todo: ui.FieldRow[] = []
425
446
  for (const k of kinds) {
426
447
  if (k === 'upstream') {
427
- for (const todo of upstream) lines.push(` ${dim(k.padEnd(todoW))} ${todo}`)
448
+ todo.push([k, ...upstream])
428
449
  continue
429
450
  }
430
- const todo =
451
+ todo.push([
452
+ k,
431
453
  k === 'config'
432
454
  ? WHAT_TO_DO[k]!.replace('`vx show <that task>`', `\`vx show ${taskId}\``)
433
- : WHAT_TO_DO[k]
434
- lines.push(` ${dim(k.padEnd(todoW))} ${todo}`)
435
- }
436
- if (writers.length > 0) {
437
- // A task re-writing a file a key reads moves that key every run:
438
- // the miss is right, and only moving the write ends it.
439
- lines.push(` ${dim('written'.padEnd(todoW))} ${WRITTEN_TODO}`)
455
+ : WHAT_TO_DO[k]!,
456
+ ])
440
457
  }
458
+ // A task re-writing a file a key reads moves that key every run:
459
+ // the miss is right, and only moving the write ends it.
460
+ if (writers.length > 0) todo.push(['written', WRITTEN_TODO])
461
+ blocks.push(ui.section('What to do', ui.fields(todo)))
441
462
  }
442
- } else if (why.hashChanged === true) {
443
- lines.push(` ${dim('detail')} ${diff.note}`)
444
463
  }
445
- process.stdout.write(`${lines.join('\n')}\n`)
464
+ ui.print('out', ...blocks)
446
465
  return 0
447
466
  } finally {
448
467
  cache.close()
@@ -451,7 +470,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
451
470
 
452
471
  const ROOTS_SHOWN = 5
453
472
 
454
- const CHANGE_HUE: Record<string, string> = { added: SUCCESS, removed: ERROR, changed: WARN }
473
+ const CHANGE_HUE: Record<string, Style> = { added: 'ok', removed: 'error', changed: 'warn' }
455
474
 
456
475
  type DiffEntry = ReturnType<typeof cacheKeyDiff>['entries'][number]
457
476
 
@@ -489,6 +508,21 @@ function upstreamTodo(upstream: readonly DiffEntry[], unrecorded: readonly DiffE
489
508
  return out
490
509
  }
491
510
 
511
+ /** Whether a project declares `target` (`pkg#task`, or a bare task); true when no config loads. */
512
+ async function declares(root: string, target: string): Promise<boolean> {
513
+ try {
514
+ const projects = await loadCliProjects(root, await discoverCliProjects(root), 'all')
515
+ const [project, task] = target.includes('#') ? splitTaskId(target) : [undefined, target]
516
+ return [...projects.values()].some(
517
+ (p) =>
518
+ (project === undefined || p.name === project) && Object.hasOwn(p.config.tasks ?? {}, task),
519
+ )
520
+ } catch {
521
+ // quiet: with no lock or a broken config, the run says why itself
522
+ return true
523
+ }
524
+ }
525
+
492
526
  /**
493
527
  * Each group in `taskId`'s graph with the tasks it gathers. Empty when a
494
528
  * config no longer loads: the advice then names the group to `vx show`.
@@ -7,7 +7,8 @@
7
7
  import { existsSync } from 'node:fs'
8
8
  import path from 'node:path'
9
9
  import type { WorkspaceConfig } from '../config.js'
10
- import { UserError, sayLine } from '../util/index.js'
10
+ import { UserError } from '../util/index.js'
11
+ import { sayLine } from '../ui/index.js'
11
12
  import {
12
13
  discoverProjects,
13
14
  loadProjects,
@@ -16,7 +16,8 @@
16
16
  import type { SandboxConfig } from '../config.js'
17
17
  import type { CaptureConfig, RunResult } from './runner.js'
18
18
  import type { ResolvedSandboxConfig, SandboxViolation } from './sandbox-runtime.js'
19
- import { thrownMessage, UserError, type ui } from '../util/index.js'
19
+ import { thrownMessage, UserError } from '../util/index.js'
20
+ import * as ui from '../ui/index.js'
20
21
 
21
22
  /** Sandbox baselines + the user's resolved sandbox block, when the task is sandboxed. */
22
23
  export interface ExecuteSandbox {
@@ -98,6 +98,9 @@ let guardProc: Child | undefined
98
98
  */
99
99
  const GUARD_STALL_MS = 1_000
100
100
 
101
+ /** Bytes per write to the guard: under a socket's buffer on every platform. */
102
+ const GUARD_PIECE = 1024
103
+
101
104
  function startGuard(): void {
102
105
  if (guardFd !== undefined) return
103
106
  guardFd = null
@@ -114,6 +117,8 @@ function startGuard(): void {
114
117
  const fd = guard.stdio[3] as number
115
118
  guardFd = fd
116
119
  guardProc = guard
120
+ // A clean exit strikes what it released: the EOF kills what is listed.
121
+ process.once('exit', flushGuard)
117
122
  // A guard that has died (killed, the OOM killer) is handed to no later
118
123
  // spawn: its pipe is broken, and a shell whose printf buffers (bash as
119
124
  // macOS's sh) flushed the failed `+<pgid>` line into the task's own
@@ -134,6 +139,7 @@ function startGuard(): void {
134
139
  function stopGuard(): void {
135
140
  const fd = guardFd
136
141
  guardFd = null
142
+ pending = ''
137
143
  guardProc?.kill('SIGKILL')
138
144
  if (typeof fd !== 'number') return
139
145
  try {
@@ -145,11 +151,18 @@ function stopGuard(): void {
145
151
 
146
152
  function guardWrite(line: string): void {
147
153
  if (typeof guardFd !== 'number') return
148
- const deadline = Date.now() + GUARD_STALL_MS
154
+ let deadline = Date.now() + GUARD_STALL_MS
155
+ // A turn's batch can be more than the queue takes at once: it goes in
156
+ // pieces as the guard drains it, and only a guard that takes nothing for
157
+ // the whole stall is gone. With one deadline for the batch, 2000 strikes
158
+ // outlasted it on macOS and the guard was stopped under a live run.
159
+ let bytes = Buffer.from(line)
149
160
  for (;;) {
150
161
  try {
151
- writeSync(guardFd, line)
152
- return
162
+ const n = writeSync(guardFd, bytes, 0, Math.min(bytes.length, GUARD_PIECE))
163
+ if (n === bytes.length) return
164
+ bytes = bytes.subarray(n)
165
+ deadline = Date.now() + GUARD_STALL_MS
153
166
  } catch (err) {
154
167
  if ((err as NodeJS.ErrnoException).code !== 'EAGAIN' || Date.now() >= deadline) break
155
168
  Bun.sleepSync(1)
@@ -158,6 +171,43 @@ function guardWrite(line: string): void {
158
171
  stopGuard()
159
172
  }
160
173
 
174
+ /**
175
+ * Lines waiting for this turn's one write: strikes, and the listings of
176
+ * spare shells. Each line was a write of its own and a wake of the
177
+ * guard's shell: on a cold run of 500 tasks, 38 ms of vx's main thread
178
+ * for the strikes and 140 ms of the guard's CPU (2026-11-03). If vx dies
179
+ * in the turn a task ended, what that task left in the background dies
180
+ * with it, as when vx dies a moment before the task ends.
181
+ */
182
+ let pending = ''
183
+ let pendingTimer: ReturnType<typeof setImmediate> | undefined
184
+
185
+ function queueLine(line: string): void {
186
+ pending += line
187
+ pendingTimer ??= setImmediate(flushGuard)
188
+ }
189
+
190
+ /** Write the lines this turn queued now: before a listed child is handed its work. */
191
+ export function flushGuard(): void {
192
+ if (pendingTimer !== undefined) clearImmediate(pendingTimer)
193
+ pendingTimer = undefined
194
+ const lines = pending
195
+ pending = ''
196
+ if (lines !== '') guardWrite(lines)
197
+ }
198
+
199
+ /**
200
+ * List `pid`'s group from vx's side, for a child that runs nothing until
201
+ * vx hands it work (a spare shell waiting on its script): the line goes
202
+ * with the turn's others, and `flushGuard` before the hand-over. A spare
203
+ * vx dies before listing reads the end of its script's pipe and exits,
204
+ * having run nothing. Starts the guard.
205
+ */
206
+ export function listGroup(pid: number): void {
207
+ startGuard()
208
+ if (typeof guardFd === 'number' && pid > 0) queueLine(`+${pid}\n`)
209
+ }
210
+
161
211
  /**
162
212
  * Spawn a task child whose group the guard kills if vx dies holding it.
163
213
  * The CHILD lists its own group, before it runs anything: vx wrote the
@@ -170,8 +220,11 @@ function guardWrite(line: string): void {
170
220
  * races it. The guard starts BEFORE the spawn, so there is a pipe to hand.
171
221
  * `undefined` when the guard could not start: nothing lists anything.
172
222
  */
173
- export function spawnGuarded(spawn: (guard: number | undefined) => Child): Child {
223
+ export function spawnGuarded<C = Child>(spawn: (guard: number | undefined) => C): C {
174
224
  startGuard()
225
+ // The turn's lines go first, as they did one write each: a child's own
226
+ // listing behind them, not raced by a batch vx writes once it runs.
227
+ flushGuard()
175
228
  if (typeof guardFd !== 'number') return spawn(undefined)
176
229
  handingTo = guardProc?.pid
177
230
  try {
@@ -223,7 +276,7 @@ export function releaseGroup(child: Child): void {
223
276
  if (!(child.pid > 0)) return
224
277
  const hold = holds.get(child.pid)
225
278
  if (hold !== undefined) hold.released = true
226
- else guardWrite(`-${child.pid}\n`)
279
+ else queueLine(`-${child.pid}\n`)
227
280
  }
228
281
 
229
282
  /**
@@ -44,6 +44,7 @@ export function localExecutor(): TaskExecutor {
44
44
  ...(req.liveChildren !== undefined ? { liveChildren: req.liveChildren } : {}),
45
45
  ...(req.onSpawn !== undefined ? { onSpawn: req.onSpawn } : {}),
46
46
  ...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
47
+ ...(req.signal !== undefined ? { signal: req.signal } : {}),
47
48
  }
48
49
  if (req.sandbox === undefined) {
49
50
  const res = await runCommand(req.terminal === true ? { ...common, terminal: true } : common)
@@ -56,7 +57,6 @@ export function localExecutor(): TaskExecutor {
56
57
  reportWithin: req.sandbox.reportWithin,
57
58
  reportLinked: req.sandbox.reportLinked,
58
59
  config: req.sandbox.config,
59
- ...(req.signal !== undefined ? { signal: req.signal } : {}),
60
60
  })
61
61
  },
62
62
  }
@@ -0,0 +1,152 @@
1
+ // A task's stdout, stderr and held stdin as pipes vx makes itself (Linux),
2
+ // shared by the runner, the spare shells and the sandbox's spawn.
3
+
4
+ import type { Pointer } from 'bun:ffi'
5
+ import { closeSync } from 'node:fs'
6
+
7
+ const O_NONBLOCK = 0o4000
8
+ const O_CLOEXEC = 0o2000000
9
+ const F_SETFL = 4
10
+ const F_SETPIPE_SZ = 1031
11
+ /**
12
+ * A pipe holds 64 KiB by default, and a 200 MB stdout drained 13% slower
13
+ * through one than through Bun's socketpair (min of 7: 293 vs 259 ms);
14
+ * at 256 KiB it drained in 230. Past the user's pipe quota the kernel
15
+ * refuses the resize and the pipe keeps its 64 KiB.
16
+ */
17
+ const PIPE_BYTES = 256 * 1024
18
+
19
+ type PipeCalls = {
20
+ pipe2: (fds: Pointer, flags: number) => number
21
+ fcntl: (fd: number, cmd: number, arg: number) => number
22
+ ptr: (view: Int32Array, byteOffset?: number) => Pointer
23
+ }
24
+ let pipeCalls: PipeCalls | null | undefined
25
+
26
+ /** libc's `pipe2` and `fcntl`, bound once; null off Linux or where libc will not load. */
27
+ function linuxPipeCalls(): PipeCalls | null {
28
+ if (pipeCalls !== undefined) return pipeCalls
29
+ pipeCalls = null
30
+ if (process.platform !== 'linux') return null
31
+ try {
32
+ // Loaded at the first spawn, not with the module: a static import cost
33
+ // every vx start 0.3 ms, a warm run that spawns nothing included.
34
+ const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
35
+ // glibc's soname; musl's loader answers any `libc.` name with itself
36
+ // (ldso/dynlink.c's reserved names).
37
+ const { symbols } = dlopen('libc.so.6', {
38
+ pipe2: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
39
+ fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
40
+ })
41
+ pipeCalls = { pipe2: symbols.pipe2, fcntl: symbols.fcntl, ptr }
42
+ } catch {
43
+ // Bun's socketpair, as before: only `/dev/stdout` by path fails on it.
44
+ }
45
+ return pipeCalls
46
+ }
47
+
48
+ /**
49
+ * Two pipes, `[out read, out write, err read, err write]`, or undefined
50
+ * when libc is not bound or a descriptor is not to be had (Bun's own
51
+ * socketpair then fails the spawn with the reason).
52
+ */
53
+ function realPipes(): Int32Array | undefined {
54
+ const calls = linuxPipeCalls()
55
+ if (calls === null) return undefined
56
+ const fds = new Int32Array(4)
57
+ // Bun's spawn closes every descriptor it is not handed (probed, Bun
58
+ // 1.4.2); O_CLOEXEC keeps any other exec from holding a write end open.
59
+ if (calls.pipe2(calls.ptr(fds), O_CLOEXEC) !== 0) return undefined
60
+ if (calls.pipe2(calls.ptr(fds, 8), O_CLOEXEC) !== 0) {
61
+ closeSync(fds[0]!)
62
+ closeSync(fds[1]!)
63
+ return undefined
64
+ }
65
+ // vx's ends only: a task whose stdout is non-blocking fails its writes with EAGAIN.
66
+ calls.fcntl(fds[0]!, F_SETFL, O_NONBLOCK)
67
+ calls.fcntl(fds[2]!, F_SETFL, O_NONBLOCK)
68
+ calls.fcntl(fds[0]!, F_SETPIPE_SZ, PIPE_BYTES)
69
+ calls.fcntl(fds[2]!, F_SETPIPE_SZ, PIPE_BYTES)
70
+ return fds
71
+ }
72
+
73
+ /** One pipe, `[read, write]`, both close-on-exec, or undefined as {@link realPipes}. */
74
+ export function heldPipe(): Int32Array | undefined {
75
+ const calls = linuxPipeCalls()
76
+ if (calls === null) return undefined
77
+ const fds = new Int32Array(2)
78
+ return calls.pipe2(calls.ptr(fds), O_CLOEXEC) === 0 ? fds : undefined
79
+ }
80
+
81
+ /**
82
+ * A task's stdout and stderr. Bun's `'pipe'` is a socketpair, and Linux
83
+ * opens `/dev/stdout` through `/proc/self/fd/1`, which a socket refuses
84
+ * (ENXIO): `echo x > /dev/stdout` and `cmd | tee /dev/stderr` failed in
85
+ * every task, where a terminal or Turbo hands real pipes (X-113). So on
86
+ * Linux vx makes the pipes (`pipe2` through `bun:ffi`) and hands the child
87
+ * the write ends. macOS opens `/dev/fd/N` as a dup of the descriptor,
88
+ * which a socket allows, so Bun's pipe stays there.
89
+ *
90
+ * `held`: stdin is a pipe vx keeps open and never writes (a persistent
91
+ * task's, schema.md), for the same reason: `cat /dev/stdin` on Bun's
92
+ * socketpair failed with ENXIO instead of blocking. Otherwise `'ignore'`.
93
+ */
94
+ export class TaskPipes {
95
+ /** Descriptor 0 for `Bun.spawn`'s `stdio`. */
96
+ readonly stdin: number | 'pipe' | 'ignore'
97
+ /** Descriptors 1 and 2 for `Bun.spawn`'s `stdio`. */
98
+ readonly stdio: readonly [number | 'pipe', number | 'pipe']
99
+ readonly #read: readonly number[]
100
+ readonly #stdinWrite: number | undefined
101
+
102
+ constructor(terminal?: boolean, held?: boolean) {
103
+ const fds = terminal === true ? undefined : realPipes()
104
+ this.stdio = fds === undefined ? ['pipe', 'pipe'] : [fds[1]!, fds[3]!]
105
+ this.#read = fds === undefined ? [] : [fds[0]!, fds[2]!]
106
+ const inFds = held === true && terminal !== true ? heldPipe() : undefined
107
+ this.stdin = held !== true ? 'ignore' : inFds === undefined ? 'pipe' : inFds[0]!
108
+ this.#stdinWrite = inFds?.[1]
109
+ }
110
+
111
+ /**
112
+ * Close vx's copies of the child's ends once the spawn has returned or
113
+ * thrown: the child holds its own, and a write end left here is a writer
114
+ * the reader's EOF would wait for.
115
+ */
116
+ spawned(): void {
117
+ for (const fd of [this.stdin, ...this.stdio]) if (typeof fd === 'number') closeSync(fd)
118
+ }
119
+
120
+ /** The child's stdout and stderr. */
121
+ streams(
122
+ child: ReturnType<typeof Bun.spawn>,
123
+ ): [
124
+ ReadableStream<Uint8Array> | number | undefined,
125
+ ReadableStream<Uint8Array> | number | undefined,
126
+ ] {
127
+ const [out, err] = this.#read
128
+ return out === undefined
129
+ ? [child.stdout, child.stderr]
130
+ : [Bun.file(out).stream(), Bun.file(err!).stream()]
131
+ }
132
+
133
+ /**
134
+ * Close the read ends, once both streams have ended or been cancelled.
135
+ * Bun reads a dup of each, but takes it on the stream's first pull: a
136
+ * number closed before then was reused by the next spawn's pipe, and
137
+ * the stream read THAT (probed, Bun 1.4.2).
138
+ */
139
+ close(): void {
140
+ for (const fd of this.#read) closeSync(fd)
141
+ }
142
+
143
+ /**
144
+ * Close the held stdin's write end, once the child has exited and its
145
+ * output has ended. Not with the output alone: a server that sends it
146
+ * to a log (`exec >log 2>&1`) ended its streams at once, read stdin EOF
147
+ * and exited.
148
+ */
149
+ closeStdin(): void {
150
+ if (this.#stdinWrite !== undefined) closeSync(this.#stdinWrite)
151
+ }
152
+ }