@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/src/wire.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import * as grpc from '@grpc/grpc-js'
7
7
  import * as protoLoader from '@grpc/proto-loader'
8
+ import { AsyncLocalStorage } from 'node:async_hooks'
8
9
  import path from 'node:path'
9
10
  import {
10
11
  canDigest,
@@ -15,6 +16,7 @@ import {
15
16
  strField,
16
17
  type DigestFunctionName,
17
18
  } from './merkle.js'
19
+ import { ui } from '@vzn/vx'
18
20
 
19
21
  /**
20
22
  * Default bytes per ByteStream message.
@@ -48,8 +50,11 @@ export const CHUNK_BYTES = 65535
48
50
  */
49
51
  export const SAFE_CHUNK_BYTES = 65535
50
52
 
51
- /** The oldest Bun whose http2 client survives a CHUNK_BYTES-sized message. */
52
- export const MIN_BUN = [1, 4, 0] as const
53
+ /**
54
+ * The package's Bun floor (`engines.bun`). Its http2 client survives a
55
+ * CHUNK_BYTES-sized message from 1.4.0 on; the floor is core's, 1.4.4.
56
+ */
57
+ export const MIN_BUN = [1, 4, 4] as const
53
58
 
54
59
  /**
55
60
  * Refuse to run on a Bun that would hang instead of uploading. A version this
@@ -165,7 +170,7 @@ async function unzstdBounded(data: Uint8Array, digest: Digest): Promise<Uint8Arr
165
170
  /** More bytes than the digest declares, refused before the rest arrive. */
166
171
  const overServed = (digest: Digest): Error =>
167
172
  new Error(
168
- `@vzn/vx-reapi: blob integrity failure for ${digest.hash.slice(0, 16)}…: ` +
173
+ `blob integrity failure for ${digest.hash.slice(0, 16)}…: ` +
169
174
  `served past its declared ${digest.size_bytes} bytes`,
170
175
  )
171
176
 
@@ -180,7 +185,7 @@ const wireBound = (size: number): number => size + (size >> 8) + 1024
180
185
  function assertServed(size: number, hash: (() => string) | undefined, digest: Digest): void {
181
186
  if (size !== Number(digest.size_bytes)) {
182
187
  throw new Error(
183
- `@vzn/vx-reapi: blob integrity failure for ${digest.hash.slice(0, 16)}…: ` +
188
+ `blob integrity failure for ${digest.hash.slice(0, 16)}…: ` +
184
189
  `size ${size} != declared ${digest.size_bytes}`,
185
190
  )
186
191
  }
@@ -188,7 +193,7 @@ function assertServed(size: number, hash: (() => string) | undefined, digest: Di
188
193
  const got = hash()
189
194
  if (got !== digest.hash) {
190
195
  throw new Error(
191
- `@vzn/vx-reapi: blob integrity failure: bytes hash to ${got.slice(0, 16)}… ` +
196
+ `blob integrity failure: bytes hash to ${got.slice(0, 16)}… ` +
192
197
  `but were served for digest ${digest.hash.slice(0, 16)}…`,
193
198
  )
194
199
  }
@@ -305,11 +310,17 @@ function isRetryable(code: number | undefined): boolean {
305
310
  )
306
311
  }
307
312
 
313
+ /** grpc-js ends an UNAVAILABLE with `Resolution note: ` and nothing after it. */
314
+ function spent(err: unknown): unknown {
315
+ if (err instanceof Error) err.message = err.message.replace(/\s*Resolution note:\s*$/, '')
316
+ return err
317
+ }
318
+
308
319
  const RETRY_DELAYS_MS = [100, 400, 1600]
309
320
 
310
321
  const deadlineOf = (bounds: grpc.CallOptions): number => (bounds.deadline as Date).getTime()
311
322
 
312
- const executionAborted = (): Error => new Error('reapi: execution aborted')
323
+ const executionAborted = (): Error => new Error('execution aborted')
313
324
 
314
325
  /** A backoff an abort ends at once: the caller's deadline may fire mid-wait. */
315
326
  function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
@@ -328,6 +339,13 @@ function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<vo
328
339
 
329
340
  const NOT_FOUND = grpc.status.NOT_FOUND
330
341
 
342
+ /**
343
+ * The action a call is made for, per async context: one client serves every
344
+ * task of a run at once, and a field on it named, on each action's uploads,
345
+ * whichever action had set it last.
346
+ */
347
+ const currentAction = new AsyncLocalStorage<{ id: string }>()
348
+
331
349
  export interface ReapiOptions {
332
350
  /** `host:port` of the REAPI server; TLS unless `grpc://`, `http://` or a socket. */
333
351
  endpoint: string
@@ -385,6 +403,13 @@ export interface ReapiOptions {
385
403
  chunkBytes?: number
386
404
  }
387
405
 
406
+ /**
407
+ * The longest a timer waits: one past 2^31-1 ms fires after 1 ms, so a
408
+ * month's bound gave up at once. Every deadline option is refused past it,
409
+ * as core refuses such an exec.timeout.
410
+ */
411
+ export const MAX_TIMER_MS = 2 ** 31 - 1
412
+
388
413
  /** The ceiling a derived control-plane deadline never exceeds, however far
389
414
  * `callTimeoutMs` is raised for a bulk transfer. */
390
415
  export const META_TIMEOUT_CAP_MS = 15_000
@@ -463,7 +488,7 @@ export class ReapiClient {
463
488
  private readonly correlatedInvocationsId: string
464
489
  /** Set when a call spent its retries on UNAVAILABLE; cleared by any answer. */
465
490
  private unreachable = false
466
- /** Set per action so the server can group its RPCs under one action. */
491
+ /** The action last named (`nameAction`); a call outside any `inAction` carries it. */
467
492
  actionId = ''
468
493
  toolInvocationId = ''
469
494
 
@@ -489,9 +514,18 @@ export class ReapiClient {
489
514
  // and quotes it in the error, so an auth header's secret reached each
490
515
  // degrade warning (item 928). Refused once, here, by key alone.
491
516
  for (const [k, v] of Object.entries(this.headers)) {
517
+ // A name too: refused on every call, it degraded the cache to misses
518
+ // and read as an unreachable server to the executor. A `-bin` name
519
+ // takes bytes, which a string header is not.
520
+ const name = k.toLowerCase()
521
+ if (!/^[0-9a-z_.-]+$/.test(name) || name.endsWith('-bin')) {
522
+ throw new Error(
523
+ `reapi: header name ${JSON.stringify(k)} is not one gRPC metadata can carry (lowercase letters, digits, "-", "_" and ".", not ending "-bin")`,
524
+ )
525
+ }
492
526
  if (!/^[ -~]*$/.test(v)) {
493
527
  throw new Error(
494
- `reapi: header ${JSON.stringify(k)} holds a character gRPC metadata cannot carry (printable ASCII only) — check it (its value is not printed)`,
528
+ `reapi(): header ${JSON.stringify(k)} holds a character gRPC metadata cannot carry (printable ASCII only) — check it (its value is not printed)`,
495
529
  )
496
530
  }
497
531
  }
@@ -503,22 +537,22 @@ export class ReapiClient {
503
537
  // Derived, not required: raising `callTimeoutMs` for a big upload must
504
538
  // not silently lengthen every metadata probe too.
505
539
  this.metaTimeoutMs = opts.metaTimeoutMs ?? Math.min(this.callTimeoutMs, META_TIMEOUT_CAP_MS)
506
- this.onWarn = opts.onWarn ?? (() => undefined)
507
- // A zero, negative or non-number deadline ended every call as it began.
540
+ // No listener is no reason to say nothing.
541
+ this.onWarn = opts.onWarn ?? ((m) => ui.say('warn', m))
542
+ // A zero, negative or non-number deadline ended every call as it began,
543
+ // and so did one past the longest timer.
508
544
  for (const [name, ms] of [
509
545
  ['callTimeoutMs', this.callTimeoutMs],
510
546
  ['metaTimeoutMs', this.metaTimeoutMs],
511
547
  ] as const) {
512
- if (!(typeof ms === 'number' && Number.isFinite(ms) && ms > 0)) {
548
+ if (!(typeof ms === 'number' && ms > 0 && ms <= MAX_TIMER_MS)) {
513
549
  throw new Error(
514
- `@vzn/vx-reapi: ${name} must be a positive number of ms (got ${JSON.stringify(ms)})`,
550
+ `reapi(): ${name} must be a positive number of ms up to ${MAX_TIMER_MS} (got ${typeof ms === 'number' ? String(ms) : JSON.stringify(ms)})`,
515
551
  )
516
552
  }
517
553
  }
518
554
  if (!Number.isInteger(this.chunkBytes) || this.chunkBytes < 1) {
519
- throw new Error(
520
- `@vzn/vx-reapi: chunkBytes must be a positive integer (got ${this.chunkBytes})`,
521
- )
555
+ throw new Error(`reapi(): chunkBytes must be a positive integer (got ${this.chunkBytes})`)
522
556
  }
523
557
  }
524
558
 
@@ -570,7 +604,7 @@ export class ReapiClient {
570
604
  return res
571
605
  } catch (err) {
572
606
  const delay = this.retryDelay(attempt, (err as grpc.ServiceError).code)
573
- if (delay === undefined) throw err
607
+ if (delay === undefined) throw spent(err)
574
608
  await Bun.sleep(delay)
575
609
  }
576
610
  }
@@ -612,12 +646,24 @@ export class ReapiClient {
612
646
  const tool = concat([strField(1, this.toolName), strField(2, this.toolVersion)])
613
647
  return concat([
614
648
  tool.length === 0 ? tool : lenField(1, tool),
615
- strField(2, this.actionId),
649
+ strField(2, currentAction.getStore()?.id ?? this.actionId),
616
650
  strField(3, this.toolInvocationId),
617
651
  strField(4, this.correlatedInvocationsId),
618
652
  ])
619
653
  }
620
654
 
655
+ /** Runs `fn` as one action's calls: each carries the id `nameAction` gives it there. */
656
+ inAction<T>(fn: () => Promise<T>): Promise<T> {
657
+ return currentAction.run({ id: '' }, fn)
658
+ }
659
+
660
+ /** Names the action of the `inAction` this runs in, so the server groups its RPCs under it. */
661
+ nameAction(id: string): void {
662
+ const scope = currentAction.getStore()
663
+ if (scope !== undefined) scope.id = id
664
+ this.actionId = id
665
+ }
666
+
621
667
  /**
622
668
  * `GetCapabilities`. `execEnabled` is what decides whether this server can
623
669
  * take an `executor` at all — a cache-only deployment (bazel-remote)
@@ -680,11 +726,10 @@ export class ReapiClient {
680
726
  if (wanted !== undefined) {
681
727
  if (!caps.digestFunctions.includes(wanted)) {
682
728
  throw new Error(
683
- `@vzn/vx-reapi: server does not support digest function ${wanted} (has ${caps.digestFunctions.join(', ')})`,
729
+ `server does not support digest function ${wanted} (has ${caps.digestFunctions.join(', ')})`,
684
730
  )
685
731
  }
686
- if (!canDigest(wanted))
687
- throw new Error(`@vzn/vx-reapi: this runtime cannot compute ${wanted}`)
732
+ if (!canDigest(wanted)) throw new Error(`this runtime cannot compute ${wanted}`)
688
733
  this.digestFunction = wanted
689
734
  } else {
690
735
  // Default stays SHA256 even when the server advertises stronger
@@ -776,7 +821,7 @@ export class ReapiClient {
776
821
  const code = r.status?.code ?? 0
777
822
  if (code !== 0) {
778
823
  throw new Error(
779
- `reapi: BatchUpdateBlobs rejected ${r.digest?.hash}: code ${code} ${r.status?.message ?? ''}`,
824
+ `BatchUpdateBlobs rejected ${r.digest?.hash}: code ${code} ${r.status?.message ?? ''}`,
780
825
  )
781
826
  }
782
827
  }
@@ -974,7 +1019,7 @@ export class ReapiClient {
974
1019
  inline?: { stdout: boolean; files: readonly string[] },
975
1020
  ): Promise<ActionResult | null> {
976
1021
  try {
977
- return await this.unary<ActionResult>(
1022
+ const result = await this.unary<ActionResult>(
978
1023
  this.svc.ac,
979
1024
  'getActionResult',
980
1025
  {
@@ -987,6 +1032,7 @@ export class ReapiClient {
987
1032
  this.meta(),
988
1033
  this.boundedMeta(),
989
1034
  )
1035
+ return withNumericSizes(result)
990
1036
  } catch (err) {
991
1037
  if ((err as grpc.ServiceError).code === NOT_FOUND) return null
992
1038
  throw err
@@ -1074,13 +1120,13 @@ export class ReapiClient {
1074
1120
  wireBytes > chunk
1075
1121
  ) {
1076
1122
  this.onWarn(
1077
- `vx/reapi: chunked write of ${digest.hash.slice(0, 12)} hit the ${chunk}-byte chunk stall (Bun http2 flow control); retrying at ${SAFE_CHUNK_BYTES}`,
1123
+ `[vx] vx-reapi: chunked write of ${digest.hash.slice(0, 12)} hit the ${chunk}-byte chunk stall (Bun http2 flow control); retrying at ${SAFE_CHUNK_BYTES}`,
1078
1124
  )
1079
1125
  chunk = SAFE_CHUNK_BYTES
1080
1126
  continue
1081
1127
  }
1082
1128
  const delay = this.retryDelay(attempt, code)
1083
- if (delay === undefined) throw err
1129
+ if (delay === undefined) throw spent(err)
1084
1130
  if (!compressed) {
1085
1131
  // Identity path: ask how far the server got and resume there.
1086
1132
  const status = await this.queryWriteStatus(resource).catch(() => null)
@@ -1097,6 +1143,7 @@ export class ReapiClient {
1097
1143
  this.bounded(),
1098
1144
  )
1099
1145
  return
1146
+ // quiet: a resume that fails starts afresh; its last failure throws
1100
1147
  } catch {
1101
1148
  // fall through to a fresh attempt
1102
1149
  }
@@ -1144,9 +1191,7 @@ export class ReapiClient {
1144
1191
  // failed the upload of an input two actions share (F-44).
1145
1192
  const expected = compressed ? total : digest.size_bytes
1146
1193
  if (committed !== expected && !(compressed && (committed > 0 || committed === -1))) {
1147
- return reject(
1148
- new Error(`reapi: short write for ${digest.hash}: ${committed}/${expected}`),
1149
- )
1194
+ return reject(new Error(`short write for ${digest.hash}: ${committed}/${expected}`))
1150
1195
  }
1151
1196
  resolve()
1152
1197
  },
@@ -1155,6 +1200,7 @@ export class ReapiClient {
1155
1200
  })
1156
1201
  // Raced against every drain wait and awaited at the end; a failure that
1157
1202
  // lands between the two is not an unhandled rejection.
1203
+ // quiet: raced and awaited below, where its error throws
1158
1204
  done.catch(() => undefined)
1159
1205
  let offset = startOffset
1160
1206
  let first = true
@@ -1204,7 +1250,7 @@ export class ReapiClient {
1204
1250
  return blob
1205
1251
  } catch (err) {
1206
1252
  const delay = this.retryDelay(attempt, (err as grpc.ServiceError).code)
1207
- if (delay === undefined) throw err
1253
+ if (delay === undefined) throw spent(err)
1208
1254
  await Bun.sleep(delay)
1209
1255
  }
1210
1256
  }
@@ -1301,7 +1347,7 @@ export class ReapiClient {
1301
1347
  } catch (err) {
1302
1348
  const code = (err as grpc.ServiceError).code
1303
1349
  const delay = this.retryDelay(attempt++, code)
1304
- if (delay === undefined) throw err
1350
+ if (delay === undefined) throw spent(err)
1305
1351
  await Bun.sleep(delay)
1306
1352
  open(size)
1307
1353
  }
@@ -1397,10 +1443,10 @@ export class ReapiClient {
1397
1443
  // An unnamed one cannot be re-attached, and a plain Error is not
1398
1444
  // retried.
1399
1445
  if (operationName === '') {
1400
- throw new Error('reapi: execution stream closed before the operation finished')
1446
+ throw new Error('execution stream closed before the operation finished')
1401
1447
  }
1402
1448
  failure = new Error(
1403
- `reapi: execution stream for ${operationName} closed before the operation finished`,
1449
+ `execution stream for ${operationName} closed before the operation finished`,
1404
1450
  )
1405
1451
  } catch (err) {
1406
1452
  const code = (err as grpc.ServiceError).code
@@ -1484,7 +1530,7 @@ export class ReapiClient {
1484
1530
  stream.on('end', () => {
1485
1531
  signal?.removeEventListener('abort', onAbort)
1486
1532
  if (last === undefined)
1487
- return reject(new Error('reapi: execution stream closed with no operation'))
1533
+ return reject(new Error('execution stream closed with no operation'))
1488
1534
  resolve(last)
1489
1535
  })
1490
1536
  })
@@ -1559,6 +1605,34 @@ export class ReapiClient {
1559
1605
  }
1560
1606
  }
1561
1607
 
1608
+ /**
1609
+ * proto-loader reads an int64 as a string (`longs: String`), and every reader
1610
+ * of a result sums and compares `size_bytes` as a number: a record replay's
1611
+ * batch budget concatenated `"12" + 128`, so each output file was a
1612
+ * BatchReadBlobs round trip of its own, and an empty file grafted from a
1613
+ * record encoded its `"0"` size where the canonical form omits it.
1614
+ */
1615
+ function withNumericSizes(result: ActionResult): ActionResult {
1616
+ const num = <D extends Digest | null | undefined>(d: D): D =>
1617
+ d === null || d === undefined ? d : ({ ...d, size_bytes: Number(d.size_bytes) } as D)
1618
+ return {
1619
+ ...result,
1620
+ ...(result.output_files === undefined
1621
+ ? {}
1622
+ : { output_files: result.output_files.map((f) => ({ ...f, digest: num(f.digest) })) }),
1623
+ ...(result.output_directories === undefined
1624
+ ? {}
1625
+ : {
1626
+ output_directories: result.output_directories.map((d) => ({
1627
+ ...d,
1628
+ tree_digest: num(d.tree_digest),
1629
+ })),
1630
+ }),
1631
+ ...(result.stdout_digest === undefined ? {} : { stdout_digest: num(result.stdout_digest) }),
1632
+ ...(result.stderr_digest === undefined ? {} : { stderr_digest: num(result.stderr_digest) }),
1633
+ }
1634
+ }
1635
+
1562
1636
  /** google.longrunning.Operation, narrowed to what Execute returns. */
1563
1637
  export interface Operation {
1564
1638
  name: string