@vzn/vx 0.0.84 → 0.0.85

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.84",
3
+ "version": "0.0.85",
4
4
  "description": "An open, extensible monorepo task runner.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.84",
21
- "@vzn/vx-linux-arm64": "0.0.84",
22
- "@vzn/vx-darwin-x64": "0.0.84",
23
- "@vzn/vx-darwin-arm64": "0.0.84"
20
+ "@vzn/vx-linux-x64": "0.0.85",
21
+ "@vzn/vx-linux-arm64": "0.0.85",
22
+ "@vzn/vx-darwin-x64": "0.0.85",
23
+ "@vzn/vx-darwin-arm64": "0.0.85"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -30,6 +30,14 @@ export class TarFormatError extends Error {
30
30
  }
31
31
 
32
32
  const BLOCK = 512
33
+ /**
34
+ * An extended header's body is read whole, so its size is a claim on
35
+ * memory the stream has not paid for: a 1 GiB pax header zstd-compresses
36
+ * to ~32 KB and cost 2 GiB of RSS where a regular entry of that size
37
+ * streams in 32 MiB (L-1). A real one holds a path (≤ PATH_MAX) and a few
38
+ * numbers; a MiB is far past any writer's.
39
+ */
40
+ const MAX_EXTENDED_HEADER = 1024 * 1024
33
41
  const decoder = new TextDecoder()
34
42
 
