@namzu/sandbox 6.0.1 → 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.
- package/CHANGELOG.md +44 -0
- package/README.md +2 -0
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +70 -31
- 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 +105 -36
- 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 +43 -25
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +3 -3
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +82 -40
- package/dist/backends/firecracker/transport.js.map +1 -1
- 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/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 +5 -2
- package/src/backends/aci-standby-pool/index.ts +99 -30
- package/src/backends/docker/index.ts +135 -44
- package/src/backends/firecracker/index.ts +49 -21
- package/src/backends/firecracker/transport.ts +87 -38
- package/src/backends/readiness.ts +173 -0
- package/src/egress/proxy.ts +18 -3
- 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(
|
|
566
|
-
|
|
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 (
|
|
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) =>
|
|
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
|
+
}
|
package/src/egress/proxy.ts
CHANGED
|
@@ -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
|
: {}),
|