@namzu/sandbox 6.1.0 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/README.md +29 -1
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +163 -111
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +212 -149
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +154 -71
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/backends/firecracker/protocol.d.ts +19 -27
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js +57 -32
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +26 -7
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +235 -72
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/http-worker-client.d.ts +17 -0
- package/dist/backends/http-worker-client.d.ts.map +1 -0
- package/dist/backends/http-worker-client.js +173 -0
- package/dist/backends/http-worker-client.js.map +1 -0
- package/dist/backends/readiness.d.ts +43 -0
- package/dist/backends/readiness.d.ts.map +1 -0
- package/dist/backends/readiness.js +150 -0
- package/dist/backends/readiness.js.map +1 -0
- package/dist/backends/remote-execution-controller.d.ts +76 -0
- package/dist/backends/remote-execution-controller.d.ts.map +1 -0
- package/dist/backends/remote-execution-controller.js +294 -0
- package/dist/backends/remote-execution-controller.js.map +1 -0
- package/dist/egress/proxy.d.ts.map +1 -1
- package/dist/egress/proxy.js +20 -3
- package/dist/egress/proxy.js.map +1 -1
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/backends/aci-standby-pool/index.ts +199 -117
- package/src/backends/docker/index.ts +235 -175
- package/src/backends/firecracker/index.ts +163 -63
- package/src/backends/firecracker/protocol.ts +67 -33
- package/src/backends/firecracker/transport.ts +301 -77
- package/src/backends/http-worker-client.ts +230 -0
- package/src/backends/readiness.ts +173 -0
- package/src/backends/remote-execution-controller.ts +462 -0
- package/src/egress/proxy.ts +18 -3
- package/src/index.ts +27 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import type { SandboxExecOptions, SandboxExecResult } from '@namzu/sdk'
|
|
2
|
+
|
|
3
|
+
const CONTROL_REQUEST_TIMEOUT_MS = 2_000
|
|
4
|
+
const CANCEL_CONFIRM_TIMEOUT_MS = 8_000
|
|
5
|
+
const RESULT_DRAIN_TIMEOUT_MS = 1_500
|
|
6
|
+
const DEFAULT_EXECUTION_TIMEOUT_MS = 5 * 60 * 1_000
|
|
7
|
+
const EXECUTION_OBSERVATION_GRACE_MS = 500
|
|
8
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
9
|
+
const EXECUTION_ID_PATTERN =
|
|
10
|
+
/^exec_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
11
|
+
|
|
12
|
+
export interface RemoteReservation {
|
|
13
|
+
readonly ok: true
|
|
14
|
+
readonly protocolVersion: 2
|
|
15
|
+
readonly executionId: string
|
|
16
|
+
readonly leaseExpiresAt: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RemoteTerminalMetadata {
|
|
20
|
+
readonly exitCode: number
|
|
21
|
+
readonly timedOut: boolean
|
|
22
|
+
readonly durationMs: number
|
|
23
|
+
readonly signal?: string
|
|
24
|
+
readonly stdoutTruncated?: boolean
|
|
25
|
+
readonly stderrTruncated?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RemoteCancellationAcknowledgement {
|
|
29
|
+
readonly ok: true
|
|
30
|
+
readonly state: 'cancelled' | 'completed' | 'failed'
|
|
31
|
+
readonly started: boolean
|
|
32
|
+
readonly result?: RemoteTerminalMetadata
|
|
33
|
+
readonly error?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class RemoteCommandError extends Error {}
|
|
37
|
+
|
|
38
|
+
export class RemoteProtocolError extends Error {}
|
|
39
|
+
|
|
40
|
+
export class RemoteCancellationUnsupportedError extends Error {}
|
|
41
|
+
|
|
42
|
+
export interface SandboxRetirementObservation {
|
|
43
|
+
readonly accepted: boolean
|
|
44
|
+
readonly error?: Error
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class RemoteCancellationUnknownError extends Error {
|
|
48
|
+
retirement?: SandboxRetirementObservation
|
|
49
|
+
|
|
50
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
51
|
+
super(message, options)
|
|
52
|
+
this.name = 'RemoteCancellationUnknownError'
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class RemoteResultIncompleteError extends Error {
|
|
57
|
+
constructor(
|
|
58
|
+
message: string,
|
|
59
|
+
readonly acknowledgement: RemoteCancellationAcknowledgement,
|
|
60
|
+
options?: ErrorOptions,
|
|
61
|
+
) {
|
|
62
|
+
super(message, options)
|
|
63
|
+
this.name = 'RemoteResultIncompleteError'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RemoteExecutionAdapter<Context = undefined> {
|
|
68
|
+
readonly label: string
|
|
69
|
+
reserve(signal: AbortSignal): Promise<unknown>
|
|
70
|
+
cancel(executionId: string, signal: AbortSignal): Promise<unknown>
|
|
71
|
+
execute(
|
|
72
|
+
executionId: string | undefined,
|
|
73
|
+
command: string,
|
|
74
|
+
argv: string[] | undefined,
|
|
75
|
+
opts: SandboxExecOptions | undefined,
|
|
76
|
+
transportSignal: AbortSignal,
|
|
77
|
+
context: Context | undefined,
|
|
78
|
+
): Promise<SandboxExecResult>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RemoteExecutionControllerOptions {
|
|
82
|
+
readonly controlRequestTimeoutMs?: number
|
|
83
|
+
readonly cancelConfirmTimeoutMs?: number
|
|
84
|
+
readonly resultDrainTimeoutMs?: number
|
|
85
|
+
readonly executionObservationGraceMs?: number
|
|
86
|
+
readonly defaultExecutionTimeoutMs?: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isTerminalMetadata(value: unknown): value is RemoteTerminalMetadata {
|
|
90
|
+
if (!value || typeof value !== 'object') return false
|
|
91
|
+
const metadata = value as Record<string, unknown>
|
|
92
|
+
return (
|
|
93
|
+
Number.isFinite(metadata.exitCode) &&
|
|
94
|
+
typeof metadata.timedOut === 'boolean' &&
|
|
95
|
+
Number.isFinite(metadata.durationMs) &&
|
|
96
|
+
(metadata.signal === undefined || typeof metadata.signal === 'string') &&
|
|
97
|
+
(metadata.stdoutTruncated === undefined || typeof metadata.stdoutTruncated === 'boolean') &&
|
|
98
|
+
(metadata.stderrTruncated === undefined || typeof metadata.stderrTruncated === 'boolean')
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseReservation(value: unknown): RemoteReservation {
|
|
103
|
+
if (!value || typeof value !== 'object') {
|
|
104
|
+
throw new RemoteProtocolError('remote sandbox returned an invalid execution reservation')
|
|
105
|
+
}
|
|
106
|
+
const reservation = value as Record<string, unknown>
|
|
107
|
+
if (
|
|
108
|
+
reservation.ok !== true ||
|
|
109
|
+
reservation.protocolVersion !== 2 ||
|
|
110
|
+
!EXECUTION_ID_PATTERN.test(String(reservation.executionId ?? '')) ||
|
|
111
|
+
!Number.isFinite(reservation.leaseExpiresAt)
|
|
112
|
+
) {
|
|
113
|
+
throw new RemoteProtocolError('remote sandbox returned an invalid execution reservation')
|
|
114
|
+
}
|
|
115
|
+
return reservation as unknown as RemoteReservation
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseCancellation(value: unknown): RemoteCancellationAcknowledgement {
|
|
119
|
+
if (!value || typeof value !== 'object') {
|
|
120
|
+
throw new RemoteProtocolError('remote sandbox returned an invalid cancellation acknowledgement')
|
|
121
|
+
}
|
|
122
|
+
const acknowledgement = value as Record<string, unknown>
|
|
123
|
+
if (
|
|
124
|
+
acknowledgement.ok !== true ||
|
|
125
|
+
!['cancelled', 'completed', 'failed'].includes(String(acknowledgement.state ?? '')) ||
|
|
126
|
+
typeof acknowledgement.started !== 'boolean' ||
|
|
127
|
+
(acknowledgement.result !== undefined && !isTerminalMetadata(acknowledgement.result)) ||
|
|
128
|
+
(acknowledgement.error !== undefined && typeof acknowledgement.error !== 'string')
|
|
129
|
+
) {
|
|
130
|
+
throw new RemoteProtocolError('remote sandbox returned an invalid cancellation acknowledgement')
|
|
131
|
+
}
|
|
132
|
+
return acknowledgement as unknown as RemoteCancellationAcknowledgement
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function cancelledBeforeStart(startedAt: number): SandboxExecResult {
|
|
136
|
+
return {
|
|
137
|
+
exitCode: 1,
|
|
138
|
+
stdout: '',
|
|
139
|
+
stderr: '',
|
|
140
|
+
timedOut: false,
|
|
141
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
142
|
+
stdoutTruncated: false,
|
|
143
|
+
stderrTruncated: false,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resultFromAcknowledgement(
|
|
148
|
+
acknowledgement: RemoteCancellationAcknowledgement,
|
|
149
|
+
startedAt: number,
|
|
150
|
+
): SandboxExecResult {
|
|
151
|
+
const result = acknowledgement.result
|
|
152
|
+
if (!result) return cancelledBeforeStart(startedAt)
|
|
153
|
+
return { ...result, stdout: '', stderr: '' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resultIncomplete(
|
|
157
|
+
error: unknown,
|
|
158
|
+
acknowledgement: RemoteCancellationAcknowledgement,
|
|
159
|
+
): RemoteResultIncompleteError {
|
|
160
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
161
|
+
return new RemoteResultIncompleteError(
|
|
162
|
+
`Remote sandbox termination was confirmed (${acknowledgement.state}), but the terminal result/output stream was incomplete: ${message}. Do not infer complete output.`,
|
|
163
|
+
acknowledgement,
|
|
164
|
+
{ cause: error },
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function cancellationUnknown(error: unknown): RemoteCancellationUnknownError {
|
|
169
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
170
|
+
return new RemoteCancellationUnknownError(
|
|
171
|
+
`Remote sandbox cancellation could not be confirmed: ${message}. The remote outcome is unknown; do not automatically retry the command.`,
|
|
172
|
+
{ cause: error },
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function observationDeadlineMs(
|
|
177
|
+
timeout: number | undefined,
|
|
178
|
+
defaultTimeoutMs: number,
|
|
179
|
+
graceMs: number,
|
|
180
|
+
): number {
|
|
181
|
+
const requested =
|
|
182
|
+
typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0
|
|
183
|
+
? timeout
|
|
184
|
+
: defaultTimeoutMs
|
|
185
|
+
return Math.min(MAX_TIMER_DELAY_MS, requested + graceMs)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function bounded<T>(
|
|
189
|
+
label: string,
|
|
190
|
+
timeoutMs: number,
|
|
191
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
192
|
+
callerSignal?: AbortSignal,
|
|
193
|
+
): Promise<T> {
|
|
194
|
+
callerSignal?.throwIfAborted()
|
|
195
|
+
const controller = new AbortController()
|
|
196
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
197
|
+
let removeAbort: (() => void) | undefined
|
|
198
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
199
|
+
timer = setTimeout(() => {
|
|
200
|
+
const error = new Error(`${label} exceeded ${timeoutMs}ms`)
|
|
201
|
+
controller.abort(error)
|
|
202
|
+
reject(error)
|
|
203
|
+
}, timeoutMs)
|
|
204
|
+
})
|
|
205
|
+
const callerAbort = callerSignal
|
|
206
|
+
? new Promise<never>((_, reject) => {
|
|
207
|
+
const abort = () => {
|
|
208
|
+
controller.abort(callerSignal.reason)
|
|
209
|
+
reject(callerSignal.reason)
|
|
210
|
+
}
|
|
211
|
+
removeAbort = () => callerSignal.removeEventListener('abort', abort)
|
|
212
|
+
if (callerSignal.aborted) abort()
|
|
213
|
+
else callerSignal.addEventListener('abort', abort, { once: true })
|
|
214
|
+
})
|
|
215
|
+
: undefined
|
|
216
|
+
try {
|
|
217
|
+
const pending = operation(controller.signal)
|
|
218
|
+
return await Promise.race([pending, timeout, ...(callerAbort ? [callerAbort] : [])])
|
|
219
|
+
} finally {
|
|
220
|
+
if (timer) clearTimeout(timer)
|
|
221
|
+
removeAbort?.()
|
|
222
|
+
controller.abort()
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
type Capability = 'unknown' | 'supported' | 'unsupported'
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* One state machine for remote command ownership. A v2 peer reserves every
|
|
230
|
+
* command, even when the caller supplied no signal, so transport loss can be
|
|
231
|
+
* reconciled by identity. Legacy execution is entered only after an explicit
|
|
232
|
+
* unsupported response from the peer and is fenced as unknown on any failure.
|
|
233
|
+
*/
|
|
234
|
+
export class RemoteExecutionController<Context = undefined> {
|
|
235
|
+
private capability: Capability = 'unknown'
|
|
236
|
+
private unsupportedError: RemoteCancellationUnsupportedError | undefined
|
|
237
|
+
private readonly controlRequestTimeoutMs: number
|
|
238
|
+
private readonly cancelConfirmTimeoutMs: number
|
|
239
|
+
private readonly resultDrainTimeoutMs: number
|
|
240
|
+
private readonly executionObservationGraceMs: number
|
|
241
|
+
private readonly defaultExecutionTimeoutMs: number
|
|
242
|
+
|
|
243
|
+
constructor(
|
|
244
|
+
private readonly adapter: RemoteExecutionAdapter<Context>,
|
|
245
|
+
options: RemoteExecutionControllerOptions = {},
|
|
246
|
+
) {
|
|
247
|
+
this.controlRequestTimeoutMs = options.controlRequestTimeoutMs ?? CONTROL_REQUEST_TIMEOUT_MS
|
|
248
|
+
this.cancelConfirmTimeoutMs = options.cancelConfirmTimeoutMs ?? CANCEL_CONFIRM_TIMEOUT_MS
|
|
249
|
+
this.resultDrainTimeoutMs = options.resultDrainTimeoutMs ?? RESULT_DRAIN_TIMEOUT_MS
|
|
250
|
+
this.executionObservationGraceMs =
|
|
251
|
+
options.executionObservationGraceMs ?? EXECUTION_OBSERVATION_GRACE_MS
|
|
252
|
+
this.defaultExecutionTimeoutMs =
|
|
253
|
+
options.defaultExecutionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async exec(
|
|
257
|
+
command: string,
|
|
258
|
+
argv: string[] | undefined,
|
|
259
|
+
opts: SandboxExecOptions | undefined,
|
|
260
|
+
context?: Context,
|
|
261
|
+
): Promise<SandboxExecResult> {
|
|
262
|
+
const startedAt = Date.now()
|
|
263
|
+
if (opts?.signal?.aborted) return cancelledBeforeStart(startedAt)
|
|
264
|
+
if (this.capability === 'unsupported') {
|
|
265
|
+
if (opts?.signal) {
|
|
266
|
+
throw (
|
|
267
|
+
this.unsupportedError ??
|
|
268
|
+
new RemoteCancellationUnsupportedError(
|
|
269
|
+
`${this.adapter.label} was classified as cancellation-unsupported`,
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
return await this.executeLegacy(command, argv, opts, context)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let reservation: RemoteReservation
|
|
277
|
+
try {
|
|
278
|
+
reservation = parseReservation(
|
|
279
|
+
await bounded(
|
|
280
|
+
`${this.adapter.label} execution reservation`,
|
|
281
|
+
this.controlRequestTimeoutMs,
|
|
282
|
+
(signal) => this.adapter.reserve(signal),
|
|
283
|
+
opts?.signal,
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
this.capability = 'supported'
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (opts?.signal?.aborted) return cancelledBeforeStart(startedAt)
|
|
289
|
+
if (error instanceof RemoteCancellationUnsupportedError) {
|
|
290
|
+
this.capability = 'unsupported'
|
|
291
|
+
this.unsupportedError = error
|
|
292
|
+
if (opts?.signal) throw error
|
|
293
|
+
return await this.executeLegacy(command, argv, opts, context)
|
|
294
|
+
}
|
|
295
|
+
throw error
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return await this.executeReserved(reservation, command, argv, opts, startedAt, context)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async executeLegacy(
|
|
302
|
+
command: string,
|
|
303
|
+
argv: string[] | undefined,
|
|
304
|
+
opts: SandboxExecOptions | undefined,
|
|
305
|
+
context: Context | undefined,
|
|
306
|
+
): Promise<SandboxExecResult> {
|
|
307
|
+
const timeoutMs = observationDeadlineMs(
|
|
308
|
+
opts?.timeout,
|
|
309
|
+
this.defaultExecutionTimeoutMs,
|
|
310
|
+
this.executionObservationGraceMs,
|
|
311
|
+
)
|
|
312
|
+
try {
|
|
313
|
+
return await bounded(
|
|
314
|
+
`${this.adapter.label} legacy execution observation`,
|
|
315
|
+
timeoutMs,
|
|
316
|
+
(signal) => this.adapter.execute(undefined, command, argv, opts, signal, context),
|
|
317
|
+
)
|
|
318
|
+
} catch (error) {
|
|
319
|
+
throw new RemoteCancellationUnknownError(
|
|
320
|
+
`The legacy ${this.adapter.label} execution failed after admission without an execution id: ${error instanceof Error ? error.message : String(error)}. The remote outcome is unknown and the sandbox must not be reused.`,
|
|
321
|
+
{ cause: error },
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private async requestCancellation(
|
|
327
|
+
executionId: string,
|
|
328
|
+
): Promise<RemoteCancellationAcknowledgement> {
|
|
329
|
+
const deadlineAt = Date.now() + this.cancelConfirmTimeoutMs
|
|
330
|
+
let lastError: unknown = new Error('no cancellation attempt completed')
|
|
331
|
+
while (Date.now() < deadlineAt) {
|
|
332
|
+
const remaining = deadlineAt - Date.now()
|
|
333
|
+
try {
|
|
334
|
+
return parseCancellation(
|
|
335
|
+
await bounded(
|
|
336
|
+
`${this.adapter.label} cancellation attempt`,
|
|
337
|
+
Math.min(this.controlRequestTimeoutMs, remaining),
|
|
338
|
+
(signal) => this.adapter.cancel(executionId, signal),
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
} catch (error) {
|
|
342
|
+
lastError = error
|
|
343
|
+
const delayMs = Math.min(50, deadlineAt - Date.now())
|
|
344
|
+
if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
throw cancellationUnknown(lastError)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private async drainConfirmedExecution(
|
|
351
|
+
pending: Promise<SandboxExecResult>,
|
|
352
|
+
cancelled: RemoteCancellationAcknowledgement,
|
|
353
|
+
startedAt: number,
|
|
354
|
+
): Promise<SandboxExecResult> {
|
|
355
|
+
if (!cancelled.started) return resultFromAcknowledgement(cancelled, startedAt)
|
|
356
|
+
try {
|
|
357
|
+
return await bounded(
|
|
358
|
+
`${this.adapter.label} terminal-result drain`,
|
|
359
|
+
this.resultDrainTimeoutMs,
|
|
360
|
+
async () => await pending,
|
|
361
|
+
)
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (error instanceof RemoteCommandError && cancelled.state === 'failed') throw error
|
|
364
|
+
throw resultIncomplete(error, cancelled)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private async executeReserved(
|
|
369
|
+
reservation: RemoteReservation,
|
|
370
|
+
command: string,
|
|
371
|
+
argv: string[] | undefined,
|
|
372
|
+
opts: SandboxExecOptions | undefined,
|
|
373
|
+
startedAt: number,
|
|
374
|
+
context: Context | undefined,
|
|
375
|
+
): Promise<SandboxExecResult> {
|
|
376
|
+
if (opts?.signal?.aborted) {
|
|
377
|
+
const cancelled = await this.requestCancellation(reservation.executionId)
|
|
378
|
+
if (cancelled.started) {
|
|
379
|
+
throw resultIncomplete(
|
|
380
|
+
new Error('the peer reported an admitted command before execute was sent'),
|
|
381
|
+
cancelled,
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
return resultFromAcknowledgement(cancelled, startedAt)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
type CancellationReason = 'caller' | 'observation-timeout' | 'reconcile'
|
|
388
|
+
let cancellationReason: CancellationReason | undefined
|
|
389
|
+
let startCancellation: ((reason: CancellationReason) => void) | undefined
|
|
390
|
+
const cancellation = new Promise<RemoteCancellationAcknowledgement>((resolve, reject) => {
|
|
391
|
+
let started = false
|
|
392
|
+
startCancellation = (reason) => {
|
|
393
|
+
if (started) return
|
|
394
|
+
started = true
|
|
395
|
+
cancellationReason = reason
|
|
396
|
+
void this.requestCancellation(reservation.executionId).then(resolve, reject)
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
const onAbort = () => startCancellation?.('caller')
|
|
400
|
+
opts?.signal?.addEventListener('abort', onAbort, { once: true })
|
|
401
|
+
if (opts?.signal?.aborted) onAbort()
|
|
402
|
+
const observationTimer = setTimeout(
|
|
403
|
+
() => startCancellation?.('observation-timeout'),
|
|
404
|
+
observationDeadlineMs(
|
|
405
|
+
opts?.timeout,
|
|
406
|
+
this.defaultExecutionTimeoutMs,
|
|
407
|
+
this.executionObservationGraceMs,
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
observationTimer.unref?.()
|
|
411
|
+
|
|
412
|
+
const executionTransport = new AbortController()
|
|
413
|
+
const execution = this.adapter.execute(
|
|
414
|
+
reservation.executionId,
|
|
415
|
+
command,
|
|
416
|
+
argv,
|
|
417
|
+
opts,
|
|
418
|
+
executionTransport.signal,
|
|
419
|
+
context,
|
|
420
|
+
)
|
|
421
|
+
const observedExecution = execution.then(
|
|
422
|
+
(result) => ({ kind: 'result' as const, result }),
|
|
423
|
+
(error: unknown) => ({ kind: 'error' as const, error }),
|
|
424
|
+
)
|
|
425
|
+
const observedCancellation = cancellation.then(
|
|
426
|
+
(result) => ({ kind: 'cancelled' as const, result }),
|
|
427
|
+
(error: unknown) => ({ kind: 'cancel-error' as const, error }),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const first = await Promise.race([observedExecution, observedCancellation])
|
|
432
|
+
if (first.kind === 'result') return first.result
|
|
433
|
+
if (first.kind === 'cancel-error') throw first.error
|
|
434
|
+
if (first.kind === 'cancelled') {
|
|
435
|
+
const result = await this.drainConfirmedExecution(execution, first.result, startedAt)
|
|
436
|
+
return cancellationReason === 'observation-timeout' && first.result.state === 'cancelled'
|
|
437
|
+
? { ...result, timedOut: true }
|
|
438
|
+
: result
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
startCancellation?.('reconcile')
|
|
442
|
+
let confirmed: RemoteCancellationAcknowledgement
|
|
443
|
+
try {
|
|
444
|
+
confirmed = await cancellation
|
|
445
|
+
} catch (cancelError) {
|
|
446
|
+
throw cancellationUnknown(cancelError)
|
|
447
|
+
}
|
|
448
|
+
if (first.error instanceof RemoteCommandError && confirmed.state === 'failed') {
|
|
449
|
+
throw first.error
|
|
450
|
+
}
|
|
451
|
+
if (opts?.signal?.aborted && confirmed.state === 'cancelled' && !confirmed.started) {
|
|
452
|
+
return resultFromAcknowledgement(confirmed, startedAt)
|
|
453
|
+
}
|
|
454
|
+
if (!confirmed.started) throw first.error
|
|
455
|
+
throw resultIncomplete(first.error, confirmed)
|
|
456
|
+
} finally {
|
|
457
|
+
clearTimeout(observationTimer)
|
|
458
|
+
opts?.signal?.removeEventListener('abort', onAbort)
|
|
459
|
+
executionTransport.abort(new Error('remote sandbox execution observation finished'))
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
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
|
: {}),
|