35
43
  function field(h: Uint8Array, off: number, len: number): string {
@@ -173,6 +181,11 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
173
181
  pendingPath = undefined
174
182
  pendingSize = undefined
175
183
 
184
+ if ((type === 'x' || type === 'L' || type === 'g') && size > MAX_EXTENDED_HEADER) {
185
+ throw new TarFormatError(
186
+ `extended header of ${size} bytes, past ${MAX_EXTENDED_HEADER} (a hostile archive?)`,
187
+ )
188
+ }
176
189
  if (type === 'x' || type === 'L') {
177
190
  // Extended header: applies to the NEXT entry only.
178
191
  const body = await src.exact(padded, 'an extended header')
@@ -182,7 +195,14 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
182
195
  const p = pax.get('path')
183
196
  if (p !== undefined) pendingPath = p
184
197
  const s = pax.get('size')
185
- if (s !== undefined) pendingSize = Number(s)
198
+ if (s !== undefined) {
199
+ // Digits only: `Number` reads `0.5` and `-1`, and a fractional
200
+ // size moved the reader to a fractional offset.
201
+ if (!/^[0-9]+$/.test(s) || !Number.isSafeInteger(Number(s))) {
202
+ throw new TarFormatError(`bad pax size: ${JSON.stringify(s)}`)
203
+ }
204
+ pendingSize = Number(s)
205
+ }
186
206
  } else {
187
207
  pendingPath = field(body, 0, size)
188
208
  }
package/src/cli/why.ts CHANGED
@@ -27,6 +27,26 @@ interface WhyArgs {
27
27
  error?: string
28
28
  }
29
29
 
30
+ /**
31
+ * What to do about a changed key component, by the kind `cache/key-fold.ts`
32
+ * captures. Held to that file's kinds in both directions by
33
+ * `tests/why.test.ts`.
34
+ */
35
+ export const WHAT_TO_DO: Readonly<Record<string, string>> = {
36
+ file: 'an edit re-runs by design; a file the task does not read belongs out of cache.inputs.files',
37
+ env: "the variable's value moved; one the task does not read belongs out of cache.inputs.env",
38
+ runtime: 'the command printed something else; drop timestamps and paths from its output',
39
+ 'ws-runtime':
40
+ 'the workspace command printed something else; drop timestamps and paths from its output',
41
+ forward: 'the arguments after -- differ; the same arguments share one entry',
42
+ package: "the project's package.json changed (a dependency, version or script)",
43
+ workspace:
44
+ 'a lockfile or root manifest changed, which re-keys every task; a lockfile plugin keys each project on its own dependencies',
45
+ config: "the task's evaluated config changed (its vx.config or a file that imports)",
46
+ upstream: "a dependency's key moved; `vx why <that task>` says why",
47
+ plugin: "a key plugin's material changed; that plugin decides what it folds",
48
+ }
49
+
30
50
  export function parseWhyArgs(args: readonly string[]): WhyArgs {
31
51
  const out: WhyArgs = { format: 'pretty' }
32
52
  for (let i = 0; i < args.length; i++) {
@@ -204,6 +224,11 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
204
224
  : `${e.before} → ${e.after}`
205
225
  lines.push(` ${e.change.padEnd(7)} ${e.kind.padEnd(kindW)} ${e.name} ${beforeAfter}`)
206
226
  }
227
+ const kinds = [...new Set(diff.entries.map((e) => e.kind))].filter((k) => k in WHAT_TO_DO)
228
+ if (kinds.length > 0) {
229
+ lines.push('', ' what to do:')
230
+ for (const k of kinds) lines.push(` ${k.padEnd(kindW)} ${WHAT_TO_DO[k]}`)
231
+ }
207
232
  } else if (why.hashChanged === true) {
208
233
  lines.push(` detail ${diff.note}`)
209
234
  }
@@ -140,10 +140,11 @@ export interface ExecuteRequest {
140
140
  readonly onStdout: (chunk: string) => void
141
141
  readonly onStderr: (chunk: string) => void
142
142
  /**
143
- * Aborted when the run stops: Ctrl-C, SIGTERM, SIGHUP or an embedder's
144
- * `RunOptions.signal`. An executor ends its work and returns; core waits
145
- * for `execute` before its teardown, and a child an executor spawned is
146
- * not one core can reach (item H-10).
143
+ * Aborted when the run stops (Ctrl-C, SIGTERM, SIGHUP or an embedder's
144
+ * `RunOptions.signal`) or when `timeoutMs` elapses. An executor ends its
145
+ * work and returns; core waits for `execute`, and a child an executor
146
+ * spawned is not one core can reach (H-10, H-12). A non-zero exit after
147
+ * the timeout's abort is recorded `timedOut`.
147
148
  */
148
149
  readonly signal?: AbortSignal
149
150
  /** See `RunOptions.liveChildren`: the run's SIGINT/SIGTERM registry. */
@@ -472,6 +472,27 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
472
472
  // (`--timeout`/`RunOptions.timeout` → `VX_TASK_TIMEOUT` → workspace
473
473
  // `timeout`, already collapsed into `args.timeout` by run.ts).
474
474
  const effectiveTimeout = step.timeout ?? args.timeout
475
+ // `signal` stops a plugin executor on the run's stop AND on
476
+ // `exec.timeout`: core cannot kill a process an executor spawned, so a
477
+ // declared timeout meant nothing on one that kept its own clock (H-12).
478
+ // The local executor keeps its own timer (it signals the process group).
479
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined
480
+ let timeoutFired = false
481
+ function requestSignal(): AbortSignal {
482
+ const stop = new AbortController()
483
+ const abort = (): void => stop.abort(args.stopSignal?.reason)
484
+ if (args.stopSignal?.aborted === true) abort()
485
+ else args.stopSignal?.addEventListener('abort', abort, { once: true })
486
+ if (effectiveTimeout !== undefined) {
487
+ clearTimeout(timeoutTimer)
488
+ timeoutFired = false
489
+ timeoutTimer = setTimeout(() => {
490
+ timeoutFired = true
491
+ stop.abort(new Error(`timed out after ${effectiveTimeout}ms`))
492
+ }, effectiveTimeout)
493
+ }
494
+ return stop.signal
495
+ }
475
496
 
476
497
  // When the task started, as a ns offset from run start — captured for
477
498
  // EVERY outcome (hits included) so the run-detail timeline reflects when
@@ -701,7 +722,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
701
722
  // no other trace. Rethrown unchanged — the scheduler still classifies it,
702
723
  // and still prints it plainly for a UserError.
703
724
  const endExec = span('miss: execute')
704
- const res = await args.executor
725
+ let res = await args.executor
705
726
  .execute(req)
706
727
  .then((r: unknown) => {
707
728
  assertExecuteResult(args.executor.name, node.id, r)
@@ -713,7 +734,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
713
734
  await sweepPlaceholders(placeholders)
714
735
  throw err
715
736
  })
737
+ .finally(() => clearTimeout(timeoutTimer))
716
738
  endExec()
739
+ // An executor that stopped on the timeout's abort exits non-zero; say
740
+ // why, so the frame, the retry line and `timedOut` read as a timeout.
741
+ if (timeoutFired && res.exitCode !== 0 && res.timedOut !== true) {
742
+ res = { ...res, timedOut: true }
743
+ }
717
744
  violations = [...res.violations]
718
745
  // A denial under a dependency the key does not answer for says only
719
746
  // ENOENT; the line beside it names the package and how to key it. Only
@@ -875,7 +902,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
875
902
  onStdout: (chunk) => log.taskStdout(node, chunk),
876
903
  onStderr: (chunk) => log.taskStderr(node, chunk),
877
904
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
878
- ...(args.stopSignal !== undefined ? { signal: args.stopSignal } : {}),
905
+ signal: requestSignal(),
879
906
  ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
880
907
  ...(inputs !== undefined ? { inputs } : {}),
881
908
  ...(cfgCacheable ? { cacheKey: hash } : {}),
@@ -81,8 +81,23 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
81
81
  // preliminary. A dependency this key folds needs no pass-through here:
82
82
  // a keyed task with such a rewriter unfolded is unstable itself, and
83
83
  // instability is inherited below.
84
+ // - wideById: the subset of outputProjects whose writes are undeclared
85
+ // (`undeclaredWriteReach`), so they may land anywhere in the project.
86
+ // A declared-output producer reaches a same-project reader only where
87
+ // its output globs can meet the reader's inputs (`outputsOf`, A-20).
84
88
  const projects = new ProjectIndex(args.nodes)
85
89
  const outputProjectsById = new Map<string, ProjectSet>()
90
+ const wideById = new Map<string, ProjectSet>()
91
+ // Every project's declared `outputs.files`, over all its tasks: a superset
92
+ // of what any one reader's upstream producers declare.
93
+ const outputsOf = new Map<string, string[]>()
94
+ for (const n of args.nodes.values()) {
95
+ const files = n.config.cache?.outputs.files ?? []
96
+ if (files.length === 0) continue
97
+ const list = outputsOf.get(n.projectName)
98
+ if (list === undefined) outputsOf.set(n.projectName, [...files])
99
+ else list.push(...files)
100
+ }
86
101
  const rewritersById = new Map<string, ProjectSet>()
87
102
  const unfoldedById = new Map<string, ProjectSet>()
88
103
  const wsRewriters = new Set<string>()
@@ -115,6 +130,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
115
130
  // Fold every dep's accumulated producers + the dep's own declared
116
131
  // outputs into this node's transitive-upstream producer sets.
117
132
  const outputProjects = projects.empty()
133
+ let wide: ProjectSet | undefined
118
134
  let rewriters: ProjectSet | undefined
119
135
  let unfolded: ProjectSet | undefined
120
136
  let folds: ((dep: string) => boolean) | undefined
@@ -126,6 +142,8 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
126
142
  if (!depNode) continue
127
143
  const upstreamOfDep = outputProjectsById.get(dep)
128
144
  if (upstreamOfDep !== undefined) outputProjects.addAll(upstreamOfDep)
145
+ const wideOfDep = wideById.get(dep)
146
+ if (wideOfDep !== undefined) (wide ??= projects.empty()).addAll(wideOfDep)
129
147
  if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
130
148
  const depOut = depNode.config.cache?.outputs
131
149
  if ((depOut?.files?.length ?? 0) > 0) outputProjects.add(depNode.projectName)
@@ -135,8 +153,10 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
135
153
  // bytes it wrote: its key taken up front was the pre-producer one, and
136
154
  // the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
137
155
  const reach = undeclaredWriteReach(depNode, args.workspaceRoot)
138
- if (reach === 'project') outputProjects.add(depNode.projectName)
139
- else if (reach === 'workspace') wsOutputUpstream = true
156
+ if (reach === 'project') {
157
+ outputProjects.add(depNode.projectName)
158
+ ;(wide ??= projects.empty()).add(depNode.projectName)
159
+ } else if (reach === 'workspace') wsOutputUpstream = true
140
160
  const cached = depNode.config.cache !== undefined
141
161
  if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot)) wsOutputUpstream = true
