@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
|
@@ -56,7 +56,15 @@
|
|
|
56
56
|
import net from 'node:net'
|
|
57
57
|
import tls from 'node:tls'
|
|
58
58
|
|
|
59
|
-
import type { SandboxExecResult } from '@namzu/sdk'
|
|
59
|
+
import type { SandboxExecOptions, SandboxExecResult } from '@namzu/sdk'
|
|
60
|
+
import { OperationDeadline, OperationDeadlineExpired } from '../readiness.js'
|
|
61
|
+
import {
|
|
62
|
+
RemoteCancellationUnknownError,
|
|
63
|
+
RemoteCancellationUnsupportedError,
|
|
64
|
+
type RemoteExecutionAdapter,
|
|
65
|
+
RemoteExecutionController,
|
|
66
|
+
RemoteProtocolError,
|
|
67
|
+
} from '../remote-execution-controller.js'
|
|
60
68
|
import {
|
|
61
69
|
type ExecRequest,
|
|
62
70
|
ExecResultAccumulator,
|
|
@@ -155,6 +163,11 @@ export type WireSandboxAgentHandle =
|
|
|
155
163
|
*/
|
|
156
164
|
export type AgentRequest =
|
|
157
165
|
| { readonly op: 'execute'; readonly body: ExecRequest }
|
|
166
|
+
| { readonly op: 'reserve-execution' }
|
|
167
|
+
| {
|
|
168
|
+
readonly op: 'cancel-execution'
|
|
169
|
+
readonly body: { readonly executionId: string }
|
|
170
|
+
}
|
|
158
171
|
| { readonly op: 'read-file'; readonly body: ReadFileRequest }
|
|
159
172
|
| { readonly op: 'write-file'; readonly body: WriteFileRequest }
|
|
160
173
|
| { readonly op: 'healthz' }
|
|
@@ -180,6 +193,14 @@ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
|
|
|
180
193
|
const DEFAULT_CONNECT_RETRY_BUDGET_MS = 30_000
|
|
181
194
|
const DEFAULT_CONNECT_RETRY_INTERVAL_MS = 100
|
|
182
195
|
const DEFAULT_READ_IDLE_TIMEOUT_MS = 60_000
|
|
196
|
+
const DEFAULT_EXECUTION_TIMEOUT_MS = 5 * 60_000
|
|
197
|
+
// The ownership controller begins reconciliation shortly after the requested
|
|
198
|
+
// command timeout. The data socket itself stays observable for the peer's
|
|
199
|
+
// bounded TERM -> KILL confirmation window so a quiet but correctly
|
|
200
|
+
// terminating command can still deliver its terminal frame and output tail.
|
|
201
|
+
const EXECUTION_TRANSPORT_GRACE_MS = 10_000
|
|
202
|
+
const POST_RESPONSE_CLOSE_TIMEOUT_MS = 1_000
|
|
203
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
183
204
|
|
|
184
205
|
/** Framing: 8 hex digits of payload byte length, then `\n`, then payload. */
|
|
185
206
|
const LENGTH_PREFIX_HEX = 8
|
|
@@ -214,6 +235,9 @@ class FrameReader {
|
|
|
214
235
|
break
|
|
215
236
|
}
|
|
216
237
|
const header = this.buf.subarray(0, nl).toString('ascii')
|
|
238
|
+
if (!/^[0-9a-fA-F]{8}$/.test(header)) {
|
|
239
|
+
throw new Error(`vsock transport: invalid frame length header ${JSON.stringify(header)}`)
|
|
240
|
+
}
|
|
217
241
|
const len = Number.parseInt(header, 16)
|
|
218
242
|
if (!Number.isInteger(len) || len < 0) {
|
|
219
243
|
throw new Error(`vsock transport: invalid frame length header ${JSON.stringify(header)}`)
|
|
@@ -226,13 +250,17 @@ class FrameReader {
|
|
|
226
250
|
}
|
|
227
251
|
return out
|
|
228
252
|
}
|
|
253
|
+
|
|
254
|
+
get bufferedBytes(): number {
|
|
255
|
+
return this.buf.length
|
|
256
|
+
}
|
|
229
257
|
}
|
|
230
258
|
|
|
231
259
|
/**
|
|
232
260
|
* The transport. One instance per sandbox handle; every request opens
|
|
233
261
|
* a fresh connection (resume-survivable — no socket lingers across a
|
|
234
|
-
* resume to be silently severed).
|
|
235
|
-
*
|
|
262
|
+
* resume to be silently severed). Execution reservation, data, cancellation,
|
|
263
|
+
* file I/O and heartbeat all use independent calls through this dialer.
|
|
236
264
|
*/
|
|
237
265
|
export class VsockAgentTransport {
|
|
238
266
|
private readonly handle: SandboxAgentHandle
|
|
@@ -240,6 +268,9 @@ export class VsockAgentTransport {
|
|
|
240
268
|
private readonly connectRetryBudgetMs: number
|
|
241
269
|
private readonly connectRetryIntervalMs: number
|
|
242
270
|
private readonly readIdleTimeoutMs: number
|
|
271
|
+
private readonly executionController: RemoteExecutionController<
|
|
272
|
+
Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>
|
|
273
|
+
>
|
|
243
274
|
|
|
244
275
|
constructor(handle: SandboxAgentHandle, options: VsockTransportOptions = {}) {
|
|
245
276
|
this.handle = handle
|
|
@@ -248,6 +279,29 @@ export class VsockAgentTransport {
|
|
|
248
279
|
this.connectRetryIntervalMs =
|
|
249
280
|
options.connectRetryIntervalMs ?? DEFAULT_CONNECT_RETRY_INTERVAL_MS
|
|
250
281
|
this.readIdleTimeoutMs = options.readIdleTimeoutMs ?? DEFAULT_READ_IDLE_TIMEOUT_MS
|
|
282
|
+
const adapter: RemoteExecutionAdapter<Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>> = {
|
|
283
|
+
label: 'framed microVM agent',
|
|
284
|
+
reserve: async (signal) => await this.reserveExecution(signal),
|
|
285
|
+
cancel: async (executionId, signal) => await this.cancelExecution(executionId, signal),
|
|
286
|
+
execute: async (executionId, command, argv, opts, signal, context) =>
|
|
287
|
+
await this.executeRaw(
|
|
288
|
+
{
|
|
289
|
+
...(executionId ? { executionId } : {}),
|
|
290
|
+
command,
|
|
291
|
+
args: argv ?? [],
|
|
292
|
+
...(opts?.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
293
|
+
...(opts?.env !== undefined ? { env: opts.env } : {}),
|
|
294
|
+
...(opts?.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
|
|
295
|
+
...(context?.stdin !== undefined ? { stdin: context.stdin } : {}),
|
|
296
|
+
...(context?.maxOutputBytes !== undefined
|
|
297
|
+
? { maxOutputBytes: context.maxOutputBytes }
|
|
298
|
+
: {}),
|
|
299
|
+
},
|
|
300
|
+
opts,
|
|
301
|
+
signal,
|
|
302
|
+
),
|
|
303
|
+
}
|
|
304
|
+
this.executionController = new RemoteExecutionController(adapter)
|
|
251
305
|
}
|
|
252
306
|
|
|
253
307
|
/**
|
|
@@ -256,16 +310,18 @@ export class VsockAgentTransport {
|
|
|
256
310
|
* failures (ECONNREFUSED while the agent re-listens after a resume,
|
|
257
311
|
* a dropped CONNECT ack) until the budget is exhausted.
|
|
258
312
|
*/
|
|
259
|
-
private async dial(): Promise<net.Socket> {
|
|
313
|
+
private async dial(signal?: AbortSignal): Promise<net.Socket> {
|
|
260
314
|
const deadline = Date.now() + this.connectRetryBudgetMs
|
|
261
315
|
let lastErr: unknown
|
|
262
316
|
for (;;) {
|
|
317
|
+
signal?.throwIfAborted()
|
|
263
318
|
try {
|
|
264
|
-
return await this.connectOnce()
|
|
319
|
+
return await this.connectOnce(signal)
|
|
265
320
|
} catch (err) {
|
|
321
|
+
if (signal?.aborted) throw signal.reason
|
|
266
322
|
lastErr = err
|
|
267
323
|
if (Date.now() >= deadline) break
|
|
268
|
-
await delay(this.connectRetryIntervalMs)
|
|
324
|
+
await delay(this.connectRetryIntervalMs, signal)
|
|
269
325
|
}
|
|
270
326
|
}
|
|
271
327
|
throw new Error(
|
|
@@ -276,36 +332,41 @@ export class VsockAgentTransport {
|
|
|
276
332
|
)
|
|
277
333
|
}
|
|
278
334
|
|
|
279
|
-
private connectOnce(): Promise<net.Socket> {
|
|
335
|
+
private connectOnce(signal?: AbortSignal): Promise<net.Socket> {
|
|
280
336
|
const handle = this.handle
|
|
281
|
-
if (handle.kind === 'mtls') return this.connectOnceMtls(handle)
|
|
337
|
+
if (handle.kind === 'mtls') return this.connectOnceMtls(handle, signal)
|
|
282
338
|
return new Promise<net.Socket>((resolve, reject) => {
|
|
283
339
|
const path = handle.kind === 'unix' ? handle.path : handle.udsPath
|
|
284
340
|
const socket = net.connect({ path })
|
|
285
341
|
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
342
|
const fail = (err: Error) => {
|
|
295
343
|
if (settled) return
|
|
296
344
|
settled = true
|
|
297
345
|
clearTimeout(timer)
|
|
346
|
+
signal?.removeEventListener('abort', abort)
|
|
298
347
|
socket.destroy()
|
|
299
348
|
reject(err)
|
|
300
349
|
}
|
|
350
|
+
const abort = () => fail(signalError(signal))
|
|
351
|
+
const timer = setTimeout(
|
|
352
|
+
() => fail(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`)),
|
|
353
|
+
this.connectTimeoutMs,
|
|
354
|
+
)
|
|
355
|
+
timer.unref()
|
|
301
356
|
|
|
302
357
|
socket.once('error', fail)
|
|
358
|
+
if (signal?.aborted) {
|
|
359
|
+
abort()
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
303
363
|
|
|
304
364
|
socket.once('connect', () => {
|
|
305
365
|
if (handle.kind === 'unix') {
|
|
306
366
|
if (settled) return
|
|
307
367
|
settled = true
|
|
308
368
|
clearTimeout(timer)
|
|
369
|
+
signal?.removeEventListener('abort', abort)
|
|
309
370
|
socket.removeListener('error', fail)
|
|
310
371
|
resolve(socket)
|
|
311
372
|
return
|
|
@@ -327,6 +388,7 @@ export class VsockAgentTransport {
|
|
|
327
388
|
if (settled) return
|
|
328
389
|
settled = true
|
|
329
390
|
clearTimeout(timer)
|
|
391
|
+
signal?.removeEventListener('abort', abort)
|
|
330
392
|
socket.removeListener('error', fail)
|
|
331
393
|
// Any bytes the ackReader over-read after the ack line are
|
|
332
394
|
// application framing; replay them into the caller.
|
|
@@ -363,6 +425,7 @@ export class VsockAgentTransport {
|
|
|
363
425
|
*/
|
|
364
426
|
private connectOnceMtls(
|
|
365
427
|
handle: Extract<SandboxAgentHandle, { kind: 'mtls' }>,
|
|
428
|
+
signal?: AbortSignal,
|
|
366
429
|
): Promise<net.Socket> {
|
|
367
430
|
return new Promise<net.Socket>((resolve, reject) => {
|
|
368
431
|
const socket = tls.connect({
|
|
@@ -376,23 +439,27 @@ export class VsockAgentTransport {
|
|
|
376
439
|
minVersion: 'TLSv1.3',
|
|
377
440
|
})
|
|
378
441
|
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
442
|
const fail = (err: Error) => {
|
|
388
443
|
if (settled) return
|
|
389
444
|
settled = true
|
|
390
445
|
clearTimeout(timer)
|
|
446
|
+
signal?.removeEventListener('abort', abort)
|
|
391
447
|
socket.destroy()
|
|
392
448
|
reject(err)
|
|
393
449
|
}
|
|
450
|
+
const abort = () => fail(signalError(signal))
|
|
451
|
+
const timer = setTimeout(
|
|
452
|
+
() => fail(new Error(`connect/handshake timed out after ${this.connectTimeoutMs}ms`)),
|
|
453
|
+
this.connectTimeoutMs,
|
|
454
|
+
)
|
|
455
|
+
timer.unref()
|
|
394
456
|
|
|
395
457
|
socket.once('error', fail)
|
|
458
|
+
if (signal?.aborted) {
|
|
459
|
+
abort()
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
396
463
|
|
|
397
464
|
// `secureConnect` fires only after the cert chain is verified
|
|
398
465
|
// (rejectUnauthorized rejects a bad/missing-CA server via 'error'
|
|
@@ -411,6 +478,7 @@ export class VsockAgentTransport {
|
|
|
411
478
|
}
|
|
412
479
|
settled = true
|
|
413
480
|
clearTimeout(timer)
|
|
481
|
+
signal?.removeEventListener('abort', abort)
|
|
414
482
|
socket.removeListener('error', fail)
|
|
415
483
|
// Routing preamble — the host-relay analogue of the vsock
|
|
416
484
|
// `CONNECT <port>` line. The relay consumes it, resolves the
|
|
@@ -427,27 +495,33 @@ export class VsockAgentTransport {
|
|
|
427
495
|
* healthz). Applies the read-idle timeout so a post-resume hung read
|
|
428
496
|
* is torn down rather than wedging the caller.
|
|
429
497
|
*/
|
|
430
|
-
async request<T>(req: AgentRequest): Promise<T> {
|
|
431
|
-
const socket = await this.dial()
|
|
498
|
+
async request<T>(req: AgentRequest, signal?: AbortSignal): Promise<T> {
|
|
499
|
+
const socket = await this.dial(signal)
|
|
432
500
|
return await new Promise<T>((resolve, reject) => {
|
|
433
501
|
const reader = new FrameReader()
|
|
434
502
|
let settled = false
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
settled = true
|
|
438
|
-
socket.destroy()
|
|
439
|
-
reject(new Error(`vsock transport: read idle timeout after ${this.readIdleTimeoutMs}ms`))
|
|
440
|
-
})
|
|
503
|
+
let response: T | undefined
|
|
504
|
+
let closeTimer: ReturnType<typeof setTimeout> | undefined
|
|
441
505
|
const finish = (err: Error | null, value?: T) => {
|
|
442
506
|
if (settled) return
|
|
443
507
|
settled = true
|
|
444
508
|
idle.clear()
|
|
509
|
+
if (closeTimer) clearTimeout(closeTimer)
|
|
510
|
+
signal?.removeEventListener('abort', abort)
|
|
445
511
|
socket.destroy()
|
|
446
512
|
if (err) reject(err)
|
|
447
513
|
else resolve(value as T)
|
|
448
514
|
}
|
|
515
|
+
const abort = () => finish(signalError(signal))
|
|
516
|
+
const idle = new IdleTimer(this.readIdleTimeoutMs, () =>
|
|
517
|
+
finish(new Error(`vsock transport: read idle timeout after ${this.readIdleTimeoutMs}ms`)),
|
|
518
|
+
)
|
|
449
519
|
socket.on('data', (chunk: Buffer) => {
|
|
450
520
|
idle.bump()
|
|
521
|
+
if (response !== undefined) {
|
|
522
|
+
finish(new Error('vsock transport: control reply emitted data after its response'))
|
|
523
|
+
return
|
|
524
|
+
}
|
|
451
525
|
let frames: string[]
|
|
452
526
|
try {
|
|
453
527
|
frames = reader.push(chunk)
|
|
@@ -455,17 +529,39 @@ export class VsockAgentTransport {
|
|
|
455
529
|
finish(err instanceof Error ? err : new Error(String(err)))
|
|
456
530
|
return
|
|
457
531
|
}
|
|
532
|
+
if (frames.length > 1) {
|
|
533
|
+
finish(new Error('vsock transport: control reply emitted multiple frames'))
|
|
534
|
+
return
|
|
535
|
+
}
|
|
458
536
|
const first = frames[0]
|
|
459
537
|
if (first !== undefined) {
|
|
460
538
|
try {
|
|
461
|
-
|
|
539
|
+
response = JSON.parse(first) as T
|
|
540
|
+
if (reader.bufferedBytes > 0) {
|
|
541
|
+
finish(new Error('vsock transport: control reply has trailing partial data'))
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
idle.clear()
|
|
545
|
+
closeTimer = setTimeout(
|
|
546
|
+
() => finish(new Error('vsock transport: control peer did not close after reply')),
|
|
547
|
+
POST_RESPONSE_CLOSE_TIMEOUT_MS,
|
|
548
|
+
)
|
|
549
|
+
closeTimer.unref()
|
|
462
550
|
} catch (err) {
|
|
463
551
|
finish(err instanceof Error ? err : new Error(String(err)))
|
|
464
552
|
}
|
|
465
553
|
}
|
|
466
554
|
})
|
|
467
555
|
socket.once('error', (err) => finish(err))
|
|
468
|
-
socket.once('close', () =>
|
|
556
|
+
socket.once('close', () => {
|
|
557
|
+
if (response !== undefined) finish(null, response)
|
|
558
|
+
else finish(new Error('vsock transport: socket closed before reply'))
|
|
559
|
+
})
|
|
560
|
+
if (signal?.aborted) {
|
|
561
|
+
abort()
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
469
565
|
idle.bump()
|
|
470
566
|
socket.write(frame(JSON.stringify(req)))
|
|
471
567
|
})
|
|
@@ -476,31 +572,52 @@ export class VsockAgentTransport {
|
|
|
476
572
|
* {@link SandboxExecResult} via the shared {@link ExecResultAccumulator}.
|
|
477
573
|
* The agent terminates the stream with a zero-length frame.
|
|
478
574
|
*/
|
|
479
|
-
async
|
|
480
|
-
|
|
575
|
+
private async executeRaw(
|
|
576
|
+
body: ExecRequest,
|
|
577
|
+
opts?: SandboxExecOptions,
|
|
578
|
+
signal?: AbortSignal,
|
|
579
|
+
): Promise<SandboxExecResult> {
|
|
580
|
+
const socket = await this.dial(signal)
|
|
481
581
|
const start = Date.now()
|
|
482
582
|
return await new Promise<SandboxExecResult>((resolve, reject) => {
|
|
483
583
|
const reader = new FrameReader()
|
|
484
|
-
const acc = new ExecResultAccumulator(start)
|
|
584
|
+
const acc = new ExecResultAccumulator(start, opts?.onOutput)
|
|
485
585
|
let settled = false
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
586
|
+
let terminated = false
|
|
587
|
+
let terminalResult: SandboxExecResult | undefined
|
|
588
|
+
let closeTimer: ReturnType<typeof setTimeout> | undefined
|
|
589
|
+
const requestedTimeout =
|
|
590
|
+
typeof body.timeoutMs === 'number' && Number.isFinite(body.timeoutMs) && body.timeoutMs > 0
|
|
591
|
+
? body.timeoutMs
|
|
592
|
+
: DEFAULT_EXECUTION_TIMEOUT_MS
|
|
593
|
+
const observationTimeoutMs = Math.min(
|
|
594
|
+
MAX_TIMER_DELAY_MS,
|
|
595
|
+
requestedTimeout + EXECUTION_TRANSPORT_GRACE_MS,
|
|
596
|
+
)
|
|
494
597
|
const finish = (err: Error | null, value?: SandboxExecResult) => {
|
|
495
598
|
if (settled) return
|
|
496
599
|
settled = true
|
|
497
|
-
|
|
600
|
+
clearTimeout(observationTimer)
|
|
601
|
+
if (closeTimer) clearTimeout(closeTimer)
|
|
602
|
+
signal?.removeEventListener('abort', abort)
|
|
498
603
|
socket.destroy()
|
|
499
604
|
if (err) reject(err)
|
|
500
605
|
else resolve(value as SandboxExecResult)
|
|
501
606
|
}
|
|
607
|
+
const abort = () => finish(signalError(signal))
|
|
608
|
+
const observationTimer = setTimeout(
|
|
609
|
+
() =>
|
|
610
|
+
finish(
|
|
611
|
+
new Error(`vsock transport: execution observation exceeded ${observationTimeoutMs}ms`),
|
|
612
|
+
),
|
|
613
|
+
observationTimeoutMs,
|
|
614
|
+
)
|
|
615
|
+
observationTimer.unref()
|
|
502
616
|
socket.on('data', (chunk: Buffer) => {
|
|
503
|
-
|
|
617
|
+
if (terminated) {
|
|
618
|
+
finish(new Error('vsock transport: exec stream emitted data after its terminator'))
|
|
619
|
+
return
|
|
620
|
+
}
|
|
504
621
|
let frames: string[]
|
|
505
622
|
try {
|
|
506
623
|
frames = reader.push(chunk)
|
|
@@ -509,49 +626,127 @@ export class VsockAgentTransport {
|
|
|
509
626
|
return
|
|
510
627
|
}
|
|
511
628
|
for (const payload of frames) {
|
|
512
|
-
if (
|
|
513
|
-
|
|
514
|
-
// done; otherwise the stream ended without a result.
|
|
515
|
-
finish(
|
|
516
|
-
acc.done ? null : new Error('exec stream ended without a result event'),
|
|
517
|
-
acc.finish(),
|
|
518
|
-
)
|
|
629
|
+
if (terminated) {
|
|
630
|
+
finish(new Error('vsock transport: exec stream emitted data after its terminator'))
|
|
519
631
|
return
|
|
520
632
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
// Terminal result seen; wait for terminator but we can
|
|
526
|
-
// resolve now — the agent closes after the terminator.
|
|
633
|
+
if (payload.length === 0) {
|
|
634
|
+
if (!acc.done) {
|
|
635
|
+
finish(new Error('exec stream ended without a result event'))
|
|
636
|
+
return
|
|
527
637
|
}
|
|
638
|
+
terminated = true
|
|
639
|
+
continue
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
const event = parseExecLine(payload)
|
|
643
|
+
if (event) acc.push(event)
|
|
528
644
|
} catch (err) {
|
|
529
645
|
finish(err instanceof Error ? err : new Error(String(err)))
|
|
530
646
|
return
|
|
531
647
|
}
|
|
532
648
|
}
|
|
649
|
+
if (terminated) {
|
|
650
|
+
if (reader.bufferedBytes > 0) {
|
|
651
|
+
finish(new Error('vsock transport: exec stream has trailing partial data'))
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
terminalResult = acc.finish()
|
|
655
|
+
closeTimer = setTimeout(
|
|
656
|
+
() => finish(new Error('vsock transport: exec peer did not close after terminator')),
|
|
657
|
+
POST_RESPONSE_CLOSE_TIMEOUT_MS,
|
|
658
|
+
)
|
|
659
|
+
closeTimer.unref()
|
|
660
|
+
}
|
|
533
661
|
})
|
|
534
662
|
socket.once('error', (err) => finish(err))
|
|
535
663
|
socket.once('close', () => {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
// otherwise it is a truncated stream.
|
|
539
|
-
finish(
|
|
540
|
-
acc.done ? null : new Error('vsock transport: socket closed before exec result'),
|
|
541
|
-
acc.finish(),
|
|
542
|
-
)
|
|
664
|
+
if (terminated && terminalResult) finish(null, terminalResult)
|
|
665
|
+
else finish(new Error('vsock transport: socket closed before exec stream terminator'))
|
|
543
666
|
})
|
|
544
|
-
|
|
667
|
+
if (signal?.aborted) {
|
|
668
|
+
abort()
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
545
672
|
socket.write(frame(JSON.stringify({ op: 'execute', body } satisfies AgentRequest)))
|
|
546
673
|
})
|
|
547
674
|
}
|
|
548
675
|
|
|
676
|
+
/**
|
|
677
|
+
* Compatibility request-shaped entry point. It now enters the same
|
|
678
|
+
* reserve-before-admission controller as {@link exec}; the raw data-plane
|
|
679
|
+
* primitive is deliberately private so aborting this public method cannot
|
|
680
|
+
* abandon a live guest command.
|
|
681
|
+
*/
|
|
682
|
+
async execute(
|
|
683
|
+
body: ExecRequest,
|
|
684
|
+
opts?: SandboxExecOptions,
|
|
685
|
+
signal?: AbortSignal,
|
|
686
|
+
): Promise<SandboxExecResult> {
|
|
687
|
+
if (body.executionId !== undefined) {
|
|
688
|
+
throw new RemoteProtocolError(
|
|
689
|
+
'VsockAgentTransport.execute does not accept caller-owned execution ids',
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
return await this.executionController.exec(
|
|
693
|
+
body.command,
|
|
694
|
+
body.args ? [...body.args] : undefined,
|
|
695
|
+
{
|
|
696
|
+
...opts,
|
|
697
|
+
...(body.cwd !== undefined ? { cwd: body.cwd } : {}),
|
|
698
|
+
...(body.env !== undefined ? { env: body.env } : {}),
|
|
699
|
+
...(body.timeoutMs !== undefined ? { timeout: body.timeoutMs } : {}),
|
|
700
|
+
...(opts?.signal === undefined && signal !== undefined ? { signal } : {}),
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
...(body.stdin !== undefined ? { stdin: body.stdin } : {}),
|
|
704
|
+
...(body.maxOutputBytes !== undefined ? { maxOutputBytes: body.maxOutputBytes } : {}),
|
|
705
|
+
},
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async exec(
|
|
710
|
+
command: string,
|
|
711
|
+
argv?: string[],
|
|
712
|
+
opts?: SandboxExecOptions,
|
|
713
|
+
): Promise<SandboxExecResult> {
|
|
714
|
+
return await this.executionController.exec(command, argv, opts)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private async reserveExecution(signal: AbortSignal): Promise<unknown> {
|
|
718
|
+
const response = await this.request<Record<string, unknown>>(
|
|
719
|
+
{ op: 'reserve-execution' },
|
|
720
|
+
signal,
|
|
721
|
+
)
|
|
722
|
+
if (
|
|
723
|
+
response.ok === false &&
|
|
724
|
+
typeof response.error === 'string' &&
|
|
725
|
+
response.error.startsWith('unknown_op:')
|
|
726
|
+
) {
|
|
727
|
+
throw new RemoteCancellationUnsupportedError(
|
|
728
|
+
'This microVM agent does not support the execution-cancellation lease protocol. Rebuild the guest image before passing SandboxExecOptions.signal; refusing rather than pretending cancellation is active.',
|
|
729
|
+
)
|
|
730
|
+
}
|
|
731
|
+
if (response.ok === false && response.error === 'agent_retiring') {
|
|
732
|
+
throw new RemoteCancellationUnknownError(
|
|
733
|
+
'The microVM agent has fenced itself because an earlier process-group shutdown could not be confirmed; the sandbox must be retired.',
|
|
734
|
+
)
|
|
735
|
+
}
|
|
736
|
+
return response
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private async cancelExecution(executionId: string, signal: AbortSignal): Promise<unknown> {
|
|
740
|
+
return await this.request<unknown>({ op: 'cancel-execution', body: { executionId } }, signal)
|
|
741
|
+
}
|
|
742
|
+
|
|
549
743
|
/** Liveness probe. Returns true on an `{ ok: true }` healthz reply. */
|
|
550
|
-
async healthz(): Promise<boolean> {
|
|
744
|
+
async healthz(signal?: AbortSignal): Promise<boolean> {
|
|
551
745
|
try {
|
|
552
|
-
const res = await this.request<{ ok?: boolean }>({ op: 'healthz' })
|
|
746
|
+
const res = await this.request<{ ok?: boolean }>({ op: 'healthz' }, signal)
|
|
553
747
|
return res.ok === true
|
|
554
748
|
} catch {
|
|
749
|
+
if (signal?.aborted) throw signal.reason
|
|
555
750
|
return false
|
|
556
751
|
}
|
|
557
752
|
}
|
|
@@ -562,17 +757,27 @@ export class VsockAgentTransport {
|
|
|
562
757
|
* (which already carries connect retry) — used by the backend's
|
|
563
758
|
* post-create readiness fence.
|
|
564
759
|
*/
|
|
565
|
-
async waitForReady(
|
|
566
|
-
|
|
760
|
+
async waitForReady(
|
|
761
|
+
timeoutMs: number,
|
|
762
|
+
pollIntervalMs: number,
|
|
763
|
+
signal?: AbortSignal,
|
|
764
|
+
): Promise<void> {
|
|
765
|
+
const deadline = new OperationDeadline(timeoutMs, 'firecracker agent readiness', signal)
|
|
567
766
|
let lastErr: unknown
|
|
568
|
-
while (
|
|
767
|
+
while (deadline.remainingMs() > 0) {
|
|
569
768
|
try {
|
|
570
|
-
if (await this.healthz()) return
|
|
769
|
+
if (await deadline.run((signal) => this.healthz(signal))) return
|
|
571
770
|
lastErr = new Error('healthz returned not-ok')
|
|
572
771
|
} catch (err) {
|
|
573
772
|
lastErr = err
|
|
773
|
+
if (err instanceof OperationDeadlineExpired) break
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
await deadline.delay(pollIntervalMs)
|
|
777
|
+
} catch (err) {
|
|
778
|
+
if (err instanceof OperationDeadlineExpired) break
|
|
779
|
+
throw err
|
|
574
780
|
}
|
|
575
|
-
await delay(pollIntervalMs)
|
|
576
781
|
}
|
|
577
782
|
throw new Error(
|
|
578
783
|
`vsock transport: agent did not become ready within ${timeoutMs}ms: ${
|
|
@@ -647,8 +852,27 @@ class IdleTimer {
|
|
|
647
852
|
}
|
|
648
853
|
}
|
|
649
854
|
|
|
650
|
-
function delay(ms: number): Promise<void> {
|
|
651
|
-
return new Promise((resolve) =>
|
|
855
|
+
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
856
|
+
return new Promise((resolve, reject) => {
|
|
857
|
+
if (signal?.aborted) {
|
|
858
|
+
reject(signalError(signal))
|
|
859
|
+
return
|
|
860
|
+
}
|
|
861
|
+
const finish = (err?: unknown) => {
|
|
862
|
+
clearTimeout(timer)
|
|
863
|
+
signal?.removeEventListener('abort', abort)
|
|
864
|
+
if (err === undefined) resolve()
|
|
865
|
+
else reject(err)
|
|
866
|
+
}
|
|
867
|
+
const abort = () => finish(signalError(signal))
|
|
868
|
+
const timer = setTimeout(() => finish(), ms)
|
|
869
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
870
|
+
})
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function signalError(signal: AbortSignal | undefined): Error {
|
|
874
|
+
if (signal?.reason instanceof Error) return signal.reason
|
|
875
|
+
return new Error(signal?.reason === undefined ? 'operation aborted' : String(signal.reason))
|
|
652
876
|
}
|
|
653
877
|
|
|
654
878
|
function describeHandle(handle: SandboxAgentHandle): string {
|