@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -37,15 +37,17 @@
37
37
  // skipped — vx's outputs are regular files, and an artifact that claims
38
38
  // otherwise silently loses the claim rather than acting on it.
39
39
 
40
- import { mkdir, chmod, realpath, rename, rmdir, stat, unlink, utimes } from 'node:fs/promises'
40
+ import { chmodSync, renameSync, statSync, utimesSync } from 'node:fs'
41
+ import { mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
41
42
  import path from 'node:path'
43
+ import { UserError } from '../util/index.js'
42
44
  import { TarFormatError, type TarInput, tarEntries, tarPack, tarSize } from './tar-stream.js'
43
45
 
44
46
  /** Archive entry name carrying the per-output mode/mtime sidecar. */
45
47
  const META_ENTRY = '.vx-meta.json'
46
48
 
47
49
  /** Archive entry name of the always-present stdout record. */
48
- export const STDOUT_ENTRY = 'stdout'
50
+ const STDOUT_ENTRY = 'stdout'
49
51
 
50
52
  /**
51
53
  * Sidecar shape. `files` maps an entry name to `[mode, mtimeMs]` — a
@@ -57,7 +59,29 @@ interface MetaFile {
57
59
  /** Bumped only if the sidecar's own shape changes; the artifact
58
60
  * container is versioned by CACHE_VERSION, which gates reads. */
59
61
  version: 1
62
+ /**
63
+ * The cache key the artifact was packed under (v35, item 943). Ingest
64
+ * refuses bytes whose key is not the one it asked for: nothing else ties
65
+ * an artifact to its key, so a remote layer that answered one key with
66
+ * another's bytes (a truncated or colliding mapping) replayed the other
67
+ * task's outputs under a green `cache-hit-remote`.
68
+ */
69
+ key?: string
60
70
  files: Record<string, [mode: number, mtimeMs: number]>
71
+ /**
72
+ * What the PRODUCING execution used (2026-09-12, additive: an artifact
73
+ * without it reads as before). Rides the artifact so a machine that
74
+ * never ran the task — a fresh CI runner on a remote hit — still learns
75
+ * what it needs; every wire ships the bytes verbatim, so no seam
76
+ * changes.
77
+ */
78
+ exec?: ExecUsage
79
+ }
80
+
81
+ /** CPU time and peak RSS of the execution that produced an artifact. */
82
+ export interface ExecUsage {
83
+ cpuMs?: number
84
+ peakRssBytes?: number
61
85
  }
62
86
 
63
87
  /** One regular file in an artifact, with its restore metadata resolved. */
