@namzu/sandbox 6.1.0 → 7.1.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 (49) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +29 -1
  3. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  4. package/dist/backends/aci-standby-pool/index.js +163 -111
  5. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  6. package/dist/backends/docker/index.d.ts.map +1 -1
  7. package/dist/backends/docker/index.js +212 -149
  8. package/dist/backends/docker/index.js.map +1 -1
  9. package/dist/backends/firecracker/index.d.ts.map +1 -1
  10. package/dist/backends/firecracker/index.js +154 -71
  11. package/dist/backends/firecracker/index.js.map +1 -1
  12. package/dist/backends/firecracker/protocol.d.ts +19 -27
  13. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  14. package/dist/backends/firecracker/protocol.js +57 -32
  15. package/dist/backends/firecracker/protocol.js.map +1 -1
  16. package/dist/backends/firecracker/transport.d.ts +26 -7
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +235 -72
  19. package/dist/backends/firecracker/transport.js.map +1 -1
  20. package/dist/backends/http-worker-client.d.ts +17 -0
  21. package/dist/backends/http-worker-client.d.ts.map +1 -0
  22. package/dist/backends/http-worker-client.js +173 -0
  23. package/dist/backends/http-worker-client.js.map +1 -0
  24. package/dist/backends/readiness.d.ts +43 -0
  25. package/dist/backends/readiness.d.ts.map +1 -0
  26. package/dist/backends/readiness.js +150 -0
  27. package/dist/backends/readiness.js.map +1 -0
  28. package/dist/backends/remote-execution-controller.d.ts +76 -0
  29. package/dist/backends/remote-execution-controller.d.ts.map +1 -0
  30. package/dist/backends/remote-execution-controller.js +294 -0
  31. package/dist/backends/remote-execution-controller.js.map +1 -0
  32. package/dist/egress/proxy.d.ts.map +1 -1
  33. package/dist/egress/proxy.js +20 -3
  34. package/dist/egress/proxy.js.map +1 -1
  35. package/dist/index.d.ts +18 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +9 -0
  38. package/dist/index.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/backends/aci-standby-pool/index.ts +199 -117
  41. package/src/backends/docker/index.ts +235 -175
  42. package/src/backends/firecracker/index.ts +163 -63
  43. package/src/backends/firecracker/protocol.ts +67 -33
  44. package/src/backends/firecracker/transport.ts +301 -77
  45. package/src/backends/http-worker-client.ts +230 -0
  46. package/src/backends/readiness.ts +173 -0
  47. package/src/backends/remote-execution-controller.ts +462 -0
  48. package/src/egress/proxy.ts +18 -3
  49. package/src/index.ts +27 -0
@@ -56,7 +56,15 @@
56
56
  import net from 'node:net'
57
57
  import tls from 'node:tls'
58
58
 
