@namzu/sandbox 7.0.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 (35) hide show
  1. package/CHANGELOG.md +32 -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 +95 -82
  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 +108 -114
  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 +117 -52
  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 +23 -4
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +153 -32
  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/remote-execution-controller.d.ts +76 -0
  25. package/dist/backends/remote-execution-controller.d.ts.map +1 -0
  26. package/dist/backends/remote-execution-controller.js +294 -0
  27. package/dist/backends/remote-execution-controller.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/backends/aci-standby-pool/index.ts +101 -88
  30. package/src/backends/docker/index.ts +101 -132
  31. package/src/backends/firecracker/index.ts +117 -45
  32. package/src/backends/firecracker/protocol.ts +67 -33
  33. package/src/backends/firecracker/transport.ts +214 -39
  34. package/src/backends/http-worker-client.ts +230 -0
  35. package/src/backends/remote-execution-controller.ts +462 -0
@@ -55,6 +55,7 @@ import type {
55
55
  import { asSandboxId } from '@namzu/sdk'
56
56
 
57
57
  import type { SandboxBackend, SandboxBackendOptions } from '../../index.js'
58
+ import { HttpWorkerClient } from '../http-worker-client.js'
58
59
  import {
59
60
  OperationDeadline,
60
61
  OperationDeadlineExpired,
@@ -62,6 +63,7 @@ import {
62
63
  resolveReadinessOptions,
63
64
  runFailureCleanup,
64
65
  } from '../readiness.js'
66
+ import { RemoteCancellationUnknownError } from '../remote-execution-controller.js'
65
67
 
66
68
  /**
67
69
  * Authentication callback. Caller returns a fresh Azure Resource
@@ -301,6 +303,7 @@ async function armCall<T>(
301
303
  getToken: ArmTokenProvider,
302
304
  body?: unknown,
303
305
  signal?: AbortSignal,
306
+ acceptedStatuses: readonly number[] = [],
304
307
  ): Promise<T | undefined> {
305
308
  signal?.throwIfAborted()
306
309
  const token = await getToken()
@@ -319,6 +322,11 @@ async function armCall<T>(
319
322
  const res = await fetch(url, init)
320
323
  signal?.throwIfAborted()
321
324
  if (!res.ok) {
325
+ if (acceptedStatuses.includes(res.status)) {
326
+ await res.text()
327
+ signal?.throwIfAborted()
328
+ return undefined
329
+ }
322
330
  const text = await res.text()
323
331
  signal?.throwIfAborted()
324
332
  throw new Error(`ARM ${method} ${url} → ${res.status}: ${text}`)
@@ -517,13 +525,92 @@ async function spawnAciSandbox(
517
525
  readiness.pollIntervalMs,
518
526
  )
519
527
 
520
- let status: SandboxStatus = 'ready'
528
+ type Lifecycle = 'active' | 'retiring' | 'destroyed'
529
+ let activeExecutions = 0
530
+ let lifecycle: Lifecycle = 'active'
531
+ let retirementPromise:
532
+ | Promise<{ readonly accepted: boolean; readonly error?: Error }>
533
+ | undefined
534
+ let teardownPromise: Promise<void> | undefined
535
+ let teardownComplete = false
521
536
  const rootDir = config.layout.outputs.containerPath
537
+ const workerClient = new HttpWorkerClient(baseUrl)
538
+ const assertActive = (): void => {
539
+ if (lifecycle !== 'active') {
540
+ throw new Error(`Sandbox ${id} is ${lifecycle}; no new worker operation can be admitted`)
541
+ }
542
+ }
543
+ const teardownSandbox = (signal?: AbortSignal): Promise<void> => {
544
+ lifecycle = 'retiring'
545
+ if (teardownComplete) return Promise.resolve()
546
+ if (teardownPromise) return teardownPromise
547
+ const attempt = armCall(
548
+ armUrl,
549
+ 'DELETE',
550
+ config.getArmToken,
551
+ undefined,
552
+ signal,
553
+ [404, 410],
554
+ ).then(() => undefined)
555
+ const shared = attempt.then(
556
+ () => {
557
+ teardownComplete = true
558
+ lifecycle = 'destroyed'
559
+ },
560
+ (error: unknown) => {
561
+ if (teardownPromise === shared) teardownPromise = undefined
562
+ throw error
563
+ },
564
+ )
565
+ teardownPromise = shared
566
+ return shared
567
+ }
568
+ const retire = (): Promise<{
569
+ readonly accepted: boolean
570
+ readonly error?: Error
571
+ }> => {
572
+ lifecycle = 'retiring'
573
+ if (retirementPromise) return retirementPromise
574
+ const deadline = new OperationDeadline(5_000, `ACI sandbox ${id} retirement`)
575
+ retirementPromise = deadline
576
+ .run(async (signal) => {
577
+ const joinedExistingAttempt = teardownPromise !== undefined
578
+ try {
579
+ await teardownSandbox(signal)
580
+ } catch (error) {
581
+ if (!joinedExistingAttempt || signal.aborted) throw error
582
+ await teardownSandbox(signal)
583
+ }
584
+ })
585
+ .then(() => {
586
+ return { accepted: true as const }
587
+ })
588
+ .catch((error: unknown) => ({
589
+ accepted: false as const,
590
+ error: error instanceof Error ? error : new Error(String(error)),
591
+ }))
592
+ return retirementPromise
593
+ }
594
+ const runExecution = async <T>(operation: () => Promise<T>): Promise<T> => {
595
+ assertActive()
596
+ activeExecutions += 1
597
+ try {
598
+ return await operation()
599
+ } catch (error) {
600
+ if (error instanceof RemoteCancellationUnknownError) {
601
+ error.retirement = await retire()
602
+ }
603
+ throw error
604
+ } finally {
605
+ activeExecutions = Math.max(0, activeExecutions - 1)
606
+ }
607
+ }
522
608
 
523
609
  return {
524
610
  id,
525
611
  get status(): SandboxStatus {
526
- return status
612
+ if (lifecycle !== 'active') return 'destroyed'
613
+ return activeExecutions > 0 ? 'busy' : 'ready'
527
614
  },
528
615
  rootDir,
529
616
  environment: detectEnvironment(),
@@ -533,15 +620,11 @@ async function spawnAciSandbox(
533
620
  argv?: string[],
534
621
  opts?: SandboxExecOptions,
535
622
  ): Promise<SandboxExecResult> {
536
- status = 'busy'
537
- try {
538
- return await execViaWorker(baseUrl, command, argv, opts)
539
- } finally {
540
- status = 'ready'
541
- }
623
+ return await runExecution(async () => await workerClient.exec(command, argv, opts))
542
624
  },
543
625
 
544
626
  async writeFile(path: string, content: string | Buffer): Promise<void> {
627
+ assertActive()
545
628
  const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
546
629
  const res = await fetch(`${baseUrl}/write-file`, {
547
630
  method: 'POST',
@@ -558,6 +641,7 @@ async function spawnAciSandbox(
558
641
  },
559
642
 
560
643
  async readFile(path: string): Promise<Buffer> {
644
+ assertActive()
561
645
  const res = await fetch(`${baseUrl}/read-file`, {
562
646
  method: 'POST',
563
647
  headers: { 'content-type': 'application/json' },
@@ -578,11 +662,15 @@ async function spawnAciSandbox(
578
662
  },
579
663
 
580
664
  async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
581
- return await listFilesViaWorker(baseUrl, rootPath)
665
+ return await runExecution(async () => await listFilesViaWorker(workerClient, rootPath))
582
666
  },
583
667
 
584
668
  async destroy(options?: SandboxDestroyOptions): Promise<void> {
585
- status = 'destroyed'
669
+ if (retirementPromise) {
670
+ const observation = await retirementPromise
671
+ if (observation.accepted) return
672
+ retirementPromise = undefined
673
+ }
586
674
  // ARM DELETE — let failures propagate. The Vandal-side
587
675
  // lifecycle wraps this in its own try/catch with logging,
588
676
  // so a silently swallowed error here means orphan ACI
@@ -591,7 +679,7 @@ async function spawnAciSandbox(
591
679
  // the WARM side topped up; that has nothing to do with
592
680
  // cleaning up a CLAIMED instance, which is exclusively the
593
681
  // claimer's responsibility.
594
- await armCall(armUrl, 'DELETE', config.getArmToken, undefined, options?.signal)
682
+ await teardownSandbox(options?.signal)
595
683
  },
596
684
  }
597
685
  } catch (err) {
@@ -659,80 +747,6 @@ async function waitForWorkerReady(
659
747
  throw new Error(`aci-standby-pool: worker /healthz never responded (${timeoutMs}ms)`)
660
748
  }
661
749
 
662
- async function execViaWorker(
663
- baseUrl: string,
664
- command: string,
665
- argv: string[] | undefined,
666
- opts: SandboxExecOptions | undefined,
667
- ): Promise<SandboxExecResult> {
668
- const start = Date.now()
669
- const res = await fetch(`${baseUrl}/execute`, {
670
- method: 'POST',
671
- headers: { 'content-type': 'application/json' },
672
- body: JSON.stringify({
673
- command,
674
- args: argv ?? [],
675
- cwd: opts?.cwd,
676
- env: opts?.env,
677
- timeoutMs: opts?.timeout,
678
- }),
679
- })
680
- if (!res.ok || !res.body) {
681
- throw new Error(`execute failed: HTTP ${res.status} ${await res.text()}`)
682
- }
683
-
684
- let stdout = ''
685
- let stderr = ''
686
- let exitCode = -1
687
- let timedOut = false
688
-
689
- const decoder = new TextDecoder()
690
- const reader = res.body.getReader()
691
- let buffered = ''
692
- for (;;) {
693
- const { value, done } = await reader.read()
694
- if (done) break
695
- buffered += decoder.decode(value, { stream: true })
696
- let newlineIdx = buffered.indexOf('\n')
697
- while (newlineIdx !== -1) {
698
- const line = buffered.slice(0, newlineIdx).trim()
699
- buffered = buffered.slice(newlineIdx + 1)
700
- if (line) {
701
- try {
702
- const event = JSON.parse(line) as
703
- | { type: 'stdout_delta'; data: string }
704
- | { type: 'stderr_delta'; data: string }
705
- | { type: 'result'; exitCode: number; timedOut: boolean }
706
- | { type: 'error'; error: string }
707
- if (event.type === 'stdout_delta') {
708
- stdout += event.data
709
- opts?.onOutput?.({ stream: 'stdout', data: event.data })
710
- } else if (event.type === 'stderr_delta') {
711
- stderr += event.data
712
- opts?.onOutput?.({ stream: 'stderr', data: event.data })
713
- } else if (event.type === 'result') {
714
- exitCode = event.exitCode
715
- timedOut = event.timedOut
716
- } else if (event.type === 'error') {
717
- throw new Error(event.error)
718
- }
719
- } catch (err) {
720
- if (!(err instanceof SyntaxError)) throw err
721
- }
722
- }
723
- newlineIdx = buffered.indexOf('\n')
724
- }
725
- }
726
-
727
- return {
728
- stdout,
729
- stderr,
730
- exitCode,
731
- timedOut,
732
- durationMs: Date.now() - start,
733
- } as SandboxExecResult
734
- }
735
-
736
750
  /**
737
751
  * Recursively list regular files under `rootPath` by shelling out to
738
752
  * the worker's `find` (GNU find on the Debian-based reference image).
@@ -742,11 +756,10 @@ async function execViaWorker(
742
756
  * have produced anything in `rootPath` yet.
743
757
  */
744
758
  async function listFilesViaWorker(
745
- baseUrl: string,
759
+ workerClient: HttpWorkerClient,
746
760
  rootPath: string,
747
761
  ): Promise<readonly SandboxFileEntry[]> {
748
- const result = await execViaWorker(
749
- baseUrl,
762
+ const result = await workerClient.exec(
750
763
  'find',
751
764
  [rootPath, '-type', 'f', '-printf', '%p\t%s\n'],
752
765
  undefined,
@@ -57,6 +57,7 @@ import {
57
57
  type SandboxBackend,
58
58
  type SandboxBackendOptions,
59
59
  } from '../../index.js'
60
+ import { HttpWorkerClient } from '../http-worker-client.js'
60
61
  import {
61
62
  OperationDeadline,
62
63
  OperationDeadlineExpired,
@@ -64,6 +65,7 @@ import {
64
65
  resolveReadinessOptions,
65
66
  runFailureCleanup,
66
67
  } from '../readiness.js'
68
+ import { RemoteCancellationUnknownError } from '../remote-execution-controller.js'
67
69
 
68
70
  /**
69
71
  * Backend-specific tuning. Most hosts use the defaults; advanced
@@ -599,12 +601,96 @@ async function spawnDockerSandbox(
599
601
  throw err
600
602
  }
601
603
 
602
- 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
+ }
603
688
 
604
689
  return {
605
690
  id,
606
691
  get status(): SandboxStatus {
607
- return status
692
+ if (lifecycle !== 'active') return 'destroyed'
693
+ return activeExecutions > 0 ? 'busy' : 'ready'
608
694
  },
609
695
  rootDir,
610
696
  environment: detectEnvironment(),
@@ -614,15 +700,11 @@ async function spawnDockerSandbox(
614
700
  argv?: string[],
615
701
  opts?: SandboxExecOptions,
616
702
  ): Promise<SandboxExecResult> {
617
- status = 'busy'
618
- try {
619
- return await execViaWorker(baseUrl, command, argv, opts)
620
- } finally {
621
- status = 'ready'
622
- }
703
+ return await runExecution(async () => await workerClient.exec(command, argv, opts))
623
704
  },
624
705
 
625
706
  async setNetworkPolicy(policy): Promise<void> {
707
+ assertActive()
626
708
  // Enforceable only through the egress proxy. Without one the
627
709
  // container's network was fixed at creation — `--network none`
628
710
  // or not — and there is nothing to narrow: accepting the policy
@@ -641,6 +723,7 @@ async function spawnDockerSandbox(
641
723
  },
642
724
 
643
725
  async writeFile(path: string, content: string | Buffer): Promise<void> {
726
+ assertActive()
644
727
  const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
645
728
  let res: Response
646
729
  try {
@@ -672,6 +755,7 @@ async function spawnDockerSandbox(
672
755
  },
673
756
 
674
757
  async readFile(path: string): Promise<Buffer> {
758
+ assertActive()
675
759
  const res = await fetch(`${baseUrl}/read-file`, {
676
760
  method: 'POST',
677
761
  headers: { 'content-type': 'application/json' },
@@ -692,17 +776,16 @@ async function spawnDockerSandbox(
692
776
  },
693
777
 
694
778
  async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
695
- return await listFilesViaWorker(baseUrl, rootPath)
779
+ return await runExecution(async () => await listFilesViaWorker(workerClient, rootPath))
696
780
  },
697
781
 
698
782
  async destroy(options?: SandboxDestroyOptions): Promise<void> {
699
- status = 'destroyed'
700
- await runOnceQuiet(docker, ['rm', '-f', containerName], options?.signal)
701
- // The proxy holds real credentials and a live allowlist. Leaving
702
- // it listening after the sandbox it was filtering for is gone
703
- // means a loopback port that still stamps a token onto anything
704
- // that asks — outliving the only thing that justified it.
705
- await egressProxy?.close()
783
+ if (retirementPromise) {
784
+ const observation = await retirementPromise
785
+ if (observation.accepted) return
786
+ retirementPromise = undefined
787
+ }
788
+ await teardownSandbox(options?.signal)
706
789
  // Backend never allocates host paths — every bind source
707
790
  // comes from the consumer-supplied layout. Container
708
791
  // teardown is sufficient; the consumer's own lifecycle
@@ -746,119 +829,6 @@ async function readMappedPort(
746
829
  return port
747
830
  }
748
831
 
749
- async function execViaWorker(
750
- baseUrl: string,
751
- command: string,
752
- argv: string[] | undefined,
753
- opts: SandboxExecOptions | undefined,
754
- ): Promise<SandboxExecResult> {
755
- // `opts.signal` is deliberately NOT passed to `fetch`. It would abort the
756
- // request and leave the worker running the command — abandoning the wait
757
- // while the process lives on is the exact failure
758
- // `SandboxExecOptions.signal` exists to prevent, and wiring it here would
759
- // make the option look honoured while delivering that failure. Honouring
760
- // it needs a cancel endpoint on the worker.
761
- const start = Date.now()
762
- let res: Response
763
- try {
764
- res = await fetch(`${baseUrl}/execute`, {
765
- method: 'POST',
766
- headers: { 'content-type': 'application/json' },
767
- body: JSON.stringify({
768
- command,
769
- args: argv ?? [],
770
- cwd: opts?.cwd,
771
- env: opts?.env,
772
- timeoutMs: opts?.timeout,
773
- }),
774
- })
775
- } catch (err) {
776
- // Surface the underlying transport error (DNS, ECONNREFUSED,
777
- // socket-hangup, …) instead of the generic "fetch failed" the
778
- // undici client throws. Without `cause`, ops cannot tell whether
779
- // the worker died, the bridge dropped, or something else.
780
- const cause = err instanceof Error ? err.cause : undefined
781
- const causeMsg =
782
- cause instanceof Error
783
- ? `${cause.message}${(cause as Error & { code?: string }).code ? ` (${(cause as Error & { code?: string }).code})` : ''}`
784
- : cause
785
- ? String(cause)
786
- : 'unknown'
787
- throw withHint(
788
- new Error(
789
- `namzu-sandbox /execute fetch failed (baseUrl=${baseUrl}): ${err instanceof Error ? err.message : String(err)} — cause: ${causeMsg}`,
790
- { cause: err },
791
- ),
792
- '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.',
793
- )
794
- }
795
- if (!res.ok || !res.body) {
796
- throw new Error(`execute failed: HTTP ${res.status} ${await res.text()}`)
797
- }
798
-
799
- let stdout = ''
800
- let stderr = ''
801
- let exitCode = -1
802
- let timedOut = false
803
- let signal: string | undefined
804
-
805
- const decoder = new TextDecoder()
806
- const reader = res.body.getReader()
807
- let buffered = ''
808
- for (;;) {
809
- const { value, done } = await reader.read()
810
- if (done) break
811
- buffered += decoder.decode(value, { stream: true })
812
- let newlineIdx = buffered.indexOf('\n')
813
- while (newlineIdx !== -1) {
814
- const line = buffered.slice(0, newlineIdx).trim()
815
- buffered = buffered.slice(newlineIdx + 1)
816
- if (line) {
817
- try {
818
- const event = JSON.parse(line) as
819
- | { type: 'stdout_delta'; data: string }
820
- | { type: 'stderr_delta'; data: string }
821
- | {
822
- type: 'result'
823
- exitCode: number
824
- timedOut: boolean
825
- durationMs: number
826
- }
827
- | { type: 'error'; error: string }
828
- if (event.type === 'stdout_delta') {
829
- stdout += event.data
830
- opts?.onOutput?.({ stream: 'stdout', data: event.data })
831
- } else if (event.type === 'stderr_delta') {
832
- stderr += event.data
833
- opts?.onOutput?.({ stream: 'stderr', data: event.data })
834
- } else if (event.type === 'result') {
835
- exitCode = event.exitCode
836
- timedOut = event.timedOut
837
- } else if (event.type === 'error') {
838
- throw new Error(event.error)
839
- }
840
- } catch (err) {
841
- if (err instanceof SyntaxError) {
842
- // Ignore malformed lines from the worker.
843
- } else {
844
- throw err
845
- }
846
- }
847
- }
848
- newlineIdx = buffered.indexOf('\n')
849
- }
850
- }
851
-
852
- return {
853
- exitCode,
854
- stdout,
855
- stderr,
856
- ...(signal ? { signal } : {}),
857
- timedOut,
858
- durationMs: Date.now() - start,
859
- }
860
- }
861
-
862
832
  /**
863
833
  * Recursively list regular files under `rootPath` by shelling out to
864
834
  * the worker's `find` (GNU find on the Debian-based reference image).
@@ -868,11 +838,10 @@ async function execViaWorker(
868
838
  * have produced anything in `rootPath` yet.
869
839
  */
870
840
  async function listFilesViaWorker(
871
- baseUrl: string,
841
+ workerClient: HttpWorkerClient,
872
842
  rootPath: string,
873
843
  ): Promise<readonly SandboxFileEntry[]> {
874
- const result = await execViaWorker(
875
- baseUrl,
844
+ const result = await workerClient.exec(
876
845
  'find',
877
846
  [rootPath, '-type', 'f', '-printf', '%p\t%s\n'],
878
847
  undefined,