@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
@@ -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,15 @@ import {
56
57
  type SandboxBackend,
57
58
  type SandboxBackendOptions,
58
59
  } from '../../index.js'
60
+ import { HttpWorkerClient } from '../http-worker-client.js'
61
+ import {
62
+ OperationDeadline,
63
+ OperationDeadlineExpired,
64
+ probeHttpHealth,
65
+ resolveReadinessOptions,
66
+ runFailureCleanup,
67
+ } from '../readiness.js'
68
+ import { RemoteCancellationUnknownError } from '../remote-execution-controller.js'
59
69
 
60
70
  /**
61
71
  * Backend-specific tuning. Most hosts use the defaults; advanced
@@ -173,11 +183,20 @@ const WORKER_PORT_INSIDE_CONTAINER = 2024
173
183
  * `create()` call.
174
184
  */
175
185
  export function buildDockerBackend(config: DockerBackendInternalConfig): SandboxBackend {
186
+ const readiness = resolveReadinessOptions(
187
+ 'docker',
188
+ config.readyTimeoutMs,
189
+ config.readyPollIntervalMs,
190
+ {
191
+ timeoutMs: DEFAULT_READY_TIMEOUT_MS,
192
+ pollIntervalMs: DEFAULT_READY_POLL_MS,
193
+ },
194
+ )
176
195
  return {
177
196
  tier: 'container',
178
197
  name: 'docker',
179
198
  async create(options: SandboxBackendOptions) {
180
- return await spawnDockerSandbox(config, options)
199
+ return await spawnDockerSandbox(config, options, readiness)
181
200
  },
182
201
  }
183
202
  }
