@vzn/vx-reapi 0.0.652 → 0.0.653

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -97,7 +97,7 @@ function connection(options: ReapiPluginOptions): ReapiOptions | undefined {
97
97
  // socket path is the file system's to read.
98
98
  const given = options.endpoint?.trim()
99
99
  const endpoint =
100
- given?.startsWith('unix') === true ? given : given?.replace(/^(.*[^/:])\/+$/, '$1')
100
+ given?.startsWith('unix:') === true ? given : given?.replace(/^(.*[^/:])\/+$/, '$1')
101
101
  if (endpoint === undefined || endpoint === '') return undefined
102
102
  assertEndpoint(endpoint, from)
103
103
  // A secrets file's trailing newline sent a name no blob is under, and
package/src/merkle.ts CHANGED
@@ -30,11 +30,11 @@ interface InputTree {
30
30
  /** File count, for logging/metrics. */
31
31
  fileCount: number
32
32
  /**
33
- * Paths where a grafted subtree SHADOWED a disk-built directory of the
34
- * same name. The graft wins (it is the upstream's authoritative output;
35
- * the disk copy at best a stale materialisation) — but any DECLARED input
36
- * files under that path are silently absent from the tree, so the caller
37
- * should surface these.
33
+ * Paths where a grafted subtree SHADOWED declared input files read from
34
+ * disk under the same name. The graft wins (it is the upstream's
35
+ * authoritative output; the disk copy at best a stale materialisation) —
36
+ * but those files are absent from the tree, so the caller should surface
37
+ * these. Another upstream's output under a graft is merged in instead.
38
38
  */
39
39
  shadowed: string[]
40
40
  /**
@@ -102,7 +102,10 @@ export interface TreeGraft {
102
102
  * the graph self-consistent regardless of who produced it — file digests are
103
103
  * untouched (content-addressed, already in the CAS).
104
104
  */
105
- function canonicaliseTree(graft: TreeGraft): { root: Digest; blobs: Blob[] } {
105
+ function canonicaliseTree(
106
+ graft: TreeGraft,
107
+ known: Map<string, Directory>,
108
+ ): { root: Digest; blobs: Blob[] } {
106
109
  // Keyed by the worker's own bytes first: a child re-encoded by US matches
107
110
  // its parent's reference only when both encoders agree byte for byte, and
108
111
  // one that does not (a field order, a `node_properties` our decoder drops)
@@ -136,6 +139,7 @@ function canonicaliseTree(graft: TreeGraft): { root: Digest; blobs: Blob[] } {
136
139
  const data = encodeDirectory(canonical)
137
140
  const digest = sha256(data)
138
141
  blobs.push({ digest, data })
142
+ known.set(digest.hash, canonical)
139
143
  return digest
140
144
  }
141
145
  return { root: rebuild(graft.root), blobs }
@@ -202,6 +206,61 @@ export async function buildInputTree(args: {
202
206
  const seen = new Set<string>()
203
207
  const moved: string[] = []
204
208
  let fileCount = 0
209
+ // A graft's directories by canonical digest, and the nodes opened from
210
+ // them: another upstream's output under a Tree graft is inserted into it,
211
+ // never dropped by it (prisma's client under install's node_modules).
212
+ const known = new Map<string, Directory>()
213
+ const fromGraft = new WeakSet<DirNode>()
214
+ const opened = (raw: Digest, at: string): Directory => {
215
+ const dir = known.get(raw.hash)
216
+ if (dir === undefined) {
217
+ throw new Error(
218
+ `vx-reapi: cannot add an upstream output under ${at}: the upstream Tree that holds it does not list it`,
219
+ )
220
+ }
221
+ return dir
222
+ }
223
+ const descend = (node: DirNode, seg: string, at: string): DirNode => {
224
+ let next = node.dirs.get(seg)
225
+ if (next !== undefined) return next
226
+ const raw = node.rawDirs.get(seg)
227
+ if (raw === undefined) {
228
+ next = emptyDir()
229
+ } else {
230
+ const dir = opened(raw, at)
231
+ next = {
232
+ files: new Map(dir.files.map((f) => [f.name, f])),
233
+ dirs: new Map(),
234
+ symlinks: new Map(dir.symlinks.map((s) => [s.name, s])),
235
+ rawDirs: new Map(dir.directories.map((d) => [d.name, d.digest])),
236
+ }
237
+ fromGraft.add(next)
238
+ node.rawDirs.delete(seg)
239
+ }
240
+ node.dirs.set(seg, next)
241
+ return next
242
+ }
243
+ const insertAt = (rel: string): { node: DirNode; leaf: string } => {
244
+ const parts = rel.split('/')
245
+ let node = root
246
+ for (let i = 0; i < parts.length - 1; i++) {
247
+ node = descend(node, parts[i]!, parts.slice(0, i + 1).join('/'))
248
+ }
249
+ return { node, leaf: parts[parts.length - 1]! }
250
+ }
251
+ /** A graft's directory laid over `target`, merging where both hold a directory. */
252
+ const overlay = (target: DirNode, raw: Digest, at: string): void => {
253
+ const dir = opened(raw, at)
254
+ for (const f of dir.files) target.files.set(f.name, f)
255
+ for (const s of dir.symlinks) target.symlinks.set(s.name, s)
256
+ for (const d of dir.directories) {
257
+ if (target.dirs.has(d.name) || target.rawDirs.has(d.name)) {
258
+ overlay(descend(target, d.name, `${at}/${d.name}`), d.digest, `${at}/${d.name}`)
259
+ } else {
260
+ target.rawDirs.set(d.name, d.digest)
261
+ }
262
+ }
263
+ }
205
264
 
206
265
  // Insertion order is NOT what makes the tree deterministic — `encodeDirectory`
207
266
  // sorts every node's names at encode time, and it has to, because grafts are
@@ -232,27 +291,27 @@ export async function buildInputTree(args: {
232
291
  if (!st.isFile()) return { kind: 'other' as const }
233
292
  return { kind: 'file' as const, data: await read(abs), mode: st.mode }
234
293
  })
294
+ // A declared input is placed before the Tree grafts, which replace one
295
+ // under them (`shadowed`); an upstream's output read from disk after
296
+ // them, as it is that upstream's own and lands beside theirs.
297
+ const later: (() => void)[] = []
235
298
  for (const [i, rel] of sorted.entries()) {
236
299
  const got = reads[i]!
237
300
  if (got.kind === 'gone') {
238
301
  moved.push(rel)
239
302
  continue
240
303
  }
304
+ const deferred = args.expected !== undefined && !args.expected.has(rel)
241
305
  if (got.kind === 'link') {
242
306
  const target = got.target
243
307
  const want = args.expected?.get(rel)
244
308
  if (want !== undefined && !carriesOid(new TextEncoder().encode(target), want)) moved.push(rel)
245
- const parts = rel.split('/')
246
- let node = root
247
- for (const seg of parts.slice(0, -1)) {
248
- let next = node.dirs.get(seg)
249
- if (next === undefined) {
250
- next = emptyDir()
251
- node.dirs.set(seg, next)
252
- }
253
- node = next
309
+ const place = (): void => {
310
+ const { node, leaf } = insertAt(rel)
311
+ node.symlinks.set(leaf, { name: leaf, target })
254
312
  }
255
- node.symlinks.set(parts[parts.length - 1]!, { name: parts[parts.length - 1]!, target })
313
+ if (deferred) later.push(place)
314
+ else place()
256
315
  continue
257
316
  }
258
317
  if (got.kind === 'other') {
@@ -272,40 +331,47 @@ export async function buildInputTree(args: {
272
331
  seen.add(digest.hash)
273
332
  blobs.push({ digest, data })
274
333
  }
275
- const parts = rel.split('/')
276
- let node = root
277
- for (const seg of parts.slice(0, -1)) {
278
- let next = node.dirs.get(seg)
279
- if (next === undefined) {
280
- next = emptyDir()
281
- node.dirs.set(seg, next)
282
- }
283
- node = next
284
- }
285
334
  // The owner-execute bit is what REAPI models; group/other add nothing a
286
335
  // worker can act on.
287
- node.files.set(parts[parts.length - 1]!, {
288
- name: parts[parts.length - 1]!,
289
- digest,
290
- is_executable: (got.mode & 0o100) !== 0,
291
- })
336
+ const isExecutable = (got.mode & 0o100) !== 0
337
+ const place = (): void => {
338
+ const { node, leaf } = insertAt(rel)
339
+ node.files.set(leaf, { name: leaf, digest, is_executable: isExecutable })
340
+ }
341
+ if (deferred) later.push(place)
342
+ else place()
292
343
  fileCount++
293
344
  }
294
345
 
295
- const insertAt = (rel: string): { node: DirNode; leaf: string } => {
296
- const parts = rel.split('/')
297
- let node = root
298
- for (const seg of parts.slice(0, -1)) {
299
- let next = node.dirs.get(seg)
300
- if (next === undefined) {
301
- next = emptyDir()
302
- node.dirs.set(seg, next)
346
+ // Ancestors first, so a nested graft finds the one above it to merge into.
347
+ const shadowed: string[] = []
348
+ const treeGrafts = [...(args.treeGrafts ?? [])].sort((a, b) =>
349
+ a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
350
+ )
351
+ for (const graft of treeGrafts) {
352
+ const { node, leaf } = insertAt(graft.path)
353
+ const canonical = canonicaliseTree(graft, known)
354
+ for (const b of canonical.blobs) {
355
+ if (!seen.has(b.digest.hash)) {
356
+ seen.add(b.digest.hash)
357
+ blobs.push(b)
303
358
  }
304
- node = next
305
359
  }
306
- return { node, leaf: parts[parts.length - 1]! }
360
+ const there = node.dirs.get(leaf)
361
+ if (node.rawDirs.has(leaf) || (there !== undefined && fromGraft.has(there))) {
362
+ overlay(descend(node, leaf, graft.path), canonical.root, graft.path)
363
+ continue
364
+ }
365
+ // The graft wins over declared inputs read from disk: it is the
366
+ // upstream's authoritative output, the disk copy at best a stale
367
+ // materialisation of it.
368
+ if (there !== undefined) {
369
+ shadowed.push(graft.path)
370
+ node.dirs.delete(leaf)
371
+ }
372
+ node.rawDirs.set(leaf, canonical.root)
307
373
  }
308
-
374
+ for (const place of later) place()
309
375
  for (const graft of args.fileGrafts ?? []) {
310
376
  const { node, leaf } = insertAt(graft.path)
311
377
  node.files.set(leaf, { name: leaf, digest: graft.digest, is_executable: graft.isExecutable })
@@ -317,31 +383,13 @@ export async function buildInputTree(args: {
317
383
  }
318
384
  for (const dir of args.ensureDirs ?? []) {
319
385
  if (dir === '') continue
386
+ const parts = dir.split('/')
320
387
  let node = root
321
- for (const seg of dir.split('/')) {
322
- let next = node.dirs.get(seg)
323
- if (next === undefined) {
324
- next = emptyDir()
325
- node.dirs.set(seg, next)
326
- }
327
- node = next
388
+ for (let i = 0; i < parts.length; i++) {
389
+ node = descend(node, parts[i]!, parts.slice(0, i + 1).join('/'))
328
390
  }
329
391
  }
330
392
 
331
- const shadowed: string[] = []
332
- for (const graft of args.treeGrafts ?? []) {
333
- const { node, leaf } = insertAt(graft.path)
334
- if (node.dirs.has(leaf)) shadowed.push(graft.path)
335
- const canonical = canonicaliseTree(graft)
336
- for (const b of canonical.blobs) {
337
- if (!seen.has(b.digest.hash)) {
338
- seen.add(b.digest.hash)
339
- blobs.push(b)
340
- }
341
- }
342
- node.rawDirs.set(leaf, canonical.root)
343
- }
344
-
345
393
  const rootDigest = serialise(root, blobs, seen)
346
394
  return { root: rootDigest, blobs, fileCount, shadowed, moved }
347
395
  }
@@ -384,13 +432,11 @@ function serialise(node: DirNode, blobs: Blob[], seen: Set<string>): Digest {
384
432
  const directories: DirectoryNode[] = []
385
433
  const dirNames = utf8Sorted([...new Set([...node.dirs.keys(), ...node.rawDirs.keys()])])
386
434
  for (const name of dirNames) {
435
+ // A name is in one map or the other, never both (`buildInputTree`).
387
436
  const sub = node.dirs.get(name)
388
437
  directories.push({
389
438
  name,
390
- // A grafted subtree wins over a disk-built one of the same name: the
391
- // graft is the upstream's AUTHORITATIVE output, the disk copy at best
392
- // a stale materialisation of it.
393
- digest: node.rawDirs.get(name) ?? serialise(sub!, blobs, seen),
439
+ digest: sub === undefined ? node.rawDirs.get(name)! : serialise(sub, blobs, seen),
394
440
  })
395
441
  }
396
442
  const files = utf8Sorted([...node.files.keys()]).map((n) => node.files.get(n)!)
package/src/wire.ts CHANGED
@@ -318,6 +318,16 @@ function spent(err: unknown): unknown {
318
318
 
319
319
  const RETRY_DELAYS_MS = [100, 400, 1600]
320
320
 
321
+ /**
322
+ * Re-attaching to an operation. A proxy that cuts long streams cuts a
323
+ * progressing action again and again, so only attaches that delivered no
324
+ * operation spend `RETRY_DELAYS_MS`'s budget. The wait still grows per
325
+ * consecutive cut, so a server that answers EXECUTING and hangs up is not
326
+ * re-dialled every 100 ms; it starts over once a stream advanced the stage
327
+ * or stayed open `steadyMs`. A mutable object: tests shorten it.
328
+ */
329
+ export const reattach = { backoffMs: [100, 400, 1600, 5000], steadyMs: 10_000 }
330
+
321
331
  const deadlineOf = (bounds: grpc.CallOptions): number => (bounds.deadline as Date).getTime()
322
332
 
323
333
  const executionAborted = (): Error => new Error('execution aborted')
@@ -909,17 +919,19 @@ export class ReapiClient {
909
919
  /**
910
920
  * Upload blobs, choosing the transport per blob: batched while they fit the
911
921
  * server's batch budget, ByteStream for the rest. Only what
912
- * `FindMissingBlobs` reports absent is sent at all.
922
+ * `FindMissingBlobs` reports absent is sent, unless `everyBlob`: a server
923
+ * whose Execute just named them missing has already contradicted it.
913
924
  */
914
925
  async uploadBlobs(
915
926
  blobs: ReadonlyArray<{ digest: Digest; data: Uint8Array }>,
916
927
  maxBatchBytes = 0,
928
+ everyBlob = false,
917
929
  ): Promise<void> {
918
930
  if (blobs.length === 0) return
919
- const missing = new Set(
920
- (await this.findMissingBlobs(blobs.map((b) => b.digest))).map((d) => d.hash),
921
- )
922
- const todo = blobs.filter((b) => missing.has(b.digest.hash))
931
+ const missing = everyBlob
932
+ ? undefined
933
+ : new Set((await this.findMissingBlobs(blobs.map((b) => b.digest))).map((d) => d.hash))
934
+ const todo = missing === undefined ? blobs : blobs.filter((b) => missing.has(b.digest.hash))
923
935
  // 0 means the server did not say; the gRPC default max message is 4 MB and
924
936
  // the request carries framing on top, so leave headroom rather than
925
937
  // discovering the limit as a RESOURCE_EXHAUSTED mid-run.
@@ -1009,14 +1021,14 @@ export class ReapiClient {
1009
1021
 
1010
1022
  /**
1011
1023
  * `GetActionResult`; `null` on NOT_FOUND — a miss is not an error.
1012
- * `inline` asks the server to return stdout and the named output files in
1013
- * the reply. The spec lets it decline (and requires it past the message
1014
- * limit), so a caller reads `stdout_raw` / `contents` when present and
1015
- * fetches otherwise.
1024
+ * `inline` asks the server to return the streams it names and the named
1025
+ * output files in the reply. The spec lets it decline (and requires it
1026
+ * past the message limit), so a caller reads `*_raw` / `contents` when
1027
+ * present and fetches otherwise.
1016
1028
  */
1017
1029
  async getActionResult(
1018
1030
  action: Digest,
1019
- inline?: { stdout: boolean; files: readonly string[] },
1031
+ inline?: { stdout: boolean; stderr?: boolean; files: readonly string[] },
1020
1032
  ): Promise<ActionResult | null> {
1021
1033
  try {
1022
1034
  const result = await this.unary<ActionResult>(
@@ -1027,7 +1039,11 @@ export class ReapiClient {
1027
1039
  action_digest: action,
1028
1040
  ...(inline === undefined
1029
1041
  ? {}
1030
- : { inline_stdout: inline.stdout, inline_output_files: inline.files }),
1042
+ : {
1043
+ inline_stdout: inline.stdout,
1044
+ ...(inline.stderr === true ? { inline_stderr: true } : {}),
1045
+ inline_output_files: inline.files,
1046
+ }),
1031
1047
  },
1032
1048
  this.meta(),
1033
1049
  this.boundedMeta(),
@@ -1317,9 +1333,13 @@ export class ReapiClient {
1317
1333
  * the bytes pass and a mismatch errors the stream at its end, so no reader
1318
1334
  * reaches the end of a blob that is not the one asked for.
1319
1335
  * Identity-encoded, as `writeBlob` streams: the blob this reads is a zstd
1320
- * artifact already.
1336
+ * artifact already. `refused` is what that error becomes, for a reader
1337
+ * that tells wrong bytes from a cut.
1321
1338
  */
1322
- async readBlobStream(digest: Digest): Promise<ReadableStream<Uint8Array> | null> {
1339
+ async readBlobStream(
1340
+ digest: Digest,
1341
+ refused: (err: Error) => Error = (err) => err,
1342
+ ): Promise<ReadableStream<Uint8Array> | null> {
1323
1343
  const resource = `${this.instance ? `${this.instance}/` : ''}blobs/${digest.hash}/${digest.size_bytes}`
1324
1344
  type Message = { data: Uint8Array }
1325
1345
  let call: AsyncIterable<Message> & { cancel(): void }
@@ -1366,14 +1386,18 @@ export class ReapiClient {
1366
1386
  const got = next ?? (await take())
1367
1387
  next = undefined
1368
1388
  if (got.done) {
1369
- assertServed(size, hasher && (() => hasher.digest('hex')), digest)
1389
+ try {
1390
+ assertServed(size, hasher && (() => hasher.digest('hex')), digest)
1391
+ } catch (err) {
1392
+ throw refused(err as Error)
1393
+ }
1370
1394
  controller.close()
1371
1395
  return
1372
1396
  }
1373
1397
  size += got.value.data.length
1374
1398
  if (size > Number(digest.size_bytes)) {
1375
1399
  call.cancel()
1376
- throw overServed(digest)
1400
+ throw refused(overServed(digest))
1377
1401
  }
1378
1402
  hasher?.update(got.value.data)
1379
1403
  controller.enqueue(got.value.data)
@@ -1421,27 +1445,47 @@ export class ReapiClient {
1421
1445
  ...(this.digestFunction === 'SHA256' ? {} : { digest_function: this.digestFunction }),
1422
1446
  }
1423
1447
  let operationName = ''
1424
- for (let attempt = 0; ; attempt++) {
1448
+ // Attaches in a row that delivered no operation, cuts since a stream last
1449
+ // advanced or held, and the furthest stage reached.
1450
+ let empty = 0
1451
+ let cuts = 0
1452
+ let reached = -1
1453
+ for (;;) {
1425
1454
  let failure: unknown
1455
+ let delivered = false
1456
+ let advanced = false
1457
+ const opened = performance.now()
1458
+ const onStage = (stage: string): void => {
1459
+ const rank = EXEC_STAGE.indexOf(stage as (typeof EXEC_STAGE)[number])
1460
+ if (rank > reached) {
1461
+ reached = rank
1462
+ advanced = true
1463
+ }
1464
+ opts.onStage?.(stage)
1465
+ }
1426
1466
  try {
1427
1467
  const op =
1428
1468
  operationName === ''
1429
- ? await this.operationStream('execute', req, signal, opts.onStage, (n) => {
1430
- operationName = n
1431
- opts.onOperation?.(n)
1469
+ ? await this.operationStream('execute', req, signal, onStage, (o) => {
1470
+ delivered = true
1471
+ if (o.name === '' || o.name === undefined) return
1472
+ operationName = o.name
1473
+ opts.onOperation?.(o.name)
1432
1474
  })
1433
1475
  : await this.operationStream(
1434
1476
  'waitExecution',
1435
1477
  { name: operationName },
1436
1478
  signal,
1437
- opts.onStage,
1479
+ onStage,
1480
+ () => {
1481
+ delivered = true
1482
+ },
1438
1483
  )
1439
1484
  if (op.done) return op
1440
1485
  // A stream can END cleanly on an operation still QUEUED or EXECUTING
1441
1486
  // (a server or proxy cutting long streams); the operation lives on,
1442
- // so it is re-attached as a dropped stream is, on the same budget.
1443
- // An unnamed one cannot be re-attached, and a plain Error is not
1444
- // retried.
1487
+ // so it is re-attached as a dropped stream is. An unnamed one cannot
1488
+ // be re-attached, and a plain Error is not retried.
1445
1489
  if (operationName === '') {
1446
1490
  throw new Error('execution stream closed before the operation finished')
1447
1491
  }
@@ -1454,12 +1498,18 @@ export class ReapiClient {
1454
1498
  // (a restart lost it). Nothing is running to re-attach to, and the
1455
1499
  // action is the same bytes, so it is executed again, on the same
1456
1500
  // budget — as Bazel's executor does.
1457
- if (code === NOT_FOUND && operationName !== '') operationName = ''
1458
- else if (!isRetryable(code)) throw err
1501
+ if (code === NOT_FOUND && operationName !== '') {
1502
+ operationName = ''
1503
+ reached = -1
1504
+ } else if (!isRetryable(code)) throw err
1459
1505
  failure = err
1460
1506
  }
1461
- const delay = RETRY_DELAYS_MS[attempt]
1462
- if (delay === undefined) throw failure
1507
+ // An EXECUTING-then-cut loop never spends this budget; the executor's
1508
+ // stall bound (`exec.timeout` / `executeTimeoutMs`) ends it.
1509
+ empty = delivered ? 0 : empty + 1
1510
+ if (empty > RETRY_DELAYS_MS.length) throw failure
1511
+ if (advanced || performance.now() - opened >= reattach.steadyMs) cuts = 0
1512
+ const delay = reattach.backoffMs[Math.min(cuts++, reattach.backoffMs.length - 1)]!
1463
1513
  await abortableSleep(delay, signal)
1464
1514
  }
1465
1515
  }
@@ -1495,7 +1545,7 @@ export class ReapiClient {
1495
1545
  req: unknown,
1496
1546
  signal?: AbortSignal,
1497
1547
  onStage?: (stage: string) => void,
1498
- onName?: (name: string) => void,
1548
+ onOperation?: (op: Operation) => void,
1499
1549
  ): Promise<Operation> {
1500
1550
  return new Promise((resolve, reject) => {
1501
1551
  // An aborted signal never fires `abort` again, and the stream has no
@@ -1514,7 +1564,7 @@ export class ReapiClient {
1514
1564
  signal?.addEventListener('abort', onAbort, { once: true })
1515
1565
  stream.on('data', (op: Operation) => {
1516
1566
  last = op
1517
- if (op.name !== '' && op.name !== undefined && onName !== undefined) onName(op.name)
1567
+ onOperation?.(op)
1518
1568
  // ExecuteOperationMetadata carries the action's STAGE
1519
1569
  // (QUEUED / EXECUTING / COMPLETED). Surfacing it is the difference
1520
1570
  // between "vx is hung" and "the action is queued behind 40 others".