@vzn/vx-reapi 0.0.646 → 0.0.649
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 +52 -40
- package/package.json +3 -3
- package/src/cache.ts +89 -23
- package/src/executor.ts +289 -109
- package/src/index.ts +67 -51
- package/src/merkle.ts +57 -18
- package/src/wire.ts +106 -32
package/src/executor.ts
CHANGED
|
@@ -9,7 +9,17 @@
|
|
|
9
9
|
// anything depending on one, and `exec.remote: false` never reach an
|
|
10
10
|
// executor. This declines the rest of what it cannot honour.
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
mkdir,
|
|
14
|
+
writeFile,
|
|
15
|
+
chmod,
|
|
16
|
+
readlink,
|
|
17
|
+
realpath,
|
|
18
|
+
rm,
|
|
19
|
+
statfs,
|
|
20
|
+
symlink,
|
|
21
|
+
unlink,
|
|
22
|
+
} from 'node:fs/promises'
|
|
13
23
|
import { constants, existsSync } from 'node:fs'
|
|
14
24
|
import path from 'node:path'
|
|
15
25
|
import {
|
|
@@ -18,6 +28,7 @@ import {
|
|
|
18
28
|
isUserError,
|
|
19
29
|
normalizeGlob,
|
|
20
30
|
restorableOutput,
|
|
31
|
+
ui,
|
|
21
32
|
UserError,
|
|
22
33
|
withForwardArgs,
|
|
23
34
|
} from '@vzn/vx'
|
|
@@ -38,7 +49,14 @@ import {
|
|
|
38
49
|
type TreeGraft,
|
|
39
50
|
} from './merkle.js'
|
|
40
51
|
import { execDigestFor } from './cache.js'
|
|
41
|
-
import
|
|
52
|
+
import {
|
|
53
|
+
type ActionResult,
|
|
54
|
+
type Digest,
|
|
55
|
+
type Directory,
|
|
56
|
+
MAX_TIMER_MS,
|
|
57
|
+
type Operation,
|
|
58
|
+
type ReapiClient,
|
|
59
|
+
} from './wire.js'
|
|
42
60
|
|
|
43
61
|
// A default decoder strips a leading U+FEFF, which is part of a path.
|
|
44
62
|
const PATH_UTF8 = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
@@ -87,7 +105,7 @@ async function decomposeOutputDir(
|
|
|
87
105
|
|
|
88
106
|
const blob = await client.readBlob(entry.tree_digest)
|
|
89
107
|
if (blob === null) {
|
|
90
|
-
warn(`vx
|
|
108
|
+
warn(`[vx] vx-reapi: could not read the Tree for ${entry.path} — recording it whole`)
|
|
91
109
|
return whole
|
|
92
110
|
}
|
|
93
111
|
// Keyed by the WORKER's own bytes, not by re-encoding our parse of them —
|
|
@@ -103,16 +121,27 @@ async function decomposeOutputDir(
|
|
|
103
121
|
const seen = new Set<string>()
|
|
104
122
|
|
|
105
123
|
// Every transitive child of `dir`, which is what a Tree message must carry.
|
|
106
|
-
|
|
124
|
+
// Each once: a subtree a Tree names under many names was walked per name,
|
|
125
|
+
// 2^depth from a Tree of depth+1 directories.
|
|
126
|
+
const descendants = (
|
|
127
|
+
dir: Directory,
|
|
128
|
+
acc: Directory[] = [],
|
|
129
|
+
had = new Set<string>(),
|
|
130
|
+
): Directory[] => {
|
|
107
131
|
for (const d of dir.directories) {
|
|
108
132
|
const child = byDigest.get(d.digest.hash)
|
|
109
|
-
if (child === undefined) continue
|
|
133
|
+
if (child === undefined || had.has(d.digest.hash)) continue
|
|
134
|
+
had.add(d.digest.hash)
|
|
110
135
|
acc.push(child)
|
|
111
|
-
descendants(child, acc)
|
|
136
|
+
descendants(child, acc, had)
|
|
112
137
|
}
|
|
113
138
|
return acc
|
|
114
139
|
}
|
|
115
140
|
|
|
141
|
+
// The whole working directory's capture is `''`: `/name` was recorded
|
|
142
|
+
// under it, and a consumer grafted a directory with an empty name.
|
|
143
|
+
const join = (prefix: string, name: string): string =>
|
|
144
|
+
prefix === '' ? name : `${prefix}/${name}`
|
|
116
145
|
const walk = (dir: Directory, segments: readonly string[], prefix: string): void => {
|
|
117
146
|
if (segments.length === 0) {
|
|
118
147
|
if (seen.has(prefix)) return
|
|
@@ -125,13 +154,13 @@ async function decomposeOutputDir(
|
|
|
125
154
|
const matches = (name: string): boolean => head === '*' || name === head
|
|
126
155
|
if (rest.length === 0) {
|
|
127
156
|
for (const f of dir.files) {
|
|
128
|
-
const at =
|
|
157
|
+
const at = join(prefix, f.name)
|
|
129
158
|
if (!matches(f.name) || seen.has(at)) continue
|
|
130
159
|
seen.add(at)
|
|
131
160
|
files.push({ path: at, digest: f.digest, is_executable: f.is_executable })
|
|
132
161
|
}
|
|
133
162
|
for (const sl of dir.symlinks) {
|
|
134
|
-
const at =
|
|
163
|
+
const at = join(prefix, sl.name)
|
|
135
164
|
if (!matches(sl.name) || seen.has(at)) continue
|
|
136
165
|
seen.add(at)
|
|
137
166
|
symlinks.push({ path: at, target: sl.target })
|
|
@@ -141,7 +170,7 @@ async function decomposeOutputDir(
|
|
|
141
170
|
if (!matches(d.name)) continue
|
|
142
171
|
const child = byDigest.get(d.digest.hash)
|
|
143
172
|
if (child === undefined) continue
|
|
144
|
-
walk(child, rest,
|
|
173
|
+
walk(child, rest, join(prefix, d.name))
|
|
145
174
|
}
|
|
146
175
|
}
|
|
147
176
|
|
|
@@ -156,7 +185,10 @@ async function decomposeOutputDir(
|
|
|
156
185
|
digest: e.tree_digest,
|
|
157
186
|
data: (e as unknown as { __data: Uint8Array }).__data,
|
|
158
187
|
}))
|
|
159
|
-
await client.uploadBlobs(blobs).catch(async () => {
|
|
188
|
+
await client.uploadBlobs(blobs).catch(async (err: unknown) => {
|
|
189
|
+
warn(
|
|
190
|
+
`[vx] vx-reapi: uploading ${entry.path}'s Tree blobs together failed (${errText(err)}) — writing each`,
|
|
191
|
+
)
|
|
160
192
|
for (const b of blobs) await client.writeBlob(b.digest, b.data)
|
|
161
193
|
})
|
|
162
194
|
return {
|
|
@@ -511,21 +543,28 @@ export function acceptsTask(task: TaskPlacement): boolean {
|
|
|
511
543
|
|
|
512
544
|
export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions = {}): TaskExecutor {
|
|
513
545
|
// A zero or non-number bound stopped every action the moment it began
|
|
514
|
-
// executing
|
|
546
|
+
// executing, and so did a month's: a timer past MAX_TIMER_MS fires after
|
|
547
|
+
// 1 ms. Core refuses such an exec.timeout, so every bound fits a timer.
|
|
515
548
|
for (const key of ['executeTimeoutMs', 'queueTimeoutMs'] as const) {
|
|
516
549
|
const ms = opts[key]
|
|
517
|
-
if (ms !== undefined && !(typeof ms === 'number' &&
|
|
550
|
+
if (ms !== undefined && !(typeof ms === 'number' && ms > 0 && ms <= MAX_TIMER_MS)) {
|
|
518
551
|
throw new Error(
|
|
519
|
-
|
|
552
|
+
`reapi(): ${key} must be a positive number of ms up to ${MAX_TIMER_MS} (got ${typeof ms === 'number' ? String(ms) : JSON.stringify(ms)})`,
|
|
520
553
|
)
|
|
521
554
|
}
|
|
522
555
|
}
|
|
523
|
-
|
|
556
|
+
// No listener is no reason to say nothing.
|
|
557
|
+
const warn = opts.warn ?? ((m: string) => ui.say('warn', m))
|
|
524
558
|
// One Capabilities round trip per executor, not per task — the answer
|
|
525
559
|
// cannot change mid-run, and a 400-task graph would otherwise ask 400 times.
|
|
526
560
|
let capsPromise: ReturnType<ReapiClient['capabilities']> | undefined
|
|
561
|
+
// A failed ask is not kept: one call past its deadline failed every later
|
|
562
|
+
// task of the run with the same error.
|
|
527
563
|
const capabilitiesOnce = (): ReturnType<ReapiClient['capabilities']> =>
|
|
528
|
-
(capsPromise ??= client.capabilities())
|
|
564
|
+
(capsPromise ??= client.capabilities().catch((err: unknown) => {
|
|
565
|
+
capsPromise = undefined
|
|
566
|
+
throw err
|
|
567
|
+
}))
|
|
529
568
|
const executor: TaskExecutor = {
|
|
530
569
|
name: 'vx/reapi',
|
|
531
570
|
remote: true,
|
|
@@ -542,9 +581,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
542
581
|
// status is turned into one by `namedRefusal`), so the scheduler
|
|
543
582
|
// prints it plainly instead of "internal error in <task>".
|
|
544
583
|
if (req.inputs === undefined) {
|
|
545
|
-
throw new Error(
|
|
546
|
-
`vx/reapi: ${req.taskId} reached the remote executor with no described inputs`,
|
|
547
|
-
)
|
|
584
|
+
throw new Error(`${req.taskId} reached the remote executor with no described inputs`)
|
|
548
585
|
}
|
|
549
586
|
|
|
550
587
|
// A key that already has an execution record needs no worker at all:
|
|
@@ -574,14 +611,22 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
574
611
|
.getActionResult(execDigestFor(req.cacheKey), { stdout: true, files: [] })
|
|
575
612
|
.catch((err) => {
|
|
576
613
|
warn(
|
|
577
|
-
`vx
|
|
614
|
+
`[vx] vx-reapi: ${req.taskId} could not read its execution record (${errText(err)}) — executing`,
|
|
578
615
|
)
|
|
579
616
|
return null
|
|
580
617
|
})
|
|
581
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
|
+
: []
|
|
582
626
|
const referenced = [
|
|
583
627
|
...(prior.output_files ?? []).map((f) => f.digest),
|
|
584
628
|
...(prior.output_directories ?? []).map((d) => d.tree_digest),
|
|
629
|
+
...stdoutBlob,
|
|
585
630
|
]
|
|
586
631
|
// AC and CAS evict independently, so a record can outlive its
|
|
587
632
|
// blobs. A gap means it cannot produce the outputs — fall through
|
|
@@ -591,7 +636,12 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
591
636
|
// and would replay a record whose blobs were never checked.
|
|
592
637
|
const gone =
|
|
593
638
|
referenced.length > 0
|
|
594
|
-
? await client.findMissingBlobs(referenced).catch(() =>
|
|
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
|
+
})
|
|
595
645
|
: []
|
|
596
646
|
// The record's paths are WORKSPACE-relative (rebased when it was
|
|
597
647
|
// written), so the anchor is the workspace root, not the cwd.
|
|
@@ -633,7 +683,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
633
683
|
: await replay().catch(async (err: unknown) => {
|
|
634
684
|
for (const p of created.reverse()) await rm(p, { recursive: true, force: true })
|
|
635
685
|
warn(
|
|
636
|
-
`vx
|
|
686
|
+
`[vx] vx-reapi: ${req.taskId} could not replay its execution record (${errText(err)}) — executing`,
|
|
637
687
|
)
|
|
638
688
|
return null
|
|
639
689
|
})
|
|
@@ -731,7 +781,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
731
781
|
if (present.length > 0) {
|
|
732
782
|
if (present.length !== up.outputs.length) {
|
|
733
783
|
warn(
|
|
734
|
-
`vx
|
|
784
|
+
`[vx] vx-reapi: ${up.taskId} has ${up.outputs.length - present.length} output${up.outputs.length - present.length === 1 ? '' : 's'} recorded ` +
|
|
735
785
|
`but missing on disk — grafting is not possible for a partial set, using what is here`,
|
|
736
786
|
)
|
|
737
787
|
}
|
|
@@ -756,7 +806,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
756
806
|
const gone = await client.findMissingBlobs(referenced)
|
|
757
807
|
if (gone.length > 0) {
|
|
758
808
|
throw new UserError(
|
|
759
|
-
`
|
|
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)`,
|
|
760
810
|
)
|
|
761
811
|
}
|
|
762
812
|
const files = (record.output_files ?? []).map((f) => ({
|
|
@@ -780,7 +830,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
780
830
|
// real and silently dropping the graft is the same wrong-result
|
|
781
831
|
// hazard as an evicted file.
|
|
782
832
|
throw new UserError(
|
|
783
|
-
`
|
|
833
|
+
`upstream ${up.taskId} tree ${d.tree_digest.hash.slice(0, 12)} evicted from CAS — re-run it (e.g. --force)`,
|
|
784
834
|
)
|
|
785
835
|
}
|
|
786
836
|
const decodedTree = decodeTreeWithBytes(treeBlob)
|
|
@@ -829,12 +879,12 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
829
879
|
// next run anywhere replayed the edited outputs (item 1037).
|
|
830
880
|
if (tree.moved.length > 0) {
|
|
831
881
|
warn(
|
|
832
|
-
`vx
|
|
882
|
+
`[vx] vx-reapi: ${req.taskId}: \`${tree.moved[0]}\` changed after its key was taken — the execution is not recorded under it`,
|
|
833
883
|
)
|
|
834
884
|
}
|
|
835
885
|
for (const shadowedPath of tree.shadowed) {
|
|
836
886
|
warn(
|
|
837
|
-
`vx
|
|
887
|
+
`[vx] vx-reapi: ${req.taskId} declares input files under ${shadowedPath}, which an upstream graft replaces — those files are NOT in the input tree`,
|
|
838
888
|
)
|
|
839
889
|
}
|
|
840
890
|
|
|
@@ -871,6 +921,9 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
871
921
|
})
|
|
872
922
|
const actionDigest = sha256(actionBytes)
|
|
873
923
|
|
|
924
|
+
// The action id lets a server group this action's CAS/AC traffic in its
|
|
925
|
+
// UI; named before the upload, which is most of that traffic.
|
|
926
|
+
client.nameAction(actionDigest.hash)
|
|
874
927
|
const caps = await capabilitiesOnce()
|
|
875
928
|
const upload: Blob[] = [
|
|
876
929
|
...tree.blobs,
|
|
@@ -879,9 +932,6 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
879
932
|
]
|
|
880
933
|
await client.uploadBlobs(upload, caps.maxBatchBytes)
|
|
881
934
|
|
|
882
|
-
// The action id lets a server group this action's CAS/AC traffic in its
|
|
883
|
-
// UI; set before Execute so the streaming call carries it too.
|
|
884
|
-
client.actionId = actionDigest.hash
|
|
885
935
|
// `exec.timeout` rides the Action so the SERVER can enforce it, but a
|
|
886
936
|
// server that ignores it — or has stopped making progress at all —
|
|
887
937
|
// leaves the client waiting forever, and the operation stream is no
|
|
@@ -917,6 +967,11 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
917
967
|
queued.signal,
|
|
918
968
|
...(req.signal === undefined ? [] : [req.signal]),
|
|
919
969
|
])
|
|
970
|
+
// A cancel the server refused leaves the action running on its worker.
|
|
971
|
+
const cancelFailed = (err: unknown): void =>
|
|
972
|
+
warn(
|
|
973
|
+
`[vx] vx-reapi: cancelling ${req.taskId}'s operation failed (${errText(err)}) — it may still run on its worker`,
|
|
974
|
+
)
|
|
920
975
|
let op: Operation
|
|
921
976
|
try {
|
|
922
977
|
if (req.signal?.aborted === true) throw new Error('aborted before Execute')
|
|
@@ -942,7 +997,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
942
997
|
// closed stream left the action running on its worker. The action
|
|
943
998
|
// is cancelled and the task ends as a killed local one does: 143,
|
|
944
999
|
// which core reports timed out when its timer fired.
|
|
945
|
-
if (operation !== '') await client.cancelOperation(operation).catch(
|
|
1000
|
+
if (operation !== '') await client.cancelOperation(operation).catch(cancelFailed)
|
|
946
1001
|
return {
|
|
947
1002
|
exitCode: 143,
|
|
948
1003
|
durationMs: Math.round((Bun.nanoseconds() - started) / 1e6),
|
|
@@ -955,14 +1010,16 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
955
1010
|
if (queued.signal.aborted) {
|
|
956
1011
|
// Best effort: a server without the service, or one that has lost
|
|
957
1012
|
// the operation, still has the task given back.
|
|
958
|
-
if (operation !== '') await client.cancelOperation(operation).catch(
|
|
1013
|
+
if (operation !== '') await client.cancelOperation(operation).catch(cancelFailed)
|
|
959
1014
|
throw executorFallback(
|
|
960
|
-
`
|
|
1015
|
+
`no worker started the action within queueTimeoutMs (${opts.queueTimeoutMs}ms); its operation was cancelled`,
|
|
961
1016
|
)
|
|
962
1017
|
}
|
|
963
1018
|
if (stall.signal.aborted) {
|
|
1019
|
+
// Closing the stream alone left the action on its worker.
|
|
1020
|
+
if (operation !== '') await client.cancelOperation(operation).catch(cancelFailed)
|
|
964
1021
|
throw new UserError(
|
|
965
|
-
|
|
1022
|
+
`${req.taskId} was still executing ${stallAfter}ms after the worker ` +
|
|
966
1023
|
`started it and the server never reported a result — giving up on the remote ` +
|
|
967
1024
|
`operation. Raise exec.timeout (or the plugin's executeTimeoutMs) if the task ` +
|
|
968
1025
|
`is legitimately this slow; otherwise the remote is not making progress.`,
|
|
@@ -975,7 +1032,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
975
1032
|
}
|
|
976
1033
|
if (op.error !== undefined && (op.error.code ?? 0) !== 0) {
|
|
977
1034
|
throw new UserError(
|
|
978
|
-
`
|
|
1035
|
+
`execution failed for ${req.taskId}: ${op.error.message ?? `code ${op.error.code}`}`,
|
|
979
1036
|
)
|
|
980
1037
|
}
|
|
981
1038
|
const decoded = decodeExecuteResponse(op)
|
|
@@ -988,23 +1045,31 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
988
1045
|
// partial result, as the spec suggests: what the command printed
|
|
989
1046
|
// is delivered before the refusal, best effort (F-48).
|
|
990
1047
|
if (result !== undefined) {
|
|
1048
|
+
const unread = (stream: string) => (e: unknown) => {
|
|
1049
|
+
warn(`[vx] vx-reapi: ${req.taskId}'s ${stream} could not be read (${errText(e)})`)
|
|
1050
|
+
return ''
|
|
1051
|
+
}
|
|
991
1052
|
const [out, err] = await Promise.all([
|
|
992
|
-
this_readStream(client, result.stdout_raw, result.stdout_digest).catch(
|
|
993
|
-
|
|
1053
|
+
this_readStream(client, result.stdout_raw, result.stdout_digest).catch(
|
|
1054
|
+
unread('stdout'),
|
|
1055
|
+
),
|
|
1056
|
+
this_readStream(client, result.stderr_raw, result.stderr_digest).catch(
|
|
1057
|
+
unread('stderr'),
|
|
1058
|
+
),
|
|
994
1059
|
])
|
|
995
1060
|
if (out.length > 0) req.onStdout(out)
|
|
996
1061
|
if (err.length > 0) req.onStderr(err)
|
|
997
1062
|
}
|
|
998
|
-
const logs = await fetchServerLogs(client, decoded.serverLogs)
|
|
1063
|
+
const logs = await fetchServerLogs(client, decoded.serverLogs, warn)
|
|
999
1064
|
throw new UserError(
|
|
1000
|
-
|
|
1065
|
+
`${req.taskId} execution failed: ${decoded.status.message || `code ${decoded.status.code}`}` +
|
|
1001
1066
|
(decoded.message === undefined ? '' : ` — ${decoded.message}`) +
|
|
1002
1067
|
logs,
|
|
1003
1068
|
)
|
|
1004
1069
|
}
|
|
1005
1070
|
if (result === undefined) {
|
|
1006
1071
|
throw new UserError(
|
|
1007
|
-
|
|
1072
|
+
`${req.taskId} returned no ActionResult${decoded.message === undefined ? '' : `: ${decoded.message}`}`,
|
|
1008
1073
|
)
|
|
1009
1074
|
}
|
|
1010
1075
|
// The worker id is REPORTED, not logged: it rides `ExecuteResult.where`
|
|
@@ -1031,7 +1096,9 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
1031
1096
|
])
|
|
1032
1097
|
// Settled below with the outputs; a log that cannot be read still
|
|
1033
1098
|
// fails the task, after the outputs have landed or failed.
|
|
1099
|
+
// quiet: settled below, where its error fails the task
|
|
1034
1100
|
logs.catch(() => undefined)
|
|
1101
|
+
// quiet: settled below, where its error fails the task
|
|
1035
1102
|
materialised.catch(() => undefined)
|
|
1036
1103
|
const [stdout, stderr] = await logs.catch(async (err: unknown) => {
|
|
1037
1104
|
await materialised.catch(() => undefined)
|
|
@@ -1082,21 +1149,33 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
1082
1149
|
stdoutDigest = result.stdout_digest
|
|
1083
1150
|
} else if (stdoutBytes.length > 0 && !inlineStdout) {
|
|
1084
1151
|
const d = sha256(stdoutBytes)
|
|
1085
|
-
const missing = await client.findMissingBlobs([d]).catch(() =>
|
|
1152
|
+
const missing = await client.findMissingBlobs([d]).catch((err: unknown) => {
|
|
1153
|
+
warn(
|
|
1154
|
+
`[vx] vx-reapi: ${req.taskId}'s stdout probe failed (${errText(err)}) — uploading it`,
|
|
1155
|
+
)
|
|
1156
|
+
return [d]
|
|
1157
|
+
})
|
|
1086
1158
|
const uploaded =
|
|
1087
1159
|
missing.length === 0
|
|
1088
1160
|
? true
|
|
1089
1161
|
: await client.writeBlob(d, stdoutBytes).then(
|
|
1090
1162
|
() => true,
|
|
1091
|
-
() =>
|
|
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
|
+
},
|
|
1092
1169
|
)
|
|
1093
1170
|
if (uploaded) stdoutDigest = d
|
|
1094
1171
|
}
|
|
1095
1172
|
// A coarse capture is split into the paths its glob really names
|
|
1096
1173
|
// before it is recorded — see decomposeOutputDir.
|
|
1097
1174
|
const declaredGlobs = [
|
|
1098
|
-
...req.outputs.workspaceFiles,
|
|
1099
|
-
...req.outputs.files
|
|
1175
|
+
...req.outputs.workspaceFiles.filter(isOutputGlob),
|
|
1176
|
+
...req.outputs.files
|
|
1177
|
+
.filter(isOutputGlob)
|
|
1178
|
+
.map((g) => (projectRel === '' ? g : `${projectRel}/${g}`)),
|
|
1100
1179
|
].map((g) => normalizeGlob(g))
|
|
1101
1180
|
const recorded: DecomposedOutputs = {
|
|
1102
1181
|
directories: [],
|
|
@@ -1117,7 +1196,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
1117
1196
|
const split = await decomposeOutputDir(client, rebased, declaredGlobs, warn).catch(
|
|
1118
1197
|
(err: unknown) => {
|
|
1119
1198
|
warn(
|
|
1120
|
-
`vx
|
|
1199
|
+
`[vx] vx-reapi: could not read the Tree for ${rebased.path} (${errText(err)}) — recording it whole`,
|
|
1121
1200
|
)
|
|
1122
1201
|
return { directories: [rebased], files: [], symlinks: [] }
|
|
1123
1202
|
},
|
|
@@ -1136,7 +1215,9 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
1136
1215
|
output_symlinks: recorded.symlinks,
|
|
1137
1216
|
})
|
|
1138
1217
|
.catch((err: Error) =>
|
|
1139
|
-
warn(
|
|
1218
|
+
warn(
|
|
1219
|
+
`[vx] vx-reapi: could not record execution for ${req.taskId}: ${err.message}`,
|
|
1220
|
+
),
|
|
1140
1221
|
)
|
|
1141
1222
|
})(req.cacheKey!)
|
|
1142
1223
|
: Promise.resolve()
|
|
@@ -1165,9 +1246,11 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
1165
1246
|
}
|
|
1166
1247
|
const run = executor.execute.bind(executor)
|
|
1167
1248
|
executor.execute = (req) =>
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1249
|
+
client
|
|
1250
|
+
.inAction(() => run(req))
|
|
1251
|
+
.catch((err: unknown) => {
|
|
1252
|
+
throw namedRefusal(req.taskId, err)
|
|
1253
|
+
})
|
|
1171
1254
|
return executor
|
|
1172
1255
|
}
|
|
1173
1256
|
|
|
@@ -1181,7 +1264,7 @@ function namedRefusal(taskId: string, err: unknown): unknown {
|
|
|
1181
1264
|
if (isUserError(err) || !(err instanceof Error)) return err
|
|
1182
1265
|
const status = err as Error & { code?: unknown; details?: unknown }
|
|
1183
1266
|
if (typeof status.code !== 'number' || typeof status.details !== 'string') return err
|
|
1184
|
-
return new UserError(
|
|
1267
|
+
return new UserError(`${taskId}: the remote server failed the call — ${err.message}`)
|
|
1185
1268
|
}
|
|
1186
1269
|
|
|
1187
1270
|
/**
|
|
@@ -1191,12 +1274,16 @@ function namedRefusal(taskId: string, err: unknown): unknown {
|
|
|
1191
1274
|
async function fetchServerLogs(
|
|
1192
1275
|
client: ReapiClient,
|
|
1193
1276
|
logs: ReadonlyArray<{ name: string; digest: Digest; humanReadable: boolean }>,
|
|
1277
|
+
warn: (m: string) => void,
|
|
1194
1278
|
): Promise<string> {
|
|
1195
1279
|
const readable = logs.filter((l) => l.humanReadable && l.digest.size_bytes <= 64 * 1024)
|
|
1196
1280
|
if (readable.length === 0) return ''
|
|
1197
1281
|
const parts: string[] = []
|
|
1198
1282
|
for (const log of readable) {
|
|
1199
|
-
const bytes = await client.readBlob(log.digest).catch(() =>
|
|
1283
|
+
const bytes = await client.readBlob(log.digest).catch((err: unknown) => {
|
|
1284
|
+
warn(`[vx] vx-reapi: the server log ${log.name} could not be read (${errText(err)})`)
|
|
1285
|
+
return null
|
|
1286
|
+
})
|
|
1200
1287
|
if (bytes !== null)
|
|
1201
1288
|
parts.push(`\n--- server log ${log.name} ---\n${new TextDecoder().decode(bytes)}`)
|
|
1202
1289
|
}
|
|
@@ -1249,17 +1336,25 @@ function fullCommand(req: ExecuteRequest, cdInto: string, projectRel: string): s
|
|
|
1249
1336
|
.join('/')}`
|
|
1250
1337
|
const root = cdInto === '' ? `"$PWD${climb}"` : '"$PWD"'
|
|
1251
1338
|
// Order matters: VX_ROOT is read BEFORE the cd, the project-local bin dir
|
|
1252
|
-
// AFTER it, so both are right whichever mode we are in.
|
|
1339
|
+
// AFTER it, so both are right whichever mode we are in. The project's
|
|
1340
|
+
// first, as core's taskBinDirs: a tool the project pins beats the root's.
|
|
1253
1341
|
// Quoted as the forwarded args are: a project directory is a name like
|
|
1254
1342
|
// any other, and `it's` ended the quote and ran the rest as script (L-7).
|
|
1255
1343
|
const cd = cdInto === '' ? '' : `cd '${cdInto.replaceAll("'", `'\\''`)}' || exit 1; `
|
|
1256
1344
|
return (
|
|
1257
1345
|
`VX_ROOT=${root}; ${cd}` +
|
|
1258
|
-
`export PATH="$
|
|
1346
|
+
`export PATH="$PWD/node_modules/.bin:$VX_ROOT/node_modules/.bin:$PATH"; ` +
|
|
1259
1347
|
command
|
|
1260
1348
|
)
|
|
1261
1349
|
}
|
|
1262
1350
|
|
|
1351
|
+
/**
|
|
1352
|
+
* A `!` take-back names what is NOT an output (core's restore gate reads
|
|
1353
|
+
* it): requested, `!dist/cache/**` was the path `!dist/cache`, and as a
|
|
1354
|
+
* matcher `Bun.Glob('!x')` matches every path but `x`.
|
|
1355
|
+
*/
|
|
1356
|
+
const isOutputGlob = (glob: string): boolean => !glob.startsWith('!')
|
|
1357
|
+
|
|
1263
1358
|
/**
|
|
1264
1359
|
* vx declares output GLOBS; REAPI `output_paths` are LITERAL paths. The
|
|
1265
1360
|
* mapping the design doc prescribes: each glob contributes the deepest
|
|
@@ -1342,9 +1437,10 @@ export function outputPathSets(
|
|
|
1342
1437
|
const prefix = (p: string): string =>
|
|
1343
1438
|
workingDirectory === '' && projectRel !== '' ? `${projectRel}/${p}` : p
|
|
1344
1439
|
// `app/\[id\]/x` names the file `app/[id]/x`: `literalHead` unescapes.
|
|
1345
|
-
const globs = [
|
|
1346
|
-
(
|
|
1347
|
-
|
|
1440
|
+
const globs = [
|
|
1441
|
+
...req.outputs.files.filter(isOutputGlob).map(prefix),
|
|
1442
|
+
...req.outputs.workspaceFiles.filter(isOutputGlob).map(rebase),
|
|
1443
|
+
].map((g) => normalizeGlob(g))
|
|
1348
1444
|
const paths = new Set<string>()
|
|
1349
1445
|
const files = new Set<string>()
|
|
1350
1446
|
const dirs = new Set<string>()
|
|
@@ -1402,17 +1498,17 @@ export async function materialiseOutputs(
|
|
|
1402
1498
|
const wholeTreeCapture = [
|
|
1403
1499
|
...(req.outputs?.files ?? []),
|
|
1404
1500
|
...(req.outputs?.workspaceFiles ?? []),
|
|
1405
|
-
].some((g) => globToOutputPath(g) === '')
|
|
1501
|
+
].some((g) => isOutputGlob(g) && globToOutputPath(g) === '')
|
|
1406
1502
|
// `abs` names a file a declared glob matches: missing, it is a hole in a
|
|
1407
1503
|
// declared output under either capture, and warning let `save` cache the
|
|
1408
1504
|
// short tree under the key (F-8).
|
|
1409
1505
|
const missing = (what: string, hash: string, abs?: string): void => {
|
|
1410
1506
|
if (!wholeTreeCapture || req.replay === true || (abs !== undefined && isDeclared(abs))) {
|
|
1411
1507
|
throw new UserError(
|
|
1412
|
-
|
|
1508
|
+
`${req.taskId} declared output ${what} is missing from the CAS (${hash.slice(0, 12)}) — re-run it (e.g. --force)`,
|
|
1413
1509
|
)
|
|
1414
1510
|
}
|
|
1415
|
-
warn(`vx
|
|
1511
|
+
warn(`[vx] vx-reapi: output ${what} missing from CAS (${hash.slice(0, 12)})`)
|
|
1416
1512
|
}
|
|
1417
1513
|
// A glob cut at its wildcard captured a directory that holds more than
|
|
1418
1514
|
// the outputs — `src/*.gen.js` returns all of `src`, inputs included.
|
|
@@ -1422,8 +1518,10 @@ export async function materialiseOutputs(
|
|
|
1422
1518
|
// the task (item 1038). A directory a LITERAL glob names is written whole.
|
|
1423
1519
|
const projectRel = req.projectRel ?? toPosix(path.relative(req.workspaceRoot, req.cwd))
|
|
1424
1520
|
const declared = [
|
|
1425
|
-
...(req.outputs?.files ?? [])
|
|
1426
|
-
|
|
1521
|
+
...(req.outputs?.files ?? [])
|
|
1522
|
+
.filter(isOutputGlob)
|
|
1523
|
+
.map((g) => (projectRel === '' ? g : `${projectRel}/${g}`)),
|
|
1524
|
+
...(req.outputs?.workspaceFiles ?? []).filter(isOutputGlob),
|
|
1427
1525
|
].map((g) => normalizeGlob(g))
|
|
1428
1526
|
const matchers = declared.map((g) => new Bun.Glob(g))
|
|
1429
1527
|
// Core's restore gate: a `!` take-back, vx's own files and a
|
|
@@ -1457,39 +1555,43 @@ export async function materialiseOutputs(
|
|
|
1457
1555
|
)
|
|
1458
1556
|
// Batch the small ones into one round trip; anything larger goes over
|
|
1459
1557
|
// ByteStream, which is also the only path that can be compressed.
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
await
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1558
|
+
// A window at a time, as a Tree's files are: one batch read of every
|
|
1559
|
+
// small output held them all in memory at once.
|
|
1560
|
+
for (const window of fetchWindows(files, (f) => f.digest)) {
|
|
1561
|
+
const batched = await client.batchReadBlobs(
|
|
1562
|
+
window.filter((f) => isSmallBlob(f.digest)).map((f) => f.digest),
|
|
1563
|
+
)
|
|
1564
|
+
await mapBounded(window, WRITE_CONCURRENCY, async (f) => {
|
|
1565
|
+
const abs = fence.lexical(req.cwd, f.path)
|
|
1566
|
+
await fence.dir(path.dirname(abs))
|
|
1567
|
+
await makeDir(path.dirname(abs), created)
|
|
1568
|
+
await fence.landing(abs)
|
|
1569
|
+
// Inlined only when it has bytes: an ActionResult read back through
|
|
1570
|
+
// proto-loader (the execution-record replay) carries `contents` as an
|
|
1571
|
+
// EMPTY Buffer on every file, and taking that as inline wrote each
|
|
1572
|
+
// replayed output empty (item 827). An empty file is the next branch.
|
|
1573
|
+
// Inline bytes are held to the digest they ride with, as fetched ones
|
|
1574
|
+
// are in wire.ts: a server's wrong inline copy was written as the output
|
|
1575
|
+
// and cached under the key (F-8). A mismatch fetches the blob instead.
|
|
1576
|
+
const inline =
|
|
1577
|
+
f.contents !== undefined &&
|
|
1578
|
+
f.contents.length > 0 &&
|
|
1579
|
+
digestWith(client.digest, f.contents).hash === f.digest.hash
|
|
1580
|
+
const bytes = inline
|
|
1581
|
+
? f.contents! // inlined by the server (`inline_output_files`): zero fetches
|
|
1582
|
+
: Number(f.digest.size_bytes) === 0
|
|
1583
|
+
? new Uint8Array()
|
|
1584
|
+
: (batched.get(f.digest.hash) ?? (await client.readBlob(f.digest)))
|
|
1585
|
+
if (bytes === null) {
|
|
1586
|
+
missing(f.path, f.digest.hash, abs)
|
|
1587
|
+
return
|
|
1588
|
+
}
|
|
1589
|
+
await writeOutput(abs, bytes, created)
|
|
1590
|
+
// REAPI carries the executable bit per output; a build that produces a
|
|
1591
|
+
// script and a later task that runs it depends on it surviving.
|
|
1592
|
+
if (f.is_executable === true) await chmod(abs, masked(0o755))
|
|
1593
|
+
})
|
|
1594
|
+
}
|
|
1493
1595
|
|
|
1494
1596
|
// `OutputSymlink` — a declared output that is a link, not a file. Restoring
|
|
1495
1597
|
// it as a copy would silently change what the next task sees.
|
|
@@ -1553,6 +1655,7 @@ class Fence {
|
|
|
1553
1655
|
* containment checks passed). The landing must itself be declared.
|
|
1554
1656
|
*/
|
|
1555
1657
|
async landing(abs: string): Promise<void> {
|
|
1658
|
+
// quiet: a local realpath; the root as given stands
|
|
1556
1659
|
this.realRoot ??= await realpath(this.root).catch(() => this.root)
|
|
1557
1660
|
const dir = path.dirname(abs)
|
|
1558
1661
|
let real = this.realDirs.get(dir)
|
|
@@ -1585,9 +1688,11 @@ class Fence {
|
|
|
1585
1688
|
/** `dir`, or its deepest existing ancestor, resolves inside the root. */
|
|
1586
1689
|
async dir(dir: string): Promise<void> {
|
|
1587
1690
|
if (this.checked.has(dir)) return
|
|
1691
|
+
// quiet: a local realpath; the root as given stands
|
|
1588
1692
|
this.realRoot ??= await realpath(this.root).catch(() => this.root)
|
|
1589
1693
|
let probe = dir
|
|
1590
1694
|
for (;;) {
|
|
1695
|
+
// quiet: a local path not there yet: its parent is checked
|
|
1591
1696
|
const real = await realpath(probe).catch(() => null)
|
|
1592
1697
|
if (real !== null) {
|
|
1593
1698
|
if (real !== this.realRoot && !real.startsWith(this.realRoot + path.sep)) {
|
|
@@ -1601,13 +1706,25 @@ class Fence {
|
|
|
1601
1706
|
this.checked.add(dir)
|
|
1602
1707
|
}
|
|
1603
1708
|
|
|
1604
|
-
/**
|
|
1709
|
+
/**
|
|
1710
|
+
* A link whose target, read from where it stands, stays under the root,
|
|
1711
|
+
* placed only where a declared output lands: through an earlier link it
|
|
1712
|
+
* planted a hook or replaced another project's file.
|
|
1713
|
+
*/
|
|
1605
1714
|
async symlink(target: string, abs: string, created: string[] | undefined): Promise<void> {
|
|
1606
1715
|
const to = path.resolve(path.dirname(abs), target)
|
|
1607
1716
|
if (target.includes('\0') || (to !== this.root && !to.startsWith(this.root + path.sep))) {
|
|
1608
1717
|
throw this.refuse(`${abs} -> ${target}`)
|
|
1609
1718
|
}
|
|
1719
|
+
await this.landing(abs)
|
|
1610
1720
|
await placeSymlink(target, abs, created)
|
|
1721
|
+
// A link re-placed at `abs` moves every directory under it: a memo
|
|
1722
|
+
// judged through the old one let the next write land through the new.
|
|
1723
|
+
for (const memo of [this.realDirs, this.checked]) {
|
|
1724
|
+
for (const dir of memo.keys()) {
|
|
1725
|
+
if (dir === abs || dir.startsWith(abs + path.sep)) memo.delete(dir)
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1611
1728
|
this.links.push({ abs, target })
|
|
1612
1729
|
}
|
|
1613
1730
|
|
|
@@ -1619,6 +1736,7 @@ class Fence {
|
|
|
1619
1736
|
* escaping link is removed before the refusal.
|
|
1620
1737
|
*/
|
|
1621
1738
|
async verifyLinks(): Promise<void> {
|
|
1739
|
+
// quiet: a local realpath; the root as given stands
|
|
1622
1740
|
this.realRoot ??= await realpath(this.root).catch(() => this.root)
|
|
1623
1741
|
for (const { abs, target } of this.links) {
|
|
1624
1742
|
const to = await resolveThrough(
|
|
@@ -1633,7 +1751,7 @@ class Fence {
|
|
|
1633
1751
|
|
|
1634
1752
|
private refuse(what: string): UserError {
|
|
1635
1753
|
return new UserError(
|
|
1636
|
-
`
|
|
1754
|
+
`the server returned an output outside the workspace (${what}) — refused; nothing is written outside ${this.root}`,
|
|
1637
1755
|
)
|
|
1638
1756
|
}
|
|
1639
1757
|
}
|
|
@@ -1694,6 +1812,7 @@ async function resolveThrough(p: string): Promise<string> {
|
|
|
1694
1812
|
continue
|
|
1695
1813
|
}
|
|
1696
1814
|
const next = path.join(at, c)
|
|
1815
|
+
// quiet: a local path that is no link
|
|
1697
1816
|
const target = await readlink(next).catch(() => null)
|
|
1698
1817
|
if (target === null || ++hops > 40) {
|
|
1699
1818
|
at = next
|
|
@@ -1723,7 +1842,7 @@ async function placeSymlink(
|
|
|
1723
1842
|
/** Stdout up to this size is recorded inline (`stdout_raw`) rather than as a CAS blob. */
|
|
1724
1843
|
const INLINE_STDOUT_BYTES = 64 * 1024
|
|
1725
1844
|
|
|
1726
|
-
/** Small blobs a
|
|
1845
|
+
/** Small blobs a restore holds in memory at once, fetched batched. */
|
|
1727
1846
|
const TREE_FETCH_WINDOW_BYTES = 64 * 1024 * 1024
|
|
1728
1847
|
|
|
1729
1848
|
/**
|
|
@@ -1763,6 +1882,33 @@ async function materialiseTree(
|
|
|
1763
1882
|
const byDigest = new Map<string, Directory>()
|
|
1764
1883
|
tree.children.forEach((child, i) => byDigest.set(tree.childDigests[i]!, child))
|
|
1765
1884
|
|
|
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
|
|
1892
|
+
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
|
|
1901
|
+
}
|
|
1902
|
+
return n
|
|
1903
|
+
}
|
|
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
|
+
)
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1766
1912
|
// Files are gathered from the whole tree before any is fetched: one
|
|
1767
1913
|
// BatchReadBlobs per DIRECTORY was a round trip each, so a `dist/` of 200
|
|
1768
1914
|
// directories restored in 200 sequential calls (F-18). Windows bound what
|
|
@@ -1795,18 +1941,9 @@ async function materialiseTree(
|
|
|
1795
1941
|
}
|
|
1796
1942
|
await walk(tree.root, destDir, declared === null)
|
|
1797
1943
|
|
|
1798
|
-
const
|
|
1799
|
-
f.digest.size_bytes > 0 && f.digest.size_bytes <= 1024 * 1024
|
|
1800
|
-
for (let i = 0; i < files.length;) {
|
|
1801
|
-
let held = 0
|
|
1802
|
-
let j = i
|
|
1803
|
-
while (j < files.length && (j === i || held < TREE_FETCH_WINDOW_BYTES)) {
|
|
1804
|
-
if (isSmall(files[j]!.f)) held += Number(files[j]!.f.digest.size_bytes)
|
|
1805
|
-
j++
|
|
1806
|
-
}
|
|
1807
|
-
const window = files.slice(i, j)
|
|
1944
|
+
for (const window of fetchWindows(files, (w) => w.f.digest)) {
|
|
1808
1945
|
const batched = await client.batchReadBlobs(
|
|
1809
|
-
window.filter((w) =>
|
|
1946
|
+
window.filter((w) => isSmallBlob(w.f.digest)).map((w) => w.f.digest),
|
|
1810
1947
|
)
|
|
1811
1948
|
await mapBounded(window, WRITE_CONCURRENCY, async ({ abs, f }) => {
|
|
1812
1949
|
const bytes =
|
|
@@ -1819,14 +1956,57 @@ async function materialiseTree(
|
|
|
1819
1956
|
}
|
|
1820
1957
|
await fence.landing(abs)
|
|
1821
1958
|
await writeOutput(abs, bytes, created)
|
|
1822
|
-
if (f.is_executable) await chmod(abs, 0o755)
|
|
1959
|
+
if (f.is_executable) await chmod(abs, masked(0o755))
|
|
1823
1960
|
// NodeProperties.unix_mode is authoritative when the server sent it.
|
|
1824
1961
|
const mode = f.node_properties?.unixMode
|
|
1825
1962
|
// Permission bits only: a server's setuid or setgid bit is not a build output's.
|
|
1826
|
-
if (mode !== undefined) await chmod(abs, mode
|
|
1963
|
+
if (mode !== undefined) await chmod(abs, masked(mode))
|
|
1827
1964
|
})
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/** A blob fetched in a batch read, and so held in memory with its window. */
|
|
1969
|
+
const isSmallBlob = (d: Digest): boolean =>
|
|
1970
|
+
Number(d.size_bytes) > 0 && Number(d.size_bytes) <= 1024 * 1024
|
|
1971
|
+
|
|
1972
|
+
/** `items` cut where their small blobs reach `TREE_FETCH_WINDOW_BYTES`. */
|
|
1973
|
+
function fetchWindows<T>(items: readonly T[], digestOf: (item: T) => Digest): T[][] {
|
|
1974
|
+
const windows: T[][] = []
|
|
1975
|
+
for (let i = 0; i < items.length;) {
|
|
1976
|
+
let held = 0
|
|
1977
|
+
let j = i
|
|
1978
|
+
while (j < items.length && (j === i || held < TREE_FETCH_WINDOW_BYTES)) {
|
|
1979
|
+
const d = digestOf(items[j]!)
|
|
1980
|
+
if (isSmallBlob(d)) held += Number(d.size_bytes)
|
|
1981
|
+
j++
|
|
1982
|
+
}
|
|
1983
|
+
windows.push(items.slice(i, j))
|
|
1828
1984
|
i = j
|
|
1829
1985
|
}
|
|
1986
|
+
return windows
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/**
|
|
1990
|
+
* A mode as this machine gives it: minus the umask, as a local build and
|
|
1991
|
+
* core's restore do. A server's 0777 was written world-writable under
|
|
1992
|
+
* umask 022, and 0755 world-readable under 077.
|
|
1993
|
+
*/
|
|
1994
|
+
function masked(mode: number): number {
|
|
1995
|
+
return mode & 0o777 & ~process.umask()
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/**
|
|
1999
|
+
* The files `dir`'s file system can still create: its free inodes, or its
|
|
2000
|
+
* free blocks where it keeps no inode count (btrfs reports 0 of 0). Asked of
|
|
2001
|
+
* the nearest ancestor that exists; unbounded when none answers.
|
|
2002
|
+
*/
|
|
2003
|
+
async function freeEntries(dir: string): Promise<number> {
|
|
2004
|
+
for (let at = dir; ; at = path.dirname(at)) {
|
|
2005
|
+
// quiet: a path not there yet: its parent answers
|
|
2006
|
+
const fs = await statfs(at).catch(() => null)
|
|
2007
|
+
if (fs !== null) return fs.files > 0 ? fs.ffree : fs.bavail
|
|
2008
|
+
if (path.dirname(at) === at) return Infinity
|
|
2009
|
+
}
|
|
1830
2010
|
}
|
|
1831
2011
|
|
|
1832
2012
|
/** Output files `materialiseOutputs` writes at once. */
|