142
162
  if (!anyUnfolded) continue
@@ -172,6 +192,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
172
192
  if (wsRewriterOfDep || rewritesWs) wsUnfoldedHere = true
173
193
  }
174
194
  outputProjectsById.set(id, outputProjects)
195
+ if (wide !== undefined) wideById.set(id, wide)
175
196
  wsOutputUpstreamById.set(id, wsOutputUpstream)
176
197
  if (rewriters !== undefined) rewritersById.set(id, rewriters)
177
198
  if (unfolded !== undefined) unfoldedById.set(id, unfolded)
@@ -207,6 +228,10 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
207
228
  unfolded === undefined ? outputProjects : unfolded.or(outputProjects),
208
229
  wsOutputUpstream || wsUnfoldedHere,
209
230
  dirByProject,
231
+ {
232
+ wide: unfolded === undefined ? wide : wide === undefined ? unfolded : wide.or(unfolded),
233
+ outputsOf,
234
+ },
210
235
  )
211
236
  if (unstable) unstableById.add(id)
212
237
 
@@ -292,12 +317,30 @@ export function dependsOnSiblingOutputs(
292
317
  upstreamOutputProjects: ProjectNames,
293
318
  hasWsOutputUpstream: boolean,
294
319
  dirByProject?: ReadonlyMap<string, string>,
320
+ sameProject?: {
321
+ /** The producers whose writes may land anywhere in their project. */
322
+ wide: ProjectNames | undefined
323
+ /** Each project's declared `outputs.files`, over all its tasks. */
324
+ outputsOf: ReadonlyMap<string, readonly string[]>
325
+ },
295
326
  ): boolean {
296
327
  const cache = node.config.cache
297
328
  // A cache-disabled task has no key to prefetch anyway; treat as
298
329
  // unstable-irrelevant (caller filters on cacheEnabled).
299
330
  if (cache === undefined) return false
300
- if (upstreamOutputProjects.has(node.projectName)) return true
331
+ if (upstreamOutputProjects.has(node.projectName)) {
332
+ // A same-project producer whose writes are all declared reaches this
333
+ // task's inputs only where the globs can meet: a `test` reading `src/**`
334
+ // after a `build` writing `dist/**` keys the same before and after it,
335
+ // and classing it unstable hashed every such key twice more at 5,000
336
+ // projects (A-20). Undeclared writes, and a rewriter this key does not
337
+ // fold, may land anywhere: those stay project-wide.
338
+ if (sameProject === undefined || sameProject.wide?.has(node.projectName) === true) return true
339
+ const inputs = cache.inputs?.files
340
+ const outputs = sameProject.outputsOf.get(node.projectName) ?? []
341
+ if (inputs === undefined || outputs.length === 0) return true
342
+ if (workspaceInputsReach(inputs, outputs.map(literalPrefix))) return true
343
+ }
301
344
  // A root-anchored output is boundary-IGNORING by design, so it can land
302
345
  // inside THIS task's own project dir — where an ordinary project-relative
303
346
  // input reads it. Neither other clause sees that: `upstreamOutputProjects`
@@ -341,15 +384,8 @@ export function workspaceInputsReach(
341
384
  ): boolean {
342
385
  for (const raw of workspaceFiles) {
343
386
  if (raw.startsWith('!')) continue
344
- const entry = normalizeGlob(raw)
345
- const segments = entry.split('/')
346
- const literal: string[] = []
347
- for (const seg of segments) {
348
- if (!isLiteralPattern(seg)) break
349
- literal.push(seg)
350
- }
351
- if (literal.length === 0) return true
352
- const prefix = literal.join('/')
387
+ const prefix = literalPrefix(raw)
388
+ if (prefix === '') return true
353
389
  for (const dir of dirs) {
354
390
  if (dir === '' || dir === '.') return true
355
391
  if (prefix === dir || prefix.startsWith(`${dir}/`) || dir.startsWith(`${prefix}/`))
@@ -359,6 +395,16 @@ export function workspaceInputsReach(
359
395
  return false
360
396
  }
361
397
 
398
+ /** A glob's leading literal segments, `''` when its first holds a wildcard. */
399
+ function literalPrefix(glob: string): string {
400
+ const literal: string[] = []
401
+ for (const seg of normalizeGlob(glob).split('/')) {
402
+ if (!isLiteralPattern(seg)) break
403
+ literal.push(seg)
404
+ }
405
+ return literal.join('/')
406
+ }
407
+
362
408
  /** What the stability gate reads of a set of project names. */
363
409
  interface ProjectNames extends Iterable<string> {
364
410
  has(name: string): boolean