@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.
- package/CHANGELOG.md +62 -0
- package/README.md +29 -1
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +163 -111
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +212 -149
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +154 -71
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/backends/firecracker/protocol.d.ts +19 -27
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js +57 -32
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +26 -7
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +235 -72
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/http-worker-client.d.ts +17 -0
- package/dist/backends/http-worker-client.d.ts.map +1 -0
- package/dist/backends/http-worker-client.js +173 -0
- package/dist/backends/http-worker-client.js.map +1 -0
- package/dist/backends/readiness.d.ts +43 -0
- package/dist/backends/readiness.d.ts.map +1 -0
- package/dist/backends/readiness.js +150 -0
- package/dist/backends/readiness.js.map +1 -0
- package/dist/backends/remote-execution-controller.d.ts +76 -0
- package/dist/backends/remote-execution-controller.d.ts.map +1 -0
- package/dist/backends/remote-execution-controller.js +294 -0
- package/dist/backends/remote-execution-controller.js.map +1 -0
- package/dist/egress/proxy.d.ts.map +1 -1
- package/dist/egress/proxy.js +20 -3
- package/dist/egress/proxy.js.map +1 -1
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/backends/aci-standby-pool/index.ts +199 -117
- package/src/backends/docker/index.ts +235 -175
- package/src/backends/firecracker/index.ts +163 -63
- package/src/backends/firecracker/protocol.ts +67 -33
- package/src/backends/firecracker/transport.ts +301 -77
- package/src/backends/http-worker-client.ts +230 -0
- package/src/backends/readiness.ts +173 -0
- package/src/backends/remote-execution-controller.ts +462 -0
- package/src/egress/proxy.ts +18 -3
- 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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
565
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
779
|
+
return await runExecution(async () => await listFilesViaWorker(workerClient, rootPath))
|
|
663
780
|
},
|
|
664
781
|
|
|
665
|
-
async destroy(): Promise<void> {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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(
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
841
|
+
workerClient: HttpWorkerClient,
|
|
831
842
|
rootPath: string,
|
|
832
843
|
): Promise<readonly SandboxFileEntry[]> {
|
|
833
|
-
const result = await
|
|
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 =
|
|
898
|
+
const deadline = new OperationDeadline(timeoutMs, 'docker worker readiness', signal)
|
|
888
899
|
let lastError: unknown
|
|
889
|
-
while (
|
|
900
|
+
while (deadline.remainingMs() > 0) {
|
|
890
901
|
try {
|
|
891
|
-
const
|
|
892
|
-
if (
|
|
893
|
-
lastError = new Error(`healthz HTTP ${
|
|
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',
|
|
957
|
+
child.on('error', (error) => finish(error))
|
|
925
958
|
child.on('close', (code) => {
|
|
926
|
-
if (code === 0)
|
|
927
|
-
else
|
|
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(
|
|
976
|
+
async function inspectNetworkInternalFlag(
|
|
977
|
+
docker: string,
|
|
978
|
+
network: string,
|
|
979
|
+
signal?: AbortSignal,
|
|
980
|
+
): Promise<string> {
|
|
942
981
|
try {
|
|
943
|
-
return await runOnce(
|
|
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
|
-
|
|
953
|
-
|
|
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
|
|