@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
@@ -44,6 +44,7 @@ import type {
44
44
  ContainerSandboxMountSource,
45
45
  ResolvedContainerSandboxLayout,
46
46
  Sandbox,
47
+ SandboxDestroyOptions,
47
48
  SandboxEnvironment,
48
49
  SandboxExecOptions,
49
50
  SandboxExecResult,
@@ -54,6 +55,15 @@ import type {
54
55
  import { asSandboxId } from '@namzu/sdk'
55
56
 
56
57
  import type { SandboxBackend, SandboxBackendOptions } from '../../index.js'
58
+ import { HttpWorkerClient } from '../http-worker-client.js'
59
+ import {
60
+ OperationDeadline,
61
+ OperationDeadlineExpired,
62
+ probeHttpHealth,
63
+ resolveReadinessOptions,
64
+ runFailureCleanup,
65
+ } from '../readiness.js'
66
+ import { RemoteCancellationUnknownError } from '../remote-execution-controller.js'
57
67
 
58
68
  /**
59
69
  * Authentication callback. Caller returns a fresh Azure Resource
@@ -147,11 +157,20 @@ const DEFAULT_CONTAINER_NAME_PREFIX = 'namzu-task'
147
157
  export function buildAciStandbyPoolBackend(
148
158
  config: ACIStandbyPoolBackendInternalConfig,
149
159
  ): SandboxBackend {
160
+ const readiness = resolveReadinessOptions(
161
+ 'aci-standby-pool',
162
+ config.readyTimeoutMs,
163
+ config.readyPollIntervalMs,
164
+ {
165
+ timeoutMs: DEFAULT_READY_TIMEOUT_MS,
166
+ pollIntervalMs: DEFAULT_READY_POLL_MS,
167
+ },
168
+ )
150
169
  return {
151
170
  tier: 'container',
152
171
  name: 'aci-standby-pool',
153
172
  async create(options: SandboxBackendOptions): Promise<Sandbox> {
154
- return await spawnAciSandbox(config, options)
173
+ return await spawnAciSandbox(config, options, readiness)
155
174
  },
156
175
  }
157
176
  }
@@ -219,7 +238,10 @@ function buildAzureFileVolumesFromLayout(layout: ResolvedContainerSandboxLayout)
219
238
  let counter = 0
220
239
 
221
240
  function add(
222
- mount: { readonly source: ContainerSandboxMountSource; readonly containerPath: string },
241
+ mount: {
242
+ readonly source: ContainerSandboxMountSource
243
+ readonly containerPath: string
244
+ },
223
245
  label: string,
224
246
  readOnly: boolean,
225
247
  ): void {
@@ -280,8 +302,12 @@ async function armCall<T>(
280
302
  method: 'GET' | 'PUT' | 'DELETE',
281
303
  getToken: ArmTokenProvider,
282
304
  body?: unknown,
305
+ signal?: AbortSignal,
306
+ acceptedStatuses: readonly number[] = [],
283
307
  ): Promise<T | undefined> {
308
+ signal?.throwIfAborted()
284
309
  const token = await getToken()
310
+ signal?.throwIfAborted()
285
311
  const init: RequestInit = {
286
312
  method,
287
313
  headers: {
@@ -292,15 +318,25 @@ async function armCall<T>(
292
318
  if (body !== undefined) {
293
319
  init.body = JSON.stringify(body)
294
320
  }
321
+ if (signal !== undefined) init.signal = signal
295
322
  const res = await fetch(url, init)
323
+ signal?.throwIfAborted()
296
324
  if (!res.ok) {
325
+ if (acceptedStatuses.includes(res.status)) {
326
+ await res.text()
327
+ signal?.throwIfAborted()
328
+ return undefined
329
+ }
297
330
  const text = await res.text()
331
+ signal?.throwIfAborted()
298
332
  throw new Error(`ARM ${method} ${url} → ${res.status}: ${text}`)
299
333
  }
300
334
  if (res.status === 204 || res.status === 202) return undefined
301
335
  const ct = res.headers.get('content-type') ?? ''
302
336
  if (ct.includes('application/json')) {
303
- return (await res.json()) as T
337
+ const json = (await res.json()) as T
338
+ signal?.throwIfAborted()
339
+ return json
304
340
  }
305
341
  return undefined
306
342
  }
@@ -389,7 +425,9 @@ export function assertNotPubliclyAddressed(config: {
389
425
  async function spawnAciSandbox(
390
426
  config: ACIStandbyPoolBackendInternalConfig,
391
427
  options: SandboxBackendOptions,
428
+ readiness: { readonly timeoutMs: number; readonly pollIntervalMs: number },
392
429
  ): Promise<Sandbox> {
430
+ options.signal?.throwIfAborted()
393
431
  assertEnforceable(options)
394
432
  assertNotPubliclyAddressed(config)
395
433
  const id = generateSandboxId()
@@ -439,8 +477,23 @@ async function spawnAciSandbox(
439
477
 
440
478
  let claimed: ArmContainerGroup | undefined
441
479
  try {
442
- claimed = await armCall<ArmContainerGroup>(armUrl, 'PUT', config.getArmToken, body)
480
+ claimed = await armCall<ArmContainerGroup>(
481
+ armUrl,
482
+ 'PUT',
483
+ config.getArmToken,
484
+ body,
485
+ options.signal,
486
+ )
443
487
  } catch (err) {
488
+ // The resource name is client-owned even when ARM never returns the
489
+ // claim response. Best-effort reconciliation is therefore possible.
490
+ // ARM still owns the create/delete ordering semantics; a resource that
491
+ // commits after this DELETE needs the deployment's normal resource reaper.
492
+ if (options.signal?.aborted) {
493
+ await runFailureCleanup(async (signal) => {
494
+ await armCall(armUrl, 'DELETE', config.getArmToken, undefined, signal)
495
+ })
496
+ }
444
497
  throw new Error(
445
498
  `aci-standby-pool: failed to claim from pool — ${err instanceof Error ? err.message : String(err)}`,
446
499
  )
@@ -448,30 +501,116 @@ async function spawnAciSandbox(
448
501
 
449
502
  const initialIp = claimed?.properties?.ipAddress?.ip
450
503
  let ip = initialIp
504
+ const readinessDeadline = new OperationDeadline(
505
+ readiness.timeoutMs,
506
+ 'aci-standby-pool readiness',
507
+ options.signal,
508
+ )
451
509
  try {
452
510
  if (!ip) {
453
511
  ip = await pollForRunningIp(
454
512
  armUrl,
455
513
  config.getArmToken,
456
- config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
457
- config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
514
+ readinessDeadline,
515
+ readiness.pollIntervalMs,
516
+ readiness.timeoutMs,
458
517
  )
459
518
  }
460
519
 
461
520
  const baseUrl = `http://${ip}:${workerPort}`
462
521
  await waitForWorkerReady(
463
522
  baseUrl,
464
- config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
465
- config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
523
+ readinessDeadline,
524
+ readiness.timeoutMs,
525
+ readiness.pollIntervalMs,
466
526
  )
467
527
 
468
- 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
469
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
+ }
470
608
 
471
609
  return {
472
610
  id,
473
611
  get status(): SandboxStatus {
474
- return status
612
+ if (lifecycle !== 'active') return 'destroyed'
613
+ return activeExecutions > 0 ? 'busy' : 'ready'
475
614
  },
476
615
  rootDir,
477
616
  environment: detectEnvironment(),
@@ -481,15 +620,11 @@ async function spawnAciSandbox(
481
620
  argv?: string[],
482
621
  opts?: SandboxExecOptions,
483
622
  ): Promise<SandboxExecResult> {
484
- status = 'busy'
485
- try {
486
- return await execViaWorker(baseUrl, command, argv, opts)
487
- } finally {
488
- status = 'ready'
489
- }
623
+ return await runExecution(async () => await workerClient.exec(command, argv, opts))
490
624
  },
491
625
 
492
626
  async writeFile(path: string, content: string | Buffer): Promise<void> {
627
+ assertActive()
493
628
  const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
494
629
  const res = await fetch(`${baseUrl}/write-file`, {
495
630
  method: 'POST',
@@ -506,6 +641,7 @@ async function spawnAciSandbox(
506
641
  },
507
642
 
508
643
  async readFile(path: string): Promise<Buffer> {
644
+ assertActive()
509
645
  const res = await fetch(`${baseUrl}/read-file`, {
510
646
  method: 'POST',
511
647
  headers: { 'content-type': 'application/json' },
@@ -514,7 +650,11 @@ async function spawnAciSandbox(
514
650
  if (!res.ok) {
515
651
  throw new Error(`read-file failed: HTTP ${res.status} ${await res.text()}`)
516
652
  }
517
- const json = (await res.json()) as { ok: boolean; content?: string; error?: string }
653
+ const json = (await res.json()) as {
654
+ ok: boolean
655
+ content?: string
656
+ error?: string
657
+ }
518
658
  if (!json.ok || typeof json.content !== 'string') {
519
659
  throw new Error(json.error ?? 'read-file: no content')
520
660
  }
@@ -522,11 +662,15 @@ async function spawnAciSandbox(
522
662
  },
523
663
 
524
664
  async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
525
- return await listFilesViaWorker(baseUrl, rootPath)
665
+ return await runExecution(async () => await listFilesViaWorker(workerClient, rootPath))
526
666
  },
527
667
 
528
- async destroy(): Promise<void> {
529
- status = 'destroyed'
668
+ async destroy(options?: SandboxDestroyOptions): Promise<void> {
669
+ if (retirementPromise) {
670
+ const observation = await retirementPromise
671
+ if (observation.accepted) return
672
+ retirementPromise = undefined
673
+ }
530
674
  // ARM DELETE — let failures propagate. The Vandal-side
531
675
  // lifecycle wraps this in its own try/catch with logging,
532
676
  // so a silently swallowed error here means orphan ACI
@@ -535,17 +679,13 @@ async function spawnAciSandbox(
535
679
  // the WARM side topped up; that has nothing to do with
536
680
  // cleaning up a CLAIMED instance, which is exclusively the
537
681
  // claimer's responsibility.
538
- await armCall(armUrl, 'DELETE', config.getArmToken)
682
+ await teardownSandbox(options?.signal)
539
683
  },
540
684
  }
541
685
  } catch (err) {
542
- try {
543
- await armCall(armUrl, 'DELETE', config.getArmToken)
544
- } catch {
545
- // Preserve the readiness failure as the primary error; the
546
- // caller cannot use a Sandbox handle yet, so best-effort ARM
547
- // cleanup is the only reliable orphan-prevention hook here.
548
- }
686
+ await runFailureCleanup(async (signal) => {
687
+ await armCall(armUrl, 'DELETE', config.getArmToken, undefined, signal)
688
+ })
549
689
  throw err
550
690
  }
551
691
  }
@@ -553,115 +693,58 @@ async function spawnAciSandbox(
553
693
  async function pollForRunningIp(
554
694
  armUrl: string,
555
695
  getToken: ArmTokenProvider,
696
+ deadline: OperationDeadline,
556
697
  pollIntervalMs: number,
557
698
  timeoutMs: number,
558
699
  ): Promise<string> {
559
- const deadline = Date.now() + timeoutMs
560
- while (Date.now() < deadline) {
561
- const cg = await armCall<ArmContainerGroup>(armUrl, 'GET', getToken)
700
+ while (deadline.remainingMs() > 0) {
701
+ let cg: ArmContainerGroup | undefined
702
+ try {
703
+ cg = await deadline.run((signal) =>
704
+ armCall<ArmContainerGroup>(armUrl, 'GET', getToken, undefined, signal),
705
+ )
706
+ } catch (err) {
707
+ if (err instanceof OperationDeadlineExpired) break
708
+ throw err
709
+ }
562
710
  const state = cg?.properties?.provisioningState
563
711
  const ip = cg?.properties?.ipAddress?.ip
564
712
  if (state === 'Succeeded' && ip) return ip
565
713
  if (state === 'Failed') {
566
714
  throw new Error('aci-standby-pool: container group provisioning failed')
567
715
  }
568
- await new Promise((r) => setTimeout(r, pollIntervalMs))
716
+ try {
717
+ await deadline.delay(pollIntervalMs)
718
+ } catch (err) {
719
+ if (err instanceof OperationDeadlineExpired) break
720
+ throw err
721
+ }
569
722
  }
570
723
  throw new Error(`aci-standby-pool: timed out waiting for container group IP (${timeoutMs}ms)`)
571
724
  }
572
725
 
573
726
  async function waitForWorkerReady(
574
727
  baseUrl: string,
728
+ deadline: OperationDeadline,
575
729
  timeoutMs: number,
576
730
  pollIntervalMs: number,
577
731
  ): Promise<void> {
578
- const deadline = Date.now() + timeoutMs
579
- while (Date.now() < deadline) {
732
+ while (deadline.remainingMs() > 0) {
580
733
  try {
581
- const res = await fetch(`${baseUrl}/healthz`, {
582
- signal: AbortSignal.timeout(2000),
583
- })
584
- if (res.ok) return
585
- } catch {
734
+ const result = await deadline.run((signal) => probeHttpHealth(`${baseUrl}/healthz`, signal))
735
+ if (result.ok) return
736
+ } catch (err) {
737
+ if (err instanceof OperationDeadlineExpired) break
586
738
  // Network not ready yet, try again.
587
739
  }
588
- await new Promise((r) => setTimeout(r, pollIntervalMs))
589
- }
590
- throw new Error(`aci-standby-pool: worker /healthz never responded (${timeoutMs}ms)`)
591
- }
592
-
593
- async function execViaWorker(
594
- baseUrl: string,
595
- command: string,
596
- argv: string[] | undefined,
597
- opts: SandboxExecOptions | undefined,
598
- ): Promise<SandboxExecResult> {
599
- const start = Date.now()
600
- const res = await fetch(`${baseUrl}/execute`, {
601
- method: 'POST',
602
- headers: { 'content-type': 'application/json' },
603
- body: JSON.stringify({
604
- command,
605
- args: argv ?? [],
606
- cwd: opts?.cwd,
607
- env: opts?.env,
608
- timeoutMs: opts?.timeout,
609
- }),
610
- })
611
- if (!res.ok || !res.body) {
612
- throw new Error(`execute failed: HTTP ${res.status} ${await res.text()}`)
613
- }
614
-
615
- let stdout = ''
616
- let stderr = ''
617
- let exitCode = -1
618
- let timedOut = false
619
-
620
- const decoder = new TextDecoder()
621
- const reader = res.body.getReader()
622
- let buffered = ''
623
- for (;;) {
624
- const { value, done } = await reader.read()
625
- if (done) break
626
- buffered += decoder.decode(value, { stream: true })
627
- let newlineIdx = buffered.indexOf('\n')
628
- while (newlineIdx !== -1) {
629
- const line = buffered.slice(0, newlineIdx).trim()
630
- buffered = buffered.slice(newlineIdx + 1)
631
- if (line) {
632
- try {
633
- const event = JSON.parse(line) as
634
- | { type: 'stdout_delta'; data: string }
635
- | { type: 'stderr_delta'; data: string }
636
- | { type: 'result'; exitCode: number; timedOut: boolean }
637
- | { type: 'error'; error: string }
638
- if (event.type === 'stdout_delta') {
639
- stdout += event.data
640
- opts?.onOutput?.({ stream: 'stdout', data: event.data })
641
- } else if (event.type === 'stderr_delta') {
642
- stderr += event.data
643
- opts?.onOutput?.({ stream: 'stderr', data: event.data })
644
- } else if (event.type === 'result') {
645
- exitCode = event.exitCode
646
- timedOut = event.timedOut
647
- } else if (event.type === 'error') {
648
- throw new Error(event.error)
649
- }
650
- } catch (err) {
651
- if (!(err instanceof SyntaxError)) throw err
652
- }
653
- }
654
- newlineIdx = buffered.indexOf('\n')
740
+ try {
741
+ await deadline.delay(pollIntervalMs)
742
+ } catch (err) {
743
+ if (err instanceof OperationDeadlineExpired) break
744
+ throw err
655
745
  }
656
746
  }
657
-
658
- return {
659
- stdout,
660
- stderr,
661
- exitCode,
662
- timedOut,
663
- durationMs: Date.now() - start,
664
- } as SandboxExecResult
747
+ throw new Error(`aci-standby-pool: worker /healthz never responded (${timeoutMs}ms)`)
665
748
  }
666
749
 
667
750
  /**
@@ -673,11 +756,10 @@ async function execViaWorker(
673
756
  * have produced anything in `rootPath` yet.
674
757
  */
675
758
  async function listFilesViaWorker(
676
- baseUrl: string,
759
+ workerClient: HttpWorkerClient,
677
760
  rootPath: string,
678
761
  ): Promise<readonly SandboxFileEntry[]> {
679
- const result = await execViaWorker(
680
- baseUrl,
762
+ const result = await workerClient.exec(
681
763
  'find',
682
764
  [rootPath, '-type', 'f', '-printf', '%p\t%s\n'],
683
765
  undefined,