@namzu/sandbox 6.0.1 → 7.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 (34) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +2 -0
  3. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  4. package/dist/backends/aci-standby-pool/index.js +70 -31
  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 +105 -36
  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 +43 -25
  11. package/dist/backends/firecracker/index.js.map +1 -1
  12. package/dist/backends/firecracker/transport.d.ts +3 -3
  13. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  14. package/dist/backends/firecracker/transport.js +82 -40
  15. package/dist/backends/firecracker/transport.js.map +1 -1
  16. package/dist/backends/readiness.d.ts +43 -0
  17. package/dist/backends/readiness.d.ts.map +1 -0
  18. package/dist/backends/readiness.js +150 -0
  19. package/dist/backends/readiness.js.map +1 -0
  20. package/dist/egress/proxy.d.ts.map +1 -1
  21. package/dist/egress/proxy.js +20 -3
  22. package/dist/egress/proxy.js.map +1 -1
  23. package/dist/index.d.ts +18 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +9 -0
  26. package/dist/index.js.map +1 -1
  27. package/package.json +5 -2
  28. package/src/backends/aci-standby-pool/index.ts +99 -30
  29. package/src/backends/docker/index.ts +135 -44
  30. package/src/backends/firecracker/index.ts +49 -21
  31. package/src/backends/firecracker/transport.ts +87 -38
  32. package/src/backends/readiness.ts +173 -0
  33. package/src/egress/proxy.ts +18 -3
  34. package/src/index.ts +27 -0
