@namzu/sandbox 16.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.
- package/CHANGELOG.md +238 -0
- package/README.md +164 -0
- package/dist/backends/aci-standby-pool/index.d.ts +22 -4
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +31 -7
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/index.d.ts +243 -24
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +717 -108
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +156 -1
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +223 -29
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/http-worker-client.d.ts +64 -2
- package/dist/backends/http-worker-client.d.ts.map +1 -1
- package/dist/backends/http-worker-client.js +78 -7
- package/dist/backends/http-worker-client.js.map +1 -1
- package/dist/backends/kubernetes/transport.d.ts +7 -0
- package/dist/backends/kubernetes/transport.d.ts.map +1 -1
- package/dist/backends/kubernetes/transport.js.map +1 -1
- package/dist/egress/proxy.d.ts +47 -2
- package/dist/egress/proxy.d.ts.map +1 -1
- package/dist/egress/proxy.js +31 -7
- package/dist/egress/proxy.js.map +1 -1
- package/dist/index.d.ts +67 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/backends/aci-standby-pool/index.ts +37 -7
- package/src/backends/docker/index.ts +909 -126
- package/src/backends/firecracker/transport.ts +387 -36
- package/src/backends/http-worker-client.ts +89 -5
- package/src/backends/kubernetes/transport.ts +7 -0
- package/src/egress/proxy.ts +65 -8
- package/src/index.ts +89 -0
|
@@ -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
|
|
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
|
-
|
|
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) =>
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
opts,
|
|
775
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1270
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
1366
|
-
|
|
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. */
|