@namzu/sandbox 7.0.0 → 7.2.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 +42 -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 +95 -82
- 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 +108 -114
- 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 +117 -52
- 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 +23 -4
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +153 -32
- 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/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/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/backends/aci-standby-pool/index.ts +101 -88
- package/src/backends/docker/index.ts +101 -132
- package/src/backends/firecracker/index.ts +117 -45
- package/src/backends/firecracker/protocol.ts +67 -33
- package/src/backends/firecracker/transport.ts +214 -39
- package/src/backends/http-worker-client.ts +230 -0
- package/src/backends/remote-execution-controller.ts +462 -0
- package/src/index.ts +4 -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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
665
|
+
return await runExecution(async () => await listFilesViaWorker(workerClient, rootPath))
|
|
582
666
|
},
|
|
583
667
|
|
|
584
668
|
async destroy(options?: SandboxDestroyOptions): Promise<void> {
|
|
585
|
-
|
|
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
|
|
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
|
-
|
|
759
|
+
workerClient: HttpWorkerClient,
|
|
746
760
|
rootPath: string,
|
|
747
761
|
): Promise<readonly SandboxFileEntry[]> {
|
|
748
|
-
const result = await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
779
|
+
return await runExecution(async () => await listFilesViaWorker(workerClient, rootPath))
|
|
696
780
|
},
|
|
697
781
|
|
|
698
782
|
async destroy(options?: SandboxDestroyOptions): Promise<void> {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
841
|
+
workerClient: HttpWorkerClient,
|
|
872
842
|
rootPath: string,
|
|
873
843
|
): Promise<readonly SandboxFileEntry[]> {
|
|
874
|
-
const result = await
|
|
875
|
-
baseUrl,
|
|
844
|
+
const result = await workerClient.exec(
|
|
876
845
|
'find',
|
|
877
846
|
[rootPath, '-type', 'f', '-printf', '%p\t%s\n'],
|
|
878
847
|
undefined,
|