@namzu/sandbox 6.1.0 → 7.0.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 (33) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  3. package/dist/backends/aci-standby-pool/index.js +70 -31
  4. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  5. package/dist/backends/docker/index.d.ts.map +1 -1
  6. package/dist/backends/docker/index.js +105 -36
  7. package/dist/backends/docker/index.js.map +1 -1
  8. package/dist/backends/firecracker/index.d.ts.map +1 -1
  9. package/dist/backends/firecracker/index.js +43 -25
  10. package/dist/backends/firecracker/index.js.map +1 -1
  11. package/dist/backends/firecracker/transport.d.ts +3 -3
  12. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  13. package/dist/backends/firecracker/transport.js +82 -40
  14. package/dist/backends/firecracker/transport.js.map +1 -1
  15. package/dist/backends/readiness.d.ts +43 -0
  16. package/dist/backends/readiness.d.ts.map +1 -0
  17. package/dist/backends/readiness.js +150 -0
  18. package/dist/backends/readiness.js.map +1 -0
  19. package/dist/egress/proxy.d.ts.map +1 -1
  20. package/dist/egress/proxy.js +20 -3
  21. package/dist/egress/proxy.js.map +1 -1
  22. package/dist/index.d.ts +18 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +9 -0
  25. package/dist/index.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/backends/aci-standby-pool/index.ts +99 -30
  28. package/src/backends/docker/index.ts +135 -44
  29. package/src/backends/firecracker/index.ts +49 -21
  30. package/src/backends/firecracker/transport.ts +87 -38
  31. package/src/backends/readiness.ts +173 -0
  32. package/src/egress/proxy.ts +18 -3
  33. package/src/index.ts +27 -0
@@ -57,6 +57,7 @@ import net from 'node:net'
57
57
  import tls from 'node:tls'
58
58
 
59
59
  import type { SandboxExecResult } from '@namzu/sdk'
