@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -13,6 +13,7 @@ import {
13
13
  isOutOfFds,
14
14
  killGraceMs,
15
15
  OUT_OF_FDS_HINT,
16
+ POST_EXIT_DRAIN_MS,
16
17
  testTimerMs,
17
18
  helperEnv,
18
19
  type SecretMask,
@@ -20,6 +21,7 @@ import {
20
21
  import {
21
22
  type Child,
22
23
  closeSignalChannel,
24
+ groupAlive,
23
25
  holdGroups,
24
26
  killTree,
25
27
  releaseGroup,
@@ -51,6 +53,9 @@ export interface RunResult {
51
53
  * Distinguishes a timeout (a real `failed`) from a Ctrl-C shutdown
52
54
  * SIGTERM (which the orchestrator reverts to `aborted`). */
53
55
  timedOut?: boolean
56
+ /** The timeout's SIGKILL past the grace took a process of the task's
57
+ * group: the child, or one it left after dying of the SIGTERM. */
58
+ killedAfterGrace?: true
54
59
  /** Total user+system CPU time for the child, in milliseconds. */
55
60
  cpuMs?: number
56
61
  /** Peak resident set size for the child, in bytes. */
@@ -58,9 +63,10 @@ export interface RunResult {
58
63
  /** `Bun.spawn` threw: no shell ran, so its 127 is vx's and says nothing
59
64
  * about the command (A-41). */
60
65
  spawnFailed?: true
61
- /** The shell exited while a process it started still held its
62
- * stdout/stderr past the drain bound (`POST_EXIT_CUT_LINE`): that process
63
- * may still write the task's outputs, so the result is not saved. */
66
+ /** The shell exited while a process it started still ran: one holding
67
+ * stdout/stderr past the drain bound (`POST_EXIT_CUT_LINE`), or one left
68
+ * in the task's process group. It may still write the task's outputs,
69
+ * so the result is not saved. */
64
70
  leftRunning?: true
65
71
  }
66
72
 
@@ -182,7 +188,12 @@ export function shellQuote(arg: string): string {
182
188
  * `<<X` in a comment or quotes opens no heredoc, and a quote in a body
183
189
  * opens nothing (X-110).
184
190
  */
185
- function shellShape(command: string): { comment: number; body: boolean[]; subshell: boolean } {
191
+ function shellShape(command: string): {
192
+ comment: number
193
+ body: boolean[]
194
+ subshell: boolean
195
+ quoted: boolean
196
+ } {
186
197
  const lines = command.split('\n')
187
198
  const body = lines.map(() => false)
188
199
  const pending: Array<{ delim: string; tabs: boolean }> = []
@@ -261,9 +272,36 @@ function shellShape(command: string): { comment: number; body: boolean[]; subshe
261
272
  }
262
273
  atWordStart = /[\s;&|()<>]/.test(c)
263
274
  }
264
- return { comment, body, subshell }
275
+ return { comment, body, subshell, quoted: quote !== '' }
265
276
  }
266
277
 
278
+ // One redirection at the end of a command: an fd, an operator, and a
279
+ // target word (quoted parts and `<(…)` / `>(…)` included).
280
+ const TRAILING_REDIRECT =
281
+ /(?:^|[\s)])(?:&>>?|\d*(?:<<<|>>|>&|<&|<>|>\||>|<))[ \t]*(?:[<>]\([^()]*\)|(?:[^\s;&|()<>'"\\]|\\.|'[^']*'|"(?:[^"\\]|\\.)*")+)$/
282
+
283
+ /**
284
+ * Where trailing redirections start, when a compound command's closer
285
+ * stands before them (`(cmd) 2>&1`, `done < f`), or -1. One inside quotes
286
+ * or a comment is not peeled.
287
+ */
288
+ function redirectsAfterCloser(command: string): number {
289
+ let head = command
290
+ for (;;) {
291
+ const m = TRAILING_REDIRECT.exec(head)
292
+ if (m === null) break
293
+ const start = m.index + (/^[\s)]/.test(m[0]) ? 1 : 0)
294
+ const rest = head.slice(0, start)
295
+ const shape = shellShape(rest)
296
+ if (shape.quoted || shape.comment >= 0 || /(?:^|[^\\])(?:\\\\)*\\$/.test(rest)) break
297
+ head = rest.trimEnd()
298
+ }
299
+ if (head.length === command.length) return -1
300
+ return shellShape(head).subshell || COMPOUND_CLOSER.test(head) ? head.length : -1
301
+ }
302
+
303
+ const COMPOUND_CLOSER = /(?:^|[^\\])(?:\\\\)*[;&\n][ \t]*(fi|done|esac|\})$/
304
+
267
305
  /**
268
306
  * The command a task runs with the args after `--` appended, shell-quoted.
269
307
  * They go before a trailing comment: appended after it, `echo args: # show`
@@ -300,10 +338,12 @@ export function withForwardArgs(command: string, args: readonly string[] | undef
300
338
  // After a compound command's closer they were a syntax error
301
339
  // (`(cmd) --fix`, `fi --fix`): they go to the last command inside it.
302
340
  // A closer word counts only where a command starts, after a separator.
303
- const closer = shape.subshell
304
- ? ')'
305
- : /(?:^|[^\\])(?:\\\\)*[;&\n][ \t]*(fi|done|esac|\})$/.exec(trimmed)?.[1]
341
+ const closer = shape.subshell ? ')' : COMPOUND_CLOSER.exec(trimmed)?.[1]
306
342
  const end = sep ?? closer
343
+ // Redirections after the closer (`(cmd) 2>&1`, `done < f`) hid it: the
344
+ // args go inside it and the redirections stay after.
345
+ const at = end === undefined ? redirectsAfterCloser(trimmed) : -1
346
+ if (at >= 0) return `${withForwardArgs(trimmed.slice(0, at), args)}${trimmed.slice(at)}`
307
347
  if (end !== undefined) {
308
348
  const head = trimmed.slice(0, -end.length).trimEnd()
309
349
  return `${withForwardArgs(head, args)}${trimmed.slice(head.length)}`
@@ -504,7 +544,8 @@ function afterPendingExits(fn: () => void): ReturnType<typeof setTimeout> {
504
544
  * classify the resulting SIGTERM as a real failure rather than a
505
545
  * Ctrl-C abort — and `settle()`, awaited once the child has exited:
506
546
  * it cancels the timers and, when the timeout fired, waits out the rest
507
- * of the grace for the child's GROUP and SIGKILLs whoever is left. The
547
+ * of the grace for the child's GROUP and SIGKILLs whoever is left, and
548
+ * `sigkilled()` says whether that SIGKILL found anyone. The
508
549
  * shell dying on the SIGTERM is not the tree dying: a backgrounded
509
550
  * process that ignores it lived on under init after vx exited (nx#11782's
510
551
  * sibling, reproduced on vx 2026-09-24). A no-op (never fires, nothing
@@ -513,16 +554,18 @@ function afterPendingExits(fn: () => void): ReturnType<typeof setTimeout> {
513
554
  export function armTimeout(
514
555
  proc: ReturnType<typeof Bun.spawn>,
515
556
  timeoutMs: number | undefined,
516
- ): { timedOut: () => boolean; settle: () => Promise<void> } {
517
- if (timeoutMs === undefined) return { timedOut: () => false, settle: async () => {} }
557
+ ): { timedOut: () => boolean; sigkilled: () => boolean; settle: () => Promise<void> } {
558
+ if (timeoutMs === undefined)
559
+ return { timedOut: () => false, sigkilled: () => false, settle: async () => {} }
518
560
  let firedAt: number | undefined
561
+ let sigkilled = false
519
562
  let killTimer: ReturnType<typeof setTimeout> | undefined
520
563
  const graceMs = killGraceMs(TIMEOUT_SIGKILL_GRACE_MS)
521
564
  let timer = setTimeout(() => {
522
565
  timer = afterPendingExits(fire)
523
566
  }, timeoutMs)
524
567
  function fire(): void {
525
- firedAt = Date.now()
568
+ firedAt = performance.now()
526
569
  killTree(proc, 'SIGTERM')
527
570
  // Escalate to SIGKILL after a grace: a child that TRAPS+IGNORES SIGTERM
528
571
  // (`trap '' TERM`) would otherwise defeat the timeout entirely and hang
@@ -530,35 +573,26 @@ export function armTimeout(
530
573
  // timeout, so a wedged child hangs the whole run forever. Mirrors the
531
574
  // end-of-run persistent-shutdown escalation. Unref'd so it never keeps
532
575
  // the CLI alive.
533
- killTimer = setTimeout(() => killTree(proc, 'SIGKILL'), graceMs)
576
+ killTimer = setTimeout(() => {
577
+ sigkilled = true
578
+ killTree(proc, 'SIGKILL')
579
+ }, graceMs)
534
580
  killTimer.unref?.()
535
581
  }
536
582
  return {
537
583
  timedOut: () => firedAt !== undefined,
584
+ sigkilled: () => sigkilled,
538
585
  settle: async () => {
539
586
  clearTimeout(timer)
540
587
  if (killTimer !== undefined) clearTimeout(killTimer)
541
588
  if (firedAt === undefined) return
542
- const left = await untilGroupsGone([proc], Math.max(0, firedAt + graceMs - Date.now()))
589
+ const left = await untilGroupsGone([proc], Math.max(0, firedAt + graceMs - performance.now()))
543
590
  for (const child of left) killTree(child, 'SIGKILL')
591
+ if (left.length > 0) sigkilled = true
544
592
  },
545
593
  }
546
594
  }
547
595
 
548
- /**
549
- * Grace after the direct child exits before we abort the stdout/stderr readers.
550
- * The pipe reaches EOF only when EVERY write-end fd is closed, so a task that
551
- * backgrounds a process inheriting fd 1/2 (`server & echo up` — `execWrap`
552
- * leaves compound commands as `sh -c`, so `sh` exits while the grandchild holds
553
- * the pipe) never EOFs. On a clean exit with no lingering writer the pipe EOFs
554
- * at once, so the readers win this race immediately and normal tasks pay
555
- * nothing; only a stuck reader waits out the grace. Residual buffered output is
556
- * bounded by the OS pipe buffer (streamToString drains continuously DURING the
557
- * run), so this is far longer than any real drain needs. What the leftover
558
- * process writes after the bound is lost, and `POST_EXIT_CUT_LINE` says so.
559
- */
560
- const POST_EXIT_DRAIN_MS = 250
561
-
562
596
  /**
563
597
  * The line a task's frame gets when the drain bound cut its pipes. The cut
564
598
  * was silent: a backgrounded child's late output vanished from the frame and
@@ -746,6 +780,8 @@ export interface PersistentSpawn {
746
780
  ready: Promise<void>
747
781
  /** ms from spawn to ready, to the readiness timeout giving up, or to now. */
748
782
  readyMs: () => number
783
+ /** Whether the readiness timeout fired: `ready` rejects only after the kill grace. */
784
+ gaveUp: () => boolean
749
785
  }
750
786
 
751
787
  // `capture` is a `runCommand` concept: a persistent task never returns a
@@ -765,6 +801,12 @@ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs' | 'cap
765
801
  * resolves before the timer can fire).
766
802
  */
767
803
  timeoutMs?: number
804
+ /**
805
+ * Called once, at ready: after `onStdout` / `onStderr` had the text up to
806
+ * the end of the ready line, before anything after it. What follows is
807
+ * the since-ready stream; a chunk holding both is split there.
808
+ */
809
+ onReady?: () => void
768
810
  /**
769
811
  * Spawn it with fd 3 as its signal channel and route its polite signals
770
812
  * there (`signalThrough`): a Linux sandboxed command, whose group signal
@@ -781,11 +823,14 @@ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs' | 'cap
781
823
  * `onStderr` callbacks for the whole lifetime of the child.
782
824
  */
783
825
  export function runPersistent(opts: PersistentOptions): PersistentSpawn {
784
- const start = Date.now()
826
+ const start = performance.now()
785
827
  let readyAt: number | undefined
786
828
  // When the readiness wait gave up: the wait's length, not the kill grace
787
829
  // a TERM-trapping server runs out after it.
788
830
  let gaveUpAt: number | undefined
831
+ // The shell exited non-zero before a match: a marker read after it, from
832
+ // a child it left running, cannot make an exited server ready.
833
+ let failedExit = false
789
834
 
790
835
  // Pattern compiled once; thrown errors surface synchronously so the
791
836
  // caller can wrap with a user-facing message.
@@ -839,13 +884,19 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
839
884
  const message =
840
885
  isExecutableMissing(err) && !existsSync(opts.cwd)
841
886
  ? `its working directory ${opts.cwd} does not exist`
842
- : spawnErrorReason(err, opts.signalChannel === true ? SANDBOXED_TASK : 'task', opts.command)
887
+ : spawnErrorReason(
888
+ err,
889
+ opts.signalChannel === true ? SANDBOXED_TASK : 'task',
890
+ opts.command,
891
+ opts.env,
892
+ )
843
893
  return {
844
894
  child: undefined,
845
895
  ready: Promise.reject(
846
896
  new PersistentReadyError(`failed to spawn persistent task: ${message}`, 'spawn'),
847
897
  ),
848
- readyMs: () => Date.now() - start,
898
+ readyMs: () => Math.round(performance.now() - start),
899
+ gaveUp: () => false,
849
900
  }
850
901
  } finally {
851
902
  pipes.spawned()
@@ -860,10 +911,11 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
860
911
 
861
912
  const markReady = (): void => {
862
913
  // A marker printed on the way down, once the wait gave up, is not ready.
863
- if (readyAt === undefined && gaveUpAt === undefined) {
864
- readyAt = Date.now()
914
+ if (readyAt === undefined && gaveUpAt === undefined && !failedExit) {
915
+ readyAt = performance.now()
865
916
  if (readyTimer !== undefined) clearTimeout(readyTimer)
866
917
  resolveReady()
918
+ opts.onReady?.()
867
919
  }
868
920
  }
869
921
 
@@ -884,45 +936,60 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
884
936
  ): Promise<void> => {
885
937
  if (!stream || typeof stream === 'number') return
886
938
  let fragment = ''
887
- const handleChunk = (chunk: string): void => {
888
- if (chunk.length === 0) return
889
- // Chunks are forwarded, never retained. The one consumer of a
890
- // persistent task's text is the logger, which keeps its OWN bounded
891
- // tail fed from these same callbacks (registered at taskStart, so it
892
- // covers the pre-ready window too) — a second copy here was read by
893
- // nobody.
894
- if (isStderr) opts.onStderr?.(chunk)
895
- else opts.onStdout?.(chunk)
896
- if (readyRe && readyAt === undefined) {
897
- fragment += chunk
898
- let start = 0
899
- for (let i = 0; i < fragment.length; i++) {
900
- const c = fragment.charCodeAt(i)
901
- if (c !== 10 && c !== 13) continue
902
- if (readyLine(readyRe, fragment.slice(start, i))) {
903
- markReady()
904
- fragment = ''
905
- return
906
- }
907
- start = i + 1
908
- }
909
- fragment = fragment.slice(start)
910
- if (readyLine(readyRe, fragment)) {
911
- markReady()
939
+ // Chunks are forwarded, never retained. The one consumer of a
940
+ // persistent task's text is the logger, which keeps its OWN bounded
941
+ // tail fed from these same callbacks (registered at taskStart, so it
942
+ // covers the pre-ready window too) — a second copy here was read by
943
+ // nobody.
944
+ const forward = (text: string): void => {
945
+ if (text.length === 0) return
946
+ if (isStderr) opts.onStderr?.(text)
947
+ else opts.onStdout?.(text)
948
+ }
949
+ // Where in `chunk` the ready line ends (past its break, a CRLF's `\n`
950
+ // too), or -1 while not ready. The fragment holds no break, so every
951
+ // break found is in `chunk`.
952
+ const readyEnd = (re: RegExp, chunk: string): number => {
953
+ const base = fragment.length
954
+ fragment += chunk
955
+ let start = 0
956
+ for (let i = base; i < fragment.length; i++) {
957
+ const c = fragment.charCodeAt(i)
958
+ if (c !== 10 && c !== 13) continue
959
+ if (readyLine(re, fragment.slice(start, i))) {
912
960
  fragment = ''
913
- return
914
- }
915
- // Dropping complete lines assumes the stream HAS line breaks. Output
916
- // with none is one endless line, so the fragment would become a
917
- // third unbounded accumulator (measured 464 MiB in 6 s, and the
918
- // per-chunk `test` over an ever-growing string makes it quadratic
919
- // in CPU too). Keep the most RECENT window: a match may span the
920
- // chunk boundary, and no readiness marker is anywhere near this
921
- // long.
922
- if (fragment.length > READY_MATCH_WINDOW_CHARS) {
923
- fragment = fragment.slice(fragment.length - READY_MATCH_WINDOW_CHARS)
961
+ return i + 1 - base + (c === 13 && chunk.charCodeAt(i + 1 - base) === 10 ? 1 : 0)
924
962
  }
963
+ start = i + 1
964
+ }
965
+ fragment = fragment.slice(start)
966
+ if (readyLine(re, fragment)) {
967
+ fragment = ''
968
+ return chunk.length
925
969
  }
970
+ // Dropping complete lines assumes the stream HAS line breaks. Output
971
+ // with none is one endless line, so the fragment would become a
972
+ // third unbounded accumulator (measured 464 MiB in 6 s, and the
973
+ // per-chunk `test` over an ever-growing string makes it quadratic
974
+ // in CPU too). Keep the most RECENT window: a match may span the
975
+ // chunk boundary, and no readiness marker is anywhere near this
976
+ // long.
977
+ if (fragment.length > READY_MATCH_WINDOW_CHARS) {
978
+ fragment = fragment.slice(fragment.length - READY_MATCH_WINDOW_CHARS)
979
+ }
980
+ return -1
981
+ }
982
+ const handleChunk = (chunk: string): void => {
983
+ if (chunk.length === 0) return
984
+ if (!readyRe || readyAt !== undefined) return forward(chunk)
985
+ const end = readyEnd(readyRe, chunk)
986
+ if (end < 0) return forward(chunk)
987
+ // Split where the ready line ends: handed over whole, the lines after
988
+ // it in the same write read as pre-ready, the frame a ready server's
989
+ // row never prints (Vite's `Network:` line after `Local:`).
990
+ forward(chunk.slice(0, end))
991
+ markReady()
992
+ forward(chunk.slice(end))
926
993
  }
927
994
  const reader = stream.getReader()
928
995
  const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
@@ -959,9 +1026,10 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
959
1026
  // fires so a healthy server is never killed by a stale timer.
960
1027
  let readyTimer: ReturnType<typeof setTimeout> | undefined
961
1028
  if (readyRe && opts.timeoutMs !== undefined) {
1029
+ const limit = opts.timeoutMs
962
1030
  const giveUp = async (): Promise<void> => {
963
1031
  if (readyAt !== undefined) return
964
- gaveUpAt = Date.now()
1032
+ gaveUpAt = performance.now()
965
1033
  // Listed on the group guard until the SIGKILL: the shell may die on
966
1034
  // the SIGTERM and let the group go while the server runs out the
967
1035
  // grace, and a vx killed inside it leaves the server to the guard
@@ -979,8 +1047,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
979
1047
  }
980
1048
  rejectReady(
981
1049
  new PersistentReadyError(
982
- `persistent task not ready within ${opts.timeoutMs}ms — ` +
983
- `readyWhen pattern never matched; child killed`,
1050
+ `no line matched readyWhen /${readyRe.source}/ within ${asWritten(limit)}; killed`,
984
1051
  'timeout',
985
1052
  ),
986
1053
  )
@@ -996,8 +1063,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
996
1063
  opts.liveChildren?.delete(child)
997
1064
  releaseServerGroup(child)
998
1065
  if (readyTimer !== undefined) clearTimeout(readyTimer)
999
- // The exit can land before the readers took the marker it printed on
1000
- // the way out: `echo READY; exit` read as never ready under load. The
1066
+ if (code !== 0) failedExit = true
1067
+ // What the shell wrote reaches the frame before the failure line. The
1001
1068
  // drain is bounded, as a task's is, for a grandchild holding the pipe.
1002
1069
  if (readyAt === undefined && gaveUpAt === undefined) {
1003
1070
  await Promise.race([
@@ -1012,8 +1079,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
1012
1079
  if (readyAt === undefined && gaveUpAt === undefined) {
1013
1080
  rejectReady(
1014
1081
  new PersistentReadyError(
1015
- `persistent task exited before becoming ready (exit ${code ?? '?'})` +
1016
- (readyRe ? ` — readyWhen pattern never matched` : ''),
1082
+ `exited (exit ${code ?? '?'})` +
1083
+ (readyRe ? ` before a line matched readyWhen /${readyRe.source}/` : ''),
1017
1084
  'exited',
1018
1085
  code ?? undefined,
1019
1086
  ),
@@ -1028,7 +1095,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
1028
1095
  return {
1029
1096
  child,
1030
1097
  ready,
1031
- readyMs: () => (readyAt ?? gaveUpAt ?? Date.now()) - start,
1098
+ readyMs: () => Math.round((readyAt ?? gaveUpAt ?? performance.now()) - start),
1099
+ gaveUp: () => gaveUpAt !== undefined,
1032
1100
  }
1033
1101
  }
1034
1102
 
@@ -1049,6 +1117,8 @@ export function spawnFailureText(
1049
1117
  cwd: string,
1050
1118
  what = 'task',
1051
1119
  command?: string,
1120
+ /** The spawn's environment: an `E2BIG` it caused names it, not the command. */
1121
+ env?: NodeJS.ProcessEnv,
1052
1122
  ): string {
1053
1123
  if (isExecutableMissing(err)) {
1054
1124
  // Bun names `sh` in the ENOENT a missing directory gives too.
@@ -1056,28 +1126,47 @@ export function spawnFailureText(
1056
1126
  ? `[vx] vx runs each task with sh -c: failed to spawn 'sh' (working dir: ${cwd}). Install a POSIX sh and re-run.\n`
1057
1127
  : `[vx] failed to spawn ${what}: its working directory ${cwd} does not exist\n`
1058
1128
  }
1059
- return `[vx] failed to spawn ${what}: ${spawnErrorReason(err, what, command)}\n`
1129
+ return `[vx] failed to spawn ${what}: ${spawnErrorReason(err, what, command, env)}\n`
1060
1130
  }
1061
1131
 
1062
1132
  /** Bun's text for a spawn that threw, and the way out when the limit hit is a known one. */
1063
- function spawnErrorReason(err: unknown, what: string, command: string | undefined): string {
1133
+ function spawnErrorReason(
1134
+ err: unknown,
1135
+ what: string,
1136
+ command: string | undefined,
1137
+ env?: NodeJS.ProcessEnv,
1138
+ ): string {
1064
1139
  const message = err instanceof Error ? err.message : String(err)
1065
1140
  const hint = isOutOfFds(err)
1066
1141
  ? ` — ${OUT_OF_FDS_HINT}`
1067
1142
  : command !== undefined && (err as NodeJS.ErrnoException | undefined)?.code === 'E2BIG'
1068
- ? ` — ${argTooLongHint(command, what === SANDBOXED_TASK)}`
1143
+ ? ` — ${argTooLongHint(command, what === SANDBOXED_TASK, env)}`
1069
1144
  : ''
1070
1145
  return `${message}${hint}`
1071
1146
  }
1072
1147
 
1148
+ const MAX_ARG_STRLEN = 128 * 1024
1149
+
1073
1150
  /**
1074
1151
  * A task's command, the args after `--` folded in, is ONE argument to
1075
1152
  * `sh -c`, and Linux caps one argument at 128 KiB (MAX_ARG_STRLEN), far
1076
1153
  * below the argv total: a file list forwarded to a linter failed with a
1077
1154
  * bare E2BIG, which reads as "too many files" and names no way out.
1078
1155
  */
1079
- function argTooLongHint(command: string, wrapped: boolean): string {
1080
- const kib = Math.ceil(Buffer.byteLength(command) / 1024)
1156
+ function argTooLongHint(command: string, wrapped: boolean, env?: NodeJS.ProcessEnv): string {
1157
+ const bytes = Buffer.byteLength(command)
1158
+ const kib = Math.ceil(bytes / 1024)
1159
+ // The limit counts the environment too, and caps each entry as it does
1160
+ // one argument: a task's 4.4 MB `define` was blamed on its 1 KiB command.
1161
+ const vars = env === undefined ? null : envSize(env)
1162
+ if (vars !== null && (bytes <= MAX_ARG_STRLEN || vars.bytes > bytes)) {
1163
+ return (
1164
+ `the task environment is ${Math.ceil(vars.bytes / 1024)} KiB in ${vars.count} variables, ` +
1165
+ `largest ${vars.largest} at ${Math.ceil(vars.largestBytes / 1024)} KiB; Linux caps one ` +
1166
+ `entry at 128 KiB and the command with the environment at a quarter of the stack limit ` +
1167
+ `(often 2 MiB): pass large values through a file the tool reads`
1168
+ )
1169
+ }
1081
1170
  const what = wrapped ? "with the args after -- and the sandbox's wrap" : 'with the args after --'
1082
1171
  return (
1083
1172
  `the command, ${what}, is ${kib} KiB, and one argument to sh -c may hold at ` +
@@ -1085,6 +1174,30 @@ function argTooLongHint(command: string, wrapped: boolean): string {
1085
1174
  )
1086
1175
  }
1087
1176
 
1177
+ /** What `execve` copies of an environment: each `name=value` and its NUL. */
1178
+ function envSize(env: NodeJS.ProcessEnv): {
1179
+ bytes: number
1180
+ count: number
1181
+ largest: string
1182
+ largestBytes: number
1183
+ } {
1184
+ let bytes = 0
1185
+ let count = 0
1186
+ let largest = ''
1187
+ let largestBytes = 0
1188
+ for (const [name, value] of Object.entries(env)) {
1189
+ if (value === undefined) continue
1190
+ const entry = Buffer.byteLength(name) + Buffer.byteLength(value) + 2
1191
+ bytes += entry
1192
+ count++
1193
+ if (entry > largestBytes) {
1194
+ largest = name
1195
+ largestBytes = entry
1196
+ }
1197
+ }
1198
+ return { bytes, count, largest, largestBytes }
1199
+ }
1200
+
1088
1201
  /** A stop's own sweep, for a child it missed: the polite signal, then SIGKILL past the grace. */
1089
1202
  async function stopLate(proc: Child, signal: 'SIGINT' | 'SIGTERM'): Promise<void> {
1090
1203
  const letGo = holdGroups([proc])
@@ -1098,7 +1211,9 @@ async function stopLate(proc: Child, signal: 'SIGINT' | 'SIGTERM'): Promise<void
1098
1211
  }
1099
1212
 
1100
1213
  export async function runCommand(opts: RunOptions): Promise<RunResult> {
1101
- const start = Date.now()
1214
+ // Elapsed time, not wall time: an NTP step mid-task gave a negative or
1215
+ // hour-long duration (the executor check refuses a negative one).
1216
+ const start = performance.now()
1102
1217
  const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
1103
1218
 
1104
1219
  let proc: Child
@@ -1148,11 +1263,11 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
1148
1263
  )
1149
1264
  } catch (err) {
1150
1265
  pipes.close()
1151
- const stderr = spawnFailureText(err, opts.cwd, 'task', fullCommand)
1266
+ const stderr = spawnFailureText(err, opts.cwd, 'task', fullCommand, opts.env)
1152
1267
  opts.onStderr?.(stderr)
1153
1268
  return {
1154
1269
  exitCode: 127,
1155
- durationMs: Date.now() - start,
1270
+ durationMs: Math.round(performance.now() - start),
1156
1271
  stdout: '',
1157
1272
  stderr,
1158
1273
  spawnFailed: true,
@@ -1196,6 +1311,10 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
1196
1311
  const cutLine = ends.own(POST_EXIT_CUT_LINE)
1197
1312
  if (cut) opts.onStderr?.(cutLine)
1198
1313
  const stderr = cut ? streamed + cutLine : streamed
1314
+ // A leftover with its stdio sent elsewhere holds no pipe for the drain to
1315
+ // cut: its group, still not empty, is what says so. One kill(0) when the
1316
+ // group is empty, as it is after any task that waits for what it starts.
1317
+ const left = cut || groupAlive(proc)
1199
1318
  opts.liveChildren?.delete(proc)
1200
1319
  releaseGroup(proc)
1201
1320
  const exited = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
@@ -1210,7 +1329,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
1210
1329
  opts.onStderr?.(text)
1211
1330
  return {
1212
1331
  exitCode: 127,
1213
- durationMs: Date.now() - start,
1332
+ durationMs: Math.round(performance.now() - start),
1214
1333
  stdout,
1215
1334
  stderr: stderr + text,
1216
1335
  spawnFailed: true,
@@ -1221,12 +1340,13 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
1221
1340
  const exitCode = exited === 0 && timeout.timedOut() ? signalExitCode('SIGTERM') : exited
1222
1341
  return {
1223
1342
  exitCode,
1224
- durationMs: Date.now() - start,
1343
+ durationMs: Math.round(performance.now() - start),
1225
1344
  stdout,
1226
1345
  stderr,
1227
1346
  ...(proc.signalCode != null ? { signal: proc.signalCode } : {}),
1228
1347
  ...(timeout.timedOut() ? { timedOut: true } : {}),
1229
- ...(cut ? { leftRunning: true } : {}),
1348
+ ...(timeout.sigkilled() ? { killedAfterGrace: true } : {}),
1349
+ ...(left ? { leftRunning: true } : {}),
1230
1350
  ...usageOf(proc, watch?.stop()),
1231
1351
  }
1232
1352
  }
@@ -1288,8 +1408,9 @@ function rssFloorFor(peak: number): number {
1288
1408
 
1289
1409
  /**
1290
1410
  * What a task's captured output keeps: the first `CAPTURE_HEAD_CHARS` and
1291
- * the last `CAPTURE_TAIL_CHARS` characters, the dropped middle counted and
1292
- * named where it was. The live stream is never bounded — every byte still
1411
+ * the last `CAPTURE_TAIL_CHARS` characters (UTF-16 units: 8 MiB of ASCII),
1412
+ * the dropped middle counted in the UTF-8 bytes the task wrote and named
1413
+ * where it was. The live stream is never bounded — every byte still
1293
1414
  * reaches the terminal as the task writes it — only the copy vx retains
1294
1415
  * for the cache entry and its replay. Unbounded, a task printing 200 MB
1295
1416
  * cost vx 620 MB of RSS on the miss AND on every hit, and its stdout sat
@@ -1328,10 +1449,22 @@ export function maskCaptured(text: string, secrets: SecretMask): string {
1328
1449
  }
1329
1450
 
1330
1451
  const DROPPED_LINE = /^\n\[vx\] [\d.]+ [KMGTP]?B of output not kept — /
1452
+ const SIZE = '[\\d.]+ [KMGTP]?B'
1453
+ const OWN_DROPPED_LINE = new RegExp(
1454
+ `\\n\\[vx\\] ([\\d.]+) ([KMGTP]?B) of output not kept — vx keeps the first ${SIZE} and the last ${SIZE} of a task's output\\n`,
1455
+ 'y',
1456
+ )
1457
+ const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
1458
+
1459
+ /**
1460
+ * The dropped lines `chunks()` returned, known by the object, not the text:
1461
+ * a task's own chunk that read like one was copied past the mask.
1462
+ */
1463
+ const droppedChunks = new WeakSet<CapturedChunk>()
1331
1464
 
1332
- /** Whether `chunk` is the line a `BoundedCapture` puts where its dropped middle was. */
1465
+ /** Whether `chunk` is the line a `BoundedCapture` put where its dropped middle was (the object `chunks()` returned). */
1333
1466
  export function isDroppedOutputChunk(chunk: CapturedChunk): boolean {
1334
- return !chunk.err && DROPPED_LINE.test(chunk.text)
1467
+ return droppedChunks.has(chunk)
1335
1468
  }
1336
1469
 
1337
1470
  const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff
@@ -1354,7 +1487,9 @@ export class BoundedCapture {
1354
1487
  private headLen = 0
1355
1488
  private readonly tail: CapturedChunk[] = []
1356
1489
  private tailLen = 0
1490
+ /** UTF-8 bytes: a count of UTF-16 units said a third of the bytes CJK text dropped. */
1357
1491
  private dropped = 0
1492
+ private seen = 0
1358
1493
 
1359
1494
  constructor(
1360
1495
  private readonly headChars = CAPTURE_HEAD_CHARS,
@@ -1362,6 +1497,29 @@ export class BoundedCapture {
1362
1497
  ) {}
1363
1498
 
1364
1499
  push(text: string, err = false): void {
1500
+ // A capture's own output replayed into another (a distributed miss
1501
+ // replays the worker's entry): its dropped line, where a capture puts
1502
+ // it, is counted, not kept. Kept as text, it fell into the new middle
1503
+ // and the entry said `98 B of output not kept` for 13 MB.
1504
+ const at = CAPTURE_HEAD_CHARS - this.seen
1505
+ this.seen += text.length
1506
+ if (!err && at >= 0 && at <= text.length) {
1507
+ // At the head's end, or a unit short of it (a surrogate pair's split).
1508
+ for (const i of [at, at - 1]) {
1509
+ if (i < 0) continue
1510
+ OWN_DROPPED_LINE.lastIndex = i
1511
+ const m = OWN_DROPPED_LINE.exec(text)
1512
+ if (m === null) continue
1513
+ this.dropped += Math.round(Number(m[1]) * 1024 ** UNITS.indexOf(m[2]!))
1514
+ this.add(text.slice(0, i), err)
1515
+ text = text.slice(i + m[0].length)
1516
+ break
1517
+ }
1518
+ }
1519
+ this.add(text, err)
1520
+ }
1521
+
1522
+ private add(text: string, err: boolean): void {
1365
1523
  // Once the tail has begun the head is closed, even a unit short of its
1366
1524
  // bound (the surrogate case below): a later short chunk fit there and
1367
1525
  // was retained ahead of the chunk before it.
@@ -1387,12 +1545,12 @@ export class BoundedCapture {
1387
1545
  if (first.text.length <= excess) {
1388
1546
  this.tail.shift()
1389
1547
  this.tailLen -= first.text.length
1390
- this.dropped += first.text.length
1548
+ this.dropped += Buffer.byteLength(first.text)
1391
1549
  } else {
1392
1550
  const cut = isHighSurrogate(first.text.charCodeAt(excess - 1)) ? excess + 1 : excess
1551
+ this.dropped += Buffer.byteLength(first.text.slice(0, cut))
1393
1552
  this.tail[0] = { text: first.text.slice(cut), err: first.err }
1394
1553
  this.tailLen -= cut
1395
- this.dropped += cut
1396
1554
  }
1397
1555
  }
1398
1556
  }
@@ -1401,8 +1559,10 @@ export class BoundedCapture {
1401
1559
  * The same output under tighter bounds. What this capture already
1402
1560
  * dropped is counted in the new one's dropped line: pushed through
1403
1561
  * `chunks()`, the old line fell into the new middle with its count.
1404
- * `through` maps each side's chunks before the new cut (the store's
1405
- * mask), so the cut cannot split a value first.
1562
+ * `through` maps the chunks before the new cut (the store's mask), so
1563
+ * the cut cannot split a value first: each side apart only when this
1564
+ * capture dropped text between them, else a value across the head's end
1565
+ * was matched by neither side.
1406
1566
  */
1407
1567
  rebound(
1408
1568
  headChars: number,
@@ -1410,24 +1570,21 @@ export class BoundedCapture {
1410
1570
  through: (chunks: CapturedChunk[]) => CapturedChunk[] = (c) => c,
1411
1571
  ): BoundedCapture {
1412
1572
  const to = new BoundedCapture(headChars, tailChars)
1413
- for (const c of through(this.head)) to.push(c.text, c.err)
1414
- for (const c of through(this.tail)) to.push(c.text, c.err)
1573
+ const sides = this.dropped === 0 ? [[...this.head, ...this.tail]] : [this.head, this.tail]
1574
+ for (const side of sides) for (const c of through(side)) to.push(c.text, c.err)
1415
1575
  to.dropped += this.dropped
1416
1576
  return to
1417
1577
  }
1418
1578
 
1419
1579
  /** The kept chunks in arrival order, the dropped middle named on stdout. */
1420
1580
  chunks(): CapturedChunk[] {
1421
- return this.dropped === 0
1422
- ? [...this.head, ...this.tail]
1423
- : [
1424
- ...this.head,
1425
- {
1426
- text: droppedOutputLine(this.dropped, this.headChars, this.tailChars),
1427
- err: false,
1428
- },
1429
- ...this.tail,
1430
- ]
1581
+ if (this.dropped === 0) return [...this.head, ...this.tail]
1582
+ const line = {
1583
+ text: droppedOutputLine(this.dropped, this.headChars, this.tailChars),
1584
+ err: false,
1585
+ }
1586
+ droppedChunks.add(line)
1587
+ return [...this.head, line, ...this.tail]
1431
1588
  }
1432
1589
 
1433
1590
  text(): string {
@@ -1553,3 +1710,8 @@ export function resourceUsageToCpuRss(
1553
1710
  const floor = typeof floorBytes === 'number' ? floorBytes : floorBytes(peak)
1554
1711
  return peak > floor + RSS_FLOOR_SLACK_BYTES ? { cpuMs, peakRssBytes: peak } : { cpuMs }
1555
1712
  }
1713
+
1714
+ /** A limit as a config writes it: `'2s'` reads back `2s`, not `2000ms`. */
1715
+ function asWritten(ms: number): string {
1716
+ return ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`
1717
+ }