@namzu/sandbox 15.0.0 → 17.0.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +324 -0
  2. package/README.md +223 -0
  3. package/dist/backends/aci-standby-pool/index.d.ts +22 -4
  4. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  5. package/dist/backends/aci-standby-pool/index.js +31 -7
  6. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  7. package/dist/backends/docker/index.d.ts +408 -26
  8. package/dist/backends/docker/index.d.ts.map +1 -1
  9. package/dist/backends/docker/index.js +1173 -168
  10. package/dist/backends/docker/index.js.map +1 -1
  11. package/dist/backends/firecracker/transport.d.ts +156 -1
  12. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  13. package/dist/backends/firecracker/transport.js +223 -29
  14. package/dist/backends/firecracker/transport.js.map +1 -1
  15. package/dist/backends/http-worker-client.d.ts +64 -2
  16. package/dist/backends/http-worker-client.d.ts.map +1 -1
  17. package/dist/backends/http-worker-client.js +78 -7
  18. package/dist/backends/http-worker-client.js.map +1 -1
  19. package/dist/backends/kubernetes/egress-policy.d.ts +193 -102
  20. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  21. package/dist/backends/kubernetes/egress-policy.js +321 -146
  22. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  23. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +6 -6
  24. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -1
  25. package/dist/backends/kubernetes/per-sandbox-policy.js +21 -53
  26. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -1
  27. package/dist/backends/kubernetes/transport.d.ts +7 -0
  28. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  29. package/dist/backends/kubernetes/transport.js.map +1 -1
  30. package/dist/egress/proxy.d.ts +47 -2
  31. package/dist/egress/proxy.d.ts.map +1 -1
  32. package/dist/egress/proxy.js +31 -7
  33. package/dist/egress/proxy.js.map +1 -1
  34. package/dist/index.d.ts +130 -6
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +55 -5
  37. package/dist/index.js.map +1 -1
  38. package/package.json +4 -4
  39. package/src/backends/aci-standby-pool/index.ts +37 -7
  40. package/src/backends/docker/index.ts +1475 -196
  41. package/src/backends/firecracker/transport.ts +387 -36
  42. package/src/backends/http-worker-client.ts +89 -5
  43. package/src/backends/kubernetes/egress-policy.ts +455 -187
  44. package/src/backends/kubernetes/per-sandbox-policy.ts +21 -66
  45. package/src/backends/kubernetes/transport.ts +7 -0
  46. package/src/egress/proxy.ts +65 -8
  47. package/src/index.ts +162 -5