@@ -34,6 +34,7 @@ import {
34
34
  SANDBOX_DEFAULT_TRANSCRIPTS_PATH,
35
35
  SANDBOX_DEFAULT_UPLOADS_PATH,
36
36
  type Sandbox,
37
+ type SandboxDestroyOptions,
37
38
  type SandboxEnvironment,
38
39
  type SandboxExecOptions,
39
40
  type SandboxExecResult,
@@ -56,6 +57,13 @@ import {
56
57
  type SandboxBackend,
57
58
  type SandboxBackendOptions,
58
59
  } from '../../index.js'
60
+ import {
61
+ OperationDeadline,
62
+ OperationDeadlineExpired,
63
+ probeHttpHealth,
64
+ resolveReadinessOptions,
65
+ runFailureCleanup,
66
+ } from '../readiness.js'
59
67
 
60
68
  /**
61
69
  * Backend-specific tuning. Most hosts use the defaults; advanced
@@ -173,11 +181,20 @@ const WORKER_PORT_INSIDE_CONTAINER = 2024
173
181
  * `create()` call.
174
182
  */
175
183
  export function buildDockerBackend(config: DockerBackendInternalConfig): SandboxBackend {
184
+ const readiness = resolveReadinessOptions(
185
+ 'docker',
186
+ config.readyTimeoutMs,
187
+ config.readyPollIntervalMs,
188
+ {
189
+ timeoutMs: DEFAULT_READY_TIMEOUT_MS,
190
+ pollIntervalMs: DEFAULT_READY_POLL_MS,
191
+ },
192
+ )
176
193
  return {
177
194
  tier: 'container',
178
195
  name: 'docker',
179
196
  async create(options: SandboxBackendOptions) {
180
- return await spawnDockerSandbox(config, options)
197
+ return await spawnDockerSandbox(config, options, readiness)
181
198
  },
182
199
  }
183
200
  }
@@ -375,7 +392,9 @@ const PROXY_HOST_ALIAS = 'namzu-egress'
375
392
  async function spawnDockerSandbox(
376
393
  config: DockerBackendInternalConfig,
377
394
  options: SandboxBackendOptions,
395
+ readiness: { readonly timeoutMs: number; readonly pollIntervalMs: number },
378
396
  ): Promise<Sandbox> {
397
+ options.signal?.throwIfAborted()
379
398
  const resolvedLayout = config.layout
380
399
  const id = generateSandboxId()
381
400
  const docker = config.dockerBinary ?? DEFAULT_DOCKER_BINARY
@@ -387,7 +406,13 @@ async function spawnDockerSandbox(
387
406
  let egressProxy: RunningEgressProxy | undefined
388
407
  if (needsEgressProxy(options.egress) && options.egress) {
389
408
  const policy = options.egress
390
- egressProxy = await new EgressProxy(egressProxyOptions(config, policy)).listen()
409
+ try {
410
+ egressProxy = await new EgressProxy(egressProxyOptions(config, policy)).listen()
411
+ options.signal?.throwIfAborted()
412
+ } catch (error) {
413
+ await egressProxy?.close().catch(() => undefined)
414
+ throw error
415
+ }
391
416
  }
392
417
 
393
418
  const hostReachability = config.hostReachability ?? 'host-port'
@@ -407,7 +432,7 @@ async function spawnDockerSandbox(
407
432
  network,
408
433
  hostReachability,
409
434
  options.egress,
410
- await inspectNetworkInternalFlag(docker, network),
435
+ await inspectNetworkInternalFlag(docker, network, options.signal),
411
436
  )
412
437
  } catch (err) {
413
438
  // The allowlist kinds start a proxy above, and this is outside the
@@ -425,12 +450,13 @@ async function spawnDockerSandbox(
425
450
  // the EACCES bug in sibling-container setups (the consumer owns
426
451
  // the host filesystem, the spawned backend can't reach it from
427
452
  // inside its own container's mount namespace). Clean break.
428
- let containerStarted = false
429
-
430
- async function cleanupOnFailure() {
431
- if (containerStarted) {
432
- await runOnceQuiet(docker, ['rm', '-f', containerName])
433
- }
453
+ async function cleanupOnFailure(signal: AbortSignal) {
454
+ // The name is known before `docker run`. Remove by name even when the
455
+ // client process was interrupted before it reported success: the daemon
456
+ // may already have committed the container. This is best-effort
457
+ // reconciliation; an external daemon that commits after this delete still
458
+ // needs its ordinary label/name reaper.
459
+ const removeContainer = runOnceQuiet(docker, ['rm', '-f', containerName], signal)
434
460
  // The proxy starts BEFORE the container and its only other close is
435
461
  // in `destroy()`, which a create that never returned can never
436
462
  // reach. So every failure between the two — a daemon that is down, a
@@ -440,8 +466,11 @@ async function spawnDockerSandbox(
440
466
  // event-loop handle, and a retry loop left one per attempt. That is
441
467
  // exactly the invariant this file states where the proxy is started:
442
468
  // it must not outlive the thing it was filtering for.
443
- await egressProxy?.close().catch(() => undefined)
469
+ // Start both teardown arms before awaiting either. A stuck runtime must
470
+ // not prevent the proxy from releasing its credential-bearing listener.
471
+ const closeProxy = egressProxy?.close().catch(() => undefined) ?? Promise.resolve()
444
472
  egressProxy = undefined
473
+ await Promise.all([removeContainer, closeProxy])
445
474
  }
446
475
 
447
476
  let hostPort: number
@@ -543,16 +572,15 @@ async function spawnDockerSandbox(
543
572
 
544
573
  args.push(config.image)
545
574
 
546
- await runOnce(docker, args)
547
- containerStarted = true
548
-
575
+ await runOnce(docker, args, options.signal)
549
576
  if (hostReachability === 'host-port') {
550
- hostPort = await readMappedPort(docker, containerName)
577
+ hostPort = await readMappedPort(docker, containerName, options.signal)
551
578
  baseUrl = `http://127.0.0.1:${hostPort}`
552
579
  await waitForWorkerReady(
553
580
  baseUrl,
554
- config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
555
- config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
581
+ readiness.timeoutMs,
582
+ readiness.pollIntervalMs,
583
+ options.signal,
556
584
  )
557
585
  } else {
558
586
  // container-network: connect by container DNS name on the
@@ -561,12 +589,13 @@ async function spawnDockerSandbox(
561
589
  baseUrl = `http://${containerName}:${WORKER_PORT_INSIDE_CONTAINER}`
562
590
  await waitForWorkerReady(
563
591
  baseUrl,
564
- config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
565
- config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
592
+ readiness.timeoutMs,
593
+ readiness.pollIntervalMs,
594
+ options.signal,
566
595
  )
567
596
  }
568
597
  } catch (err) {
569
- await cleanupOnFailure()
598
+ await runFailureCleanup(cleanupOnFailure)
570
599
  throw err
571
600
  }
572
601
 
@@ -651,7 +680,11 @@ async function spawnDockerSandbox(
651
680
  if (!res.ok) {
652
681
  throw new Error(`read-file failed: HTTP ${res.status} ${await res.text()}`)
653
682
  }
654
- const json = (await res.json()) as { ok: boolean; content?: string; error?: string }
683
+ const json = (await res.json()) as {
684
+ ok: boolean
685
+ content?: string
686
+ error?: string
687
+ }
655
688
  if (!json.ok || typeof json.content !== 'string') {
656
689
  throw new Error(json.error ?? 'read-file: no content')
657
690
  }
@@ -662,9 +695,9 @@ async function spawnDockerSandbox(
662
695
  return await listFilesViaWorker(baseUrl, rootPath)
663
696
  },
664
697
 
665
- async destroy(): Promise<void> {
698
+ async destroy(options?: SandboxDestroyOptions): Promise<void> {
666
699
  status = 'destroyed'
667
- await runOnceQuiet(docker, ['rm', '-f', containerName])
700
+ await runOnceQuiet(docker, ['rm', '-f', containerName], options?.signal)
668
701
  // The proxy holds real credentials and a live allowlist. Leaving
669
702
  // it listening after the sandbox it was filtering for is gone
670
703
  // means a loopback port that still stamps a token onto anything
@@ -686,13 +719,21 @@ async function spawnDockerSandbox(
686
719
  * process could grab it in the meantime). Letting Docker
687
720
  * allocate and reading the mapping back is race-free.
688
721
  */
689
- async function readMappedPort(docker: string, containerName: string): Promise<number> {
690
- const inspectOutput = await runOnce(docker, [
691
- 'inspect',
692
- '--format',
693
- `{{(index (index .NetworkSettings.Ports "${WORKER_PORT_INSIDE_CONTAINER}/tcp") 0).HostPort}}`,
694
- containerName,
695
- ])
722
+ async function readMappedPort(
723
+ docker: string,
724
+ containerName: string,
725
+ signal?: AbortSignal,
726
+ ): Promise<number> {
727
+ const inspectOutput = await runOnce(
728
+ docker,
729
+ [
730
+ 'inspect',
731
+ '--format',
732
+ `{{(index (index .NetworkSettings.Ports "${WORKER_PORT_INSIDE_CONTAINER}/tcp") 0).HostPort}}`,
733
+ containerName,
734
+ ],
735
+ signal,
736
+ )
696
737
  const port = Number(inspectOutput.trim())
697
738
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
698
739
  throw withHint(
@@ -883,18 +924,25 @@ async function waitForWorkerReady(
883
924
  baseUrl: string,
884
925
  timeoutMs: number,
885
926
  pollMs: number,
927
+ signal?: AbortSignal,
886
928
  ): Promise<void> {
887
- const deadline = Date.now() + timeoutMs
929
+ const deadline = new OperationDeadline(timeoutMs, 'docker worker readiness', signal)
888
930
  let lastError: unknown
889
- while (Date.now() < deadline) {
931
+ while (deadline.remainingMs() > 0) {
890
932
  try {
891
- const res = await fetch(`${baseUrl}/healthz`)
892
- if (res.ok) return
893
- lastError = new Error(`healthz HTTP ${res.status}`)
933
+ const result = await deadline.run((signal) => probeHttpHealth(`${baseUrl}/healthz`, signal))
934
+ if (result.ok) return
935
+ lastError = new Error(`healthz HTTP ${result.status}`)
894
936
  } catch (err) {
895
937
  lastError = err
938
+ if (err instanceof OperationDeadlineExpired) break
939
+ }
940
+ try {
941
+ await deadline.delay(pollMs)
942
+ } catch (err) {
943
+ if (err instanceof OperationDeadlineExpired) break
944
+ throw err
896
945
  }
897
- await new Promise((resolve) => setTimeout(resolve, pollMs))
898
946
  }
899
947
  // A hint attached at the throw site, where the cause is actually known.
900
948
  // The container runtime's own message says a request failed; it cannot
@@ -910,22 +958,40 @@ async function waitForWorkerReady(
910
958
  )
911
959
  }
912
960
 
913
- function runOnce(binary: string, args: string[]): Promise<string> {
961
+ function runOnce(binary: string, args: string[], signal?: AbortSignal): Promise<string> {
914
962
  return new Promise((resolve, reject) => {
963
+ signal?.throwIfAborted()
915
964
  const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] })
916
965
  let stdout = ''
917
966
  let stderr = ''
967
+ let settled = false
968
+ const finish = (error?: unknown, value?: string) => {
969
+ if (settled) return
970
+ settled = true
971
+ signal?.removeEventListener('abort', abort)
972
+ child.removeAllListeners('error')
973
+ child.removeAllListeners('close')
974
+ if (error !== undefined) reject(error)
975
+ else resolve(value ?? '')
976
+ }
977
+ const abort = () => {
978
+ child.kill('SIGKILL')
979
+ child.unref()
980
+ finish(signal?.reason ?? new Error('operation aborted'))
981
+ }
918
982
  child.stdout.on('data', (chunk: Buffer) => {
919
983
  stdout += chunk.toString('utf8')
920
984
  })
921
985
  child.stderr.on('data', (chunk: Buffer) => {
922
986
  stderr += chunk.toString('utf8')
923
987
  })
924
- child.on('error', reject)
988
+ child.on('error', (error) => finish(error))
925
989
  child.on('close', (code) => {
926
- if (code === 0) resolve(stdout.trim())
927
- else reject(new Error(`${binary} ${args.join(' ')} exited ${code}: ${stderr.trim()}`))
990
+ if (code === 0) finish(undefined, stdout.trim())
991
+ else finish(new Error(`${binary} ${args.join(' ')} exited ${code}: ${stderr.trim()}`))
928
992
  })
993
+ if (signal?.aborted) abort()
994
+ else signal?.addEventListener('abort', abort, { once: true })
929
995
  })
930
996
  }
931
997
 
@@ -938,19 +1004,44 @@ function runOnce(binary: string, args: string[]): Promise<string> {
938
1004
  * a boundary" — instead of surfacing a docker CLI error that says nothing
939
1005
  * about the egress policy that prompted the lookup.
940
1006
  */
941
- async function inspectNetworkInternalFlag(docker: string, network: string): Promise<string> {
1007
+ async function inspectNetworkInternalFlag(
1008
+ docker: string,
1009
+ network: string,
1010
+ signal?: AbortSignal,
1011
+ ): Promise<string> {
942
1012
  try {
943
- return await runOnce(docker, ['network', 'inspect', '--format', '{{.Internal}}', network])
1013
+ return await runOnce(
1014
+ docker,
1015
+ ['network', 'inspect', '--format', '{{.Internal}}', network],
1016
+ signal,
1017
+ )
944
1018
  } catch {
1019
+ signal?.throwIfAborted()
945
1020
  return ''
946
1021
  }
947
1022
  }
948
1023
 
949
- function runOnceQuiet(binary: string, args: string[]): Promise<void> {
1024
+ function runOnceQuiet(binary: string, args: string[], signal?: AbortSignal): Promise<void> {
950
1025
  return new Promise((resolve) => {
951
1026
  const child = spawn(binary, args, { stdio: 'ignore' })
952
- child.on('error', () => resolve())
953
- child.on('close', () => resolve())
1027
+ let settled = false
1028
+ const finish = () => {
1029
+ if (settled) return
1030
+ settled = true
1031
+ signal?.removeEventListener('abort', abort)
1032
+ child.removeListener('error', finish)
1033
+ child.removeListener('close', finish)
1034
+ resolve()
1035
+ }
1036
+ const abort = () => {
1037
+ child.kill('SIGKILL')
1038
+ child.unref()
1039
+ finish()
1040
+ }
1041
+ child.on('error', finish)
1042
+ child.on('close', finish)
1043
+ if (signal?.aborted) abort()
1044
+ else signal?.addEventListener('abort', abort, { once: true })
954
1045
  })
955
1046
  }
956
1047
 
@@ -48,6 +48,7 @@ import https from 'node:https'
48
48
 
49
49
  import type {
50
50
  Sandbox,
51
+ SandboxDestroyOptions,
51
52
  SandboxEnvironment,
52
53
  SandboxExecOptions,
53
54
  SandboxExecResult,
@@ -57,6 +58,7 @@ import type {
57
58
  } from '@namzu/sdk'
58
59
 
59
60
  import type { AgentSnapshotRef, SandboxBackend, SandboxBackendOptions } from '../../index.js'
61
+ import { resolveReadinessOptions, runFailureCleanup } from '../readiness.js'
60
62
  import type {
61
63
  MtlsClientMaterial,
62
64
  SandboxAgentHandle,
@@ -148,11 +150,20 @@ const DEFAULT_READY_POLL_MS = 250
148
150
  * on the first `create()`.
149
151
  */
150
152
  export function buildFirecrackerBackend(config: FirecrackerBackendInternalConfig): SandboxBackend {
153
+ const readiness = resolveReadinessOptions(
154
+ 'firecracker',
155
+ config.readyTimeoutMs,
156
+ config.readyPollIntervalMs,
157
+ {
158
+ timeoutMs: DEFAULT_READY_TIMEOUT_MS,
159
+ pollIntervalMs: DEFAULT_READY_POLL_MS,
160
+ },
161
+ )
151
162
  return {
152
163
  tier: 'microvm',
153
164
  name: 'firecracker',
154
165
  async create(options: SandboxBackendOptions): Promise<Sandbox> {
155
- return await spawnFirecrackerSandbox(config, options)
166
+ return await spawnFirecrackerSandbox(config, options, readiness)
156
167
  },
157
168
  }
158
169
  }
@@ -208,8 +219,11 @@ async function orchestratorCall<T>(
208
219
  getToken: OrchestratorTokenProvider,
209
220
  body?: unknown,
210
221
  mtls?: MtlsClientMaterial,
222
+ signal?: AbortSignal,
211
223
  ): Promise<T | undefined> {
224
+ signal?.throwIfAborted()
212
225
  const token = await getToken()
226
+ signal?.throwIfAborted()
213
227
  const url = `${stripTrailingSlashes(endpoint)}${pathSuffix}`
214
228
  const payload = body !== undefined ? JSON.stringify(body) : undefined
215
229
  const headers: Record<string, string> = {
@@ -226,8 +240,8 @@ async function orchestratorCall<T>(
226
240
  // than fetch+undici-dispatcher because the package declares no undici
227
241
  // dependency — node:https is always importable and needs nothing added.
228
242
  res = mtls
229
- ? await httpsOrchestratorRequest(url, method, headers, payload, mtls)
230
- : await fetchOrchestratorRequest(url, method, headers, payload)
243
+ ? await httpsOrchestratorRequest(url, method, headers, payload, mtls, signal)
244
+ : await fetchOrchestratorRequest(url, method, headers, payload, signal)
231
245
  } catch (err) {
232
246
  const cause = err instanceof Error ? err.cause : undefined
233
247
  throw new Error(
@@ -238,12 +252,16 @@ async function orchestratorCall<T>(
238
252
  )
239
253
  }
240
254
  if (res.status < 200 || res.status >= 300) {
241
- throw new Error(
242
- `firecracker orchestrator ${method} ${url} → ${res.status}: ${await res.text()}`,
243
- )
255
+ const text = await res.text()
256
+ signal?.throwIfAborted()
257
+ throw new Error(`firecracker orchestrator ${method} ${url} → ${res.status}: ${text}`)
244
258
  }
245
259
  if (res.status === 204) return undefined
246
- if (res.contentType.includes('application/json')) return JSON.parse(await res.text()) as T
260
+ if (res.contentType.includes('application/json')) {
261
+ const text = await res.text()
262
+ signal?.throwIfAborted()
263
+ return JSON.parse(text) as T
264
+ }
247
265
  return undefined
248
266
  }
249
267
 
@@ -253,9 +271,11 @@ async function fetchOrchestratorRequest(
253
271
  method: 'POST' | 'DELETE',
254
272
  headers: Record<string, string>,
255
273
  payload: string | undefined,
274
+ signal?: AbortSignal,
256
275
  ): Promise<OrchestratorRawResponse> {
257
276
  const init: RequestInit = { method, headers }
258
277
  if (payload !== undefined) init.body = payload
278
+ if (signal !== undefined) init.signal = signal
259
279
  const res = await fetch(url, init)
260
280
  return {
261
281
  status: res.status,
@@ -276,6 +296,7 @@ function httpsOrchestratorRequest(
276
296
  headers: Record<string, string>,
277
297
  payload: string | undefined,
278
298
  mtls: MtlsClientMaterial,
299
+ signal?: AbortSignal,
279
300
  ): Promise<OrchestratorRawResponse> {
280
301
  const target = new URL(url)
281
302
  return new Promise<OrchestratorRawResponse>((resolve, reject) => {
@@ -293,6 +314,7 @@ function httpsOrchestratorRequest(
293
314
  rejectUnauthorized: true,
294
315
  minVersion: 'TLSv1.3',
295
316
  ...(mtls.servername !== undefined ? { servername: mtls.servername } : {}),
317
+ ...(signal !== undefined ? { signal } : {}),
296
318
  },
297
319
  (res) => {
298
320
  const chunks: Buffer[] = []
@@ -321,9 +343,12 @@ function httpsOrchestratorRequest(
321
343
  async function spawnFirecrackerSandbox(
322
344
  config: FirecrackerBackendInternalConfig,
323
345
  options: SandboxBackendOptions,
346
+ readiness: { readonly timeoutMs: number; readonly pollIntervalMs: number },
324
347
  ): Promise<Sandbox> {
348
+ options.signal?.throwIfAborted()
325
349
  const endpoint = config.orchestratorEndpoint
326
350
  const egressAllowlist = await resolveEgressAllowlist(options)
351
+ options.signal?.throwIfAborted()
327
352
  const createBody: OrchestratorCreateRequest = {
328
353
  ...(config.template !== undefined ? { template: config.template } : {}),
329
354
  ...(config.agentSnapshot !== undefined ? { agentSnapshot: config.agentSnapshot } : {}),
@@ -345,11 +370,17 @@ async function spawnFirecrackerSandbox(
345
370
  config.getToken,
346
371
  createBody,
347
372
  config.controlPlaneMtls,
373
+ options.signal,
348
374
  )
349
375
  } catch (err) {
376
+ const interrupted = options.signal?.aborted
350
377
  throw new Error(
351
378
  `firecracker: failed to create microVM sandbox — ${
352
379
  err instanceof Error ? err.message : String(err)
380
+ }${
381
+ interrupted
382
+ ? '; allocation outcome is unresolved because the orchestrator did not return a sandboxId — the deployment fleet reaper must reconcile it'
383
+ : ''
353
384
  }`,
354
385
  { cause: err },
355
386
  )
@@ -380,7 +411,7 @@ async function spawnFirecrackerSandbox(
380
411
  )
381
412
  const transport = new VsockAgentTransport(handle, config.transport ?? {})
382
413
 
383
- const destroy = async (): Promise<void> => {
414
+ const destroy = async (signal?: AbortSignal): Promise<void> => {
384
415
  await orchestratorCall(
385
416
  endpoint,
386
417
  `/sandboxes/${encodeURIComponent(id)}:delete`,
@@ -388,28 +419,25 @@ async function spawnFirecrackerSandbox(
388
419
  config.getToken,
389
420
  undefined,
390
421
  config.controlPlaneMtls,
422
+ signal,
391
423
  )
392
424
  }
393
425
 
394
426
  try {
427
+ options.signal?.throwIfAborted()
395
428
  // Readiness fence: the orchestrator's resume returns BEFORE the
396
429
  // guest agent has reseeded entropy and re-listened on vsock. Stop
397
430
  // the clock on the agent's healthz, exactly as the HTTP backends
398
431
  // wait on `/healthz` — never on the orchestrator's 2xx, which
399
432
  // fires before the guest runs (§5 clock semantics).
400
- await transport.waitForReady(
401
- config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
402
- config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
403
- )
433
+ await transport.waitForReady(readiness.timeoutMs, readiness.pollIntervalMs, options.signal)
404
434
  } catch (err) {
405
435
  // Best-effort orchestrator teardown so a readiness failure does not
406
- // orphan a microVM/netns/UFFD handler (the reaper backstops, but
407
- // surfacing the delete failure keeps the leak observable).
408
- try {
409
- await destroy()
410
- } catch {
411
- // Preserve the readiness error as primary.
412
- }
436
+ // orphan a microVM/netns/UFFD handler. The fleet reaper backstops a
437
+ // failed delete; this call gets a separate short grace so cleanup
438
+ // cannot turn the captured readiness error back into an unbounded
439
+ // create operation.
440
+ await runFailureCleanup(async (signal) => destroy(signal))
413
441
  throw err
414
442
  }
415
443
 
@@ -482,13 +510,13 @@ async function spawnFirecrackerSandbox(
482
510
  return entries
483
511
  },
484
512
 
485
- async destroy(): Promise<void> {
513
+ async destroy(options?: SandboxDestroyOptions): Promise<void> {
486
514
  status = 'destroyed'
487
515
  // Let the orchestrator DELETE failure propagate — the
488
516
  // Vandal-side lifecycle wraps this with logging, and a
489
517
  // swallowed error here means orphaned microVMs (and their
490
518
  // netns / UFFD handlers) pile up with no observability handle.
491
- await destroy()
519
+ await destroy(options?.signal)
492
520
  },
493
521
  }
494
522
  }