60
+ import { OperationDeadline, OperationDeadlineExpired } from '../readiness.js'
60
61
  import {
61
62
  type ExecRequest,
62
63
  ExecResultAccumulator,
@@ -256,16 +257,18 @@ export class VsockAgentTransport {
256
257
  * failures (ECONNREFUSED while the agent re-listens after a resume,
257
258
  * a dropped CONNECT ack) until the budget is exhausted.
258
259
  */
259
- private async dial(): Promise<net.Socket> {
260
+ private async dial(signal?: AbortSignal): Promise<net.Socket> {
260
261
  const deadline = Date.now() + this.connectRetryBudgetMs
261
262
  let lastErr: unknown
262
263
  for (;;) {
264
+ signal?.throwIfAborted()
263
265
  try {
264
- return await this.connectOnce()
266
+ return await this.connectOnce(signal)
265
267
  } catch (err) {
268
+ if (signal?.aborted) throw signal.reason
266
269
  lastErr = err
267
270
  if (Date.now() >= deadline) break
268
- await delay(this.connectRetryIntervalMs)
271
+ await delay(this.connectRetryIntervalMs, signal)
269
272
  }
270
273
  }
271
274
  throw new Error(
@@ -276,36 +279,41 @@ export class VsockAgentTransport {
276
279
  )
277
280
  }
278
281
 
279
- private connectOnce(): Promise<net.Socket> {
282
+ private connectOnce(signal?: AbortSignal): Promise<net.Socket> {
280
283
  const handle = this.handle
281
- if (handle.kind === 'mtls') return this.connectOnceMtls(handle)
284
+ if (handle.kind === 'mtls') return this.connectOnceMtls(handle, signal)
282
285
  return new Promise<net.Socket>((resolve, reject) => {
283
286
  const path = handle.kind === 'unix' ? handle.path : handle.udsPath
284
287
  const socket = net.connect({ path })
285
288
  let settled = false
286
- const timer = setTimeout(() => {
287
- if (settled) return
288
- settled = true
289
- socket.destroy()
290
- reject(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`))
291
- }, this.connectTimeoutMs)
292
- timer.unref()
293
-
294
289
  const fail = (err: Error) => {
295
290
  if (settled) return
296
291
  settled = true
297
292
  clearTimeout(timer)
293
+ signal?.removeEventListener('abort', abort)
298
294
  socket.destroy()
299
295
  reject(err)
300
296
  }
297
+ const abort = () => fail(signalError(signal))
298
+ const timer = setTimeout(
299
+ () => fail(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`)),
300
+ this.connectTimeoutMs,
301
+ )
302
+ timer.unref()
301
303
 
302
304
  socket.once('error', fail)
305
+ if (signal?.aborted) {
306
+ abort()
307
+ return
308
+ }
309
+ signal?.addEventListener('abort', abort, { once: true })
303
310
 
304
311
  socket.once('connect', () => {
305
312
  if (handle.kind === 'unix') {
306
313
  if (settled) return
307
314
  settled = true
308
315
  clearTimeout(timer)
316
+ signal?.removeEventListener('abort', abort)
309
317
  socket.removeListener('error', fail)
310
318
  resolve(socket)
311
319
  return
@@ -327,6 +335,7 @@ export class VsockAgentTransport {
327
335
  if (settled) return
328
336
  settled = true
329
337
  clearTimeout(timer)
338
+ signal?.removeEventListener('abort', abort)
330
339
  socket.removeListener('error', fail)
331
340
  // Any bytes the ackReader over-read after the ack line are
332
341
  // application framing; replay them into the caller.
@@ -363,6 +372,7 @@ export class VsockAgentTransport {
363
372
  */
364
373
  private connectOnceMtls(
365
374
  handle: Extract<SandboxAgentHandle, { kind: 'mtls' }>,
375
+ signal?: AbortSignal,
366
376
  ): Promise<net.Socket> {
367
377
  return new Promise<net.Socket>((resolve, reject) => {
368
378
  const socket = tls.connect({
@@ -376,23 +386,27 @@ export class VsockAgentTransport {
376
386
  minVersion: 'TLSv1.3',
377
387
  })
378
388
  let settled = false
379
- const timer = setTimeout(() => {
380
- if (settled) return
381
- settled = true
382
- socket.destroy()
383
- reject(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`))
384
- }, this.connectTimeoutMs)
385
- timer.unref()
386
-
387
389
  const fail = (err: Error) => {
388
390
  if (settled) return
389
391
  settled = true
390
392
  clearTimeout(timer)
393
+ signal?.removeEventListener('abort', abort)
391
394
  socket.destroy()
392
395
  reject(err)
393
396
  }
397
+ const abort = () => fail(signalError(signal))
398
+ const timer = setTimeout(
399
+ () => fail(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`)),
400
+ this.connectTimeoutMs,
401
+ )
402
+ timer.unref()
394
403
 
395
404
  socket.once('error', fail)
405
+ if (signal?.aborted) {
406
+ abort()
407
+ return
408
+ }
409
+ signal?.addEventListener('abort', abort, { once: true })
396
410
 
397
411
  // `secureConnect` fires only after the cert chain is verified
398
412
  // (rejectUnauthorized rejects a bad/missing-CA server via 'error'
@@ -411,6 +425,7 @@ export class VsockAgentTransport {
411
425
  }
412
426
  settled = true
413
427
  clearTimeout(timer)
428
+ signal?.removeEventListener('abort', abort)
414
429
  socket.removeListener('error', fail)
415
430
  // Routing preamble — the host-relay analogue of the vsock
416
431
  // `CONNECT <port>` line. The relay consumes it, resolves the
@@ -427,25 +442,24 @@ export class VsockAgentTransport {
427
442
  * healthz). Applies the read-idle timeout so a post-resume hung read
428
443
  * is torn down rather than wedging the caller.
429
444
  */
430
- async request<T>(req: AgentRequest): Promise<T> {
431
- const socket = await this.dial()
445
+ async request<T>(req: AgentRequest, signal?: AbortSignal): Promise<T> {
446
+ const socket = await this.dial(signal)
432
447
  return await new Promise<T>((resolve, reject) => {
433
448
  const reader = new FrameReader()
434
449
  let settled = false
435
- const idle = new IdleTimer(this.readIdleTimeoutMs, () => {
436
- if (settled) return
437
- settled = true
438
- socket.destroy()
439
- reject(new Error(`vsock transport: read idle timeout after ${this.readIdleTimeoutMs}ms`))
440
- })
441
450
  const finish = (err: Error | null, value?: T) => {
442
451
  if (settled) return
443
452
  settled = true
444
453
  idle.clear()
454
+ signal?.removeEventListener('abort', abort)
445
455
  socket.destroy()
446
456
  if (err) reject(err)
447
457
  else resolve(value as T)
448
458
  }
459
+ const abort = () => finish(signalError(signal))
460
+ const idle = new IdleTimer(this.readIdleTimeoutMs, () =>
461
+ finish(new Error(`vsock transport: read idle timeout after ${this.readIdleTimeoutMs}ms`)),
462
+ )
449
463
  socket.on('data', (chunk: Buffer) => {
450
464
  idle.bump()
451
465
  let frames: string[]
@@ -466,6 +480,11 @@ export class VsockAgentTransport {
466
480
  })
467
481
  socket.once('error', (err) => finish(err))
468
482
  socket.once('close', () => finish(new Error('vsock transport: socket closed before reply')))
483
+ if (signal?.aborted) {
484
+ abort()
485
+ return
486
+ }
487
+ signal?.addEventListener('abort', abort, { once: true })
469
488
  idle.bump()
470
489
  socket.write(frame(JSON.stringify(req)))
471
490
  })
@@ -547,11 +566,12 @@ export class VsockAgentTransport {
547
566
  }
548
567
 
549
568
  /** Liveness probe. Returns true on an `{ ok: true }` healthz reply. */
550
- async healthz(): Promise<boolean> {
569
+ async healthz(signal?: AbortSignal): Promise<boolean> {
551
570
  try {
552
- const res = await this.request<{ ok?: boolean }>({ op: 'healthz' })
571
+ const res = await this.request<{ ok?: boolean }>({ op: 'healthz' }, signal)
553
572
  return res.ok === true
554
573
  } catch {
574
+ if (signal?.aborted) throw signal.reason
555
575
  return false
556
576
  }
557
577
  }
@@ -562,17 +582,27 @@ export class VsockAgentTransport {
562
582
  * (which already carries connect retry) — used by the backend's
563
583
  * post-create readiness fence.
564
584
  */
565
- async waitForReady(timeoutMs: number, pollIntervalMs: number): Promise<void> {
566
- const deadline = Date.now() + timeoutMs
585
+ async waitForReady(
586
+ timeoutMs: number,
587
+ pollIntervalMs: number,
588
+ signal?: AbortSignal,
589
+ ): Promise<void> {
590
+ const deadline = new OperationDeadline(timeoutMs, 'firecracker agent readiness', signal)
567
591
  let lastErr: unknown
568
- while (Date.now() < deadline) {
592
+ while (deadline.remainingMs() > 0) {
569
593
  try {
570
- if (await this.healthz()) return
594
+ if (await deadline.run((signal) => this.healthz(signal))) return
571
595
  lastErr = new Error('healthz returned not-ok')
572
596
  } catch (err) {
573
597
  lastErr = err
598
+ if (err instanceof OperationDeadlineExpired) break
599
+ }
600
+ try {
601
+ await deadline.delay(pollIntervalMs)
602
+ } catch (err) {
603
+ if (err instanceof OperationDeadlineExpired) break
604
+ throw err
574
605
  }
575
- await delay(pollIntervalMs)
576
606
  }
577
607
  throw new Error(
578
608
  `vsock transport: agent did not become ready within ${timeoutMs}ms: ${
@@ -647,8 +677,27 @@ class IdleTimer {
647
677
  }
648
678
  }
649
679
 
650
- function delay(ms: number): Promise<void> {
651
- return new Promise((resolve) => setTimeout(resolve, ms))
680
+ function delay(ms: number, signal?: AbortSignal): Promise<void> {
681
+ return new Promise((resolve, reject) => {
682
+ if (signal?.aborted) {
683
+ reject(signalError(signal))
684
+ return
685
+ }
686
+ const finish = (err?: unknown) => {
687
+ clearTimeout(timer)
688
+ signal?.removeEventListener('abort', abort)
689
+ if (err === undefined) resolve()
690
+ else reject(err)
691
+ }
692
+ const abort = () => finish(signalError(signal))
693
+ const timer = setTimeout(() => finish(), ms)
694
+ signal?.addEventListener('abort', abort, { once: true })
695
+ })
696
+ }
697
+
698
+ function signalError(signal: AbortSignal | undefined): Error {
699
+ if (signal?.reason instanceof Error) return signal.reason
700
+ return new Error(signal?.reason === undefined ? 'operation aborted' : String(signal.reason))
652
701
  }
653
702
 
654
703
  function describeHandle(handle: SandboxAgentHandle): string {
@@ -0,0 +1,173 @@
1
+ /**
2
+ * One owner for backend readiness clocks.
3
+ *
4
+ * A timestamp checked between retries is not a deadline when the thing inside
5
+ * a retry can remain pending. This owner races every foreign promise against
6
+ * the remaining operation budget and supplies a private signal so cooperative
7
+ * transports can release their sockets too.
8
+ */
9
+
10
+ const MAX_NODE_TIMER_MS = 2_147_483_647
11
+
12
+ /**
13
+ * Readiness failure teardown is deliberately a second, short budget. The
14
+ * health deadline has already expired, so pretending cleanup fits inside it
15
+ * either skips cleanup entirely or keeps `create()` pending without a bound.
16
+ */
17
+ export const FAILURE_CLEANUP_GRACE_MS = 1_000
18
+
19
+ export interface ReadinessOptions {
20
+ readonly timeoutMs: number
21
+ readonly pollIntervalMs: number
22
+ }
23
+
24
+ export class OperationDeadlineExpired extends Error {
25
+ constructor(readonly label: string) {
26
+ super(`${label} deadline expired`)
27
+ this.name = 'OperationDeadlineExpired'
28
+ }
29
+ }
30
+
31
+ function assertTimerValue(field: string, value: number): void {
32
+ if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_NODE_TIMER_MS) {
33
+ throw new RangeError(
34
+ `${field} must be a positive safe integer no greater than ${MAX_NODE_TIMER_MS}; received ${String(value)}`,
35
+ )
36
+ }
37
+ }
38
+
39
+ export function resolveReadinessOptions(
40
+ owner: string,
41
+ timeoutMs: number | undefined,
42
+ pollIntervalMs: number | undefined,
43
+ defaults: ReadinessOptions,
44
+ ): ReadinessOptions {
45
+ const resolved = {
46
+ timeoutMs: timeoutMs ?? defaults.timeoutMs,
47
+ pollIntervalMs: pollIntervalMs ?? defaults.pollIntervalMs,
48
+ }
49
+ assertTimerValue(`${owner}.readyTimeoutMs`, resolved.timeoutMs)
50
+ assertTimerValue(`${owner}.readyPollIntervalMs`, resolved.pollIntervalMs)
51
+ return resolved
52
+ }
53
+
54
+ export class OperationDeadline {
55
+ private readonly expiresAt: number
56
+
57
+ constructor(
58
+ timeoutMs: number,
59
+ readonly label: string,
60
+ private readonly callerSignal?: AbortSignal,
61
+ ) {
62
+ assertTimerValue(`${label} timeout`, timeoutMs)
63
+ this.expiresAt = performance.now() + timeoutMs
64
+ }
65
+
66
+ remainingMs(): number {
67
+ return Math.max(0, this.expiresAt - performance.now())
68
+ }
69
+
70
+ async run<T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
71
+ this.callerSignal?.throwIfAborted()
72
+ const remaining = this.remainingMs()
73
+ if (remaining <= 0) throw new OperationDeadlineExpired(this.label)
74
+
75
+ const controller = new AbortController()
76
+ const expired = new OperationDeadlineExpired(this.label)
77
+ let timer: ReturnType<typeof setTimeout> | undefined
78
+ let onCallerAbort: (() => void) | undefined
79
+ const cancelled = this.callerSignal
80
+ ? new Promise<never>((_resolve, reject) => {
81
+ onCallerAbort = () => {
82
+ const reason = this.callerSignal?.reason
83
+ controller.abort(reason)
84
+ reject(reason)
85
+ }
86
+ this.callerSignal?.addEventListener('abort', onCallerAbort, {
87
+ once: true,
88
+ })
89
+ })
90
+ : undefined
91
+ const expiry = new Promise<never>((_resolve, reject) => {
92
+ timer = setTimeout(() => {
93
+ controller.abort(expired)
94
+ reject(expired)
95
+ }, remaining)
96
+ })
97
+ const pending = Promise.resolve().then(() => operation(controller.signal))
98
+
99
+ try {
100
+ const value = await Promise.race(cancelled ? [pending, expiry, cancelled] : [pending, expiry])
101
+ // Publication fence: a foreign promise that settles at the boundary
102
+ // does not get to turn an already-expired attempt into readiness.
103
+ if (this.callerSignal?.aborted) throw this.callerSignal.reason
104
+ if (controller.signal.aborted || this.remainingMs() <= 0) {
105
+ controller.abort(expired)
106
+ throw expired
107
+ }
108
+ return value
109
+ } finally {
110
+ if (timer !== undefined) clearTimeout(timer)
111
+ if (onCallerAbort) this.callerSignal?.removeEventListener('abort', onCallerAbort)
112
+ }
113
+ }
114
+
115
+ async delay(maximumMs: number): Promise<void> {
116
+ await this.run(
117
+ (signal) =>
118
+ new Promise<void>((resolve, reject) => {
119
+ if (signal.aborted) {
120
+ reject(signal.reason)
121
+ return
122
+ }
123
+ const finish = (err?: unknown) => {
124
+ clearTimeout(timer)
125
+ signal.removeEventListener('abort', abort)
126
+ if (err === undefined) resolve()
127
+ else reject(err)
128
+ }
129
+ const abort = () => {
130
+ finish(signal.reason)
131
+ }
132
+ const timer = setTimeout(() => finish(), maximumMs)
133
+ signal.addEventListener('abort', abort, { once: true })
134
+ }),
135
+ )
136
+ }
137
+ }
138
+
139
+ export async function probeHttpHealth(
140
+ url: string,
141
+ signal: AbortSignal,
142
+ ): Promise<{ readonly ok: boolean; readonly status: number }> {
143
+ signal.throwIfAborted()
144
+ const response = await fetch(url, { signal })
145
+ const result = { ok: response.ok, status: response.status }
146
+ try {
147
+ await response.body?.cancel()
148
+ } catch {
149
+ // The status is already known. A body cancellation failure must not
150
+ // hide it; the owning deadline still aborts the transport if needed.
151
+ }
152
+ signal.throwIfAborted()
153
+ return result
154
+ }
155
+
156
+ /**
157
+ * Failure cleanup must never replace or indefinitely delay the primary
158
+ * readiness error. Cooperative cleanup observes the signal; an implementation
159
+ * that ignores it is still detached from the caller by the independent race.
160
+ */
161
+ export async function runFailureCleanup(
162
+ cleanup: (signal: AbortSignal) => Promise<void>,
163
+ graceMs = FAILURE_CLEANUP_GRACE_MS,
164
+ ): Promise<void> {
165
+ const deadline = new OperationDeadline(graceMs, 'sandbox failure cleanup')
166
+ try {
167
+ await deadline.run(cleanup)
168
+ } catch {
169
+ // The readiness failure remains primary. Promise.race installed a
170
+ // rejection observer on the losing cleanup promise, so a late failure
171
+ // cannot become an unhandled rejection.
172
+ }
173
+ }
@@ -121,6 +121,11 @@ export class EgressProxy {
121
121
  const server = createServer((req, res) => {
122
122
  void this.handleRequest(req, res)
123
123
  })
124
+ const connections = new Set<Duplex>()
125
+ server.on('connection', (socket) => {
126
+ connections.add(socket)
127
+ socket.once('close', () => connections.delete(socket))
128
+ })
124
129
  server.on('connect', (req, socket, head) => {
125
130
  void this.handleConnect(req, socket, head)
126
131
  })
@@ -144,6 +149,7 @@ export class EgressProxy {
144
149
  // process runs out. Found by a test that hung rather than failed,
145
150
  // which is the shape this failure takes in production too.
146
151
  this.selfPort = boundPort
152
+ let closePromise: Promise<void> | undefined
147
153
 
148
154
  return {
149
155
  port: boundPort,
@@ -151,10 +157,16 @@ export class EgressProxy {
151
157
  setAllowedHosts: (resolve) => {
152
158
  this.resolveAllowed = resolve
153
159
  },
154
- close: () =>
155
- new Promise<void>((resolve) => {
160
+ close: () => {
161
+ closePromise ??= new Promise<void>((resolve) => {
162
+ // `server.close()` waits for existing connections. A readiness
163
+ // failure has no returned Sandbox handle through which those
164
+ // sockets can later be retired, so teardown owns them here.
165
+ for (const socket of connections) socket.destroy()
156
166
  server.close(() => resolve())
157
- }),
167
+ })
168
+ return closePromise
169
+ },
158
170
  }
159
171
  }
160
172
 
@@ -346,6 +358,9 @@ export class EgressProxy {
346
358
  socket.on('error', () => {
347
359
  upstream.destroy()
348
360
  })
361
+ socket.once('close', () => {
362
+ upstream.destroy()
363
+ })
349
364
  }
350
365
 
351
366
  /** Whether a target names this proxy. See the loop guard in `listen`. */
package/src/index.ts CHANGED
@@ -155,7 +155,9 @@ export interface ACIStandbyPoolBackendConfig {
155
155
  */
156
156
  readonly getArmToken: () => Promise<string>
157
157
  readonly subnetId?: string
158
+ /** Delay between IP / health probes. Default 500ms. */
158
159
  readonly readyPollIntervalMs?: number
160
+ /** Total deadline across IP publication and worker health. Default 60000ms. */
159
161
  readonly readyTimeoutMs?: number
160
162
  readonly workerPort?: number
161
163
  readonly armApiVersion?: string
@@ -218,6 +220,18 @@ export interface ContainerBackendConfig {
218
220
  * which is checked against this network rather than trusted.
219
221
  */
220
222
  readonly network?: 'none' | 'bridge' | string
223
+ /**
224
+ * Maximum time spent waiting for the container worker's `/healthz`
225
+ * readiness probe. Must be a positive integer within Node's timer range.
226
+ * Default 30000ms.
227
+ */
228
+ readonly readyTimeoutMs?: number
229
+ /**
230
+ * Delay between worker readiness probes. Must be a positive integer within
231
+ * Node's timer range; the final delay is capped by `readyTimeoutMs`.
232
+ * Default 100ms.
233
+ */
234
+ readonly readyPollIntervalMs?: number
221
235
  /**
222
236
  * Allowlisted hosts permitted to resolve to an inward address anyway.
223
237
  *
@@ -297,7 +311,9 @@ export type MicroVMBackendConfig = {
297
311
  readonly agentSnapshot?: AgentSnapshotRef
298
312
  /** Fixed guest AF_VSOCK port the in-VM agent listens on. */
299
313
  readonly agentVsockPort?: number
314
+ /** Total guest-agent health deadline after the orchestrator claim. Default 60000ms. */
300
315
  readonly readyTimeoutMs?: number
316
+ /** Delay between guest-agent health probes. Default 250ms. */
301
317
  readonly readyPollIntervalMs?: number
302
318
  /**
303
319
  * NETWORK-mode mTLS client material (ses_051 P4 client-proxy
@@ -453,6 +469,8 @@ export interface SandboxBackend {
453
469
  * shape.
454
470
  */
455
471
  export interface SandboxBackendOptions {
472
+ /** Run authority for allocation/readiness. See `SandboxCreateConfig.signal`. */
473
+ readonly signal?: AbortSignal
456
474
  readonly workingDirectory: string
457
475
  readonly egress?: EgressPolicy
458
476
  readonly timeoutMs?: number
@@ -531,6 +549,7 @@ export function createSandboxProvider(config: SandboxProviderConfig): SandboxPro
531
549
  environment: 'basic',
532
550
  async create(perCall?: SandboxCreateConfig): Promise<Sandbox> {
533
551
  return await backend.create({
552
+ ...(perCall?.signal !== undefined ? { signal: perCall.signal } : {}),
534
553
  workingDirectory: perCall?.workingDirectory ?? '/workspace',
535
554
  ...(config.defaultEgress !== undefined ? { egress: config.defaultEgress } : {}),
536
555
  ...(perCall?.timeoutMs !== undefined
@@ -569,6 +588,10 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
569
588
  return buildDockerBackend({
570
589
  image: backend.image,
571
590
  layout: resolved,
591
+ ...(backend.readyTimeoutMs !== undefined ? { readyTimeoutMs: backend.readyTimeoutMs } : {}),
592
+ ...(backend.readyPollIntervalMs !== undefined
593
+ ? { readyPollIntervalMs: backend.readyPollIntervalMs }
594
+ : {}),
572
595
  ...(backend.hostReachability !== undefined
573
596
  ? { hostReachability: backend.hostReachability }
574
597
  : {}),
@@ -620,6 +643,10 @@ function pickBackend(config: SandboxProviderConfig): SandboxBackend {
620
643
  image: backend.image,
621
644
  layout: resolved,
622
645
  runtime: 'runsc',
646
+ ...(backend.readyTimeoutMs !== undefined ? { readyTimeoutMs: backend.readyTimeoutMs } : {}),
647
+ ...(backend.readyPollIntervalMs !== undefined
648
+ ? { readyPollIntervalMs: backend.readyPollIntervalMs }
649
+ : {}),
623
650
  ...(backend.hostReachability !== undefined
624
651
  ? { hostReachability: backend.hostReachability }
625
652
  : {}),