@@ -250,6 +250,94 @@ export type AgentRequest = (
250
250
  // {@link AgentRequestCredential}.
251
251
  AgentRequestCredential
252
252
 
253
+ /**
254
+ * One `exec()` call's wall-time breakdown on the Firecracker tier.
255
+ *
256
+ * The first four fields are the same four the kubernetes tier reports
257
+ * through `KubernetesTransportTiming` — this backend is the transport that
258
+ * tier WRAPS, so the phases are the same phases and deliberately carry the
259
+ * same names and the same meanings. The last three are what only this tier
260
+ * can see, because only this tier owns the socket that carries the execute
261
+ * round trip: the first reply frame, the zero-length terminator frame the
262
+ * guest writes when the command's process group is done, and the peer's own
263
+ * close after it.
264
+ *
265
+ * Durations are NOT a partition of a single total — `reserveMs` and
266
+ * `executeMs` each include their OWN dial, which is also folded into
267
+ * `dialMs`, and the three execute sub-phases are intervals INSIDE
268
+ * `executeMs`, all three measured from the moment the execute request was
269
+ * written to the socket. This is a diagnostic breakdown for attribution, not
270
+ * an accounting identity.
271
+ *
272
+ * The three sub-phases are ABSENT when the phase was never reached (a call
273
+ * that failed at the dial, or a stream that ended without a terminator),
274
+ * which is a different fact from a phase that measured 0 ms.
275
+ *
276
+ * Never carries a token, a command, its arguments, or any output.
277
+ */
278
+ export interface FirecrackerTransportTiming {
279
+ /**
280
+ * Total time spent establishing connections for this call.
281
+ *
282
+ * Counts ESTABLISHED connections only: a dial that never connected adds
283
+ * nothing here, exactly as it never fires
284
+ * {@link VsockTransportOptions.onDial}. The time such an attempt spent is
285
+ * inside the phase that asked for the connection (`reserveMs`,
286
+ * `executeMs`), and "a connection was never made" is what
287
+ * {@link VsockTransportOptions.onDialAttempt} paired with `onDial` says —
288
+ * not something this number can express.
289
+ */
290
+ readonly dialMs: number
291
+ /** Time spent on the `reserve-execution` round trip (dial included). */
292
+ readonly reserveMs: number
293
+ /** Time spent on the `execute` round trip (dial included). */
294
+ readonly executeMs: number
295
+ /**
296
+ * Time between the execute round trip settling and `exec()` itself
297
+ * resolving — this transport's own post-execute bookkeeping (clearing
298
+ * timers, tearing down the observation race). Always small on the happy
299
+ * path; distinct from `executeMs` because it is spent locally, after the
300
+ * peer has nothing left to do.
301
+ */
302
+ readonly drainMs: number
303
+ /**
304
+ * Request written → the guest FIRST SPOKE. The guest agent writes
305
+ * nothing until the command it spawned produces output or ends, so this
306
+ * interval carries the spawn and startup of the command plus whatever
307
+ * the guest does before that — and for a command that prints NOTHING it
308
+ * carries the command's whole runtime, because the first frame the host
309
+ * sees is then the terminal one. Read it against a command that talks
310
+ * early (`sh -c 'echo ok'`): the spawn latency lands here, and a wait on
311
+ * the guest's side before the command starts moves this number without
312
+ * moving `terminatorMs`' interval past it.
313
+ */
314
+ readonly firstFrameMs?: number
315
+ /**
316
+ * Request written → the zero-length terminator frame, which the guest
317
+ * writes when the command's process group is done and its output is
318
+ * flushed. The number that separates a cost the command paid from a cost
319
+ * the path paid is THIS interval minus `firstFrameMs` — the time the
320
+ * guest went on for after it first spoke. That is a fact about a command
321
+ * that produced output near its start; for a silent command the two
322
+ * intervals are nearly equal and both carry the runtime.
323
+ */
324
+ readonly terminatorMs?: number
325
+ /**
326
+ * Terminator seen → the peer's socket close — the peer's OWN close, and
327
+ * never one this transport caused itself. Time a relay in front of the
328
+ * guest spends holding the FIN (buffering it, or waiting for its own idle
329
+ * timer) lands here and nowhere else in this object, for as long as it
330
+ * lands UNDER {@link POST_RESPONSE_CLOSE_TIMEOUT_MS}.
331
+ *
332
+ * A hold at or past that bound does not appear here at all, and the field
333
+ * is ABSENT from the report: the call rejects on the guard, and a constant
334
+ * this transport chose is not a duration the peer took. A host diagnosing
335
+ * a slow close therefore reads this field when the call resolved, and the
336
+ * named rejection when it did not.
337
+ */
338
+ readonly peerCloseMs?: number
339
+ }
340
+
253
341
  export interface VsockTransportOptions {
254
342
  /** Per-attempt connect + handshake timeout. Default 5000ms. */
255
343
  readonly connectTimeoutMs?: number
@@ -310,6 +398,31 @@ export interface VsockTransportOptions {
310
398
  * vsock/mtls/unix arms are free to ignore it.
311
399
  */
312
400
  readonly onDialAttempt?: () => void
401
+ /**
402
+ * Fires once per completed `exec()` (and {@link VsockAgentTransport.execute})
403
+ * call — success or failure — with that call's wall-time breakdown, so a
404
+ * host can attribute an exec's wall clock to a phase without patching this
405
+ * package. The payload is exactly the numbers described on
406
+ * {@link FirecrackerTransportTiming}: never the token, a command, its
407
+ * arguments, or any output. An OBSERVER, like every other hook on these
408
+ * options: it cannot change the call's result, it is called after the
409
+ * call has settled, and a listener that throws is the listener's problem.
410
+ *
411
+ * Named `onExecTiming` rather than `onTiming` because these options are
412
+ * the BASE of `KubernetesTransportOptions`, which already spends the name
413
+ * `onTiming` on a payload of its own; one name for two different payloads
414
+ * on two transports is the kind of footgun this package refuses
415
+ * elsewhere. A consequence worth stating plainly: a wire belonging to the
416
+ * kubernetes tier INHERITS this field and never fires it, because that
417
+ * tier builds its own adapter and drives the shared transport through
418
+ * {@link VsockAgentTransport.executeStreamed}, not through `exec()`.
419
+ *
420
+ * Absent by default, and a host that sets nothing pays nothing: the
421
+ * ledger that carries these numbers is created only when this hook is
422
+ * set, and the same check that skips creating it skips every measurement
423
+ * that would have filled it.
424
+ */
425
+ readonly onExecTiming?: (timing: FirecrackerTransportTiming) => void
313
426
  /**
314
427
  * Fires once for every reply this transport reads that the guest
315
428
  * answered on an AUTHENTICATED basis — one control/file reply per
@@ -379,6 +492,45 @@ export interface VsockTransportOptions {
379
492
  readonly permanentDialFailure?: (error: unknown) => boolean
380
493
  }
381
494
 
495
+ /**
496
+ * The per-call accumulator behind {@link VsockTransportOptions.onExecTiming}.
497
+ *
498
+ * One instance per `exec()`/`execute()` call, closed over by that call's
499
+ * adapter and threaded into the dial and the execute round trip the adapter
500
+ * wraps — never a field on the transport, so two concurrent `exec()` calls
501
+ * on one transport cannot race on the same accumulator. That is the same
502
+ * reason the kubernetes tier builds its adapter per call rather than once.
503
+ *
504
+ * Every field is a millisecond duration except `executeSettledAt`, which is
505
+ * a `Date.now()` stamp the drain interval is measured back from, and `0`
506
+ * meaning "the execute round trip never settled".
507
+ */
508
+ interface ExecTimingLedger {
509
+ dialMs: number
510
+ reserveMs: number
511
+ executeMs: number
512
+ executeSettledAt: number
513
+ firstFrameMs?: number
514
+ terminatorMs?: number
515
+ peerCloseMs?: number
516
+ }
517
+
518
+ /** The reportable view of a ledger — see {@link FirecrackerTransportTiming}. */
519
+ function timingOf(ledger: ExecTimingLedger): FirecrackerTransportTiming {
520
+ return {
521
+ dialMs: ledger.dialMs,
522
+ reserveMs: ledger.reserveMs,
523
+ executeMs: ledger.executeMs,
524
+ drainMs: ledger.executeSettledAt > 0 ? Date.now() - ledger.executeSettledAt : 0,
525
+ // Spread conditionally, not set to a sentinel: "this phase was never
526
+ // reached" and "this phase took no measurable time" are different
527
+ // statements, and only an absent field says the first.
528
+ ...(ledger.firstFrameMs !== undefined ? { firstFrameMs: ledger.firstFrameMs } : {}),
529
+ ...(ledger.terminatorMs !== undefined ? { terminatorMs: ledger.terminatorMs } : {}),
530
+ ...(ledger.peerCloseMs !== undefined ? { peerCloseMs: ledger.peerCloseMs } : {}),
531
+ }
532
+ }
533
+
382
534
  const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
383
535
  const DEFAULT_CONNECT_RETRY_BUDGET_MS = 30_000
384
536
  const DEFAULT_CONNECT_RETRY_INTERVAL_MS = 100
@@ -389,6 +541,37 @@ const DEFAULT_EXECUTION_TIMEOUT_MS = 5 * 60_000
389
541
  // bounded TERM -> KILL confirmation window so a quiet but correctly
390
542
  // terminating command can still deliver its terminal frame and output tail.
391
543
  const EXECUTION_TRANSPORT_GRACE_MS = 10_000
544
+ /**
545
+ * How long a reply that has already TERMINATED waits for the peer's own close
546
+ * before this transport gives up on it. Three callers, all of them read-reply
547
+ * loops that resolve on that close: {@link VsockAgentTransport.request} (one
548
+ * control or file reply), {@link VsockAgentTransport.executeRaw} (an exec's
549
+ * zero-length terminator frame) and `streamFramedRequest` (a
550
+ * `read-file-stream`'s end frame).
551
+ *
552
+ * **A reject-only guard, and deliberately not a budget to spend.** It can
553
+ * only turn a socket whose peer never closes into a named failure
554
+ * (`exec peer did not close after terminator`, or the `stream`/`control`
555
+ * wordings); it can never resolve a call, because every resolution path in
556
+ * this file requires the peer's `close` event. Raising it therefore buys no
557
+ * slow success — it delays a diagnosis — and lowering it fails a peer that
558
+ * was merely slow to close. It is not a wait any caller is expected to pay:
559
+ * a call that resolves has paid a real close, and how long the peer took to
560
+ * deliver it is reported as
561
+ * {@link FirecrackerTransportTiming.peerCloseMs}.
562
+ *
563
+ * **The contract this states for a host-owned relay.** After writing the
564
+ * terminator frame the agent calls `socket.end()` — a half-close — and this
565
+ * transport resolves the call on the resulting `close`. A relay between the
566
+ * guest and this process that forwards the payload but holds the FIN (its
567
+ * own idle timer, a full-duplex buffering policy, a proxy that waits for the
568
+ * guest process to exit) transfers that wait onto every call: under this
569
+ * bound it is reported as `peerCloseMs`, and at or past it the call REJECTS
570
+ * by name, with no `peerCloseMs` in the report — the number a host would
571
+ * otherwise be reading is one this transport chose, and it does not supply
572
+ * it. A relay that forwards the FIN promptly costs the guest's RTT and
573
+ * nothing else.
574
+ */
392
575
  const POST_RESPONSE_CLOSE_TIMEOUT_MS = 1_000
393
576
  const MAX_TIMER_DELAY_MS = 2_147_483_647
394
577
 
@@ -731,9 +914,7 @@ export class VsockAgentTransport {
731
914
  */
732
915
  private guestFeatureList?: readonly string[]
733
916
  private readonly permanentDialFailure?: (error: unknown) => boolean
734
- private readonly executionController: RemoteExecutionController<
735
- Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>
736
- >
917
+ private readonly onExecTiming?: (timing: FirecrackerTransportTiming) => void
737
918
 
738
919
  constructor(handle: SandboxAgentHandle, options: VsockTransportOptions = {}) {
739
920
  this.handle = handle
@@ -753,29 +934,65 @@ export class VsockAgentTransport {
753
934
  this.writeFilePartBytes = Math.max(1, Math.floor(options.writeFilePartBytes))
754
935
  }
755
936
  this.permanentDialFailure = options.permanentDialFailure
756
- const adapter: RemoteExecutionAdapter<Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>> = {
937
+ this.onExecTiming = options.onExecTiming
938
+ }
939
+
940
+ /**
941
+ * The reserve-before-admission triple this transport hands the shared
942
+ * {@link RemoteExecutionController}, with `ledger` — when a timing hook
943
+ * asked for one — accumulating the phases it wraps.
944
+ *
945
+ * A FACTORY rather than a constructor-built field, because the ledger is
946
+ * per call: this transport holds no cross-call connection state (it dials
947
+ * fresh every time), so building an adapter per `exec()` is free and
948
+ * makes concurrent `exec()` calls correctly independent — each gets its
949
+ * own accumulator, with no shared mutable field for two in-flight calls
950
+ * to race on. Same arrangement, and the same reason, as the kubernetes
951
+ * tier's `KubernetesAgentTransport.exec`.
952
+ */
953
+ private executionAdapter(
954
+ ledger?: ExecTimingLedger,
955
+ ): RemoteExecutionAdapter<Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>> {
956
+ return {
757
957
  label: 'framed microVM agent',
758
- reserve: async (signal) => await this.reserveExecution(signal),
759
- cancel: async (executionId, signal) => await this.cancelExecution(executionId, signal),
760
- execute: async (executionId, command, argv, opts, signal, context) =>
761
- await this.executeRaw(
762
- {
763
- ...(executionId ? { executionId } : {}),
764
- command,
765
- args: argv ?? [],
766
- ...(opts?.cwd !== undefined ? { cwd: opts.cwd } : {}),
767
- ...(opts?.env !== undefined ? { env: opts.env } : {}),
768
- ...(opts?.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
769
- ...(context?.stdin !== undefined ? { stdin: context.stdin } : {}),
770
- ...(context?.maxOutputBytes !== undefined
771
- ? { maxOutputBytes: context.maxOutputBytes }
772
- : {}),
773
- },
774
- opts,
775
- signal,
776
- ),
958
+ reserve: async (signal) => {
959
+ if (ledger === undefined) return await this.reserveExecution(signal)
960
+ const startedAt = Date.now()
961
+ try {
962
+ return await this.reserveExecution(signal, ledger)
963
+ } finally {
964
+ ledger.reserveMs += Date.now() - startedAt
965
+ }
966
+ },
967
+ cancel: async (executionId, signal) =>
968
+ await this.cancelExecution(executionId, signal, ledger),
969
+ execute: async (executionId, command, argv, opts, signal, context) => {
970
+ const body: ExecRequest = {
971
+ ...(executionId ? { executionId } : {}),
972
+ command,
973
+ args: argv ?? [],
974
+ ...(opts?.cwd !== undefined ? { cwd: opts.cwd } : {}),
975
+ ...(opts?.env !== undefined ? { env: opts.env } : {}),
976
+ ...(opts?.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
977
+ ...(context?.stdin !== undefined ? { stdin: context.stdin } : {}),
978
+ ...(context?.maxOutputBytes !== undefined
979
+ ? { maxOutputBytes: context.maxOutputBytes }
980
+ : {}),
981
+ }
982
+ if (ledger === undefined) return await this.executeRaw(body, opts, signal)
983
+ const startedAt = Date.now()
984
+ try {
985
+ return await this.executeRaw(body, opts, signal, ledger)
986
+ } finally {
987
+ ledger.executeMs += Date.now() - startedAt
988
+ // Stamped in the `finally` so it is set on the failure path
989
+ // too: a drain interval is only worth reporting when the
990
+ // round trip settled, and "settled" includes "settled by
991
+ // rejecting".
992
+ ledger.executeSettledAt = Date.now()
993
+ }
994
+ },
777
995
  }
778
- this.executionController = new RemoteExecutionController(adapter)
779
996
  }
780
997
 
781
998
  /**
@@ -786,7 +1003,7 @@ export class VsockAgentTransport {
786
1003
  * {@link VsockTransportOptions.permanentDialFailure} says this particular
787
1004
  * failure is not one waiting will cure.
788
1005
  */
789
- private async dial(signal?: AbortSignal): Promise<net.Socket> {
1006
+ private async dial(signal?: AbortSignal, ledger?: ExecTimingLedger): Promise<net.Socket> {
790
1007
  const deadline = Date.now() + this.connectRetryBudgetMs
791
1008
  const dialStartedAt = Date.now()
792
1009
  let lastErr: unknown
@@ -799,7 +1016,13 @@ export class VsockAgentTransport {
799
1016
  // is precisely the case a watcher needs to hear about.
800
1017
  this.onDialAttempt?.()
801
1018
  const socket = await this.connectOnce(signal)
802
- this.onDial?.(Date.now() - dialStartedAt)
1019
+ const durationMs = Date.now() - dialStartedAt
1020
+ this.onDial?.(durationMs)
1021
+ // The same interval the hook above reports, folded into the
1022
+ // call's own ledger: a caller asking where an exec's wall time
1023
+ // went wants it as one number per call, and this dial belongs
1024
+ // to that call.
1025
+ if (ledger !== undefined) ledger.dialMs += durationMs
803
1026
  return socket
804
1027
  } catch (err) {
805
1028
  if (signal?.aborted) throw signal.reason
@@ -1094,10 +1317,26 @@ export class VsockAgentTransport {
1094
1317
  * is torn down rather than wedging the caller.
1095
1318
  */
1096
1319
  async request<T>(req: AgentRequest, signal?: AbortSignal): Promise<T> {
1320
+ return await this.requestFramed<T>(req, signal)
1321
+ }
1322
+
1323
+ /**
1324
+ * {@link request}, with the one thing the public signature has no place
1325
+ * for: the exec call's timing ledger, so the reserve round trip that
1326
+ * reaches the guest through this method is counted against the call that
1327
+ * paid for its dial. Private rather than a third parameter, because a
1328
+ * public method whose extra argument exists only for internal
1329
+ * instrumentation is a parameter a caller can only misuse.
1330
+ */
1331
+ private async requestFramed<T>(
1332
+ req: AgentRequest,
1333
+ signal?: AbortSignal,
1334
+ ledger?: ExecTimingLedger,
1335
+ ): Promise<T> {
1097
1336
  const envelope = this.withCredential(req)
1098
1337
  const payload = JSON.stringify(envelope)
1099
1338
  this.assertPreauthBudget(payload)
1100
- const socket = await this.dial(signal)
1339
+ const socket = await this.dial(signal, ledger)
1101
1340
  return await new Promise<T>((resolve, reject) => {
1102
1341
  const reader = new FrameReader()
1103
1342
  let settled = false
@@ -1178,12 +1417,22 @@ export class VsockAgentTransport {
1178
1417
  body: ExecRequest,
1179
1418
  opts?: SandboxExecOptions,
1180
1419
  signal?: AbortSignal,
1420
+ ledger?: ExecTimingLedger,
1181
1421
  ): Promise<SandboxExecResult> {
1182
1422
  const envelope = this.withCredential({ op: 'execute', body } satisfies AgentRequest)
1183
1423
  const payload = JSON.stringify(envelope)
1184
1424
  this.assertPreauthBudget(payload)
1185
- const socket = await this.dial(signal)
1425
+ const socket = await this.dial(signal, ledger)
1186
1426
  const start = Date.now()
1427
+ // The instant the request goes on the wire. The three execute
1428
+ // sub-phases of {@link FirecrackerTransportTiming} are measured from
1429
+ // here, so they answer "how long after the guest was asked" rather
1430
+ // than "how long after this call began" — the dial that precedes it
1431
+ // is already counted in `dialMs` and inside `executeMs`.
1432
+ let writtenAt = 0
1433
+ // Stamped once, when the terminator frame is parsed, so `peerCloseMs`
1434
+ // measures the peer's close and not the whole round trip.
1435
+ let terminatorAt = 0
1187
1436
  return await new Promise<SandboxExecResult>((resolve, reject) => {
1188
1437
  const reader = new FrameReader()
1189
1438
  const acc = new ExecResultAccumulator(start, opts?.onOutput)
@@ -1230,6 +1479,12 @@ export class VsockAgentTransport {
1230
1479
  finish(err instanceof Error ? err : new Error(String(err)))
1231
1480
  return
1232
1481
  }
1482
+ // Recorded before the terminator check below and before any
1483
+ // parsing: the first frame ARRIVED, which stays true even if
1484
+ // this call goes on to reject over the frame's contents.
1485
+ if (ledger !== undefined && ledger.firstFrameMs === undefined && frames.length > 0) {
1486
+ ledger.firstFrameMs = Date.now() - writtenAt
1487
+ }
1233
1488
  for (const payload of frames) {
1234
1489
  if (terminated) {
1235
1490
  finish(new Error('vsock transport: exec stream emitted data after its terminator'))
@@ -1252,6 +1507,14 @@ export class VsockAgentTransport {
1252
1507
  }
1253
1508
  }
1254
1509
  if (terminated) {
1510
+ // The terminator WAS read, so it is reported even if this
1511
+ // call is about to reject over what followed it: the
1512
+ // question these numbers answer is where the time went,
1513
+ // not whether the call ended well.
1514
+ if (ledger !== undefined && ledger.terminatorMs === undefined) {
1515
+ terminatorAt = Date.now()
1516
+ ledger.terminatorMs = terminatorAt - writtenAt
1517
+ }
1255
1518
  if (reader.bufferedBytes > 0) {
1256
1519
  finish(new Error('vsock transport: exec stream has trailing partial data'))
1257
1520
  return
@@ -1266,14 +1529,31 @@ export class VsockAgentTransport {
1266
1529
  })
1267
1530
  socket.once('error', (err) => finish(err))
1268
1531
  socket.once('close', () => {
1269
- if (terminated && terminalResult) finish(null, terminalResult)
1270
- else finish(new Error('vsock transport: socket closed before exec stream terminator'))
1532
+ // A close that arrives with this call ALREADY settled is one
1533
+ // this transport caused itself: `finish` is the only writer of
1534
+ // `settled` and its next statement is `socket.destroy()`, so
1535
+ // the guard timer, the observation timer and the caller's abort
1536
+ // each destroy the socket and then react to the `close` that
1537
+ // destroy emits. Crediting the peer for it would report
1538
+ // {@link POST_RESPONSE_CLOSE_TIMEOUT_MS} — a constant this
1539
+ // transport chose — as a duration the peer took, in exactly the
1540
+ // case a host is reading the field to diagnose. The same rule
1541
+ // `readFileFrames` applies in the same position: an end this
1542
+ // side already reached is not news.
1543
+ if (settled) return
1544
+ if (terminated && terminalResult) {
1545
+ if (ledger !== undefined && terminatorAt > 0) {
1546
+ ledger.peerCloseMs = Date.now() - terminatorAt
1547
+ }
1548
+ finish(null, terminalResult)
1549
+ } else finish(new Error('vsock transport: socket closed before exec stream terminator'))
1271
1550
  })
1272
1551
  if (signal?.aborted) {
1273
1552
  abort()
1274
1553
  return
1275
1554
  }
1276
1555
  signal?.addEventListener('abort', abort, { once: true })
1556
+ writtenAt = Date.now()
1277
1557
  socket.write(frame(payload))
1278
1558
  })
1279
1559
  }
@@ -1294,7 +1574,7 @@ export class VsockAgentTransport {
1294
1574
  'VsockAgentTransport.execute does not accept caller-owned execution ids',
1295
1575
  )
1296
1576
  }
1297
- return await this.executionController.exec(
1577
+ return await this.runExec(
1298
1578
  body.command,
1299
1579
  body.args ? [...body.args] : undefined,
1300
1580
  {
@@ -1316,7 +1596,69 @@ export class VsockAgentTransport {
1316
1596
  argv?: string[],
1317
1597
  opts?: SandboxExecOptions,
1318
1598
  ): Promise<SandboxExecResult> {
1319
- return await this.executionController.exec(command, argv, opts)
1599
+ return await this.runExec(command, argv, opts)
1600
+ }
1601
+
1602
+ /**
1603
+ * One command through a call-scoped adapter + controller, reporting the
1604
+ * call's phases to {@link VsockTransportOptions.onExecTiming} when a host
1605
+ * asked for them.
1606
+ *
1607
+ * The ledger is created HERE and nowhere else — because it is per call,
1608
+ * not per transport, so two concurrent `exec()` calls each get their own
1609
+ * accumulator. Constructing a controller per call costs an allocation and
1610
+ * nothing else: the controller's only per-instance state is its readonly
1611
+ * configuration, so a fresh one behaves exactly like a shared one, and
1612
+ * this transport dials fresh per request either way.
1613
+ *
1614
+ * The hook fires in a `finally`, on the failure paths as well as the
1615
+ * happy one: an exec that rejected after its reserve round trip is
1616
+ * precisely the call whose phase breakdown a host needs. Measuring into a
1617
+ * ledger that nobody reads is what "no hook, no cost" means here — with
1618
+ * no hook there is no ledger, and the `undefined` checks in `dial` and
1619
+ * `executeRaw` skip the clock reads entirely.
1620
+ */
1621
+ private async runExec(
1622
+ command: string,
1623
+ argv: string[] | undefined,
1624
+ opts: SandboxExecOptions | undefined,
1625
+ context?: Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>,
1626
+ ): Promise<SandboxExecResult> {
1627
+ const hook = this.onExecTiming
1628
+ if (hook === undefined) {
1629
+ return await new RemoteExecutionController(this.executionAdapter()).exec(
1630
+ command,
1631
+ argv,
1632
+ opts,
1633
+ context,
1634
+ )
1635
+ }
1636
+ const ledger: ExecTimingLedger = {
1637
+ dialMs: 0,
1638
+ reserveMs: 0,
1639
+ executeMs: 0,
1640
+ executeSettledAt: 0,
1641
+ }
1642
+ try {
1643
+ return await new RemoteExecutionController(this.executionAdapter(ledger)).exec(
1644
+ command,
1645
+ argv,
1646
+ opts,
1647
+ context,
1648
+ )
1649
+ } finally {
1650
+ // Caught, not propagated: this hook is an OBSERVER, and a listener
1651
+ // that throws must not turn a command that ran into a caller's
1652
+ // exception — the rule {@link VsockTransportOptions.onGuestReply}
1653
+ // already states for this transport's other observers. A `finally`
1654
+ // that let it through would replace the call's own error with the
1655
+ // listener's, which is the worst version of that failure.
1656
+ try {
1657
+ hook(timingOf(ledger))
1658
+ } catch {
1659
+ // The listener's problem, and only the listener's.
1660
+ }
1661
+ }
1320
1662
  }
1321
1663
 
1322
1664
  /**
@@ -1340,10 +1682,11 @@ export class VsockAgentTransport {
1340
1682
  return await this.executeRaw(body, opts, signal)
1341
1683
  }
1342
1684
 
1343
- private async reserveExecution(signal: AbortSignal): Promise<unknown> {
1344
- const response = await this.request<Record<string, unknown>>(
1685
+ private async reserveExecution(signal: AbortSignal, ledger?: ExecTimingLedger): Promise<unknown> {
1686
+ const response = await this.requestFramed<Record<string, unknown>>(
1345
1687
  { op: 'reserve-execution' },
1346
1688
  signal,
1689
+ ledger,
1347
1690
  )
1348
1691
  if (
1349
1692
  response.ok === false &&
@@ -1362,8 +1705,16 @@ export class VsockAgentTransport {
1362
1705
  return response
1363
1706
  }
1364
1707
 
1365
- private async cancelExecution(executionId: string, signal: AbortSignal): Promise<unknown> {
1366
- return await this.request<unknown>({ op: 'cancel-execution', body: { executionId } }, signal)
1708
+ private async cancelExecution(
1709
+ executionId: string,
1710
+ signal: AbortSignal,
1711
+ ledger?: ExecTimingLedger,
1712
+ ): Promise<unknown> {
1713
+ return await this.requestFramed<unknown>(
1714
+ { op: 'cancel-execution', body: { executionId } },
1715
+ signal,
1716
+ ledger,
1717
+ )
1367
1718
  }
1368
1719
 
1369
1720
  /** Readiness probe. A healthy guest must also speak the exact host protocol. */