59
- import type { SandboxExecResult } from '@namzu/sdk'
59
+ import type { SandboxExecOptions, SandboxExecResult } from '@namzu/sdk'
60
+ import { OperationDeadline, OperationDeadlineExpired } from '../readiness.js'
61
+ import {
62
+ RemoteCancellationUnknownError,
63
+ RemoteCancellationUnsupportedError,
64
+ type RemoteExecutionAdapter,
65
+ RemoteExecutionController,
66
+ RemoteProtocolError,
67
+ } from '../remote-execution-controller.js'
60
68
  import {
61
69
  type ExecRequest,
62
70
  ExecResultAccumulator,
@@ -155,6 +163,11 @@ export type WireSandboxAgentHandle =
155
163
  */
156
164
  export type AgentRequest =
157
165
  | { readonly op: 'execute'; readonly body: ExecRequest }
166
+ | { readonly op: 'reserve-execution' }
167
+ | {
168
+ readonly op: 'cancel-execution'
169
+ readonly body: { readonly executionId: string }
170
+ }
158
171
  | { readonly op: 'read-file'; readonly body: ReadFileRequest }
159
172
  | { readonly op: 'write-file'; readonly body: WriteFileRequest }
160
173
  | { readonly op: 'healthz' }
@@ -180,6 +193,14 @@ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
180
193
  const DEFAULT_CONNECT_RETRY_BUDGET_MS = 30_000
181
194
  const DEFAULT_CONNECT_RETRY_INTERVAL_MS = 100
182
195
  const DEFAULT_READ_IDLE_TIMEOUT_MS = 60_000
196
+ const DEFAULT_EXECUTION_TIMEOUT_MS = 5 * 60_000
197
+ // The ownership controller begins reconciliation shortly after the requested
198
+ // command timeout. The data socket itself stays observable for the peer's
199
+ // bounded TERM -> KILL confirmation window so a quiet but correctly
200
+ // terminating command can still deliver its terminal frame and output tail.
201
+ const EXECUTION_TRANSPORT_GRACE_MS = 10_000
202
+ const POST_RESPONSE_CLOSE_TIMEOUT_MS = 1_000
203
+ const MAX_TIMER_DELAY_MS = 2_147_483_647
183
204
 
184
205
  /** Framing: 8 hex digits of payload byte length, then `\n`, then payload. */
185
206
  const LENGTH_PREFIX_HEX = 8
@@ -214,6 +235,9 @@ class FrameReader {
214
235
  break
215
236
  }
216
237
  const header = this.buf.subarray(0, nl).toString('ascii')
238
+ if (!/^[0-9a-fA-F]{8}$/.test(header)) {
239
+ throw new Error(`vsock transport: invalid frame length header ${JSON.stringify(header)}`)
240
+ }
217
241
  const len = Number.parseInt(header, 16)
218
242
  if (!Number.isInteger(len) || len < 0) {
219
243
  throw new Error(`vsock transport: invalid frame length header ${JSON.stringify(header)}`)
@@ -226,13 +250,17 @@ class FrameReader {
226
250
  }
227
251
  return out
228
252
  }
253
+
254
+ get bufferedBytes(): number {
255
+ return this.buf.length
256
+ }
229
257
  }
230
258
 
231
259
  /**
232
260
  * The transport. One instance per sandbox handle; every request opens
233
261
  * a fresh connection (resume-survivable — no socket lingers across a
234
- * resume to be silently severed). All four ops + the heartbeat go
235
- * through {@link request} / {@link execute}.
262
+ * resume to be silently severed). Execution reservation, data, cancellation,
263
+ * file I/O and heartbeat all use independent calls through this dialer.
236
264
  */
