@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/README.md +77 -34
- package/package.json +2 -2
- package/src/cache.ts +30 -14
- package/src/executor.ts +569 -263
- package/src/index.ts +1 -1
- package/src/merkle.ts +113 -67
- package/src/wire.ts +79 -29
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 = (
|
|
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
|
-
|
|
150
|
-
|
|
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 =
|
|
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
|
|
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?:
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
609
|
-
// costs the replay a Read
|
|
610
|
-
const prior = await
|
|
611
|
-
.getActionResult(execDigestFor(req.cacheKey), {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
...
|
|
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(
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
|
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
|
|
787
|
+
return streams
|
|
679
788
|
}
|
|
680
|
-
const
|
|
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 (
|
|
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
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1191
|
+
readStream(client, result.stdout_raw, result.stdout_digest, 'stdout').catch(
|
|
1054
1192
|
unread('stdout'),
|
|
1055
1193
|
),
|
|
1056
|
-
|
|
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(
|
|
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
|
-
|
|
1095
|
-
|
|
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
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
// never wrong bytes.
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
|
1319
|
+
`[vx] vx-reapi: ${req.taskId}'s ${name} probe failed (${errText(err)}) — uploading it`,
|
|
1155
1320
|
)
|
|
1156
1321
|
return [d]
|
|
1157
1322
|
})
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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(
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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(
|
|
1379
|
+
.updateActionResult(recordKey, {
|
|
1210
1380
|
exit_code: 0,
|
|
1211
|
-
...(
|
|
1212
|
-
...(
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
/**
|
|
1843
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
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
|
|
1894
|
-
if (
|
|
1895
|
-
|
|
1896
|
-
|
|
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
|
|
2175
|
+
return { entries, longest }
|
|
1903
2176
|
}
|
|
1904
|
-
|
|
1905
|
-
const
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
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(
|
|
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(
|