@@ -86,8 +110,12 @@ export class ArchiveSecurityError extends Error {
86
110
 
87
111
  /** What `packArtifactStream` packs: stdout plus archive name → absolute source path. */
88
112
  export interface PackArgs {
113
+ /** The cache key, recorded in the sidecar (item 943); ingest refuses an artifact without it. */
114
+ key?: string
89
115
  stdout: string
90
116
  outputs: ReadonlyMap<string, string>
117
+ /** The producing execution's usage, when the runner reported it. */
118
+ exec?: ExecUsage | undefined
91
119
  }
92
120
 
93
121
  /** A packed artifact's plan: its entries with their stats, and the tar's exact size. */
@@ -106,9 +134,29 @@ export interface ArtifactPlan {
106
134
  */
107
135
  export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
108
136
  const meta: MetaFile = { version: 1, files: {} }
137
+ if (args.key !== undefined) meta.key = args.key
138
+ const exec = usageOf(args.exec)
139
+ if (exec !== undefined) meta.exec = exec
109
140
  const files = await Promise.all(
110
141
  [...args.outputs].map(async ([name, abs]) => {
111
- const st = await stat(abs)
142
+ // `stat` follows a symlink, so a link to a file is packed as that
143
+ // file's bytes and comes back as a regular file. A link to a
144
+ // directory, or a dangling one, has no bytes to pack: refuse loudly
145
+ // rather than store an entry that restores to nothing.
146
+ const shown = name.startsWith('outputs/') ? name.slice('outputs/'.length) : name
147
+ const st = await stat(abs).catch((err: NodeJS.ErrnoException) => {
148
+ if (err.code === 'ENOENT') {
149
+ throw new UserError(
150
+ `output ${shown} is a dangling symlink: vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces (output globs take no '!')`,
151
+ )
152
+ }
153
+ throw err
154
+ })
155
+ if (!st.isFile()) {
156
+ throw new UserError(
157
+ `output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces (output globs take no '!')`,
158
+ )
159
+ }
112
160
  meta.files[name] = [st.mode & 0o777, Math.floor(st.mtimeMs)]
113
161
  return {
114
162
  name,
@@ -187,16 +235,26 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
187
235
  * name is unsafe — a partial reading of a poisoned artifact is the
188
236
  * outcome the traversal defense exists to prevent.
189
237
  */
190
- export async function scanArtifact(
191
- tar: ReadableStream<Uint8Array>,
192
- ): Promise<{ entries: ArchiveEntry[]; stdout: string | null }> {
238
+ export async function scanArtifact(tar: ReadableStream<Uint8Array>): Promise<{
239
+ entries: ArchiveEntry[]
240
+ stdout: string | null
241
+ exec: ExecUsage | undefined
242
+ key: string | undefined
243
+ }> {
193
244
  const seen: Array<{ name: string; size: number; mtimeMs: number }> = []
194
245
  let meta: MetaFile['files'] = {}
246
+ let exec: ExecUsage | undefined
247
+ let key: string | undefined
195
248
  let stdout: string | null = null
196
249
  for await (const e of tarEntries(tar)) {
197
250
  if (e.type !== '0') continue
198
251
  if (e.name === META_ENTRY) {
199
- meta = (JSON.parse(await textOf(e.body)) as MetaFile).files ?? {}
252
+ const parsed = JSON.parse(await textOf(e.body)) as MetaFile
253
+ meta = parsed.files ?? {}
254
+ // The ingest side is the untrusted boundary: a foreign sidecar's
255
+ // usage is taken only when it is a plain non-negative number.
256
+ exec = usageOf(parsed.exec)
257
+ if (typeof parsed.key === 'string') key = parsed.key
200
258
  continue
201
259
  }
202
260
  assertSafeName(e.name)
@@ -212,9 +270,22 @@ export async function scanArtifact(
212
270
  return { name: s.name, size: s.size, mode: m?.[0] ?? 0o644, mtimeMs: m?.[1] ?? s.mtimeMs }
213
271
  }),
214
272
  stdout,
273
+ exec,
274
+ key,
215
275
  }
216
276
  }
217
277
 
278
+ /** The usage worth carrying: each axis a finite non-negative number, or absent; nothing → undefined. */
279
+ function usageOf(raw: unknown): ExecUsage | undefined {
280
+ if (typeof raw !== 'object' || raw === null) return undefined
281
+ const { cpuMs, peakRssBytes } = raw as { cpuMs?: unknown; peakRssBytes?: unknown }
282
+ const out: { cpuMs?: number; peakRssBytes?: number } = {}
283
+ if (typeof cpuMs === 'number' && Number.isFinite(cpuMs) && cpuMs >= 0) out.cpuMs = cpuMs
284
+ if (typeof peakRssBytes === 'number' && Number.isFinite(peakRssBytes) && peakRssBytes >= 0)
285
+ out.peakRssBytes = peakRssBytes
286
+ return out.cpuMs === undefined && out.peakRssBytes === undefined ? undefined : out
287
+ }
288
+
218
289
  /**
219
290
  * Restore from `tar`, the DECOMPRESSED archive as a byte stream:
220
291
  * `outputs/<rel>` lands under `destDir/<rel>` and — when `workspaceDest`
@@ -232,7 +303,10 @@ export async function extractArtifactStream(
232
303
  workspaceDest: string | undefined,
233
304
  verify?: (provided: ReadonlySet<string>) => void,
234
305
  ): Promise<Set<string>> {
235
- await mkdir(destDir, { recursive: true })
306
+ // No `mkdir -p` of the destination here: `stage` creates each entry's
307
+ // directory chain, the destination included, and the cache restores
308
+ // into a project directory that exists. The eager one cost every
309
+ // restore a thread-pool round trip for an EEXIST and a stat (item 627).
236
310
  const x = new Extractor(destDir, workspaceDest)
237
311
  const provided = new Set<string>()
238
312
  const headerMtime = new Map<string, number>()
@@ -251,7 +325,12 @@ export async function extractArtifactStream(
251
325
  await x.assertContained(dest.base, target, e.name)
252
326
  provided.add(e.name)
253
327
  headerMtime.set(e.name, e.mtimeMs)
254
- await x.stage(e.name, target, e.size <= SMALL_ENTRY ? await bytesOf(e.body) : e.body)
328
+ await x.stage(
329
+ e.name,
330
+ dest.base,
331
+ target,
332
+ e.size <= SMALL_ENTRY ? await bytesOf(e.body) : e.body,
333
+ )
255
334
  }
256
335
  verify?.(provided)
257
336
  await x.commit((name) => {
@@ -314,6 +393,16 @@ class Extractor {
314
393
  private inflight: Promise<unknown>[] = []
315
394
  private inflightBytes = 0
316
395
  private readonly realBaseCache = new Map<string, string>()
396
+ // Per-directory memos. An artifact holds a few files per directory
397
+ // (payload's ui: 4,069 files in 535 directories), and the containment
398
+ // walk and the `mkdir -p` are properties of the directory, not the
399
+ // entry: doing both once per directory took the restore of that
400
+ // artifact from 220–300 µs per file to the write itself. The window
401
+ // this opens — a symlink planted at a validated directory between two
402
+ // of its entries — is the same one that already lies between an
403
+ // entry's own check and its write.
404
+ private readonly containedDirs = new Set<string>()
405
+ private readonly ensuredDirs = new Set<string>()
317
406
 
318
407
  constructor(
319
408
  private readonly destDir: string,
@@ -382,12 +471,23 @@ class Extractor {
382
471
  if (targetResolved !== baseResolved && !targetResolved.startsWith(baseResolved + path.sep)) {
383
472
  throw new ArchiveSecurityError(`archive entry escapes destDir (unsafe): ${name}`)
384
473
  }
385
- const realBase = await this.realBaseOf(base)
474
+ const parentResolved = path.dirname(targetResolved)
475
+ if (this.containedDirs.has(parentResolved)) return
476
+ await this.assertParentContained(base, targetResolved)
477
+ this.containedDirs.add(parentResolved)
478
+ }
479
+
480
+ private async assertParentContained(base: string, targetResolved: string): Promise<void> {
481
+ const baseResolved = path.resolve(base)
386
482
  // Only ancestors strictly BELOW the base are candidates — those are the
387
483
  // ones a poisoned entry could follow out of the tree. The walk must never
388
484
  // climb past the base: when the base itself does not exist yet (the
389
485
  // workspace-outputs anchor is created lazily), its parent legitimately
390
486
  // resolves outside and comparing against it would reject every entry.
487
+ // The base's own real path is resolved only once an ancestor EXISTS to
488
+ // compare it with: after a clean pruned the output directories (the
489
+ // common restore) nothing below the base is there, and the memoised
490
+ // resolve was a round trip spent on a comparison never made (item 627).
391
491
  let probe = path.dirname(targetResolved)
392
492
  while (probe.startsWith(baseResolved + path.sep)) {
393
493
  const real = await realpath(probe).then(
@@ -395,10 +495,9 @@ class Extractor {
395
495
  () => null,
396
496
  )
397
497
  if (real !== null) {
498
+ const realBase = await this.realBaseOf(base)
398
499
  if (real !== realBase && !real.startsWith(realBase + path.sep)) {
399
- throw new ArchiveSecurityError(
400
- `archive entry escapes destDir via a symlinked parent: ${name}`,
401
- )
500
+ throw await linkOutError(baseResolved, probe, realBase)
402
501
  }
403
502
  return
404
503
  }
@@ -408,6 +507,33 @@ class Extractor {
408
507
  // pre-existing symlink to follow — the chain will be created fresh.
409
508
  }
410
509
 
510
+ /**
511
+ * `mkdir -p` does not follow a DANGLING link: it fails EEXIST at the link,
512
+ * ENOENT below it, ELOOP in a cycle — and the containment walk passed it,
513
+ * finding nothing that resolves between it and the base. A live link that
514
+ * stays inside the base is written through, so a dangling one is too: the
515
+ * directory it names is created and the entry lands through the link,
516
+ * which stays the user's (never replaced, nx#37061). One that leads out of
517
+ * the base is the refusal a live link out gets. Only the failure pays for
518
+ * the walk; a clean tree never reaches it.
519
+ */
520
+ private async mkdirThroughLink(
521
+ base: string,
522
+ dir: string,
523
+ err: NodeJS.ErrnoException,
524
+ ): Promise<string | undefined> {
525
+ if (err.code !== 'EEXIST' && err.code !== 'ENOENT' && err.code !== 'ELOOP') throw err
526
+ const real = await resolveThrough(dir)
527
+ const realBase = await this.realBaseOf(base)
528
+ if (real !== realBase && !real.startsWith(realBase + path.sep)) {
529
+ throw await linkOutError(path.resolve(base), dir, realBase)
530
+ }
531
+ // What stands there without a link on the way (a file named like the
532
+ // directory) fails this mkdir as it failed the first: the caller names
533
+ // it as a stray.
534
+ return mkdir(real, { recursive: true })
535
+ }
536
+
411
537
  /**
412
538
  * Write the entry beside its target. A buffer is written without
413
539
  * waiting, bounded by `INFLIGHT_BYTES`; a chunk stream goes to a file
@@ -415,10 +541,20 @@ class Extractor {
415
541
  */
416
542
  async stage(
417
543
  name: string,
544
+ base: string,
418
545
  target: string,
419
546
  body: Uint8Array | AsyncIterable<Uint8Array>,
420
547
  ): Promise<void> {
421
- const created = await mkdir(path.dirname(target), { recursive: true })
548
+ const dir = path.dirname(target)
549
+ let created: string | undefined
550
+ if (!this.ensuredDirs.has(dir)) {
551
+ try {
552
+ created = await mkdir(dir, { recursive: true })
553
+ } catch (err) {
554
+ created = await this.mkdirThroughLink(base, dir, err as NodeJS.ErrnoException)
555
+ }
556
+ this.ensuredDirs.add(dir)
557
+ }
422
558
  // Write beside the target and RENAME into place. rename(2) replaces
423
559
  // the destination's directory ENTRY without following it, which is
424
560
  // what makes this both link-safe and concurrency-safe:
@@ -436,11 +572,24 @@ class Extractor {
436
572
  //
437
573
  // A directory at the target makes the rename fail — the same
438
574
  // fail-closed outcome the plain write had.
439
- const tmp = `${target}.vx-tmp-${process.pid.toString(36)}-${(tmpSeq++).toString(36)}`
575
+ // The temp is a sibling with its own short name, never `<target>.suffix`:
576
+ // a suffix pushed a legal name of 242–255 bytes past NAME_MAX, and the
577
+ // restore of a valid artifact failed with ENAMETOOLONG (item 666).
578
+ const tmp = path.join(
579
+ path.dirname(target),
580
+ `.vx-tmp-${process.pid.toString(36)}-${(tmpSeq++).toString(36)}`,
581
+ )
440
582
  this.staged.push({ name, tmp, target, created })
441
583
  if (body instanceof Uint8Array) {
442
584
  this.inflightBytes += body.byteLength
443
- const write = Bun.write(tmp, body)
585
+ // `writeFile`, not `Bun.write`: for a buffer this size `Bun.write`
586
+ // does the open, write and close on the calling thread and only
587
+ // its promise is asynchronous, so ten restores in flight wrote
588
+ // their 14,430 files one after another — a serial restore of
589
+ // payload took the same 6–7 s as one at concurrency 10, and a CPU
590
+ // profile put 6.7 of its 8.0 s inside `write` (2026-09-11). The
591
+ // thread-pool write lets the restores overlap their I/O.
592
+ const write = writeFile(tmp, body)
444
593
  // A write that fails before `commit` or `abort` awaits it must not
445
594
  // surface as an unhandled rejection; the awaiting `Promise.all` still
446
595
  // sees the error through the original promise.
@@ -464,20 +613,31 @@ class Extractor {
464
613
  await Promise.all(pending)
465
614
  }
466
615
 
467
- /** Apply each entry's mode and mtime, then rename everything into place. */
616
+ /**
617
+ * Apply each entry's mode and mtime, then rename everything into place.
618
+ * Synchronous calls in batches: the three metadata calls per file are a
619
+ * few microseconds each on the calling thread and ~40 µs each as
620
+ * thread-pool round trips, and a large artifact has thousands of them
621
+ * (measured 2026-09-11 on payload's ui, 4,069 files: 892 ms → see the
622
+ * archive bench). A yield every `COMMIT_BATCH` files keeps the other
623
+ * restores' round trips flowing. The chmod is skipped when the mode is
624
+ * the one the temp file was created with — the common case.
625
+ */
468
626
  async commit(metaFor: (name: string) => [mode: number, mtimeMs: number]): Promise<void> {
469
627
  await this.drain()
470
- await Promise.all(
471
- this.staged.map(async (s) => {
472
- const [mode, mtimeMs] = metaFor(s.name)
473
- if (mode !== 0) await chmod(s.tmp, mode & 0o777)
474
- if (mtimeMs > 0) {
475
- const t = mtimeMs / 1000
476
- await utimes(s.tmp, t, t)
477
- }
478
- await rename(s.tmp, s.target)
479
- }),
480
- )
628
+ const first = this.staged[0]
629
+ const createdMode = first === undefined ? -1 : statSync(first.tmp).mode & 0o777
630
+ let n = 0
631
+ for (const s of this.staged) {
632
+ const [mode, mtimeMs] = metaFor(s.name)
633
+ if (mode !== 0 && (mode & 0o777) !== createdMode) chmodSync(s.tmp, mode & 0o777)
634
+ if (mtimeMs > 0) {
635
+ const t = mtimeMs / 1000
636
+ utimesSync(s.tmp, t, t)
637
+ }
638
+ renameSync(s.tmp, s.target)
639
+ if (++n % COMMIT_BATCH === 0) await new Promise<void>((r) => setImmediate(r))
640
+ }
481
641
  this.staged.length = 0
482
642
  }
483
643
 
@@ -495,6 +655,9 @@ class Extractor {
495
655
  if (s.created === undefined) continue
496
656
  let dir = path.dirname(s.target)
497
657
  const top = path.resolve(s.created)
658
+ // Created at a dangling link's resolved target (`mkdirThroughLink`),
659
+ // so the chain is pruned from where the link leads.
660
+ if (!dir.startsWith(top)) dir = await realpath(dir).catch(() => dir)
498
661
  while (dir.startsWith(top)) {
499
662
  if (
500
663
  !(await rmdir(dir).then(
@@ -511,11 +674,73 @@ class Extractor {
511
674
  }
512
675
  }
513
676
 
677
+ /** Files renamed into place between two yields of a commit. */
678
+ const COMMIT_BATCH = 256
679
+
514
680
  /** Per-process counter for extract temp names; uniqueness only. */
515
681
  let tmpSeq = 0
516
682
 
517
683
  const WORKSPACE_PREFIX = 'workspace-outputs/'
518
684
 
685
+ /**
686
+ * The on-disk half of containment failed: `probe`, a directory under
687
+ * `base`, resolves outside it, so a directory between them is a link out.
688
+ * That is the tree, not the artifact — an output directory made a link
689
+ * after the entry was saved (upstream survey, nx#37061) — so it is the
690
+ * user's to fix, and the refusal names the link and where it leads. The
691
+ * restore never writes through it and never replaces it: the link is the
692
+ * user's, and replacing it is the bug nx#37061 reports.
693
+ */
694
+ async function linkOutError(base: string, probe: string, realBase: string): Promise<UserError> {
695
+ let dir = base
696
+ let real = realBase
697
+ for (const part of path.relative(base, probe).split(path.sep)) {
698
+ dir = path.join(dir, part)
699
+ real = await resolveThrough(dir)
700
+ if (real !== realBase && !real.startsWith(realBase + path.sep)) break
701
+ }
702
+ return new UserError(
703
+ `${dir} is a symbolic link to ${real}, outside ${base} — a cache restore never writes ` +
704
+ 'through a link that leaves its directory. Remove the link and re-run (the restore ' +
705
+ 'puts a real directory there), or stop declaring outputs under it.',
706
+ )
707
+ }
708
+
709
+ /**
710
+ * Where `p` leads, every link on the way followed, a dangling one included:
711
+ * `realpath` for the part that exists, and past the first missing name the
712
+ * rest as written — where `mkdir -p` would create it if it followed links.
713
+ * A cycle is the user's tree, not the artifact, so it is refused by name.
714
+ */
715
+ async function resolveThrough(p: string): Promise<string> {
716
+ const pending = path.resolve(p).split(path.sep).filter(Boolean).reverse()
717
+ let at: string = path.sep
718
+ let hops = 0
719
+ while (pending.length > 0) {
720
+ const next = path.join(at, pending.pop()!)
721
+ const link = await readlink(next).catch((err: NodeJS.ErrnoException) => {
722
+ if (err.code === 'EINVAL') return null
723
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return undefined
724
+ throw err
725
+ })
726
+ if (link === undefined) return path.join(next, ...pending.reverse())
727
+ if (link === null) {
728
+ at = next
729
+ continue
730
+ }
731
+ // Linux's own bound on links followed in one lookup (MAXSYMLINKS).
732
+ if (++hops > 40) {
733
+ throw new UserError(
734
+ `${p} goes through a cycle of symbolic links (at ${next}) — a cache restore cannot ` +
735
+ 'write through it. Remove the link and re-run.',
736
+ )
737
+ }
738
+ if (path.isAbsolute(link)) at = path.sep
739
+ pending.push(...link.split(path.sep).filter(Boolean).reverse())
740
+ }
741
+ return at
742
+ }
743
+
519
744
  /**
520
745
  * Path-traversal defense, applied to every entry name at read time.
521
746
  * Mirrors Turbo's `restore.rs:check_path` normalization: an artifact is
@@ -549,8 +774,34 @@ function assertSafeName(name: string): void {
549
774
  if (name.includes('\0')) {
550
775
  throw new ArchiveSecurityError(`archive entry name contains a null byte (unsafe): ${name}`)
551
776
  }
777
+ // Only a pax `path` record can carry a name this long. Left to the file
778
+ // system it is a raw ENAMETOOLONG, which the restore reports as an
779
+ // internal error rather than a refused artifact. A UTF-16 unit is at most
780
+ // three UTF-8 bytes, so the common short name never pays for the count.
781
+ if (name.length * 3 >= PATH_MAX && Buffer.byteLength(name) >= PATH_MAX) {
782
+ throw new ArchiveSecurityError(
783
+ `archive entry name is ${Buffer.byteLength(name)} bytes, past PATH_MAX (${PATH_MAX}) (unsafe): ${name.slice(0, 64)}…`,
784
+ )
785
+ }
786
+ // The same raw ENAMETOOLONG for one component past NAME_MAX, the same on
787
+ // Linux and macOS. Same cheap bound: a name under 86 units cannot hold one.
788
+ if (name.length * 3 > NAME_MAX) {
789
+ for (const part of name.split('/')) {
790
+ if (part.length * 3 > NAME_MAX && Buffer.byteLength(part) > NAME_MAX) {
791
+ throw new ArchiveSecurityError(
792
+ `archive entry has a ${Buffer.byteLength(part)}-byte component, past NAME_MAX (${NAME_MAX}) (unsafe): ${part.slice(0, 64)}…`,
793
+ )
794
+ }
795
+ }
796
+ }
552
797
  }
553
798
 
799
+ /** Bytes one path component may hold (limits.h), on Linux and macOS alike. */
800
+ const NAME_MAX = 255
801
+
802
+ /** Bytes a path may hold, its terminating NUL included (limits.h). */
803
+ const PATH_MAX = process.platform === 'darwin' ? 1024 : 4096
804
+
554
805
  function hasParentSegment(p: string): boolean {
555
806
  if (p === '..' || p.startsWith('../') || p.endsWith('/..')) return true
556
807
  return p.includes('/../')