237
265
  export class VsockAgentTransport {
238
266
  private readonly handle: SandboxAgentHandle
@@ -240,6 +268,9 @@ export class VsockAgentTransport {
240
268
  private readonly connectRetryBudgetMs: number
241
269
  private readonly connectRetryIntervalMs: number
242
270
  private readonly readIdleTimeoutMs: number
271
+ private readonly executionController: RemoteExecutionController<
272
+ Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>
273
+ >
243
274
 
244
275
  constructor(handle: SandboxAgentHandle, options: VsockTransportOptions = {}) {
245
276
  this.handle = handle
@@ -248,6 +279,29 @@ export class VsockAgentTransport {
248
279
  this.connectRetryIntervalMs =
249
280
  options.connectRetryIntervalMs ?? DEFAULT_CONNECT_RETRY_INTERVAL_MS
250
281
  this.readIdleTimeoutMs = options.readIdleTimeoutMs ?? DEFAULT_READ_IDLE_TIMEOUT_MS
282
+ const adapter: RemoteExecutionAdapter<Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>> = {
283
+ label: 'framed microVM agent',
284
+ reserve: async (signal) => await this.reserveExecution(signal),
285
+ cancel: async (executionId, signal) => await this.cancelExecution(executionId, signal),
286
+ execute: async (executionId, command, argv, opts, signal, context) =>
287
+ await this.executeRaw(
288
+ {
289
+ ...(executionId ? { executionId } : {}),
290
+ command,
291
+ args: argv ?? [],
292
+ ...(opts?.cwd !== undefined ? { cwd: opts.cwd } : {}),
293
+ ...(opts?.env !== undefined ? { env: opts.env } : {}),
294
+ ...(opts?.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
295
+ ...(context?.stdin !== undefined ? { stdin: context.stdin } : {}),
296
+ ...(context?.maxOutputBytes !== undefined
297
+ ? { maxOutputBytes: context.maxOutputBytes }
298
+ : {}),
299
+ },
300
+ opts,
301
+ signal,
302
+ ),
303
+ }
304
+ this.executionController = new RemoteExecutionController(adapter)
251
305
  }
252
306
 
253
307
  /**
@@ -256,16 +310,18 @@ export class VsockAgentTransport {
256
310
  * failures (ECONNREFUSED while the agent re-listens after a resume,
257
311
  * a dropped CONNECT ack) until the budget is exhausted.
258
312
  */
259
- private async dial(): Promise<net.Socket> {
313
+ private async dial(signal?: AbortSignal): Promise<net.Socket> {
260
314
  const deadline = Date.now() + this.connectRetryBudgetMs
261
315
  let lastErr: unknown
262
316
  for (;;) {
317
+ signal?.throwIfAborted()
263
318
  try {
264
- return await this.connectOnce()
319
+ return await this.connectOnce(signal)
265
320
  } catch (err) {
321
+ if (signal?.aborted) throw signal.reason
266
322
  lastErr = err
267
323
  if (Date.now() >= deadline) break
268
- await delay(this.connectRetryIntervalMs)
324
+ await delay(this.connectRetryIntervalMs, signal)
269
325
  }
270
326
  }
271
327
  throw new Error(
@@ -276,36 +332,41 @@ export class VsockAgentTransport {
276
332
  )
277
333
  }
278
334
 
279
- private connectOnce(): Promise<net.Socket> {
335
+ private connectOnce(signal?: AbortSignal): Promise<net.Socket> {
280
336
  const handle = this.handle
281
- if (handle.kind === 'mtls') return this.connectOnceMtls(handle)
337
+ if (handle.kind === 'mtls') return this.connectOnceMtls(handle, signal)
282
338
  return new Promise<net.Socket>((resolve, reject) => {
283
339
  const path = handle.kind === 'unix' ? handle.path : handle.udsPath
284
340
  const socket = net.connect({ path })
285
341
  let settled = false
286
- const timer = setTimeout(() => {
287
- if (settled) return
288
- settled = true
289
- socket.destroy()
290
- reject(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`))
291
- }, this.connectTimeoutMs)
292
- timer.unref()
293
-
294
342
  const fail = (err: Error) => {
295
343
  if (settled) return
296
344
  settled = true
297
345
  clearTimeout(timer)
346
+ signal?.removeEventListener('abort', abort)
298
347
  socket.destroy()
299
348
  reject(err)
300
349
  }
350
+ const abort = () => fail(signalError(signal))
351
+ const timer = setTimeout(
352
+ () => fail(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`)),
353
+ this.connectTimeoutMs,
354
+ )
355
+ timer.unref()
301
356
 
302
357
  socket.once('error', fail)
358
+ if (signal?.aborted) {
359
+ abort()
360
+ return
361
+ }
362
+ signal?.addEventListener('abort', abort, { once: true })
303
363
 
304
364
  socket.once('connect', () => {
305
365
  if (handle.kind === 'unix') {
306
366
  if (settled) return
307
367
  settled = true
308
368
  clearTimeout(timer)
369
+ signal?.removeEventListener('abort', abort)
309
370
  socket.removeListener('error', fail)
310
371
  resolve(socket)
311
372
  return
@@ -327,6 +388,7 @@ export class VsockAgentTransport {
327
388
  if (settled) return
328
389
  settled = true
329
390
  clearTimeout(timer)
391
+ signal?.removeEventListener('abort', abort)
330
392
  socket.removeListener('error', fail)
331
393
  // Any bytes the ackReader over-read after the ack line are
332
394
  // application framing; replay them into the caller.
@@ -363,6 +425,7 @@ export class VsockAgentTransport {
363
425
  */
364
426
  private connectOnceMtls(
365
427
  handle: Extract<SandboxAgentHandle, { kind: 'mtls' }>,
428
+ signal?: AbortSignal,
366
429
  ): Promise<net.Socket> {
367
430
  return new Promise<net.Socket>((resolve, reject) => {
368
431
  const socket = tls.connect({
@@ -376,23 +439,27 @@ export class VsockAgentTransport {
376
439
  minVersion: 'TLSv1.3',
377
440
  })
378
441
  let settled = false
379
- const timer = setTimeout(() => {
380
- if (settled) return
381
- settled = true
382
- socket.destroy()
383
- reject(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`))
384
- }, this.connectTimeoutMs)
385
- timer.unref()
386
-
387
442
  const fail = (err: Error) => {
388
443
  if (settled) return
389
444
  settled = true
390
445
  clearTimeout(timer)
446
+ signal?.removeEventListener('abort', abort)
391
447
  socket.destroy()
392
448
  reject(err)
393
449
  }
450
+ const abort = () => fail(signalError(signal))
451
+ const timer = setTimeout(
452
+ () => fail(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`)),
453
+ this.connectTimeoutMs,
454
+ )
455
+ timer.unref()
394
456
 
395
457
  socket.once('error', fail)
458
+ if (signal?.aborted) {
459
+ abort()
460
+ return
461
+ }
462
+ signal?.addEventListener('abort', abort, { once: true })
396
463
 
397
464
  // `secureConnect` fires only after the cert chain is verified
398
465
  // (rejectUnauthorized rejects a bad/missing-CA server via 'error'
@@ -411,6 +478,7 @@ export class VsockAgentTransport {
411
478
  }
412
479
  settled = true
413
480
  clearTimeout(timer)
481
+ signal?.removeEventListener('abort', abort)
414
482
  socket.removeListener('error', fail)
415
483
  // Routing preamble — the host-relay analogue of the vsock
416
484
  // `CONNECT <port>` line. The relay consumes it, resolves the
@@ -427,27 +495,33 @@ export class VsockAgentTransport {
427
495
  * healthz). Applies the read-idle timeout so a post-resume hung read
428
496
  * is torn down rather than wedging the caller.
429
497
  */
430
- async request<T>(req: AgentRequest): Promise<T> {
431
- const socket = await this.dial()
498
+ async request<T>(req: AgentRequest, signal?: AbortSignal): Promise<T> {
499
+ const socket = await this.dial(signal)
432
500
  return await new Promise<T>((resolve, reject) => {
433
501
  const reader = new FrameReader()
434
502
  let settled = false
435
- const idle = new IdleTimer(this.readIdleTimeoutMs, () => {
436
- if (settled) return
437
- settled = true
438
- socket.destroy()
439
- reject(new Error(`vsock transport: read idle timeout after ${this.readIdleTimeoutMs}ms`))
440
- })
503
+ let response: T | undefined
504
+ let closeTimer: ReturnType<typeof setTimeout> | undefined
441
505
  const finish = (err: Error | null, value?: T) => {
442
506
  if (settled) return
443
507
  settled = true
444
508
  idle.clear()
509
+ if (closeTimer) clearTimeout(closeTimer)
510
+ signal?.removeEventListener('abort', abort)
445
511
  socket.destroy()
446
512
  if (err) reject(err)
447
513
  else resolve(value as T)
448
514
  }
515
+ const abort = () => finish(signalError(signal))
516
+ const idle = new IdleTimer(this.readIdleTimeoutMs, () =>
517
+ finish(new Error(`vsock transport: read idle timeout after ${this.readIdleTimeoutMs}ms`)),
518
+ )
449
519
  socket.on('data', (chunk: Buffer) => {
450
520
  idle.bump()
521
+ if (response !== undefined) {
522
+ finish(new Error('vsock transport: control reply emitted data after its response'))
523
+ return
524
+ }
451
525
  let frames: string[]
452
526
  try {
453
527
  frames = reader.push(chunk)
@@ -455,17 +529,39 @@ export class VsockAgentTransport {
455
529
  finish(err instanceof Error ? err : new Error(String(err)))
456
530
  return
457
531
  }
532
+ if (frames.length > 1) {
533
+ finish(new Error('vsock transport: control reply emitted multiple frames'))
534
+ return
535
+ }
458
536
  const first = frames[0]
459
537
  if (first !== undefined) {
460
538
  try {
461
- finish(null, JSON.parse(first) as T)
539
+ response = JSON.parse(first) as T
540
+ if (reader.bufferedBytes > 0) {
541
+ finish(new Error('vsock transport: control reply has trailing partial data'))
542
+ return
543
+ }
544
+ idle.clear()
545
+ closeTimer = setTimeout(
546
+ () => finish(new Error('vsock transport: control peer did not close after reply')),
547
+ POST_RESPONSE_CLOSE_TIMEOUT_MS,
548
+ )
549
+ closeTimer.unref()
462
550
  } catch (err) {
463
551
  finish(err instanceof Error ? err : new Error(String(err)))
464
552
  }
465
553
  }
466
554
  })
467
555
  socket.once('error', (err) => finish(err))
468
- socket.once('close', () => finish(new Error('vsock transport: socket closed before reply')))
556
+ socket.once('close', () => {
557
+ if (response !== undefined) finish(null, response)
558
+ else finish(new Error('vsock transport: socket closed before reply'))
559
+ })
560
+ if (signal?.aborted) {
561
+ abort()
562
+ return
563
+ }
564
+ signal?.addEventListener('abort', abort, { once: true })
469
565
  idle.bump()
470
566
  socket.write(frame(JSON.stringify(req)))
471
567
  })
@@ -476,31 +572,52 @@ export class VsockAgentTransport {
476
572
  * {@link SandboxExecResult} via the shared {@link ExecResultAccumulator}.
477
573
  * The agent terminates the stream with a zero-length frame.
478
574
  */
479
- async execute(body: ExecRequest): Promise<SandboxExecResult> {
480
- const socket = await this.dial()
575
+ private async executeRaw(
576
+ body: ExecRequest,
577
+ opts?: SandboxExecOptions,
578
+ signal?: AbortSignal,
579
+ ): Promise<SandboxExecResult> {
580
+ const socket = await this.dial(signal)
481
581
  const start = Date.now()
482
582
  return await new Promise<SandboxExecResult>((resolve, reject) => {
483
583
  const reader = new FrameReader()
484
- const acc = new ExecResultAccumulator(start)
584
+ const acc = new ExecResultAccumulator(start, opts?.onOutput)
485
585
  let settled = false
486
- const idle = new IdleTimer(this.readIdleTimeoutMs, () => {
487
- if (settled) return
488
- settled = true
489
- socket.destroy()
490
- reject(
491
- new Error(`vsock transport: exec read idle timeout after ${this.readIdleTimeoutMs}ms`),
492
- )
493
- })
586
+ let terminated = false
587
+ let terminalResult: SandboxExecResult | undefined
588
+ let closeTimer: ReturnType<typeof setTimeout> | undefined
589
+ const requestedTimeout =
590
+ typeof body.timeoutMs === 'number' && Number.isFinite(body.timeoutMs) && body.timeoutMs > 0
591
+ ? body.timeoutMs
592
+ : DEFAULT_EXECUTION_TIMEOUT_MS
593
+ const observationTimeoutMs = Math.min(
594
+ MAX_TIMER_DELAY_MS,
595
+ requestedTimeout + EXECUTION_TRANSPORT_GRACE_MS,
596
+ )
494
597
  const finish = (err: Error | null, value?: SandboxExecResult) => {
495
598
  if (settled) return
496
599
  settled = true
497
- idle.clear()
600
+ clearTimeout(observationTimer)
601
+ if (closeTimer) clearTimeout(closeTimer)
602
+ signal?.removeEventListener('abort', abort)
498
603
  socket.destroy()
499
604
  if (err) reject(err)
500
605
  else resolve(value as SandboxExecResult)
501
606
  }
607
+ const abort = () => finish(signalError(signal))
608
+ const observationTimer = setTimeout(
609
+ () =>
610
+ finish(
611
+ new Error(`vsock transport: execution observation exceeded ${observationTimeoutMs}ms`),
612
+ ),
613
+ observationTimeoutMs,
614
+ )
615
+ observationTimer.unref()
502
616
  socket.on('data', (chunk: Buffer) => {
503
- idle.bump()
617
+ if (terminated) {
618
+ finish(new Error('vsock transport: exec stream emitted data after its terminator'))
619
+ return
620
+ }
504
621
  let frames: string[]
505
622
  try {
506
623
  frames = reader.push(chunk)
@@ -509,49 +626,127 @@ export class VsockAgentTransport {
509
626
  return
510
627
  }
511
628
  for (const payload of frames) {
512
- if (payload.length === 0) {
513
- // Zero-length terminator. If a result was seen, we are
514
- // done; otherwise the stream ended without a result.
515
- finish(
516
- acc.done ? null : new Error('exec stream ended without a result event'),
517
- acc.finish(),
518
- )
629
+ if (terminated) {
630
+ finish(new Error('vsock transport: exec stream emitted data after its terminator'))
519
631
  return
520
632
  }
521
- const event = parseExecLine(payload)
522
- if (!event) continue // malformed line — swallow (docker parity)
523
- try {
524
- if (acc.push(event)) {
525
- // Terminal result seen; wait for terminator but we can
526
- // resolve now — the agent closes after the terminator.
633
+ if (payload.length === 0) {
634
+ if (!acc.done) {
635
+ finish(new Error('exec stream ended without a result event'))
636
+ return
527
637
  }
638
+ terminated = true
639
+ continue
640
+ }
641
+ try {
642
+ const event = parseExecLine(payload)
643
+ if (event) acc.push(event)
528
644
  } catch (err) {
529
645
  finish(err instanceof Error ? err : new Error(String(err)))
530
646
  return
531
647
  }
532
648
  }
649
+ if (terminated) {
650
+ if (reader.bufferedBytes > 0) {
651
+ finish(new Error('vsock transport: exec stream has trailing partial data'))
652
+ return
653
+ }
654
+ terminalResult = acc.finish()
655
+ closeTimer = setTimeout(
656
+ () => finish(new Error('vsock transport: exec peer did not close after terminator')),
657
+ POST_RESPONSE_CLOSE_TIMEOUT_MS,
658
+ )
659
+ closeTimer.unref()
660
+ }
533
661
  })
534
662
  socket.once('error', (err) => finish(err))
535
663
  socket.once('close', () => {
536
- // Stream closed. If a result arrived, deliver it (some agents
537
- // close right after the terminator without a separate event);
538
- // otherwise it is a truncated stream.
539
- finish(
540
- acc.done ? null : new Error('vsock transport: socket closed before exec result'),
541
- acc.finish(),
542
- )
664
+ if (terminated && terminalResult) finish(null, terminalResult)
665
+ else finish(new Error('vsock transport: socket closed before exec stream terminator'))
543
666
  })
544
- idle.bump()
667
+ if (signal?.aborted) {
668
+ abort()
669
+ return
670
+ }
671
+ signal?.addEventListener('abort', abort, { once: true })
545
672
  socket.write(frame(JSON.stringify({ op: 'execute', body } satisfies AgentRequest)))
546
673
  })
547
674
  }
548
675
 
676
+ /**
677
+ * Compatibility request-shaped entry point. It now enters the same
678
+ * reserve-before-admission controller as {@link exec}; the raw data-plane
679
+ * primitive is deliberately private so aborting this public method cannot
680
+ * abandon a live guest command.
681
+ */
682
+ async execute(
683
+ body: ExecRequest,
684
+ opts?: SandboxExecOptions,
685
+ signal?: AbortSignal,
686
+ ): Promise<SandboxExecResult> {
687
+ if (body.executionId !== undefined) {
688
+ throw new RemoteProtocolError(
689
+ 'VsockAgentTransport.execute does not accept caller-owned execution ids',
690
+ )
691
+ }
692
+ return await this.executionController.exec(
693
+ body.command,
694
+ body.args ? [...body.args] : undefined,
695
+ {
696
+ ...opts,
697
+ ...(body.cwd !== undefined ? { cwd: body.cwd } : {}),
698
+ ...(body.env !== undefined ? { env: body.env } : {}),
699
+ ...(body.timeoutMs !== undefined ? { timeout: body.timeoutMs } : {}),
700
+ ...(opts?.signal === undefined && signal !== undefined ? { signal } : {}),
701
+ },
702
+ {
703
+ ...(body.stdin !== undefined ? { stdin: body.stdin } : {}),
704
+ ...(body.maxOutputBytes !== undefined ? { maxOutputBytes: body.maxOutputBytes } : {}),
705
+ },
706
+ )
707
+ }
708
+
709
+ async exec(
710
+ command: string,
711
+ argv?: string[],
712
+ opts?: SandboxExecOptions,
713
+ ): Promise<SandboxExecResult> {
714
+ return await this.executionController.exec(command, argv, opts)
715
+ }
716
+
717
+ private async reserveExecution(signal: AbortSignal): Promise<unknown> {
718
+ const response = await this.request<Record<string, unknown>>(
719
+ { op: 'reserve-execution' },
720
+ signal,
721
+ )
722
+ if (
723
+ response.ok === false &&
724
+ typeof response.error === 'string' &&
725
+ response.error.startsWith('unknown_op:')
726
+ ) {
727
+ throw new RemoteCancellationUnsupportedError(
728
+ 'This microVM agent does not support the execution-cancellation lease protocol. Rebuild the guest image before passing SandboxExecOptions.signal; refusing rather than pretending cancellation is active.',
729
+ )
730
+ }
731
+ if (response.ok === false && response.error === 'agent_retiring') {
732
+ throw new RemoteCancellationUnknownError(
733
+ 'The microVM agent has fenced itself because an earlier process-group shutdown could not be confirmed; the sandbox must be retired.',
734
+ )
735
+ }
736
+ return response
737
+ }
738
+
739
+ private async cancelExecution(executionId: string, signal: AbortSignal): Promise<unknown> {
740
+ return await this.request<unknown>({ op: 'cancel-execution', body: { executionId } }, signal)
741
+ }
742
+
549
743
  /** Liveness probe. Returns true on an `{ ok: true }` healthz reply. */
550
- async healthz(): Promise<boolean> {
744
+ async healthz(signal?: AbortSignal): Promise<boolean> {
551
745
  try {
552
- const res = await this.request<{ ok?: boolean }>({ op: 'healthz' })
746
+ const res = await this.request<{ ok?: boolean }>({ op: 'healthz' }, signal)
553
747
  return res.ok === true
554
748
  } catch {
749
+ if (signal?.aborted) throw signal.reason
555
750
  return false
556
751
  }
557
752
  }
@@ -562,17 +757,27 @@ export class VsockAgentTransport {
562
757
  * (which already carries connect retry) — used by the backend's
563
758
  * post-create readiness fence.
564
759
  */
565
- async waitForReady(timeoutMs: number, pollIntervalMs: number): Promise<void> {
566
- const deadline = Date.now() + timeoutMs
760
+ async waitForReady(
761
+ timeoutMs: number,
762
+ pollIntervalMs: number,
763
+ signal?: AbortSignal,
764
+ ): Promise<void> {
765
+ const deadline = new OperationDeadline(timeoutMs, 'firecracker agent readiness', signal)
567
766
  let lastErr: unknown
568
- while (Date.now() < deadline) {
767
+ while (deadline.remainingMs() > 0) {
569
768
  try {
570
- if (await this.healthz()) return
769
+ if (await deadline.run((signal) => this.healthz(signal))) return
571
770
  lastErr = new Error('healthz returned not-ok')
572
771
  } catch (err) {
573
772
  lastErr = err
773
+ if (err instanceof OperationDeadlineExpired) break
774
+ }
775
+ try {
776
+ await deadline.delay(pollIntervalMs)
777
+ } catch (err) {
778
+ if (err instanceof OperationDeadlineExpired) break
779
+ throw err
574
780
  }
575
- await delay(pollIntervalMs)
576
781
  }
577
782
  throw new Error(
578
783
  `vsock transport: agent did not become ready within ${timeoutMs}ms: ${
@@ -647,8 +852,27 @@ class IdleTimer {
647
852
  }
648
853
  }
649
854
 
650
- function delay(ms: number): Promise<void> {
651
- return new Promise((resolve) => setTimeout(resolve, ms))
855
+ function delay(ms: number, signal?: AbortSignal): Promise<void> {
856
+ return new Promise((resolve, reject) => {
857
+ if (signal?.aborted) {
858
+ reject(signalError(signal))
859
+ return
860
+ }
861
+ const finish = (err?: unknown) => {
862
+ clearTimeout(timer)
863
+ signal?.removeEventListener('abort', abort)
864
+ if (err === undefined) resolve()
865
+ else reject(err)
866
+ }
867
+ const abort = () => finish(signalError(signal))
868
+ const timer = setTimeout(() => finish(), ms)
869
+ signal?.addEventListener('abort', abort, { once: true })
870
+ })
871
+ }
872
+
873
+ function signalError(signal: AbortSignal | undefined): Error {
874
+ if (signal?.reason instanceof Error) return signal.reason
875
+ return new Error(signal?.reason === undefined ? 'operation aborted' : String(signal.reason))
652
876
  }
653
877
 
654
878
  function describeHandle(handle: SandboxAgentHandle): string {