@@ -375,7 +394,9 @@ const PROXY_HOST_ALIAS = 'namzu-egress'
375
394
  async function spawnDockerSandbox(
376
395
  config: DockerBackendInternalConfig,
377
396
  options: SandboxBackendOptions,
397
+ readiness: { readonly timeoutMs: number; readonly pollIntervalMs: number },
378
398
  ): Promise<Sandbox> {
399
+ options.signal?.throwIfAborted()
379
400
  const resolvedLayout = config.layout
380
401
  const id = generateSandboxId()
381
402
  const docker = config.dockerBinary ?? DEFAULT_DOCKER_BINARY
@@ -387,7 +408,13 @@ async function spawnDockerSandbox(
387
408
  let egressProxy: RunningEgressProxy | undefined
388
409
  if (needsEgressProxy(options.egress) && options.egress) {
389
410
  const policy = options.egress
390
- egressProxy = await new EgressProxy(egressProxyOptions(config, policy)).listen()
411
+ try {
412
+ egressProxy = await new EgressProxy(egressProxyOptions(config, policy)).listen()
413
+ options.signal?.throwIfAborted()
414
+ } catch (error) {
415
+ await egressProxy?.close().catch(() => undefined)
416
+ throw error
417
+ }
391
418
  }
392
419
 
393
420
  const hostReachability = config.hostReachability ?? 'host-port'
@@ -407,7 +434,7 @@ async function spawnDockerSandbox(
407
434
  network,
408
435
  hostReachability,
409
436
  options.egress,
410
- await inspectNetworkInternalFlag(docker, network),
437
+ await inspectNetworkInternalFlag(docker, network, options.signal),
411
438
  )
412
439
  } catch (err) {
413
440
  // The allowlist kinds start a proxy above, and this is outside the
@@ -425,12 +452,13 @@ async function spawnDockerSandbox(
425
452
  // the EACCES bug in sibling-container setups (the consumer owns
426
453
  // the host filesystem, the spawned backend can't reach it from
427
454
  // 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
- }
455
+ async function cleanupOnFailure(signal: AbortSignal) {
456
+ // The name is known before `docker run`. Remove by name even when the
457
+ // client process was interrupted before it reported success: the daemon
458
+ // may already have committed the container. This is best-effort
459
+ // reconciliation; an external daemon that commits after this delete still
460
+ // needs its ordinary label/name reaper.
461
+ const removeContainer = runOnceQuiet(docker, ['rm', '-f', containerName], signal)
434
462
  // The proxy starts BEFORE the container and its only other close is
435
463
  // in `destroy()`, which a create that never returned can never
436
464
  // reach. So every failure between the two — a daemon that is down, a
@@ -440,8 +468,11 @@ async function spawnDockerSandbox(
440
468
  // event-loop handle, and a retry loop left one per attempt. That is
441
469
  // exactly the invariant this file states where the proxy is started:
442
470
  // it must not outlive the thing it was filtering for.
443
- await egressProxy?.close().catch(() => undefined)
471
+ // Start both teardown arms before awaiting either. A stuck runtime must
472
+ // not prevent the proxy from releasing its credential-bearing listener.
473
+ const closeProxy = egressProxy?.close().catch(() => undefined) ?? Promise.resolve()
444
474
  egressProxy = undefined
475
+ await Promise.all([removeContainer, closeProxy])
445
476
  }
446
477
 
447
478
  let hostPort: number
@@ -543,16 +574,15 @@ async function spawnDockerSandbox(
543
574
 
544
575
  args.push(config.image)
545
576
 
546
- await runOnce(docker, args)
547
- containerStarted = true
548
-
577
+ await runOnce(docker, args, options.signal)
549
578
  if (hostReachability === 'host-port') {
550
- hostPort = await readMappedPort(docker, containerName)
579
+ hostPort = await readMappedPort(docker, containerName, options.signal)
551
580
  baseUrl = `http://127.0.0.1:${hostPort}`
552
581
  await waitForWorkerReady(
553
582
  baseUrl,
554
- config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
555
- config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
583
+ readiness.timeoutMs,
584
+ readiness.pollIntervalMs,
585
+ options.signal,
556
586
  )
557
587
  } else {
558
588
  // container-network: connect by container DNS name on the
@@ -561,21 +591,106 @@ async function spawnDockerSandbox(
561
591
  baseUrl = `http://${containerName}:${WORKER_PORT_INSIDE_CONTAINER}`
562
592
  await waitForWorkerReady(
563
593
  baseUrl,
564
- config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
565
- config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
594
+ readiness.timeoutMs,
595
+ readiness.pollIntervalMs,
596
+ options.signal,
566
597
  )
567
598
  }
568
599
  } catch (err) {
569
- await cleanupOnFailure()
600
+ await runFailureCleanup(cleanupOnFailure)
570
601
  throw err
571
602
  }
572
603
 
573
- let status: SandboxStatus = 'ready'
604
+ type Lifecycle = 'active' | 'retiring' | 'destroyed'
605
+ let activeExecutions = 0
606
+ let lifecycle: Lifecycle = 'active'
607
+ let retirementPromise: Promise<{ readonly accepted: boolean; readonly error?: Error }> | undefined
608
+ let teardownPromise: Promise<void> | undefined
609
+ let teardownComplete = false
610
+ const workerClient = new HttpWorkerClient(baseUrl)
611
+ const assertActive = (): void => {
612
+ if (lifecycle !== 'active') {
613
+ throw new Error(`Sandbox ${id} is ${lifecycle}; no new worker operation can be admitted`)
614
+ }
615
+ }
616
+ const teardownSandbox = (signal?: AbortSignal): Promise<void> => {
617
+ lifecycle = 'retiring'
618
+ if (teardownComplete) return Promise.resolve()
619
+ if (teardownPromise) return teardownPromise
620
+ const attempt = (async () => {
621
+ let teardownError: unknown
622
+ try {
623
+ await runOnce(docker, ['rm', '-f', containerName], signal)
624
+ } catch (error) {
625
+ teardownError = error
626
+ } finally {
627
+ try {
628
+ await egressProxy?.close()
629
+ } catch (error) {
630
+ teardownError ??= error
631
+ }
632
+ }
633
+ if (teardownError !== undefined) throw teardownError
634
+ })()
635
+ const shared = attempt.then(
636
+ () => {
637
+ teardownComplete = true
638
+ lifecycle = 'destroyed'
639
+ },
640
+ (error: unknown) => {
641
+ if (teardownPromise === shared) teardownPromise = undefined
642
+ throw error
643
+ },
644
+ )
645
+ teardownPromise = shared
646
+ return shared
647
+ }
648
+ const retire = (): Promise<{
649
+ readonly accepted: boolean
650
+ readonly error?: Error
651
+ }> => {
652
+ lifecycle = 'retiring'
653
+ if (retirementPromise) return retirementPromise
654
+ const deadline = new OperationDeadline(5_000, `docker sandbox ${id} retirement`)
655
+ retirementPromise = deadline
656
+ .run(async (signal) => {
657
+ const joinedExistingAttempt = teardownPromise !== undefined
658
+ try {
659
+ await teardownSandbox(signal)
660
+ } catch (error) {
661
+ if (!joinedExistingAttempt || signal.aborted) throw error
662
+ await teardownSandbox(signal)
663
+ }
664
+ })
665
+ .then(() => {
666
+ return { accepted: true as const }
667
+ })
668
+ .catch((error: unknown) => ({
669
+ accepted: false as const,
670
+ error: error instanceof Error ? error : new Error(String(error)),
671
+ }))
672
+ return retirementPromise
673
+ }
674
+ const runExecution = async <T>(operation: () => Promise<T>): Promise<T> => {
675
+ assertActive()
676
+ activeExecutions += 1
677
+ try {
678
+ return await operation()
679
+ } catch (error) {
680
+ if (error instanceof RemoteCancellationUnknownError) {
681
+ error.retirement = await retire()
682
+ }
683
+ throw error
684
+ } finally {
685
+ activeExecutions = Math.max(0, activeExecutions - 1)
686
+ }
687
+ }
574
688
 
575
689
  return {
576
690
  id,
577
691
  get status(): SandboxStatus {
578
- return status
692
+ if (lifecycle !== 'active') return 'destroyed'
693
+ return activeExecutions > 0 ? 'busy' : 'ready'
579
694
  },
580
695
  rootDir,
581
696
  environment: detectEnvironment(),
@@ -585,15 +700,11 @@ async function spawnDockerSandbox(
585
700
  argv?: string[],
586
701
  opts?: SandboxExecOptions,
587
702
  ): Promise<SandboxExecResult> {
588
- status = 'busy'
589
- try {
590
- return await execViaWorker(baseUrl, command, argv, opts)
591
- } finally {
592
- status = 'ready'
593
- }
703
+ return await runExecution(async () => await workerClient.exec(command, argv, opts))
594
704
  },
595
705
 
596
706
  async setNetworkPolicy(policy): Promise<void> {
707
+ assertActive()
597
708
  // Enforceable only through the egress proxy. Without one the
598
709
  // container's network was fixed at creation — `--network none`
599
710
  // or not — and there is nothing to narrow: accepting the policy
@@ -612,6 +723,7 @@ async function spawnDockerSandbox(
612
723
  },
613
724
 
614
725
  async writeFile(path: string, content: string | Buffer): Promise<void> {
726
+ assertActive()
615
727
  const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
616
728
  let res: Response
617
729
  try {
@@ -643,6 +755,7 @@ async function spawnDockerSandbox(
643
755
  },
644
756
 
645
757
  async readFile(path: string): Promise<Buffer> {
758
+ assertActive()
646
759
  const res = await fetch(`${baseUrl}/read-file`, {
647
760
  method: 'POST',
648
761
  headers: { 'content-type': 'application/json' },
@@ -651,7 +764,11 @@ async function spawnDockerSandbox(
651
764
  if (!res.ok) {
652
765
  throw new Error(`read-file failed: HTTP ${res.status} ${await res.text()}`)
653
766
  }
654
- const json = (await res.json()) as { ok: boolean; content?: string; error?: string }
767
+ const json = (await res.json()) as {
768
+ ok: boolean
769
+ content?: string
770
+ error?: string
771
+ }
655
772
  if (!json.ok || typeof json.content !== 'string') {
656
773
  throw new Error(json.error ?? 'read-file: no content')
657
774
  }
@@ -659,17 +776,16 @@ async function spawnDockerSandbox(
659
776
  },
660
777
 
661
778
  async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
662
- return await listFilesViaWorker(baseUrl, rootPath)
779
+ return await runExecution(async () => await listFilesViaWorker(workerClient, rootPath))
663
780
  },
664
781
 
665
- async destroy(): Promise<void> {
666
- status = 'destroyed'
667
- await runOnceQuiet(docker, ['rm', '-f', containerName])
668
- // The proxy holds real credentials and a live allowlist. Leaving
669
- // it listening after the sandbox it was filtering for is gone
670
- // means a loopback port that still stamps a token onto anything
671
- // that asks — outliving the only thing that justified it.
672
- await egressProxy?.close()
782
+ async destroy(options?: SandboxDestroyOptions): Promise<void> {
783
+ if (retirementPromise) {
784
+ const observation = await retirementPromise
785
+ if (observation.accepted) return
786
+ retirementPromise = undefined
787
+ }
788
+ await teardownSandbox(options?.signal)
673
789
  // Backend never allocates host paths — every bind source
674
790
  // comes from the consumer-supplied layout. Container
675
791
  // teardown is sufficient; the consumer's own lifecycle
@@ -686,13 +802,21 @@ async function spawnDockerSandbox(
686
802
  * process could grab it in the meantime). Letting Docker
687
803
  * allocate and reading the mapping back is race-free.
688
804
  */
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
- ])
805
+ async function readMappedPort(
806
+ docker: string,
807
+ containerName: string,
808
+ signal?: AbortSignal,
809
+ ): Promise<number> {
810
+ const inspectOutput = await runOnce(
811
+ docker,
812
+ [
813
+ 'inspect',
814
+ '--format',
815
+ `{{(index (index .NetworkSettings.Ports "${WORKER_PORT_INSIDE_CONTAINER}/tcp") 0).HostPort}}`,
816
+ containerName,
817
+ ],
818
+ signal,
819
+ )
696
820
  const port = Number(inspectOutput.trim())
697
821
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
698
822
  throw withHint(
@@ -705,119 +829,6 @@ async function readMappedPort(docker: string, containerName: string): Promise<nu
705
829
  return port
706
830
  }
707
831
 
708
- async function execViaWorker(
709
- baseUrl: string,
710
- command: string,
711
- argv: string[] | undefined,
712
- opts: SandboxExecOptions | undefined,
713
- ): Promise<SandboxExecResult> {
714
- // `opts.signal` is deliberately NOT passed to `fetch`. It would abort the
715
- // request and leave the worker running the command — abandoning the wait
716
- // while the process lives on is the exact failure
717
- // `SandboxExecOptions.signal` exists to prevent, and wiring it here would
718
- // make the option look honoured while delivering that failure. Honouring
719
- // it needs a cancel endpoint on the worker.
720
- const start = Date.now()
721
- let res: Response
722
- try {
723
- res = await fetch(`${baseUrl}/execute`, {
724
- method: 'POST',
725
- headers: { 'content-type': 'application/json' },
726
- body: JSON.stringify({
727
- command,
728
- args: argv ?? [],
729
- cwd: opts?.cwd,
730
- env: opts?.env,
731
- timeoutMs: opts?.timeout,
732
- }),
733
- })
734
- } catch (err) {
735
- // Surface the underlying transport error (DNS, ECONNREFUSED,
736
- // socket-hangup, …) instead of the generic "fetch failed" the
737
- // undici client throws. Without `cause`, ops cannot tell whether
738
- // the worker died, the bridge dropped, or something else.
739
- const cause = err instanceof Error ? err.cause : undefined
740
- const causeMsg =
741
- cause instanceof Error
742
- ? `${cause.message}${(cause as Error & { code?: string }).code ? ` (${(cause as Error & { code?: string }).code})` : ''}`
743
- : cause
744
- ? String(cause)
745
- : 'unknown'
746
- throw withHint(
747
- new Error(
748
- `namzu-sandbox /execute fetch failed (baseUrl=${baseUrl}): ${err instanceof Error ? err.message : String(err)} — cause: ${causeMsg}`,
749
- { cause: err },
750
- ),
751
- 'The container was reachable when it started, so it has most likely exited or been killed since — an out-of-memory kill under `memoryLimitMb` is the common cause. Check the container logs and its exit code.',
752
- )
753
- }
754
- if (!res.ok || !res.body) {
755
- throw new Error(`execute failed: HTTP ${res.status} ${await res.text()}`)
756
- }
757
-
758
- let stdout = ''
759
- let stderr = ''
760
- let exitCode = -1
761
- let timedOut = false
762
- let signal: string | undefined
763
-
764
- const decoder = new TextDecoder()
765
- const reader = res.body.getReader()
766
- let buffered = ''
767
- for (;;) {
768
- const { value, done } = await reader.read()
769
- if (done) break
770
- buffered += decoder.decode(value, { stream: true })
771
- let newlineIdx = buffered.indexOf('\n')
772
- while (newlineIdx !== -1) {
773
- const line = buffered.slice(0, newlineIdx).trim()
774
- buffered = buffered.slice(newlineIdx + 1)
775
- if (line) {
776
- try {
777
- const event = JSON.parse(line) as
778
- | { type: 'stdout_delta'; data: string }
779
- | { type: 'stderr_delta'; data: string }
780
- | {
781
- type: 'result'
782
- exitCode: number
783
- timedOut: boolean
784
- durationMs: number
785
- }
786
- | { type: 'error'; error: string }
787
- if (event.type === 'stdout_delta') {
788
- stdout += event.data
789
- opts?.onOutput?.({ stream: 'stdout', data: event.data })
790
- } else if (event.type === 'stderr_delta') {
791
- stderr += event.data
792
- opts?.onOutput?.({ stream: 'stderr', data: event.data })
793
- } else if (event.type === 'result') {
794
- exitCode = event.exitCode
795
- timedOut = event.timedOut
796
- } else if (event.type === 'error') {
797
- throw new Error(event.error)
798
- }
799
- } catch (err) {
800
- if (err instanceof SyntaxError) {
801
- // Ignore malformed lines from the worker.
802
- } else {
803
- throw err
804
- }
805
- }
806
- }
807
- newlineIdx = buffered.indexOf('\n')
808
- }
809
- }
810
-
811
- return {
812
- exitCode,
813
- stdout,
814
- stderr,
815
- ...(signal ? { signal } : {}),
816
- timedOut,
817
- durationMs: Date.now() - start,
818
- }
819
- }
820
-
821
832
  /**
822
833
  * Recursively list regular files under `rootPath` by shelling out to
823
834
  * the worker's `find` (GNU find on the Debian-based reference image).
@@ -827,11 +838,10 @@ async function execViaWorker(
827
838
  * have produced anything in `rootPath` yet.
828
839
  */
829
840
  async function listFilesViaWorker(
830
- baseUrl: string,
841
+ workerClient: HttpWorkerClient,
831
842
  rootPath: string,
832
843
  ): Promise<readonly SandboxFileEntry[]> {
833
- const result = await execViaWorker(
834
- baseUrl,
844
+ const result = await workerClient.exec(
835
845
  'find',
836
846
  [rootPath, '-type', 'f', '-printf', '%p\t%s\n'],
837
847
  undefined,
@@ -883,18 +893,25 @@ async function waitForWorkerReady(
883
893
  baseUrl: string,
884
894
  timeoutMs: number,
885
895
  pollMs: number,
896
+ signal?: AbortSignal,
886
897
  ): Promise<void> {
887
- const deadline = Date.now() + timeoutMs
898
+ const deadline = new OperationDeadline(timeoutMs, 'docker worker readiness', signal)
888
899
  let lastError: unknown
889
- while (Date.now() < deadline) {
900
+ while (deadline.remainingMs() > 0) {
890
901
  try {
891
- const res = await fetch(`${baseUrl}/healthz`)
892
- if (res.ok) return
893
- lastError = new Error(`healthz HTTP ${res.status}`)
902
+ const result = await deadline.run((signal) => probeHttpHealth(`${baseUrl}/healthz`, signal))
903
+ if (result.ok) return
904
+ lastError = new Error(`healthz HTTP ${result.status}`)
894
905
  } catch (err) {
895
906
  lastError = err
907
+ if (err instanceof OperationDeadlineExpired) break
908
+ }
909
+ try {
910
+ await deadline.delay(pollMs)
911
+ } catch (err) {
912
+ if (err instanceof OperationDeadlineExpired) break
913
+ throw err
896
914
  }
897
- await new Promise((resolve) => setTimeout(resolve, pollMs))
898
915
  }
899
916
  // A hint attached at the throw site, where the cause is actually known.
900
917
  // The container runtime's own message says a request failed; it cannot
@@ -910,22 +927,40 @@ async function waitForWorkerReady(
910
927
  )
911
928
  }
912
929
 
913
- function runOnce(binary: string, args: string[]): Promise<string> {
930
+ function runOnce(binary: string, args: string[], signal?: AbortSignal): Promise<string> {
914
931
  return new Promise((resolve, reject) => {
932
+ signal?.throwIfAborted()
915
933
  const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] })
916
934
  let stdout = ''
917
935
  let stderr = ''
936
+ let settled = false
937
+ const finish = (error?: unknown, value?: string) => {
938
+ if (settled) return
939
+ settled = true
940
+ signal?.removeEventListener('abort', abort)
941
+ child.removeAllListeners('error')
942
+ child.removeAllListeners('close')
943
+ if (error !== undefined) reject(error)
944
+ else resolve(value ?? '')
945
+ }
946
+ const abort = () => {
947
+ child.kill('SIGKILL')
948
+ child.unref()
949
+ finish(signal?.reason ?? new Error('operation aborted'))
950
+ }
918
951
  child.stdout.on('data', (chunk: Buffer) => {
919
952
  stdout += chunk.toString('utf8')
920
953
  })
921
954
  child.stderr.on('data', (chunk: Buffer) => {
922
955
  stderr += chunk.toString('utf8')
923
956
  })
924
- child.on('error', reject)
957
+ child.on('error', (error) => finish(error))
925
958
  child.on('close', (code) => {
926
- if (code === 0) resolve(stdout.trim())
927
- else reject(new Error(`${binary} ${args.join(' ')} exited ${code}: ${stderr.trim()}`))
959
+ if (code === 0) finish(undefined, stdout.trim())
960
+ else finish(new Error(`${binary} ${args.join(' ')} exited ${code}: ${stderr.trim()}`))
928
961
  })
962
+ if (signal?.aborted) abort()
963
+ else signal?.addEventListener('abort', abort, { once: true })
929
964
  })
930
965
  }
931
966
 
@@ -938,19 +973,44 @@ function runOnce(binary: string, args: string[]): Promise<string> {
938
973
  * a boundary" — instead of surfacing a docker CLI error that says nothing
939
974
  * about the egress policy that prompted the lookup.
940
975
  */
941
- async function inspectNetworkInternalFlag(docker: string, network: string): Promise<string> {
976
+ async function inspectNetworkInternalFlag(
977
+ docker: string,
978
+ network: string,
979
+ signal?: AbortSignal,
980
+ ): Promise<string> {
942
981
  try {
943
- return await runOnce(docker, ['network', 'inspect', '--format', '{{.Internal}}', network])
982
+ return await runOnce(
983
+ docker,
984
+ ['network', 'inspect', '--format', '{{.Internal}}', network],
985
+ signal,
986
+ )
944
987
  } catch {
988
+ signal?.throwIfAborted()
945
989
  return ''
946
990
  }
947
991
  }
948
992
 
949
- function runOnceQuiet(binary: string, args: string[]): Promise<void> {
993
+ function runOnceQuiet(binary: string, args: string[], signal?: AbortSignal): Promise<void> {
950
994
  return new Promise((resolve) => {
951
995
  const child = spawn(binary, args, { stdio: 'ignore' })
952
- child.on('error', () => resolve())
953
- child.on('close', () => resolve())
996
+ let settled = false
997
+ const finish = () => {
998
+ if (settled) return
999
+ settled = true
1000
+ signal?.removeEventListener('abort', abort)
1001
+ child.removeListener('error', finish)
1002
+ child.removeListener('close', finish)
1003
+ resolve()
1004
+ }
1005
+ const abort = () => {
1006
+ child.kill('SIGKILL')
1007
+ child.unref()
1008
+ finish()
1009
+ }
1010
+ child.on('error', finish)
1011
+ child.on('close', finish)
1012
+ if (signal?.aborted) abort()
1013
+ else signal?.addEventListener('abort', abort, { once: true })
954
1014
  })
955
1015
  }
956
1016