@vzn/vx-reapi 0.0.652 → 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.
package/src/executor.ts CHANGED
@@ -89,6 +89,7 @@ async function decomposeOutputDir(
89
89
  entry: { path: string; tree_digest: Digest },
90
90
  globs: readonly string[],
91
91
  warn: (m: string) => void,
92
+ workspaceRoot: string,
92
93
  ): Promise<DecomposedOutputs> {
93
94
  const whole: DecomposedOutputs = { directories: [entry], files: [], symlinks: [] }
94
95
  // Only globs that COLLAPSED to this entry are interesting; a literal glob
@@ -114,8 +115,22 @@ async function decomposeOutputDir(
114
115
  if (tree.root === undefined) return whole
115
116
  const byDigest = new Map<string, Directory>()
116
117
  tree.children.forEach((c, i) => byDigest.set(tree.childDigests[i]!, c))
118
+ // Every match's Tree is written by a replay, and their sum is at most the
119
+ // capture's own expansion. One past the free inodes is refused by the
120
+ // download (materialiseOutputs), so splitting it first is wasted work:
121
+ // recorded whole, a replay refuses it the same.
122
+ const room = await freeEntries(path.join(workspaceRoot, entry.path))
123
+ // A path past PATH_MAX is refused the same, and splitting it walked its
124
+ // depth by recursion.
125
+ const shape = treeShape(tree.root, byDigest)
126
+ const deepest = Buffer.byteLength(path.join(workspaceRoot, entry.path)) + shape.longest
127
+ if (shape.entries > room || deepest >= PATH_MAX) return whole
117
128
 
118
129
  const out: Array<{ path: string; tree_digest: Digest }> = []
130
+ // A match's Tree by its directory's digest: a subtree named under many
131
+ // names was encoded, held and uploaded once per name, 4000 names over one
132
+ // 4000-directory subtree 65 s and 3.9 GB.
133
+ const encoded = new Map<string, Blob>()
119
134
  const files: DecomposedOutputs['files'] = []
120
135
  const symlinks: DecomposedOutputs['symlinks'] = []
121
136
  const seen = new Set<string>()
@@ -142,12 +157,22 @@ async function decomposeOutputDir(
142
157
  // under it, and a consumer grafted a directory with an empty name.
143
158
  const join = (prefix: string, name: string): string =>
144
159
  prefix === '' ? name : `${prefix}/${name}`
145
- const walk = (dir: Directory, segments: readonly string[], prefix: string): void => {
160
+ const walk = (
161
+ dir: Directory,
162
+ segments: readonly string[],
163
+ prefix: string,
164
+ hash: string,
165
+ ): void => {
146
166
  if (segments.length === 0) {
147
167
  if (seen.has(prefix)) return
148
168
  seen.add(prefix)
149
- const data = encodeTree(dir, descendants(dir))
150
- out.push({ path: prefix, tree_digest: sha256(data), __data: data } as never)
169
+ let blob = encoded.get(hash)
170
+ if (blob === undefined) {
171
+ const data = encodeTree(dir, descendants(dir))
172
+ blob = { digest: sha256(data), data }
173
+ encoded.set(hash, blob)
174
+ }
175
+ out.push({ path: prefix, tree_digest: blob.digest })
151
176
  return
152
177
  }
153
178
  const [head, ...rest] = segments
@@ -170,21 +195,18 @@ async function decomposeOutputDir(
170
195
  if (!matches(d.name)) continue
171
196
  const child = byDigest.get(d.digest.hash)
172
197
  if (child === undefined) continue
173
- walk(child, rest, join(prefix, d.name))
198
+ walk(child, rest, join(prefix, d.name), d.digest.hash)
174
199
  }
175
200
  }
176
201
 
177
- for (const rest of wild) walk(tree.root, rest, entry.path)
202
+ for (const rest of wild) walk(tree.root, rest, entry.path, entry.tree_digest.hash)
178
203
  if (out.length + files.length + symlinks.length === 0) return whole
179
204
 
180
205
  // Upload the Tree blobs the new entries point at: one probe for all, the
181
206
  // small ones batched, one past the batch limit streamed. One probe and
182
207
  // write per entry cost 100 packages 3.4 s through 15 ms each way (F-31).
183
208
  // A failed probe or batch still uploads: each blob is written.
184
- const blobs = out.map((e) => ({
185
- digest: e.tree_digest,
186
- data: (e as unknown as { __data: Uint8Array }).__data,
187
- }))
209
+ const blobs = [...encoded.values()]
188
210
  await client.uploadBlobs(blobs).catch(async (err: unknown) => {
189
211
  warn(
190
212
  `[vx] vx-reapi: uploading ${entry.path}'s Tree blobs together failed (${errText(err)}) — writing each`,
@@ -192,7 +214,7 @@ async function decomposeOutputDir(
192
214
  for (const b of blobs) await client.writeBlob(b.digest, b.data)
193
215
  })
194
216
  return {
195
- directories: out.map((e) => ({ path: e.path, tree_digest: e.tree_digest })),
217
+ directories: out,
196
218
  files,
197
219
  symlinks,
198
220
  }
@@ -209,6 +231,9 @@ interface DecomposedOutputs {
209
231
  * it for one alias is the speculative widening the project rejects. */
210
232
  type DescribedInputs = NonNullable<ExecuteRequest['inputs']>
211
233
 
234
+ /** `google.rpc.Code.FAILED_PRECONDITION`: the action's inputs are not all in the CAS. */
235
+ const FAILED_PRECONDITION = 9
236
+
212
237
  /** Message text from an unknown throw, for a warning line. */
213
238
  function errText(err: unknown): string {
214
239
  return err instanceof Error ? err.message.split('\n')[0]! : String(err)
@@ -218,7 +243,7 @@ interface DecodedExecuteResponse {
218
243
  result?: ActionResult
219
244
  message?: string
220
245
  cachedResult?: boolean
221
- status?: { code: number; message: string }
246
+ status?: RpcStatus
222
247
  /** name → log blob; fetched and surfaced when the action FAILS. */
223
248
  serverLogs: Array<{ name: string; digest: Digest; humanReadable: boolean }>
224
249
  }
@@ -265,9 +290,16 @@ export function decodeExecuteResponseBytes(buf: Uint8Array): DecodedExecuteRespo
265
290
  return out
266
291
  }
267
292
 
268
- /** `google.rpc.Status { code = 1, message = 2 }` */
269
- function decodeRpcStatus(buf: Uint8Array): { code: number; message: string } {
270
- const st = { code: 0, message: '' }
293
+ interface RpcStatus {
294
+ code: number
295
+ message: string
296
+ /** Hashes a `PreconditionFailure` detail names MISSING. */
297
+ missing: string[]
298
+ }
299
+
300
+ /** `google.rpc.Status { code = 1, message = 2, details = 3 (repeated Any) }` */
301
+ function decodeRpcStatus(buf: Uint8Array): RpcStatus {
302
+ const st: RpcStatus = { code: 0, message: '', missing: [] }
271
303
  let i = 0
272
304
  while (i < buf.length) {
273
305
  const [key, k] = readVarintAt(buf, i)
@@ -281,13 +313,63 @@ function decodeRpcStatus(buf: Uint8Array): { code: number; message: string } {
281
313
  } else if (wire === 2) {
282
314
  const [len, l] = readVarintAt(buf, i)
283
315
  i = l
284
- if (field === 2) st.message = new TextDecoder().decode(buf.subarray(i, i + len))
316
+ const slice = buf.subarray(i, i + len)
317
+ if (field === 2) st.message = new TextDecoder().decode(slice)
318
+ else if (field === 3) st.missing.push(...decodeMissing(slice))
285
319
  i += len
286
320
  } else break
287
321
  }
288
322
  return st
289
323
  }
290
324
 
325
+ /** Length-delimited fields of a message, as `[field, bytes]`; other wire types skipped. */
326
+ function* bytesFields(buf: Uint8Array): Generator<[number, Uint8Array]> {
327
+ let i = 0
328
+ while (i < buf.length) {
329
+ const [key, k] = readVarintAt(buf, i)
330
+ i = k
331
+ const wire = key & 7
332
+ if (wire === 0) i = readVarintAt(buf, i)[1]
333
+ else if (wire === 2) {
334
+ const [len, l] = readVarintAt(buf, i)
335
+ yield [key >>> 3, buf.subarray(l, l + len)]
336
+ i = l + len
337
+ } else if (wire === 5) i += 4
338
+ else if (wire === 1) i += 8
339
+ else return
340
+ }
341
+ }
342
+
343
+ /**
344
+ * A detail `Any { type_url = 1, value = 2 }` holding
345
+ * `PreconditionFailure { violations = 1 (Violation { type = 1, subject = 2 }) }`:
346
+ * the hash of each MISSING `blobs/{hash}/{size}` subject.
347
+ */
348
+ function decodeMissing(any: Uint8Array): string[] {
349
+ const text = new TextDecoder()
350
+ let typeUrl = ''
351
+ let value: Uint8Array | undefined
352
+ for (const [f, b] of bytesFields(any)) {
353
+ if (f === 1) typeUrl = text.decode(b)
354
+ else if (f === 2) value = b
355
+ }
356
+ if (!typeUrl.endsWith('/google.rpc.PreconditionFailure') || value === undefined) return []
357
+ const hashes: string[] = []
358
+ for (const [f, violation] of bytesFields(value)) {
359
+ if (f !== 1) continue
360
+ let type = ''
361
+ let subject = ''
362
+ for (const [vf, b] of bytesFields(violation)) {
363
+ if (vf === 1) type = text.decode(b)
364
+ else if (vf === 2) subject = text.decode(b)
365
+ }
366
+ const [kind, hash] = subject.split('/')
367
+ if (type === 'MISSING' && kind === 'blobs' && hash !== undefined && hash !== '')
368
+ hashes.push(hash)
369
+ }
370
+ return hashes
371
+ }
372
+
291
373
  /** One `server_logs` map entry: `{ key = 1 (string), value = 2 (LogFile{digest=1, human_readable=2}) }` */
292
374
  function decodeLogEntry(
293
375
  buf: Uint8Array,
@@ -565,6 +647,12 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
565
647
  capsPromise = undefined
566
648
  throw err
567
649
  }))
650
+ // The record each execution here wrote, by vx key, for its dependants to
651
+ // graft: a run core will not save writes under a key no later run asks for
652
+ // (as vx-dte's `runKey`), since the key's own would replay as its hit
653
+ // (`unsaved`). Null: deferred outputs no record holds.
654
+ const runNonce = crypto.randomUUID()
655
+ const ranHere = new Map<string, Digest | null>()
568
656
  const executor: TaskExecutor = {
569
657
  name: 'vx/reapi',
570
658
  remote: true,
@@ -605,28 +693,38 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
605
693
  // decide whether a dependency's bytes exist at all, and treating a
606
694
  // failure there as "carry on" is how an action runs without its
607
695
  // inputs and caches the result.
608
- // stdout asked inline: a server that moved it into CAS otherwise
609
- // costs the replay a Read for it (F-25).
610
- const prior = await client
611
- .getActionResult(execDigestFor(req.cacheKey), { stdout: true, files: [] })
612
- .catch((err) => {
613
- warn(
614
- `[vx] vx-reapi: ${req.taskId} could not read its execution record (${errText(err)}) — executing`,
615
- )
616
- return null
617
- })
618
- if (prior !== null) {
619
- // The stdout blob too: a Read of an evicted one answers "none", and
620
- // the replay printed nothing where the task had printed.
621
- const stdoutBlob =
622
- (prior.stdout_raw?.length ?? 0) === 0 &&
623
- Number(prior.stdout_digest?.size_bytes ?? 0) > 0
624
- ? [prior.stdout_digest!]
625
- : []
696
+ // Both streams asked inline: a server that moved them into CAS
697
+ // otherwise costs the replay a Read each (F-25).
698
+ const prior = await untilAborted(
699
+ client.getActionResult(execDigestFor(req.cacheKey), {
700
+ stdout: true,
701
+ stderr: true,
702
+ files: [],
703
+ }),
704
+ req.signal,
705
+ ).catch((err: unknown) => {
706
+ if (req.signal?.aborted === true) throw err
707
+ warn(
708
+ `[vx] vx-reapi: ${req.taskId} could not read its execution record (${errText(err)}) — executing`,
709
+ )
710
+ return null
711
+ })
712
+ // A record of a failed run is no hit: this module records only exit 0,
713
+ // but the action cache is the server's, and a worker or another client
714
+ // may cache a failure. Replayed, it was a success with its outputs saved.
715
+ if (prior !== null && (prior.exit_code ?? 0) === 0) {
716
+ // The stream blobs too: an evicted one fails the replay, and the
717
+ // probe says so before any output is written.
718
+ const streamBlobs = [
719
+ [prior.stdout_raw, prior.stdout_digest] as const,
720
+ [prior.stderr_raw, prior.stderr_digest] as const,
721
+ ].flatMap(([raw, digest]) =>
722
+ (raw?.length ?? 0) === 0 && Number(digest?.size_bytes ?? 0) > 0 ? [digest!] : [],
723
+ )
626
724
  const referenced = [
627
725
  ...(prior.output_files ?? []).map((f) => f.digest),
628
726
  ...(prior.output_directories ?? []).map((d) => d.tree_digest),
629
- ...stdoutBlob,
727
+ ...streamBlobs,
630
728
  ]
631
729
  // AC and CAS evict independently, so a record can outlive its
632
730
  // blobs. A gap means it cannot produce the outputs — fall through
@@ -636,15 +734,20 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
636
734
  // and would replay a record whose blobs were never checked.
637
735
  const gone =
638
736
  referenced.length > 0
639
- ? await client.findMissingBlobs(referenced).catch((err: unknown) => {
640
- warn(
641
- `[vx] vx-reapi: ${req.taskId} could not check its execution record's blobs (${errText(err)}) — executing`,
642
- )
643
- return referenced
644
- })
737
+ ? await untilAborted(client.findMissingBlobs(referenced), req.signal).catch(
738
+ (err: unknown) => {
739
+ if (req.signal?.aborted === true) throw err
740
+ warn(
741
+ `[vx] vx-reapi: ${req.taskId} could not check its execution record's blobs (${errText(err)}) — executing`,
742
+ )
743
+ return referenced
744
+ },
745
+ )
645
746
  : []
646
747
  // The record's paths are WORKSPACE-relative (rebased when it was
647
748
  // written), so the anchor is the workspace root, not the cwd.
749
+ // Only the replay in this call stops with the attempt: a deferred
750
+ // one runs later, for a consumer, past this attempt's signal.
648
751
  const fromRecord = (created?: string[]): Promise<void> =>
649
752
  materialiseOutputs(
650
753
  client,
@@ -657,6 +760,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
657
760
  // replayed as a warning under a whole-tree capture: the task
658
761
  // succeeded, a declared output missing, on every run.
659
762
  replay: true,
763
+ ...(created === undefined || req.signal === undefined ? {} : { stop: req.signal }),
660
764
  },
661
765
  prior,
662
766
  warn,
@@ -671,33 +775,41 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
671
775
  // those paths must not have them saved as its outputs. What it
672
776
  // overwrote was on disk before (a whole-tree capture lists the
673
777
  // inputs) and stays.
778
+ // An abort takes back the same: core settles the attempt on it and
779
+ // may retry, and a replay still writing landed over the retry.
674
780
  const created: string[] = []
675
- const replay = async (): Promise<string> => {
676
- const stdout = await this_readStream(client, prior.stdout_raw, prior.stdout_digest)
781
+ const replay = async (): Promise<[string, string]> => {
782
+ const streams = await Promise.all([
783
+ readStream(client, prior.stdout_raw, prior.stdout_digest, `${req.taskId}'s stdout`),
784
+ readStream(client, prior.stderr_raw, prior.stderr_digest, `${req.taskId}'s stderr`),
785
+ ])
677
786
  if (req.remoteOnly !== true && !deferRecord) await fromRecord(created)
678
- return stdout
787
+ return streams
679
788
  }
680
- const priorStdout =
789
+ const printed =
681
790
  gone.length > 0
682
791
  ? null
683
- : await replay().catch(async (err: unknown) => {
792
+ : await untilAborted(replay(), req.signal).catch(async (err: unknown) => {
684
793
  for (const p of created.reverse()) await rm(p, { recursive: true, force: true })
794
+ if (req.signal?.aborted === true) throw err
685
795
  warn(
686
796
  `[vx] vx-reapi: ${req.taskId} could not replay its execution record (${errText(err)}) — executing`,
687
797
  )
688
798
  return null
689
799
  })
690
- if (priorStdout !== null) {
800
+ if (printed !== null) {
801
+ const [priorStdout, priorStderr] = printed
691
802
  // Delivered whatever `capture` says, as on the execute path below:
692
803
  // a deferred producer saves nothing, so its replay had printed
693
804
  // nothing at all (item 827). Only once the replay has landed, so
694
805
  // a replay that falls through does not print twice.
695
806
  if (priorStdout.length > 0) req.onStdout(priorStdout)
807
+ if (priorStderr.length > 0) req.onStderr(priorStderr)
696
808
  return {
697
809
  exitCode: 0,
698
810
  durationMs: Math.round((Bun.nanoseconds() - started) / 1e6),
699
811
  stdout: req.capture.stdout === false ? '' : priorStdout,
700
- stderr: '',
812
+ stderr: req.capture.stderr === false ? '' : priorStderr,
701
813
  violations: [],
702
814
  ...(deferRecord
703
815
  ? { outputs: { kind: 'deferred' as const, materialize: () => fromRecord() } }
@@ -749,103 +861,117 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
749
861
  // Every upstream at once, and each one's trees at once (F-30): a
750
862
  // remote-only upstream costs a record read, a probe and a read per
751
863
  // tree, and in sequence N upstreams cost N times that.
752
- const grafts = await Promise.all(
753
- req.inputs.upstream.map(
754
- async (
755
- up,
756
- ): Promise<{
757
- local?: string[]
758
- files?: FileGraft[]
759
- trees?: TreeGraft[]
760
- symlinks?: { path: string; target: string }[]
761
- }> => {
762
- // LOCAL DISK IS TRUTH when the upstream's outputs are materialised
763
- // here (core restored or produced them before this task started).
764
- // Grafting from the remote execution record instead can DIVERGE: two
765
- // machines racing a nondeterministic miss leave the artifact store
766
- // and the execution record holding results of DIFFERENT executions
767
- // under one pure-input key, and a worker fed the record would see
768
- // bytes this machine's own tasks do not. The graft is for outputs
769
- // that exist nowhere locally — a remote-only upstream.
770
- // "Materialised here" has to be CHECKED, not assumed. `up.outputs`
771
- // comes from the local index, which records what an entry contains —
772
- // not whether those files are on this disk right now. A task whose
773
- // producer ran remotely and did not bring its outputs home has index
774
- // rows and no files, and the tree build then dies on `stat` with
775
- // ENOENT naming a path the user never asked about. When they are
776
- // genuinely absent, the record graft is the correct source, which is
777
- // the branch below.
778
- const present = up.outputs.filter((rel) =>
779
- existsSync(path.join(req.workspaceRoot, rel)),
780
- )
781
- if (present.length > 0) {
782
- if (present.length !== up.outputs.length) {
783
- warn(
784
- `[vx] vx-reapi: ${up.taskId} has ${up.outputs.length - present.length} output${up.outputs.length - present.length === 1 ? '' : 's'} recorded ` +
785
- `but missing on disk — grafting is not possible for a partial set, using what is here`,
786
- )
787
- }
788
- return { local: present }
789
- }
790
- const record = await client.getActionResult(execDigestFor(up.hash))
791
- if (record === null) return {}
792
- // A record can OUTLIVE its blobs: the AC and the CAS evict on
793
- // independent schedules. On THIS branch nothing is local (that is
794
- // why we are grafting), so an evicted blob has no local path to
795
- // demote to — the declared upstream's outputs exist NOWHERE. An
796
- // action shipped without them is not a degraded build, it is a
797
- // different one: a command that tolerates the absence exits 0, and
798
- // vx caches that result under a key asserting those inputs were
799
- // present. Which upstream bytes a command reads is unknowable —
800
- // that is what `dependsOn` declares — so refuse, exactly as core's
801
- // own materialisation path does. Verified in one round trip.
802
- const referenced = [
803
- ...(record.output_files ?? []).map((f) => f.digest),
804
- ...(record.output_directories ?? []).map((d) => d.tree_digest),
805
- ]
806
- const gone = await client.findMissingBlobs(referenced)
807
- if (gone.length > 0) {
808
- throw new UserError(
809
- `upstream ${up.taskId} outputs evicted from the remote store (${gone.length} blob${gone.length === 1 ? '' : 's'}) and never materialised locally — re-run it (e.g. --force)`,
864
+ const grafts = await untilAborted(
865
+ Promise.all(
866
+ req.inputs.upstream.map(
867
+ async (
868
+ up,
869
+ ): Promise<{
870
+ local?: string[]
871
+ files?: FileGraft[]
872
+ trees?: TreeGraft[]
873
+ symlinks?: { path: string; target: string }[]
874
+ }> => {
875
+ // LOCAL DISK IS TRUTH when the upstream's outputs are materialised
876
+ // here (core restored or produced them before this task started).
877
+ // Grafting from the remote execution record instead can DIVERGE: two
878
+ // machines racing a nondeterministic miss leave the artifact store
879
+ // and the execution record holding results of DIFFERENT executions
880
+ // under one pure-input key, and a worker fed the record would see
881
+ // bytes this machine's own tasks do not. The graft is for outputs
882
+ // that exist nowhere locally — a remote-only upstream.
883
+ // "Materialised here" has to be CHECKED, not assumed. `up.outputs`
884
+ // comes from the local index, which records what an entry contains —
885
+ // not whether those files are on this disk right now. A task whose
886
+ // producer ran remotely and did not bring its outputs home has index
887
+ // rows and no files, and the tree build then dies on `stat` with
888
+ // ENOENT naming a path the user never asked about. When they are
889
+ // genuinely absent, the record graft is the correct source, which is
890
+ // the branch below.
891
+ const present = up.outputs.filter((rel) =>
892
+ existsSync(path.join(req.workspaceRoot, rel)),
810
893
  )
811
- }
812
- const files = (record.output_files ?? []).map((f) => ({
813
- path: f.path, // recorded workspace-relative — see the record write below
814
- digest: f.digest,
815
- isExecutable: f.is_executable === true,
816
- }))
817
- // A record's symlinks are outputs too (a worker's own, and a glob's
818
- // last-segment matches since item 911); without them the action ran
819
- // without an input it declared, and its result was cached (item 920).
820
- const symlinks = (record.output_symlinks ?? []).map((sl) => ({
821
- path: sl.path,
822
- target: sl.target,
823
- }))
824
- const trees = await Promise.all(
825
- (record.output_directories ?? []).map(async (d): Promise<TreeGraft | null> => {
826
- const treeBlob = await client.readBlob(d.tree_digest)
827
- if (treeBlob === null) {
828
- // Raced an eviction between the completeness check and this
829
- // read; on this branch no local copy exists, so the loss is
830
- // real and silently dropping the graft is the same wrong-result
831
- // hazard as an evicted file.
832
- throw new UserError(
833
- `upstream ${up.taskId} tree ${d.tree_digest.hash.slice(0, 12)} evicted from CAS — re-run it (e.g. --force)`,
894
+ if (present.length > 0) {
895
+ if (present.length !== up.outputs.length) {
896
+ warn(
897
+ `[vx] vx-reapi: ${up.taskId} has ${up.outputs.length - present.length} output${up.outputs.length - present.length === 1 ? '' : 's'} recorded ` +
898
+ `but missing on disk — grafting is not possible for a partial set, using what is here`,
834
899
  )
835
900
  }
836
- const decodedTree = decodeTreeWithBytes(treeBlob)
837
- if (decodedTree.root === undefined) return null
838
- return {
839
- path: d.path,
840
- root: decodedTree.root,
841
- children: decodedTree.children,
842
- childDigests: decodedTree.childDigests,
843
- }
844
- }),
845
- )
846
- return { files, symlinks, trees: trees.filter((t) => t !== null) }
847
- },
901
+ return { local: present }
902
+ }
903
+ const known = ranHere.get(up.hash)
904
+ const record =
905
+ known === null
906
+ ? null
907
+ : await client.getActionResult(known ?? execDigestFor(up.hash))
908
+ if (record === null) {
909
+ // An upstream with nothing to bring (no outputs, or one that
910
+ // ran here locally and wrote none) has no record either.
911
+ if (known === undefined && up.outputs.length === 0) return {}
912
+ throw new UserError(
913
+ `upstream ${up.taskId} outputs are neither on this machine nor in the remote store — re-run it (e.g. --force)`,
914
+ )
915
+ }
916
+ // A record can OUTLIVE its blobs: the AC and the CAS evict on
917
+ // independent schedules. On THIS branch nothing is local (that is
918
+ // why we are grafting), so an evicted blob has no local path to
919
+ // demote to — the declared upstream's outputs exist NOWHERE. An
920
+ // action shipped without them is not a degraded build, it is a
921
+ // different one: a command that tolerates the absence exits 0, and
922
+ // vx caches that result under a key asserting those inputs were
923
+ // present. Which upstream bytes a command reads is unknowable —
924
+ // that is what `dependsOn` declares — so refuse, exactly as core's
925
+ // own materialisation path does. Verified in one round trip.
926
+ const referenced = [
927
+ ...(record.output_files ?? []).map((f) => f.digest),
928
+ ...(record.output_directories ?? []).map((d) => d.tree_digest),
929
+ ]
930
+ const gone = await client.findMissingBlobs(referenced)
931
+ if (gone.length > 0) {
932
+ throw new UserError(
933
+ `upstream ${up.taskId} outputs evicted from the remote store (${gone.length} blob${gone.length === 1 ? '' : 's'}) and never materialised locally — re-run it (e.g. --force)`,
934
+ )
935
+ }
936
+ const files = (record.output_files ?? []).map((f) => ({
937
+ path: f.path, // recorded workspace-relative — see the record write below
938
+ digest: f.digest,
939
+ isExecutable: f.is_executable === true,
940
+ }))
941
+ // A record's symlinks are outputs too (a worker's own, and a glob's
942
+ // last-segment matches since item 911); without them the action ran
943
+ // without an input it declared, and its result was cached (item 920).
944
+ const symlinks = (record.output_symlinks ?? []).map((sl) => ({
945
+ path: sl.path,
946
+ target: sl.target,
947
+ }))
948
+ const trees = await Promise.all(
949
+ (record.output_directories ?? []).map(async (d): Promise<TreeGraft | null> => {
950
+ const treeBlob = await client.readBlob(d.tree_digest)
951
+ if (treeBlob === null) {
952
+ // Raced an eviction between the completeness check and this
953
+ // read; on this branch no local copy exists, so the loss is
954
+ // real and silently dropping the graft is the same wrong-result
955
+ // hazard as an evicted file.
956
+ throw new UserError(
957
+ `upstream ${up.taskId} tree ${d.tree_digest.hash.slice(0, 12)} evicted from CAS — re-run it (e.g. --force)`,
958
+ )
959
+ }
960
+ const decodedTree = decodeTreeWithBytes(treeBlob)
961
+ if (decodedTree.root === undefined) return null
962
+ return {
963
+ path: d.path,
964
+ root: decodedTree.root,
965
+ children: decodedTree.children,
966
+ childDigests: decodedTree.childDigests,
967
+ }
968
+ }),
969
+ )
970
+ return { files, symlinks, trees: trees.filter((t) => t !== null) }
971
+ },
972
+ ),
848
973
  ),
974
+ req.signal,
849
975
  )
850
976
  for (const g of grafts) {
851
977
  localUpstreamPaths.push(...(g.local ?? []))
@@ -924,13 +1050,13 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
924
1050
  // The action id lets a server group this action's CAS/AC traffic in its
925
1051
  // UI; named before the upload, which is most of that traffic.
926
1052
  client.nameAction(actionDigest.hash)
927
- const caps = await capabilitiesOnce()
1053
+ const caps = await untilAborted(capabilitiesOnce(), req.signal)
928
1054
  const upload: Blob[] = [
929
1055
  ...tree.blobs,
930
1056
  { digest: commandDigest, data: commandBytes },
931
1057
  { digest: actionDigest, data: actionBytes },
932
1058
  ]
933
- await client.uploadBlobs(upload, caps.maxBatchBytes)
1059
+ await untilAborted(client.uploadBlobs(upload, caps.maxBatchBytes), req.signal)
934
1060
 
935
1061
  // `exec.timeout` rides the Action so the SERVER can enforce it, but a
936
1062
  // server that ignores it — or has stopped making progress at all —
@@ -974,22 +1100,38 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
974
1100
  )
975
1101
  let op: Operation
976
1102
  try {
977
- if (req.signal?.aborted === true) throw new Error('aborted before Execute')
978
- op = await client.execute(
979
- actionDigest,
980
- {
981
- ...(opts.priority === undefined ? {} : { priority: opts.priority }),
982
- onOperation: (name) => {
983
- operation = name
984
- },
985
- // Stage transitions are consumed, not printed: they arrive many
986
- // times per action and said nothing a reader could act on.
987
- onStage: (stage) => {
988
- if (stage.toUpperCase() === 'EXECUTING') armStall()
1103
+ for (let reuploaded = false; ; reuploaded = true) {
1104
+ if (req.signal?.aborted === true) throw new Error('aborted before Execute')
1105
+ op = await client.execute(
1106
+ actionDigest,
1107
+ {
1108
+ ...(opts.priority === undefined ? {} : { priority: opts.priority }),
1109
+ onOperation: (name) => {
1110
+ operation = name
1111
+ },
1112
+ // Stage transitions are consumed, not printed: they arrive many
1113
+ // times per action and said nothing a reader could act on.
1114
+ onStage: (stage) => {
1115
+ if (stage.toUpperCase() === 'EXECUTING') armStall()
1116
+ },
989
1117
  },
990
- },
991
- stop,
992
- )
1118
+ stop,
1119
+ )
1120
+ // FAILED_PRECONDITION: inputs the server lacks, evicted since
1121
+ // FindMissingBlobs answered (or it answered wrong). The spec's
1122
+ // remedy is to upload them and execute again; once, so a server
1123
+ // that keeps refusing still fails the task. Without the named
1124
+ // violations every input is sent, past FindMissingBlobs.
1125
+ const status = op.error === undefined ? decodeExecuteResponse(op).status : undefined
1126
+ if (reuploaded || status?.code !== FAILED_PRECONDITION) break
1127
+ operation = ''
1128
+ const named = new Set(status.missing)
1129
+ const again = upload.filter((b) => named.has(b.digest.hash))
1130
+ await untilAborted(
1131
+ client.uploadBlobs(again.length > 0 ? again : upload, caps.maxBatchBytes, true),
1132
+ stop,
1133
+ )
1134
+ }
993
1135
  } catch (err) {
994
1136
  if (req.signal?.aborted === true) {
995
1137
  // Core aborts on the run's stop and on `exec.timeout`. A refusal
@@ -998,14 +1140,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
998
1140
  // is cancelled and the task ends as a killed local one does: 143,
999
1141
  // which core reports timed out when its timer fired.
1000
1142
  if (operation !== '') await client.cancelOperation(operation).catch(cancelFailed)
1001
- return {
1002
- exitCode: 143,
1003
- durationMs: Math.round((Bun.nanoseconds() - started) / 1e6),
1004
- stdout: '',
1005
- stderr: '',
1006
- violations: [],
1007
- signal: 'SIGTERM',
1008
- }
1143
+ return killed(started)
1009
1144
  }
1010
1145
  if (queued.signal.aborted) {
1011
1146
  // Best effort: a server without the service, or one that has lost
@@ -1025,6 +1160,9 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1025
1160
  `is legitimately this slow; otherwise the remote is not making progress.`,
1026
1161
  )
1027
1162
  }
1163
+ // The client gave up on the stream (its re-attach budget spent);
1164
+ // the action it named still runs on its worker unless cancelled.
1165
+ if (operation !== '') await client.cancelOperation(operation).catch(cancelFailed)
1028
1166
  throw err
1029
1167
  } finally {
1030
1168
  clearTimeout(queueTimer)
@@ -1050,10 +1188,10 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1050
1188
  return ''
1051
1189
  }
1052
1190
  const [out, err] = await Promise.all([
1053
- this_readStream(client, result.stdout_raw, result.stdout_digest).catch(
1191
+ readStream(client, result.stdout_raw, result.stdout_digest, 'stdout').catch(
1054
1192
  unread('stdout'),
1055
1193
  ),
1056
- this_readStream(client, result.stderr_raw, result.stderr_digest).catch(
1194
+ readStream(client, result.stderr_raw, result.stderr_digest, 'stderr').catch(
1057
1195
  unread('stderr'),
1058
1196
  ),
1059
1197
  ])
@@ -1088,11 +1226,18 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1088
1226
  const deferred = req.remoteOnly !== true && req.download === 'deferred'
1089
1227
  const materialised =
1090
1228
  req.remoteOnly !== true && !deferred
1091
- ? materialiseOutputs(client, matReq, result, warn)
1229
+ ? materialiseOutputs(
1230
+ client,
1231
+ req.signal === undefined ? matReq : { ...matReq, stop: req.signal },
1232
+ result,
1233
+ warn,
1234
+ )
1092
1235
  : Promise.resolve()
1236
+ // A stream the server names and the CAS does not hold fails the task:
1237
+ // read as '', it succeeded printing nothing and was recorded so.
1093
1238
  const logs = Promise.all([
1094
- this_readStream(client, result.stdout_raw, result.stdout_digest),
1095
- this_readStream(client, result.stderr_raw, result.stderr_digest),
1239
+ readStream(client, result.stdout_raw, result.stdout_digest, `${req.taskId}'s stdout`),
1240
+ readStream(client, result.stderr_raw, result.stderr_digest, `${req.taskId}'s stderr`),
1096
1241
  ])
1097
1242
  // Settled below with the outputs; a log that cannot be read still
1098
1243
  // fails the task, after the outputs have landed or failed.
@@ -1122,9 +1267,25 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1122
1267
  // The record is written while the outputs come down: the two share
1123
1268
  // nothing, and in turn the record's round trips (a stdout blob, Tree
1124
1269
  // reads, the update) all sat before the first output byte (F-27).
1270
+ // A run core will not save (`unsaved`) records under this run's own
1271
+ // key instead: under the task's it would replay as that key's hit
1272
+ // (executor.ts contract), and with none a deferred one's dependants
1273
+ // had nothing to graft.
1274
+ const hasOutputs =
1275
+ (result.output_files ?? []).length +
1276
+ (result.output_directories ?? []).length +
1277
+ (result.output_symlinks ?? []).length >
1278
+ 0
1279
+ if (req.cacheKey !== undefined && deferred && hasOutputs && (result.exit_code ?? 0) === 0) {
1280
+ ranHere.set(req.cacheKey, null)
1281
+ }
1125
1282
  const recording: Promise<void> =
1126
1283
  req.cacheKey !== undefined && (result.exit_code ?? 0) === 0 && tree.moved.length === 0
1127
1284
  ? (async (cacheKey: string): Promise<void> => {
1285
+ const recordKey =
1286
+ req.unsaved === true
1287
+ ? execDigestFor(`${cacheKey}\0${runNonce}`)
1288
+ : execDigestFor(cacheKey)
1128
1289
  // A whole-tree capture's path is '' — the working directory itself,
1129
1290
  // which `${wd}/` spelled `pkg/`: never matched by a decomposition,
1130
1291
  // and grafted as a directory with an empty name (item 1040).
@@ -1134,41 +1295,46 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1134
1295
  : rel === ''
1135
1296
  ? workingDirectory
1136
1297
  : `${workingDirectory}/${rel}`
1137
- // Stdout rides the record as a blob so a short-circuited repeat run
1138
- // can replay it. Best-effort: a record without it replays empty,
1139
- // never wrong bytes.
1140
- let stdoutDigest: Digest | undefined
1141
- const stdoutBytes = new TextEncoder().encode(stdout)
1142
- // Small stdout rides the record itself (`stdout_raw`), which the
1143
- // replay reads as it reads a digest: two CAS round trips fewer (F-27).
1144
- const inlineStdout =
1145
- stdoutBytes.length > 0 && stdoutBytes.length <= INLINE_STDOUT_BYTES
1146
- // A larger one the server already keeps in CAS is recorded by
1147
- // its digest: no hash, probe or upload of our own (F-35).
1148
- if (!inlineStdout && result.stdout_digest !== undefined && stdoutBytes.length > 0) {
1149
- stdoutDigest = result.stdout_digest
1150
- } else if (stdoutBytes.length > 0 && !inlineStdout) {
1151
- const d = sha256(stdoutBytes)
1298
+ // Both streams ride the record so a short-circuited repeat run
1299
+ // replays them. Best-effort: a stream the record could not keep
1300
+ // is left out and replays empty, never as wrong bytes.
1301
+ const kept = async (
1302
+ name: 'stdout' | 'stderr',
1303
+ text: string,
1304
+ serverDigest: Digest | undefined | null,
1305
+ ): Promise<{ raw?: Uint8Array; digest?: Digest }> => {
1306
+ const bytes = new TextEncoder().encode(text)
1307
+ if (bytes.length === 0) return {}
1308
+ // A small one rides the record itself (`*_raw`), which the
1309
+ // replay reads as it reads a digest: two CAS round trips fewer (F-27).
1310
+ if (bytes.length <= INLINE_STREAM_BYTES) return { raw: bytes }
1311
+ // A larger one the server already keeps in CAS is recorded by
1312
+ // its digest: no hash, probe or upload of our own (F-35).
1313
+ if (serverDigest !== undefined && serverDigest !== null) {
1314
+ return { digest: serverDigest }
1315
+ }
1316
+ const d = sha256(bytes)
1152
1317
  const missing = await client.findMissingBlobs([d]).catch((err: unknown) => {
1153
1318
  warn(
1154
- `[vx] vx-reapi: ${req.taskId}'s stdout probe failed (${errText(err)}) — uploading it`,
1319
+ `[vx] vx-reapi: ${req.taskId}'s ${name} probe failed (${errText(err)}) — uploading it`,
1155
1320
  )
1156
1321
  return [d]
1157
1322
  })
1158
- const uploaded =
1159
- missing.length === 0
1160
- ? true
1161
- : await client.writeBlob(d, stdoutBytes).then(
1162
- () => true,
1163
- (err: unknown) => {
1164
- warn(
1165
- `[vx] vx-reapi: ${req.taskId}'s stdout upload failed (${errText(err)}) — recorded without it`,
1166
- )
1167
- return false
1168
- },
1169
- )
1170
- if (uploaded) stdoutDigest = d
1323
+ if (missing.length === 0) return { digest: d }
1324
+ return client.writeBlob(d, bytes).then(
1325
+ () => ({ digest: d }),
1326
+ (err: unknown) => {
1327
+ warn(
1328
+ `[vx] vx-reapi: ${req.taskId}'s ${name} upload failed (${errText(err)}) — recorded without it`,
1329
+ )
1330
+ return {}
1331
+ },
1332
+ )
1171
1333
  }
1334
+ const [out, err] = await Promise.all([
1335
+ kept('stdout', stdout, result.stdout_digest),
1336
+ kept('stderr', stderr, result.stderr_digest),
1337
+ ])
1172
1338
  // A coarse capture is split into the paths its glob really names
1173
1339
  // before it is recorded — see decomposeOutputDir.
1174
1340
  const declaredGlobs = [
@@ -1193,27 +1359,34 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1193
1359
  const rebased = { path: rebase(d.path), tree_digest: d.tree_digest }
1194
1360
  // The record is best-effort like its write below: a Read that
1195
1361
  // fails while splitting failed a task whose action had succeeded.
1196
- const split = await decomposeOutputDir(client, rebased, declaredGlobs, warn).catch(
1197
- (err: unknown) => {
1198
- warn(
1199
- `[vx] vx-reapi: could not read the Tree for ${rebased.path} (${errText(err)}) — recording it whole`,
1200
- )
1201
- return { directories: [rebased], files: [], symlinks: [] }
1202
- },
1203
- )
1362
+ const split = await decomposeOutputDir(
1363
+ client,
1364
+ rebased,
1365
+ declaredGlobs,
1366
+ warn,
1367
+ req.workspaceRoot,
1368
+ ).catch((err: unknown) => {
1369
+ warn(
1370
+ `[vx] vx-reapi: could not read the Tree for ${rebased.path} (${errText(err)}) — recording it whole`,
1371
+ )
1372
+ return { directories: [rebased], files: [], symlinks: [] }
1373
+ })
1204
1374
  recorded.directories.push(...split.directories)
1205
1375
  recorded.files.push(...split.files)
1206
1376
  recorded.symlinks.push(...split.symlinks)
1207
1377
  }
1208
1378
  await client
1209
- .updateActionResult(execDigestFor(cacheKey), {
1379
+ .updateActionResult(recordKey, {
1210
1380
  exit_code: 0,
1211
- ...(stdoutDigest === undefined ? {} : { stdout_digest: stdoutDigest }),
1212
- ...(inlineStdout ? { stdout_raw: stdoutBytes } : {}),
1381
+ ...(out.digest === undefined ? {} : { stdout_digest: out.digest }),
1382
+ ...(out.raw === undefined ? {} : { stdout_raw: out.raw }),
1383
+ ...(err.digest === undefined ? {} : { stderr_digest: err.digest }),
1384
+ ...(err.raw === undefined ? {} : { stderr_raw: err.raw }),
1213
1385
  output_files: recorded.files,
1214
1386
  output_directories: recorded.directories,
1215
1387
  output_symlinks: recorded.symlinks,
1216
1388
  })
1389
+ .then(() => ranHere.set(cacheKey, recordKey))
1217
1390
  .catch((err: Error) =>
1218
1391
  warn(
1219
1392
  `[vx] vx-reapi: could not record execution for ${req.taskId}: ${err.message}`,
@@ -1245,15 +1418,52 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1245
1418
  },
1246
1419
  }
1247
1420
  const run = executor.execute.bind(executor)
1248
- executor.execute = (req) =>
1249
- client
1421
+ executor.execute = (req) => {
1422
+ const started = Bun.nanoseconds()
1423
+ return client
1250
1424
  .inAction(() => run(req))
1251
1425
  .catch((err: unknown) => {
1426
+ // A step the abort cut short (a record replay, an upstream read, the
1427
+ // upload, a restore) ends the task as the abort at Execute does.
1428
+ if (req.signal?.aborted === true) return killed(started)
1252
1429
  throw namedRefusal(req.taskId, err)
1253
1430
  })
1431
+ }
1254
1432
  return executor
1255
1433
  }
1256
1434
 
1435
+ /**
1436
+ * The run's stop or `exec.timeout` ended the task: as a killed local one
1437
+ * ends (143, `SIGTERM`), which core reports timed out when its timer fired.
1438
+ */
1439
+ function killed(started: number): ExecuteResult {
1440
+ return {
1441
+ exitCode: 143,
1442
+ durationMs: Math.round((Bun.nanoseconds() - started) / 1e6),
1443
+ stdout: '',
1444
+ stderr: '',
1445
+ violations: [],
1446
+ signal: 'SIGTERM',
1447
+ }
1448
+ }
1449
+
1450
+ /**
1451
+ * `work`, or a rejection the moment `signal` aborts. The calls `work` made
1452
+ * are not cancelled and run on unheard; a write they would lead to is
1453
+ * refused by the Fence, which checks the same signal.
1454
+ */
1455
+ function untilAborted<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
1456
+ if (signal === undefined) return work
1457
+ // quiet: abandoned on abort, which is the task's outcome
1458
+ work.catch(() => undefined)
1459
+ if (signal.aborted) return Promise.reject(signal.reason)
1460
+ return new Promise<T>((resolve, reject) => {
1461
+ const onAbort = (): void => reject(signal.reason)
1462
+ signal.addEventListener('abort', onAbort, { once: true })
1463
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
1464
+ })
1465
+ }
1466
+
1257
1467
  /**
1258
1468
  * A gRPC status that escaped `execute` (an Execute the server refused, an
1259
1469
  * upload or upstream read that failed) is the server's answer, not a vx bug.
@@ -1300,15 +1510,19 @@ async function fetchServerLogs(
1300
1510
  * caught by the node_modules chain test the moment the record
1301
1511
  * short-circuit widened past `remote: 'only'`.
1302
1512
  */
1303
- async function this_readStream(
1513
+ async function readStream(
1304
1514
  client: ReapiClient,
1305
1515
  raw: Uint8Array | undefined,
1306
1516
  digest: Digest | undefined | null,
1517
+ what: string,
1307
1518
  ): Promise<string> {
1308
1519
  if (raw !== undefined && raw !== null && raw.length > 0) return new TextDecoder().decode(raw)
1309
1520
  if (digest !== undefined && digest !== null && digest.size_bytes > 0) {
1310
1521
  const bytes = await client.readBlob(digest)
1311
- if (bytes !== null) return new TextDecoder().decode(bytes)
1522
+ if (bytes === null) {
1523
+ throw new UserError(`${what} ${digest.hash.slice(0, 12)} is missing from the CAS`)
1524
+ }
1525
+ return new TextDecoder().decode(bytes)
1312
1526
  }
1313
1527
  return ''
1314
1528
  }
@@ -1385,7 +1599,8 @@ function literalHead(glob: string): { path: string; rest: string[] } {
1385
1599
  /**
1386
1600
  * The action's environment: `inputs.env` (values read from THIS
1387
1601
  * machine's environment, already folded into the cache key) plus
1388
- * `exec.env.define` (literals from the task config). A define wins on
1602
+ * `exec.env.define` (literals from the task config), plus the
1603
+ * `FORCE_COLOR=1` vx sets on every local child. A define wins on
1389
1604
  * collision — it is the more explicit statement of intent. An `inputs.env`
1390
1605
  * name crosses only when the local child gets the same value (`childEnv`,
1391
1606
  * the request's resolved environment): a name the config only TRACKS is in
@@ -1414,6 +1629,10 @@ export function commandEnvironment(
1414
1629
  if (e.value !== undefined && childEnv[e.name] === e.value) merged.set(e.name, e.value)
1415
1630
  }
1416
1631
  for (const [name, value] of Object.entries(envDefine)) merged.set(name, value)
1632
+ // Core's exec/env.ts gives the local child FORCE_COLOR=1 unless it already
1633
+ // sees FORCE_COLOR or a non-empty NO_COLOR. Without it here a worker
1634
+ // stored plain bytes under the key a local run stores coloured ones.
1635
+ if (!merged.has('FORCE_COLOR') && !merged.get('NO_COLOR')) merged.set('FORCE_COLOR', '1')
1417
1636
  return [...merged].map(([name, value]) => ({ name, value }))
1418
1637
  }
1419
1638
 
@@ -1472,6 +1691,8 @@ type MaterialiseRequest = ExecuteRequest & {
1472
1691
  * whose capture holds more than the outputs warns instead.
1473
1692
  */
1474
1693
  readonly replay?: boolean
1694
+ /** The attempt's abort: once it fires, nothing more is written. */
1695
+ readonly stop?: AbortSignal
1475
1696
  }
1476
1697
 
1477
1698
  /**
@@ -1545,7 +1766,42 @@ export async function materialiseOutputs(
1545
1766
  }
1546
1767
  return false
1547
1768
  }
1548
- const fence = new Fence(req.workspaceRoot, underDeclared)
1769
+ const fence = new Fence(req.workspaceRoot, underDeclared, req.stop)
1770
+ // Every Tree is read and counted before anything is written, and the sum
1771
+ // is held to the free inodes: a record split into one entry per glob
1772
+ // match fitted Tree by Tree, and its replay wrote them all. One Tree
1773
+ // named by many entries is read once.
1774
+ const dirs = result.output_directories ?? []
1775
+ const trees = new Map<string, ReadTree | null>()
1776
+ let expands = 0
1777
+ for (const d of dirs) {
1778
+ let read = trees.get(d.tree_digest.hash)
1779
+ if (read === undefined) {
1780
+ read = await readTree(client, d.tree_digest, missing)
1781
+ trees.set(d.tree_digest.hash, read)
1782
+ }
1783
+ if (read === null) continue
1784
+ expands += read.entries
1785
+ // Past PATH_MAX the walk made the directories of a path the file system
1786
+ // then refused with a raw ENAMETOOLONG.
1787
+ const dest = fence.lexical(req.cwd, d.path)
1788
+ const longest = Buffer.byteLength(dest) + read.longest
1789
+ if (longest >= PATH_MAX) {
1790
+ throw new UserError(
1791
+ `the server's Tree for ${dest} holds a path ${longest} bytes long, past PATH_MAX (${PATH_MAX}) — refused`,
1792
+ )
1793
+ }
1794
+ }
1795
+ const room = expands > 0 ? await freeEntries(req.workspaceRoot) : Infinity
1796
+ if (expands > room) {
1797
+ const what =
1798
+ dirs.length === 1
1799
+ ? `Tree for ${fence.lexical(req.cwd, dirs[0]!.path)} expands`
1800
+ : `${dirs.length} output directories expand`
1801
+ throw new UserError(
1802
+ `the server's ${what} to ${expands < 1e6 ? expands : expands.toPrecision(2)} entries, more than the ${room} this file system has free — refused`,
1803
+ )
1804
+ }
1549
1805
  // A file or link no declared glob names is neither fetched nor written:
1550
1806
  // the workspace root was the only fence, so a result (or a record planted
1551
1807
  // in the action cache) could write `.git/hooks/post-checkout` 0755 or
@@ -1603,18 +1859,12 @@ export async function materialiseOutputs(
1603
1859
  await fence.symlink(sl.target, abs, created)
1604
1860
  }
1605
1861
 
1606
- for (const d of result.output_directories ?? []) {
1862
+ for (const d of dirs) {
1863
+ const read = trees.get(d.tree_digest.hash)
1864
+ if (read === null || read === undefined) continue
1607
1865
  const dest = fence.lexical(req.cwd, d.path)
1608
1866
  const whole = isDeclared(dest)
1609
- await materialiseTree(
1610
- client,
1611
- fence,
1612
- dest,
1613
- d.tree_digest,
1614
- missing,
1615
- created,
1616
- whole ? null : isDeclared,
1617
- )
1867
+ await materialiseTree(client, fence, dest, read, missing, created, whole ? null : isDeclared)
1618
1868
  }
1619
1869
  await fence.verifyLinks()
1620
1870
  }
@@ -1644,6 +1894,12 @@ class Fence {
1644
1894
  root: string,
1645
1895
  /** A workspace-relative path the task declared as output, or under one. */
1646
1896
  private readonly declared: (rel: string) => boolean = () => true,
1897
+ /**
1898
+ * Once aborted, nothing more is written: each write is preceded by
1899
+ * `landing` or `dir`, which then refuse. A Read already in flight when
1900
+ * the attempt was abandoned lands here, not on disk.
1901
+ */
1902
+ private readonly stop?: AbortSignal,
1647
1903
  ) {
1648
1904
  this.root = path.resolve(root)
1649
1905
  }
@@ -1666,6 +1922,7 @@ class Fence {
1666
1922
  const rel = toPosix(path.relative(this.realRoot, path.join(await real, path.basename(abs))))
1667
1923
  if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel) || !this.declared(rel))
1668
1924
  throw this.refuse(abs)
1925
+ this.stop?.throwIfAborted()
1669
1926
  }
1670
1927
 
1671
1928
  /** `rel` joined under `base`, refused when the join leaves the root. */
@@ -1687,6 +1944,7 @@ class Fence {
1687
1944
 
1688
1945
  /** `dir`, or its deepest existing ancestor, resolves inside the root. */
1689
1946
  async dir(dir: string): Promise<void> {
1947
+ this.stop?.throwIfAborted()
1690
1948
  if (this.checked.has(dir)) return
1691
1949
  // quiet: a local realpath; the root as given stands
1692
1950
  this.realRoot ??= await realpath(this.root).catch(() => this.root)
@@ -1703,6 +1961,7 @@ class Fence {
1703
1961
  if (probe === this.root || path.dirname(probe) === probe) break
1704
1962
  probe = path.dirname(probe)
1705
1963
  }
1964
+ this.stop?.throwIfAborted()
1706
1965
  this.checked.add(dir)
1707
1966
  }
1708
1967
 
@@ -1839,12 +2098,18 @@ async function placeSymlink(
1839
2098
  }
1840
2099
  }
1841
2100
 
1842
- /** Stdout up to this size is recorded inline (`stdout_raw`) rather than as a CAS blob. */
1843
- const INLINE_STDOUT_BYTES = 64 * 1024
2101
+ /** A stream up to this size is recorded inline (`stdout_raw`, `stderr_raw`), not as a CAS blob. */
2102
+ const INLINE_STREAM_BYTES = 64 * 1024
1844
2103
 
1845
2104
  /** Small blobs a restore holds in memory at once, fetched batched. */
1846
2105
  const TREE_FETCH_WINDOW_BYTES = 64 * 1024 * 1024
1847
2106
 
2107
+ /** A Tree read, its children by digest, and what it writes. */
2108
+ interface ReadTree extends TreeShape {
2109
+ root: Directory
2110
+ byDigest: Map<string, Directory>
2111
+ }
2112
+
1848
2113
  /**
1849
2114
  * An `OutputDirectory` points at the digest of an encoded **`Tree` proto**
1850
2115
  * (root Directory + every descendant), NOT at a Directory to be walked with
@@ -1852,28 +2117,20 @@ const TREE_FETCH_WINDOW_BYTES = 64 * 1024 * 1024
1852
2117
  * this module's first version — because `GetTree` takes a Directory root and
1853
2118
  * would either error or, worse, traverse something else.
1854
2119
  */
1855
- async function materialiseTree(
2120
+ async function readTree(
1856
2121
  client: ReapiClient,
1857
- fence: Fence,
1858
- destDir: string,
1859
2122
  treeDigest: Digest,
1860
- // Same policy as the file path: under a literal capture an unmaterialisable
1861
- // entry is a hole in a DECLARED output directory, so it fails the task.
1862
2123
  missing: (what: string, hash: string, abs?: string) => void,
1863
- created: string[] | undefined,
1864
- // Null writes the whole Tree; otherwise only an entry it names, or one
1865
- // under a directory it names, is written.
1866
- declared: ((abs: string) => boolean) | null,
1867
- ): Promise<void> {
2124
+ ): Promise<ReadTree | null> {
1868
2125
  const blob = await client.readBlob(treeDigest)
1869
2126
  if (blob === null) {
1870
2127
  missing('tree', treeDigest.hash)
1871
- return
2128
+ return null
1872
2129
  }
1873
2130
  const tree = decodeTreeWithBytes(blob)
1874
2131
  if (tree.root === undefined) {
1875
2132
  missing('tree (no root directory)', treeDigest.hash)
1876
- return
2133
+ return null
1877
2134
  }
1878
2135
  // Children are addressed by the digest of the bytes the WORKER encoded.
1879
2136
  // Re-encoding our parse reproduces it only when both encoders agree byte
@@ -1881,34 +2138,83 @@ async function materialiseTree(
1881
2138
  // (item 827), the miss decomposeOutputDir's own comment measured.
1882
2139
  const byDigest = new Map<string, Directory>()
1883
2140
  tree.children.forEach((child, i) => byDigest.set(tree.childDigests[i]!, child))
2141
+ return { root: tree.root, byDigest, ...treeShape(tree.root, byDigest) }
2142
+ }
2143
+
2144
+ /** What a Tree writes: its paths, and the bytes of its longest one. */
2145
+ interface TreeShape {
2146
+ entries: number
2147
+ longest: number
2148
+ }
1884
2149
 
1885
- // A Tree may name one subtree under many names, so 41 directories can
1886
- // expand to 2^41 paths, and the walk wrote until the disk gave out. Each
1887
- // path is an inode on disk: counted once per distinct directory, a Tree
1888
- // that expands past what the file system has free is refused unwritten.
1889
- const counted = new Map<string, number>()
1890
- const entries = (dir: Directory): number => {
1891
- let n = 1 + dir.files.length + dir.symlinks.length
2150
+ /** Bytes a path may hold, its terminating NUL included (limits.h). */
2151
+ const PATH_MAX = process.platform === 'darwin' ? 1024 : 4096
2152
+
2153
+ /**
2154
+ * The paths `root` expands to on disk, itself included, and the bytes of
2155
+ * the longest below it. A Tree may name one subtree under many names, so 41
2156
+ * directories can expand to 2^41 paths, and the walk wrote until the disk
2157
+ * gave out: counted once per distinct directory, a Tree that expands past
2158
+ * what the file system has free is refused unwritten. Walked without
2159
+ * recursion: a Tree 100 000 directories deep overflowed the stack.
2160
+ */
2161
+ function treeShape(root: Directory, byDigest: ReadonlyMap<string, Directory>): TreeShape {
2162
+ const shapes = new Map<string, TreeShape>()
2163
+ const shapeOf = (dir: Directory): TreeShape => {
2164
+ let entries = 1 + dir.files.length + dir.symlinks.length
2165
+ let longest = 0
2166
+ for (const f of [...dir.files, ...dir.symlinks]) {
2167
+ longest = Math.max(longest, 1 + Buffer.byteLength(f.name))
2168
+ }
1892
2169
  for (const d of dir.directories) {
1893
- const node = byDigest.get(d.digest.hash)
1894
- if (node === undefined) continue
1895
- let below = counted.get(d.digest.hash)
1896
- if (below === undefined) {
1897
- below = entries(node)
1898
- counted.set(d.digest.hash, below)
1899
- }
1900
- n += below
2170
+ const below = shapes.get(d.digest.hash)
2171
+ if (below === undefined) continue
2172
+ entries += below.entries
2173
+ longest = Math.max(longest, 1 + Buffer.byteLength(d.name) + below.longest)
1901
2174
  }
1902
- return n
2175
+ return { entries, longest }
1903
2176
  }
1904
- const expands = entries(tree.root)
1905
- const room = await freeEntries(destDir)
1906
- if (expands > room) {
1907
- throw new UserError(
1908
- `the server's Tree for ${destDir} expands to ${expands < 1e6 ? expands : expands.toPrecision(2)} entries, more than the ${room} this file system has free — refused`,
1909
- )
2177
+ // Each directory after its children: a child under many names is shaped once.
2178
+ const stack: Array<{ dir: Directory; hash: string; open: boolean }> = [
2179
+ { dir: root, hash: '', open: false },
2180
+ ]
2181
+ for (;;) {
2182
+ const top = stack.at(-1)!
2183
+ if (top.hash !== '' && shapes.has(top.hash)) {
2184
+ stack.pop()
2185
+ continue
2186
+ }
2187
+ if (!top.open) {
2188
+ top.open = true
2189
+ for (const d of top.dir.directories) {
2190
+ const node = byDigest.get(d.digest.hash)
2191
+ if (node !== undefined && !shapes.has(d.digest.hash)) {
2192
+ stack.push({ dir: node, hash: d.digest.hash, open: false })
2193
+ }
2194
+ }
2195
+ continue
2196
+ }
2197
+ stack.pop()
2198
+ const shape = shapeOf(top.dir)
2199
+ if (top.hash === '') return shape
2200
+ shapes.set(top.hash, shape)
1910
2201
  }
2202
+ }
1911
2203
 
2204
+ /** Writes a Tree `readTree` read and `materialiseOutputs` counted. */
2205
+ async function materialiseTree(
2206
+ client: ReapiClient,
2207
+ fence: Fence,
2208
+ destDir: string,
2209
+ { root, byDigest }: ReadTree,
2210
+ // Same policy as the file path: under a literal capture an unmaterialisable
2211
+ // entry is a hole in a DECLARED output directory, so it fails the task.
2212
+ missing: (what: string, hash: string, abs?: string) => void,
2213
+ created: string[] | undefined,
2214
+ // Null writes the whole Tree; otherwise only an entry it names, or one
2215
+ // under a directory it names, is written.
2216
+ declared: ((abs: string) => boolean) | null,
2217
+ ): Promise<void> {
1912
2218
  // Files are gathered from the whole tree before any is fetched: one
1913
2219
  // BatchReadBlobs per DIRECTORY was a round trip each, so a `dist/` of 200
1914
2220
  // directories restored in 200 sequential calls (F-18). Windows bound what
@@ -1939,7 +2245,7 @@ async function materialiseTree(
1939
2245
  await walk(node, childAt, whole || declared!(childAt))
1940
2246
  }
1941
2247
  }
1942
- await walk(tree.root, destDir, declared === null)
2248
+ await walk(root, destDir, declared === null)
1943
2249
 
1944
2250
  for (const window of fetchWindows(files, (w) => w.f.digest)) {
1945
2251
  const batched = await client.batchReadBlobs(