@multiplayer-app/sandbox 7.0.1 → 7.0.2
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/README.md +7 -0
- package/dist/cli.js +504 -105
- package/dist/cli.js.map +4 -4
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/console-relays.d.ts +4 -4
- package/dist/console-relays.d.ts.map +1 -1
- package/dist/executor/index.d.ts +12 -1
- package/dist/executor/index.d.ts.map +1 -1
- package/dist/executor/lambda-microvm-executor.d.ts +13 -3
- package/dist/executor/lambda-microvm-executor.d.ts.map +1 -1
- package/dist/executor/types.d.ts +16 -2
- package/dist/executor/types.d.ts.map +1 -1
- package/dist/images.d.ts.map +1 -1
- package/dist/index.js +409 -53
- package/dist/index.js.map +4 -4
- package/dist/services/serial-relay.d.ts +11 -0
- package/dist/services/serial-relay.d.ts.map +1 -1
- package/dist/socket-client.d.ts.map +1 -1
- package/dist/vendor/builder-types/events.d.ts +10 -0
- package/dist/vendor/builder-types/events.d.ts.map +1 -1
- package/dist/vendor/builder-types/index.d.ts +1 -0
- package/dist/vendor/builder-types/index.d.ts.map +1 -1
- package/dist/vendor/builder-types/release.d.ts +5 -5
- package/dist/vendor/builder-types/release.d.ts.map +1 -1
- package/dist/vendor/builder-types/vm-cost.d.ts +88 -0
- package/dist/vendor/builder-types/vm-cost.d.ts.map +1 -0
- package/dist/vendor/builder-types/vm.d.ts +5 -0
- package/dist/vendor/builder-types/vm.d.ts.map +1 -1
- package/dist/worker-identity.d.ts +11 -0
- package/dist/worker-identity.d.ts.map +1 -0
- package/package.json +8 -4
package/dist/cli.js
CHANGED
|
@@ -28,7 +28,7 @@ var import_config5 = require("dotenv/config");
|
|
|
28
28
|
var import_commander = require("commander");
|
|
29
29
|
|
|
30
30
|
// package.json
|
|
31
|
-
var version = "7.0.
|
|
31
|
+
var version = "7.0.2";
|
|
32
32
|
|
|
33
33
|
// src/config.ts
|
|
34
34
|
var import_os2 = __toESM(require("os"));
|
|
@@ -51,6 +51,14 @@ var WorkerEvent = {
|
|
|
51
51
|
// worker → sandbox, fire-and-forget like HEARTBEAT — sent once when a 'job' VM's
|
|
52
52
|
// entrypoint exits on its own (the worker has already torn the resource down by then).
|
|
53
53
|
VM_RUN_EXITED: "vm:run-exited",
|
|
54
|
+
// worker → sandbox after an asynchronous VM_CREATE finishes. Socket.IO acknowledgements
|
|
55
|
+
// cannot be relied on across the detached Redis-adapter emitter used by the API.
|
|
56
|
+
VM_CREATE_RESULT: "vm:create-result",
|
|
57
|
+
// worker → sandbox, fire-and-forget — sent when periodic reconciliation finds the
|
|
58
|
+
// underlying resource gone without this worker ever issuing a destroy (e.g. AWS's own
|
|
59
|
+
// idle-suspend/max-duration policies terminating a Lambda MicroVM). Only engines whose
|
|
60
|
+
// VMs can disappear outside an explicit command from this process emit it.
|
|
61
|
+
VM_EXTERNALLY_TERMINATED: "vm:externally-terminated",
|
|
54
62
|
// sandbox → worker, generated by the control plane and reused by this worker process
|
|
55
63
|
// on Socket.IO reconnects. This keeps duplicate configured WORKER_ID values isolated.
|
|
56
64
|
ASSIGNED_ID: "worker:assigned-id",
|
|
@@ -78,6 +86,8 @@ var WorkerEvent = {
|
|
|
78
86
|
// sandbox → worker (browser input forwarded downstream)
|
|
79
87
|
VNC_MOUSE: "vnc:mouse",
|
|
80
88
|
VNC_KEYBOARD: "vnc:keyboard",
|
|
89
|
+
TERMINAL_ATTACH: "terminal:attach",
|
|
90
|
+
TERMINAL_DETACH: "terminal:detach",
|
|
81
91
|
TERMINAL_INPUT: "terminal:input"
|
|
82
92
|
};
|
|
83
93
|
var VM_ACTION_EVENTS = {
|
|
@@ -122,7 +132,20 @@ function s3Key(prefix, arch, stack, file) {
|
|
|
122
132
|
async function makeS3Client(endpoint) {
|
|
123
133
|
const { S3Client } = await import("@aws-sdk/client-s3");
|
|
124
134
|
return new S3Client({
|
|
125
|
-
...endpoint ? {
|
|
135
|
+
...endpoint ? {
|
|
136
|
+
endpoint,
|
|
137
|
+
forcePathStyle: true,
|
|
138
|
+
// A custom endpoint means a local/self-hosted store (MinIO, etc.), not real AWS.
|
|
139
|
+
// Use its own static credentials explicitly rather than the ambient default
|
|
140
|
+
// provider chain — otherwise, whenever AWS_PROFILE is also set (e.g. for the
|
|
141
|
+
// lambda-microvm engine's real AWS calls), the SDK unconditionally skips static env
|
|
142
|
+
// credentials process-wide and sends the real AWS SSO session to this local store
|
|
143
|
+
// instead, which rejects it since it only recognizes its own configured user.
|
|
144
|
+
credentials: {
|
|
145
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "minioadmin",
|
|
146
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "minioadmin"
|
|
147
|
+
}
|
|
148
|
+
} : {},
|
|
126
149
|
...process.env.AWS_REGION ? {} : { region: "us-east-1" },
|
|
127
150
|
// aws-chunked checksum streaming breaks older S3-compatible stores (MinIO)
|
|
128
151
|
requestChecksumCalculation: "WHEN_REQUIRED"
|
|
@@ -143,7 +166,10 @@ async function downloadFromHttp(opts, stack, file, dest) {
|
|
|
143
166
|
if (!response.ok || !response.body) {
|
|
144
167
|
throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`);
|
|
145
168
|
}
|
|
146
|
-
await (0, import_promises.pipeline)(
|
|
169
|
+
await (0, import_promises.pipeline)(
|
|
170
|
+
import_stream.Readable.fromWeb(response.body),
|
|
171
|
+
import_fs.default.createWriteStream(dest)
|
|
172
|
+
);
|
|
147
173
|
}
|
|
148
174
|
async function pullImages(opts, destDir) {
|
|
149
175
|
const isS3 = opts.location.startsWith("s3://");
|
|
@@ -178,11 +204,13 @@ async function pushImages(opts, srcDir) {
|
|
|
178
204
|
throw new Error(`${src} not found \u2014 run scripts/setup-vm-images.sh first`);
|
|
179
205
|
}
|
|
180
206
|
const key = s3Key(prefix, opts.arch, stack, file);
|
|
181
|
-
await client.send(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
207
|
+
await client.send(
|
|
208
|
+
new PutObjectCommand({
|
|
209
|
+
Bucket: bucket,
|
|
210
|
+
Key: key,
|
|
211
|
+
Body: import_fs.default.readFileSync(src)
|
|
212
|
+
})
|
|
213
|
+
);
|
|
186
214
|
uploaded.push(`s3://${bucket}/${key}`);
|
|
187
215
|
}
|
|
188
216
|
}
|
|
@@ -221,12 +249,14 @@ function resolveSandboxEndpoint(rawUrl) {
|
|
|
221
249
|
}
|
|
222
250
|
function loadWorkerConfigFromEnv(env = process.env) {
|
|
223
251
|
const workerId = env.WORKER_ID?.trim() || import_os2.default.hostname();
|
|
252
|
+
const workerIdFile = env.WORKER_ID_FILE?.trim() || import_path2.default.join(import_os2.default.tmpdir(), `multiplayer-sandbox-worker-${workerId}.id`);
|
|
224
253
|
const token = env.WORKER_TOKEN?.trim() || void 0;
|
|
225
254
|
const sandboxEndpoint = resolveSandboxEndpoint(
|
|
226
255
|
env.SANDBOX_URL ?? "https://api.sandbox.filingramp.com/v0/gapi"
|
|
227
256
|
);
|
|
228
257
|
return {
|
|
229
258
|
workerId,
|
|
259
|
+
workerIdFile,
|
|
230
260
|
token,
|
|
231
261
|
sandboxUrl: sandboxEndpoint.sandboxUrl,
|
|
232
262
|
workerSocketPath: sandboxEndpoint.workerSocketPath,
|
|
@@ -261,7 +291,10 @@ function loadWorkerConfigFromEnv(env = process.env) {
|
|
|
261
291
|
// 0 disables auto-suspend; the sandbox drives pause/resume explicitly by default
|
|
262
292
|
maxIdleSeconds: Number(env.MICROVM_MAX_IDLE_SECONDS ?? 900),
|
|
263
293
|
suspendedDurationSeconds: Number(env.MICROVM_SUSPENDED_DURATION_SECONDS ?? 28800),
|
|
264
|
-
maximumDurationInSeconds: Number(env.MICROVM_MAX_DURATION_SECONDS ?? 28800)
|
|
294
|
+
maximumDurationInSeconds: Number(env.MICROVM_MAX_DURATION_SECONDS ?? 28800),
|
|
295
|
+
// A real cold MicroVM boot can comfortably exceed the old 25s hardcoded default —
|
|
296
|
+
// must stay well under the api's own VM_CREATE ack timeout (see vm.service.ts).
|
|
297
|
+
createTimeoutMs: Number(env.MICROVM_CREATE_TIMEOUT_MS ?? 6e4)
|
|
265
298
|
},
|
|
266
299
|
fargate: {
|
|
267
300
|
region: env.FARGATE_AWS_REGION ?? env.AWS_REGION,
|
|
@@ -2466,8 +2499,8 @@ var RemoteExecutor = class {
|
|
|
2466
2499
|
body: result.body ? Buffer.from(result.body, "base64") : Buffer.alloc(0)
|
|
2467
2500
|
};
|
|
2468
2501
|
}
|
|
2469
|
-
request(method,
|
|
2470
|
-
const url = new URL(this.base +
|
|
2502
|
+
request(method, path8, body) {
|
|
2503
|
+
const url = new URL(this.base + path8);
|
|
2471
2504
|
const lib = url.protocol === "https:" ? import_https.default : import_http5.default;
|
|
2472
2505
|
const payload = body !== void 0 ? JSON.stringify(body) : void 0;
|
|
2473
2506
|
return new Promise((resolve, reject) => {
|
|
@@ -2488,7 +2521,7 @@ var RemoteExecutor = class {
|
|
|
2488
2521
|
res.on("end", () => {
|
|
2489
2522
|
const text = Buffer.concat(chunks).toString();
|
|
2490
2523
|
if (res.statusCode && res.statusCode >= 400) {
|
|
2491
|
-
reject(new Error(`vm-agent ${method} ${
|
|
2524
|
+
reject(new Error(`vm-agent ${method} ${path8} \u2192 ${res.statusCode}: ${text}`));
|
|
2492
2525
|
return;
|
|
2493
2526
|
}
|
|
2494
2527
|
if (res.statusCode === 204 || !text) {
|
|
@@ -2515,6 +2548,23 @@ var import_https2 = __toESM(require("https"));
|
|
|
2515
2548
|
var import_client_lambda_microvms = require("@aws-sdk/client-lambda-microvms");
|
|
2516
2549
|
var AUTH_TOKEN_TTL_MINUTES = 60;
|
|
2517
2550
|
var AUTH_TOKEN_REFRESH_MARGIN_MS = 2 * 6e4;
|
|
2551
|
+
var SHELL_PORT = 8022;
|
|
2552
|
+
function managedConnectorArn(imageIdentifier, name) {
|
|
2553
|
+
const [arn, partition, service, region] = imageIdentifier.split(":");
|
|
2554
|
+
if (arn !== "arn" || !partition || service !== "lambda" || !region) {
|
|
2555
|
+
throw new Error(`cannot derive Lambda MicroVM connector ARN from image "${imageIdentifier}"`);
|
|
2556
|
+
}
|
|
2557
|
+
return `arn:${partition}:lambda:${region}:aws:network-connector:aws-network-connector:${name}`;
|
|
2558
|
+
}
|
|
2559
|
+
function runtimeEnv(spec) {
|
|
2560
|
+
const env = {
|
|
2561
|
+
...spec.env,
|
|
2562
|
+
// A release URL is unique and short-lived, so it belongs in per-VM run data rather than
|
|
2563
|
+
// the image-level environment shared by every MicroVM launched from that image.
|
|
2564
|
+
...spec.appUrl ? { APP_URL: spec.appUrl } : {}
|
|
2565
|
+
};
|
|
2566
|
+
return Object.keys(env).length ? env : void 0;
|
|
2567
|
+
}
|
|
2518
2568
|
var LambdaMicrovmExecutor = class {
|
|
2519
2569
|
constructor(config) {
|
|
2520
2570
|
this.config = config;
|
|
@@ -2524,10 +2574,15 @@ var LambdaMicrovmExecutor = class {
|
|
|
2524
2574
|
async create(spec) {
|
|
2525
2575
|
const imageIdentifier = this.config.stackImageArns?.[spec.stack] ?? this.config.imageArn;
|
|
2526
2576
|
const maxIdle = this.config.maxIdleSeconds ?? 900;
|
|
2577
|
+
const shellConnectorArn = managedConnectorArn(imageIdentifier, "SHELL_INGRESS");
|
|
2578
|
+
const ingressNetworkConnectors = [
|
|
2579
|
+
.../* @__PURE__ */ new Set([...this.config.ingressConnectorArns ?? [], shellConnectorArn])
|
|
2580
|
+
];
|
|
2527
2581
|
const run = await this.client.send(
|
|
2528
2582
|
new import_client_lambda_microvms.RunMicrovmCommand({
|
|
2529
2583
|
imageIdentifier,
|
|
2530
|
-
|
|
2584
|
+
// Required by CreateMicrovmShellAuthToken and the /shell WebSocket used by vm-console.
|
|
2585
|
+
ingressNetworkConnectors,
|
|
2531
2586
|
egressNetworkConnectors: this.config.egressConnectorArns,
|
|
2532
2587
|
executionRoleArn: this.config.executionRoleArn,
|
|
2533
2588
|
maximumDurationInSeconds: this.config.maximumDurationInSeconds ?? 28800,
|
|
@@ -2544,9 +2599,11 @@ var LambdaMicrovmExecutor = class {
|
|
|
2544
2599
|
vmId: spec.vmId,
|
|
2545
2600
|
sessionId: spec.sessionId,
|
|
2546
2601
|
stack: spec.stack,
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2602
|
+
env: runtimeEnv(spec)
|
|
2603
|
+
}),
|
|
2604
|
+
// VM_CREATE may be delivered more than once by the Redis-backed control plane.
|
|
2605
|
+
// AWS returns the original request result instead of provisioning another MicroVM.
|
|
2606
|
+
clientToken: spec.vmId
|
|
2550
2607
|
})
|
|
2551
2608
|
);
|
|
2552
2609
|
if (!run.microvmId || !run.endpoint) {
|
|
@@ -2609,6 +2666,31 @@ var LambdaMicrovmExecutor = class {
|
|
|
2609
2666
|
list() {
|
|
2610
2667
|
return [...this.vms.values()].map((v) => v.handle);
|
|
2611
2668
|
}
|
|
2669
|
+
getSerialSource(vmId) {
|
|
2670
|
+
const vm = this.vms.get(vmId);
|
|
2671
|
+
if (!vm) return void 0;
|
|
2672
|
+
return {
|
|
2673
|
+
type: "websocket",
|
|
2674
|
+
url: `wss://${vm.endpoint}/shell`,
|
|
2675
|
+
protocols: async () => {
|
|
2676
|
+
const response = await this.client.send(
|
|
2677
|
+
new import_client_lambda_microvms.CreateMicrovmShellAuthTokenCommand({
|
|
2678
|
+
microvmIdentifier: vm.microvmId,
|
|
2679
|
+
expirationInMinutes: AUTH_TOKEN_TTL_MINUTES
|
|
2680
|
+
})
|
|
2681
|
+
);
|
|
2682
|
+
const token = Object.entries(response.authToken ?? {}).find(
|
|
2683
|
+
([key]) => key.toLowerCase() === "x-aws-proxy-auth"
|
|
2684
|
+
)?.[1];
|
|
2685
|
+
if (!token) throw new Error("create-microvm-shell-auth-token returned no auth token");
|
|
2686
|
+
return [
|
|
2687
|
+
"lambda-microvms",
|
|
2688
|
+
`lambda-microvms.authentication.${token}`,
|
|
2689
|
+
`lambda-microvms.port.${SHELL_PORT}`
|
|
2690
|
+
];
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
}
|
|
2612
2694
|
async proxyHttp(vmId, request) {
|
|
2613
2695
|
const vm = this.mustGet(vmId);
|
|
2614
2696
|
const auth = await this.getAuthHeaders(vm);
|
|
@@ -2676,6 +2758,47 @@ var LambdaMicrovmExecutor = class {
|
|
|
2676
2758
|
);
|
|
2677
2759
|
return handle;
|
|
2678
2760
|
}
|
|
2761
|
+
/**
|
|
2762
|
+
* AWS can suspend or terminate a MicroVM on its own (idle-suspend policy, the suspended-
|
|
2763
|
+
* duration cap, the hard maximumDurationInSeconds lifetime) without this process ever
|
|
2764
|
+
* issuing a command — nothing else here polls for that. Called periodically by the
|
|
2765
|
+
* worker: re-describes every tracked VM, quietly reconciles running/suspended drift, and
|
|
2766
|
+
* reports (then forgets) any that AWS has actually terminated so the control plane's
|
|
2767
|
+
* record doesn't stay stuck saying "running" forever.
|
|
2768
|
+
*/
|
|
2769
|
+
async reconcile() {
|
|
2770
|
+
const externallyTerminated = [];
|
|
2771
|
+
for (const [vmId, vm] of [...this.vms.entries()]) {
|
|
2772
|
+
try {
|
|
2773
|
+
const described = await this.client.send(
|
|
2774
|
+
new import_client_lambda_microvms.GetMicrovmCommand({ microvmIdentifier: vm.microvmId })
|
|
2775
|
+
);
|
|
2776
|
+
if (described.state === "TERMINATED" || described.state === "TERMINATING") {
|
|
2777
|
+
this.vms.delete(vmId);
|
|
2778
|
+
externallyTerminated.push(vmId);
|
|
2779
|
+
src_default.warn(
|
|
2780
|
+
{ vmId, microvmId: vm.microvmId, state: described.state },
|
|
2781
|
+
"[LambdaMicrovm] vm terminated outside our control"
|
|
2782
|
+
);
|
|
2783
|
+
} else if (typeof described.state === "string") {
|
|
2784
|
+
const running = described.state === "RUNNING";
|
|
2785
|
+
if (running !== vm.handle.running) vm.handle = { ...vm.handle, running };
|
|
2786
|
+
}
|
|
2787
|
+
} catch (error) {
|
|
2788
|
+
if (error instanceof Error && error.name === "ResourceNotFoundException") {
|
|
2789
|
+
this.vms.delete(vmId);
|
|
2790
|
+
externallyTerminated.push(vmId);
|
|
2791
|
+
src_default.warn(
|
|
2792
|
+
{ vmId, microvmId: vm.microvmId },
|
|
2793
|
+
"[LambdaMicrovm] vm no longer exists in AWS"
|
|
2794
|
+
);
|
|
2795
|
+
} else {
|
|
2796
|
+
src_default.error({ vmId, err: error }, "[LambdaMicrovm] reconcile check failed");
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
return externallyTerminated;
|
|
2801
|
+
}
|
|
2679
2802
|
mustGet(vmId) {
|
|
2680
2803
|
const vm = this.vms.get(vmId);
|
|
2681
2804
|
if (!vm) throw new Error(`vm ${vmId} not found`);
|
|
@@ -3248,6 +3371,8 @@ function detectEngines(config) {
|
|
|
3248
3371
|
}
|
|
3249
3372
|
|
|
3250
3373
|
// src/executor/index.ts
|
|
3374
|
+
var VmResourceGoneError = class extends Error {
|
|
3375
|
+
};
|
|
3251
3376
|
function findNotAttachedVms(executor, records) {
|
|
3252
3377
|
return records.filter((record) => !executor.get(record.vmId));
|
|
3253
3378
|
}
|
|
@@ -3277,11 +3402,25 @@ var MultiEngineExecutor = class {
|
|
|
3277
3402
|
constructor(executors, defaultEngine) {
|
|
3278
3403
|
this.executors = executors;
|
|
3279
3404
|
this.defaultEngine = defaultEngine;
|
|
3405
|
+
this.creations = /* @__PURE__ */ new Map();
|
|
3280
3406
|
}
|
|
3281
3407
|
get engines() {
|
|
3282
3408
|
return [...this.executors.keys()];
|
|
3283
3409
|
}
|
|
3284
3410
|
async create(spec) {
|
|
3411
|
+
const existing = this.get(spec.vmId);
|
|
3412
|
+
if (existing) return existing;
|
|
3413
|
+
const pending = this.creations.get(spec.vmId);
|
|
3414
|
+
if (pending) return pending;
|
|
3415
|
+
const creation = this.createOnce(spec);
|
|
3416
|
+
this.creations.set(spec.vmId, creation);
|
|
3417
|
+
try {
|
|
3418
|
+
return await creation;
|
|
3419
|
+
} finally {
|
|
3420
|
+
this.creations.delete(spec.vmId);
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
async createOnce(spec) {
|
|
3285
3424
|
const engine = spec.engine ?? this.defaultEngine;
|
|
3286
3425
|
const executor = this.executors.get(engine);
|
|
3287
3426
|
if (!executor) {
|
|
@@ -3292,22 +3431,27 @@ var MultiEngineExecutor = class {
|
|
|
3292
3431
|
const handle = await executor.create(spec);
|
|
3293
3432
|
return { ...handle, engine };
|
|
3294
3433
|
}
|
|
3295
|
-
|
|
3434
|
+
// async here isn't a no-op: route() throws synchronously when the vmId isn't tracked in any
|
|
3435
|
+
// executor's in-memory map (e.g. worker restarted and hasn't reattached this VM yet). Without
|
|
3436
|
+
// `async`, that throw escapes as a real exception instead of a rejected promise — which,
|
|
3437
|
+
// called from socket-client.ts's wrap(), skips its .catch() entirely and leaves the command
|
|
3438
|
+
// un-acked until the caller's timeout, looking like the action "does nothing".
|
|
3439
|
+
async pause(vmId) {
|
|
3296
3440
|
return this.route(vmId).pause(vmId);
|
|
3297
3441
|
}
|
|
3298
|
-
resume(vmId) {
|
|
3442
|
+
async resume(vmId) {
|
|
3299
3443
|
return this.route(vmId).resume(vmId);
|
|
3300
3444
|
}
|
|
3301
|
-
stop(vmId) {
|
|
3445
|
+
async stop(vmId) {
|
|
3302
3446
|
return this.route(vmId).stop(vmId);
|
|
3303
3447
|
}
|
|
3304
|
-
restore(vmId) {
|
|
3448
|
+
async restore(vmId) {
|
|
3305
3449
|
return this.route(vmId).restore(vmId);
|
|
3306
3450
|
}
|
|
3307
|
-
destroy(vmId) {
|
|
3451
|
+
async destroy(vmId) {
|
|
3308
3452
|
return this.route(vmId).destroy(vmId);
|
|
3309
3453
|
}
|
|
3310
|
-
proxyHttp(vmId, request) {
|
|
3454
|
+
async proxyHttp(vmId, request) {
|
|
3311
3455
|
return this.route(vmId).proxyHttp(vmId, request);
|
|
3312
3456
|
}
|
|
3313
3457
|
get(vmId) {
|
|
@@ -3326,15 +3470,14 @@ var MultiEngineExecutor = class {
|
|
|
3326
3470
|
}
|
|
3327
3471
|
return void 0;
|
|
3328
3472
|
}
|
|
3329
|
-
exec(vmId, request) {
|
|
3473
|
+
async exec(vmId, request) {
|
|
3330
3474
|
const executor = this.route(vmId);
|
|
3331
|
-
if (!executor.exec)
|
|
3475
|
+
if (!executor.exec) throw new Error("executor does not support guest exec");
|
|
3332
3476
|
return executor.exec(vmId, request);
|
|
3333
3477
|
}
|
|
3334
|
-
writeFiles(vmId, files) {
|
|
3478
|
+
async writeFiles(vmId, files) {
|
|
3335
3479
|
const executor = this.route(vmId);
|
|
3336
|
-
if (!executor.writeFiles)
|
|
3337
|
-
return Promise.reject(new Error("executor does not support guest file writes"));
|
|
3480
|
+
if (!executor.writeFiles) throw new Error("executor does not support guest file writes");
|
|
3338
3481
|
return executor.writeFiles(vmId, files);
|
|
3339
3482
|
}
|
|
3340
3483
|
onExit(vmId, handler) {
|
|
@@ -3342,6 +3485,15 @@ var MultiEngineExecutor = class {
|
|
|
3342
3485
|
if (!executor.onExit) throw new Error("executor does not support job run mode");
|
|
3343
3486
|
executor.onExit(vmId, handler);
|
|
3344
3487
|
}
|
|
3488
|
+
/** Fans out to every sub-executor that supports it (currently just lambda-microvm). */
|
|
3489
|
+
async reconcile() {
|
|
3490
|
+
const externallyTerminated = [];
|
|
3491
|
+
for (const executor of this.executors.values()) {
|
|
3492
|
+
if (!executor.reconcile) continue;
|
|
3493
|
+
externallyTerminated.push(...await executor.reconcile());
|
|
3494
|
+
}
|
|
3495
|
+
return externallyTerminated;
|
|
3496
|
+
}
|
|
3345
3497
|
/**
|
|
3346
3498
|
* Recover VMs sandbox says this workerId owns but that aren't currently tracked (a worker
|
|
3347
3499
|
* restart, most likely). Runs in parallel — probing many VMs serially would needlessly
|
|
@@ -3350,9 +3502,16 @@ var MultiEngineExecutor = class {
|
|
|
3350
3502
|
* never thrown — a VM that can't be reattached just stays unavailable until something
|
|
3351
3503
|
* touches it again (create/proxy/lifecycle), which already errors gracefully today.
|
|
3352
3504
|
*/
|
|
3505
|
+
/**
|
|
3506
|
+
* Returns vmIds found genuinely gone (executor.reattach resolved undefined — the
|
|
3507
|
+
* resource itself no longer exists, e.g. AWS already terminated it while this worker
|
|
3508
|
+
* was down) as distinct from any other reattach failure (engine unavailable, no reattach
|
|
3509
|
+
* support, a transient describe error, ...), which leave the VM merely unavailable
|
|
3510
|
+
* rather than confirmed gone.
|
|
3511
|
+
*/
|
|
3353
3512
|
async reattachAll(records) {
|
|
3354
3513
|
const pending = findNotAttachedVms(this, records);
|
|
3355
|
-
if (!pending.length) return;
|
|
3514
|
+
if (!pending.length) return [];
|
|
3356
3515
|
const results = await Promise.allSettled(
|
|
3357
3516
|
pending.map(async (record) => {
|
|
3358
3517
|
const executor = record.engine ? this.executors.get(record.engine) : void 0;
|
|
@@ -3361,18 +3520,25 @@ var MultiEngineExecutor = class {
|
|
|
3361
3520
|
if (!executor.reattach)
|
|
3362
3521
|
throw new Error(`engine "${record.engine}" does not support reattach`);
|
|
3363
3522
|
const handle = await executor.reattach(record);
|
|
3364
|
-
if (!handle) throw new
|
|
3523
|
+
if (!handle) throw new VmResourceGoneError();
|
|
3365
3524
|
})
|
|
3366
3525
|
);
|
|
3526
|
+
const externallyTerminated = [];
|
|
3367
3527
|
results.forEach((result, i) => {
|
|
3368
3528
|
const vmId = pending[i].vmId;
|
|
3369
|
-
if (result.status === "fulfilled")
|
|
3370
|
-
|
|
3529
|
+
if (result.status === "fulfilled") {
|
|
3530
|
+
src_default.info({ vmId }, "vm reattached");
|
|
3531
|
+
} else if (result.reason instanceof VmResourceGoneError) {
|
|
3532
|
+
externallyTerminated.push(vmId);
|
|
3533
|
+
src_default.warn({ vmId }, "vm reattach found resource already gone");
|
|
3534
|
+
} else {
|
|
3371
3535
|
src_default.warn(
|
|
3372
3536
|
{ vmId, err: result.reason },
|
|
3373
3537
|
"vm reattach failed \u2014 staying unavailable until next touch"
|
|
3374
3538
|
);
|
|
3539
|
+
}
|
|
3375
3540
|
});
|
|
3541
|
+
return externallyTerminated;
|
|
3376
3542
|
}
|
|
3377
3543
|
route(vmId) {
|
|
3378
3544
|
for (const executor of this.executors.values()) {
|
|
@@ -3394,8 +3560,36 @@ function createExecutor(config) {
|
|
|
3394
3560
|
// src/socket-client.ts
|
|
3395
3561
|
var import_os9 = __toESM(require("os"));
|
|
3396
3562
|
var import_socket = require("socket.io-client");
|
|
3563
|
+
|
|
3564
|
+
// src/worker-identity.ts
|
|
3565
|
+
var import_fs3 = __toESM(require("fs"));
|
|
3566
|
+
var import_path7 = __toESM(require("path"));
|
|
3567
|
+
function loadPersistedWorkerId(filePath) {
|
|
3568
|
+
try {
|
|
3569
|
+
const id = import_fs3.default.readFileSync(filePath, "utf8").trim();
|
|
3570
|
+
return id || void 0;
|
|
3571
|
+
} catch {
|
|
3572
|
+
return void 0;
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
function savePersistedWorkerId(filePath, workerId) {
|
|
3576
|
+
try {
|
|
3577
|
+
import_fs3.default.mkdirSync(import_path7.default.dirname(filePath), { recursive: true });
|
|
3578
|
+
import_fs3.default.writeFileSync(filePath, workerId, "utf8");
|
|
3579
|
+
} catch (error) {
|
|
3580
|
+
src_default.warn(
|
|
3581
|
+
{ err: error, filePath },
|
|
3582
|
+
"failed to persist assigned worker id \u2014 reattach after a restart may not find its VMs"
|
|
3583
|
+
);
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
// src/socket-client.ts
|
|
3397
3588
|
var HEARTBEAT_INTERVAL_MS = 1e4;
|
|
3589
|
+
var RECONCILE_INTERVAL_MS = 6e4;
|
|
3398
3590
|
var REATTACH_FALLBACK_MS = 5e3;
|
|
3591
|
+
var RECONNECTION_DELAY_MS = 1e3;
|
|
3592
|
+
var RECONNECTION_DELAY_MAX_MS = 1e4;
|
|
3399
3593
|
function primaryIp() {
|
|
3400
3594
|
for (const ifaces of Object.values(import_os9.default.networkInterfaces())) {
|
|
3401
3595
|
for (const iface of ifaces ?? []) {
|
|
@@ -3424,7 +3618,11 @@ function wrap(handler) {
|
|
|
3424
3618
|
return (payload, callback) => {
|
|
3425
3619
|
handler(payload).then((result) => callback({ ok: true, result })).catch((error) => {
|
|
3426
3620
|
src_default.error({ err: error }, "command failed");
|
|
3427
|
-
|
|
3621
|
+
try {
|
|
3622
|
+
callback({ ok: false, error: error.message });
|
|
3623
|
+
} catch (ackError) {
|
|
3624
|
+
src_default.error({ err: ackError }, "failed to send command failure ack");
|
|
3625
|
+
}
|
|
3428
3626
|
});
|
|
3429
3627
|
};
|
|
3430
3628
|
}
|
|
@@ -3432,20 +3630,32 @@ function registerCommandHandlers(socket, executor) {
|
|
|
3432
3630
|
socket.on(
|
|
3433
3631
|
WorkerEvent.VM_CREATE,
|
|
3434
3632
|
wrap(async (spec) => {
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3633
|
+
try {
|
|
3634
|
+
const handle = await executor.create(spec);
|
|
3635
|
+
if (spec.runMode === "job") {
|
|
3438
3636
|
executor.onExit(
|
|
3439
3637
|
spec.vmId,
|
|
3440
|
-
(
|
|
3638
|
+
(result2) => socket.emit(WorkerEvent.VM_RUN_EXITED, { vmId: spec.vmId, ...result2 })
|
|
3441
3639
|
);
|
|
3442
|
-
} catch (error) {
|
|
3443
|
-
await executor.destroy(spec.vmId).catch(() => {
|
|
3444
|
-
});
|
|
3445
|
-
throw error;
|
|
3446
3640
|
}
|
|
3641
|
+
const result = {
|
|
3642
|
+
vmId: spec.vmId,
|
|
3643
|
+
ok: true,
|
|
3644
|
+
metadata: handle.metadata
|
|
3645
|
+
};
|
|
3646
|
+
socket.emit(WorkerEvent.VM_CREATE_RESULT, result);
|
|
3647
|
+
return handle;
|
|
3648
|
+
} catch (error) {
|
|
3649
|
+
if (spec.runMode === "job") await executor.destroy(spec.vmId).catch(() => {
|
|
3650
|
+
});
|
|
3651
|
+
const result = {
|
|
3652
|
+
vmId: spec.vmId,
|
|
3653
|
+
ok: false,
|
|
3654
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3655
|
+
};
|
|
3656
|
+
socket.emit(WorkerEvent.VM_CREATE_RESULT, result);
|
|
3657
|
+
throw error;
|
|
3447
3658
|
}
|
|
3448
|
-
return handle;
|
|
3449
3659
|
})
|
|
3450
3660
|
);
|
|
3451
3661
|
socket.on(
|
|
@@ -3497,7 +3707,7 @@ function registerCommandHandlers(socket, executor) {
|
|
|
3497
3707
|
);
|
|
3498
3708
|
}
|
|
3499
3709
|
function connectToSandbox(config, executor) {
|
|
3500
|
-
let assignedWorkerId;
|
|
3710
|
+
let assignedWorkerId = loadPersistedWorkerId(config.workerIdFile);
|
|
3501
3711
|
const socket = (0, import_socket.io)(config.sandboxUrl, {
|
|
3502
3712
|
path: config.workerSocketPath,
|
|
3503
3713
|
auth: (callback) => callback({
|
|
@@ -3506,12 +3716,17 @@ function connectToSandbox(config, executor) {
|
|
|
3506
3716
|
...config.token ? { token: config.token } : {}
|
|
3507
3717
|
}),
|
|
3508
3718
|
transports: ["websocket"],
|
|
3509
|
-
reconnection: true
|
|
3719
|
+
reconnection: true,
|
|
3720
|
+
reconnectionAttempts: Infinity,
|
|
3721
|
+
reconnectionDelay: RECONNECTION_DELAY_MS,
|
|
3722
|
+
reconnectionDelayMax: RECONNECTION_DELAY_MAX_MS
|
|
3510
3723
|
});
|
|
3511
3724
|
let heartbeatTimer;
|
|
3725
|
+
let reconcileTimer;
|
|
3512
3726
|
let reattachFallbackTimer;
|
|
3513
3727
|
socket.on(WorkerEvent.ASSIGNED_ID, ({ workerId }) => {
|
|
3514
3728
|
assignedWorkerId = workerId;
|
|
3729
|
+
savePersistedWorkerId(config.workerIdFile, workerId);
|
|
3515
3730
|
src_default.info({ workerId: config.workerId, assignedWorkerId }, "worker runtime ID assigned");
|
|
3516
3731
|
});
|
|
3517
3732
|
const sendHeartbeat = () => socket.emit(WorkerEvent.HEARTBEAT, buildHeartbeat(config, executor));
|
|
@@ -3520,6 +3735,17 @@ function connectToSandbox(config, executor) {
|
|
|
3520
3735
|
sendHeartbeat();
|
|
3521
3736
|
heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
|
|
3522
3737
|
}
|
|
3738
|
+
const runReconcile = () => {
|
|
3739
|
+
executor.reconcile().then((externallyTerminated) => {
|
|
3740
|
+
for (const vmId of externallyTerminated) {
|
|
3741
|
+
socket.emit(WorkerEvent.VM_EXTERNALLY_TERMINATED, { vmId });
|
|
3742
|
+
}
|
|
3743
|
+
}).catch((error) => src_default.error({ err: error }, "vm reconcile pass failed"));
|
|
3744
|
+
};
|
|
3745
|
+
function startReconcile() {
|
|
3746
|
+
if (reconcileTimer) return;
|
|
3747
|
+
reconcileTimer = setInterval(runReconcile, RECONCILE_INTERVAL_MS);
|
|
3748
|
+
}
|
|
3523
3749
|
socket.on("connect", () => {
|
|
3524
3750
|
src_default.info(
|
|
3525
3751
|
{ workerId: config.workerId, assignedWorkerId, url: config.sandboxUrl },
|
|
@@ -3532,10 +3758,26 @@ function connectToSandbox(config, executor) {
|
|
|
3532
3758
|
"no reattach handshake from sandbox \u2014 starting heartbeat anyway"
|
|
3533
3759
|
);
|
|
3534
3760
|
startHeartbeat();
|
|
3761
|
+
startReconcile();
|
|
3535
3762
|
}, REATTACH_FALLBACK_MS);
|
|
3536
3763
|
socket.once(WorkerEvent.REATTACH, (records) => {
|
|
3537
3764
|
clearTimeout(reattachFallbackTimer);
|
|
3538
|
-
executor.reattachAll(records).
|
|
3765
|
+
executor.reattachAll(records).then((externallyTerminated) => {
|
|
3766
|
+
for (const vmId of externallyTerminated) {
|
|
3767
|
+
socket.emit(WorkerEvent.VM_EXTERNALLY_TERMINATED, { vmId });
|
|
3768
|
+
}
|
|
3769
|
+
}).catch((error) => src_default.error({ err: error }, "reattach batch failed")).finally(() => {
|
|
3770
|
+
for (const record of records) {
|
|
3771
|
+
const handle = executor.get(record.vmId);
|
|
3772
|
+
if (record.state === "creating" && handle) {
|
|
3773
|
+
const result = {
|
|
3774
|
+
vmId: record.vmId,
|
|
3775
|
+
ok: true,
|
|
3776
|
+
metadata: handle.metadata
|
|
3777
|
+
};
|
|
3778
|
+
socket.emit(WorkerEvent.VM_CREATE_RESULT, result);
|
|
3779
|
+
}
|
|
3780
|
+
}
|
|
3539
3781
|
for (const record of records) {
|
|
3540
3782
|
if (record.runMode !== "job" || !executor.get(record.vmId)) continue;
|
|
3541
3783
|
try {
|
|
@@ -3551,21 +3793,36 @@ function connectToSandbox(config, executor) {
|
|
|
3551
3793
|
}
|
|
3552
3794
|
}
|
|
3553
3795
|
startHeartbeat();
|
|
3796
|
+
startReconcile();
|
|
3554
3797
|
});
|
|
3555
3798
|
});
|
|
3556
3799
|
});
|
|
3557
3800
|
socket.on("disconnect", (reason) => {
|
|
3558
3801
|
src_default.warn({ workerId: config.workerId, reason }, "disconnected from sandbox");
|
|
3559
3802
|
clearInterval(heartbeatTimer);
|
|
3803
|
+
clearInterval(reconcileTimer);
|
|
3560
3804
|
clearTimeout(reattachFallbackTimer);
|
|
3561
3805
|
heartbeatTimer = void 0;
|
|
3806
|
+
reconcileTimer = void 0;
|
|
3562
3807
|
socket.off(WorkerEvent.REATTACH);
|
|
3563
3808
|
});
|
|
3564
3809
|
socket.on("connect_error", (error) => {
|
|
3565
|
-
src_default.error(
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3810
|
+
src_default.error(
|
|
3811
|
+
{
|
|
3812
|
+
err: error,
|
|
3813
|
+
sandboxUrl: config.sandboxUrl
|
|
3814
|
+
},
|
|
3815
|
+
"sandbox connection error"
|
|
3816
|
+
);
|
|
3817
|
+
});
|
|
3818
|
+
socket.io.on("reconnect_attempt", (attempt) => {
|
|
3819
|
+
src_default.warn({ workerId: config.workerId, attempt }, "reconnecting to sandbox");
|
|
3820
|
+
});
|
|
3821
|
+
socket.io.on("reconnect_error", (error) => {
|
|
3822
|
+
src_default.warn({ workerId: config.workerId, err: error }, "sandbox reconnect attempt failed");
|
|
3823
|
+
});
|
|
3824
|
+
socket.io.on("reconnect", (attempt) => {
|
|
3825
|
+
src_default.info({ workerId: config.workerId, attempt }, "reconnected to sandbox");
|
|
3569
3826
|
});
|
|
3570
3827
|
registerCommandHandlers(socket, executor);
|
|
3571
3828
|
return socket;
|
|
@@ -3675,19 +3932,42 @@ var VncRelay = class {
|
|
|
3675
3932
|
|
|
3676
3933
|
// src/services/serial-relay.ts
|
|
3677
3934
|
var import_net3 = __toESM(require("net"));
|
|
3935
|
+
var import_ws = __toESM(require("ws"));
|
|
3678
3936
|
var SerialRelay = class {
|
|
3679
3937
|
constructor(vmId, source, socket) {
|
|
3680
3938
|
this.vmId = vmId;
|
|
3681
3939
|
this.source = source;
|
|
3682
3940
|
this.socket = socket;
|
|
3683
3941
|
this.conn = null;
|
|
3942
|
+
this.webSocket = null;
|
|
3943
|
+
this.reconnectTimer = null;
|
|
3944
|
+
this.connecting = false;
|
|
3945
|
+
this.browserCount = 0;
|
|
3946
|
+
this.pendingInput = [];
|
|
3947
|
+
this.destroyed = false;
|
|
3684
3948
|
this.inputHandler = null;
|
|
3949
|
+
this.attachHandler = null;
|
|
3950
|
+
this.detachHandler = null;
|
|
3685
3951
|
}
|
|
3686
3952
|
connect() {
|
|
3953
|
+
this.destroyed = false;
|
|
3687
3954
|
if (this.source.type === "tcp") {
|
|
3688
3955
|
this.connectTcp();
|
|
3689
|
-
} else {
|
|
3956
|
+
} else if (this.source.type === "pipe") {
|
|
3690
3957
|
this.connectPipe();
|
|
3958
|
+
} else {
|
|
3959
|
+
this.attachHandler = ({ vmId }) => {
|
|
3960
|
+
if (vmId !== this.vmId) return;
|
|
3961
|
+
this.browserCount += 1;
|
|
3962
|
+
if (this.browserCount === 1) void this.connectWebSocket();
|
|
3963
|
+
};
|
|
3964
|
+
this.detachHandler = ({ vmId }) => {
|
|
3965
|
+
if (vmId !== this.vmId) return;
|
|
3966
|
+
this.browserCount = Math.max(0, this.browserCount - 1);
|
|
3967
|
+
if (this.browserCount === 0) this.disconnectWebSocket();
|
|
3968
|
+
};
|
|
3969
|
+
this.socket.on(WorkerEvent.TERMINAL_ATTACH, this.attachHandler);
|
|
3970
|
+
this.socket.on(WorkerEvent.TERMINAL_DETACH, this.detachHandler);
|
|
3691
3971
|
}
|
|
3692
3972
|
this.inputHandler = (e) => {
|
|
3693
3973
|
if (e.vmId !== this.vmId) return;
|
|
@@ -3696,6 +3976,15 @@ var SerialRelay = class {
|
|
|
3696
3976
|
} else if (this.source.type === "pipe") {
|
|
3697
3977
|
const proc = this.source.process;
|
|
3698
3978
|
proc.stdin?.write(e.data);
|
|
3979
|
+
} else if (this.source.type === "websocket") {
|
|
3980
|
+
if (this.webSocket?.readyState === import_ws.default.OPEN) {
|
|
3981
|
+
this.webSocket.send(e.data);
|
|
3982
|
+
} else {
|
|
3983
|
+
this.browserCount = Math.max(1, this.browserCount);
|
|
3984
|
+
this.pendingInput.push(e.data);
|
|
3985
|
+
if (this.pendingInput.length > 100) this.pendingInput.shift();
|
|
3986
|
+
void this.connectWebSocket();
|
|
3987
|
+
}
|
|
3699
3988
|
}
|
|
3700
3989
|
};
|
|
3701
3990
|
this.socket.on(WorkerEvent.TERMINAL_INPUT, this.inputHandler);
|
|
@@ -3714,7 +4003,9 @@ var SerialRelay = class {
|
|
|
3714
4003
|
sock.on("end", () => src_default.info({ vmId: this.vmId }, "[Serial] TCP ended"));
|
|
3715
4004
|
sock.on("error", (err) => src_default.error({ vmId: this.vmId, err }, "[Serial] TCP error"));
|
|
3716
4005
|
});
|
|
3717
|
-
sock.once("error", () =>
|
|
4006
|
+
sock.once("error", () => {
|
|
4007
|
+
if (!this.destroyed) setTimeout(attempt, 1e3);
|
|
4008
|
+
});
|
|
3718
4009
|
};
|
|
3719
4010
|
attempt();
|
|
3720
4011
|
}
|
|
@@ -3729,13 +4020,65 @@ var SerialRelay = class {
|
|
|
3729
4020
|
(d) => this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: d })
|
|
3730
4021
|
);
|
|
3731
4022
|
}
|
|
4023
|
+
async connectWebSocket() {
|
|
4024
|
+
if (this.destroyed || this.browserCount === 0 || this.source.type !== "websocket" || this.webSocket || this.connecting) {
|
|
4025
|
+
return;
|
|
4026
|
+
}
|
|
4027
|
+
try {
|
|
4028
|
+
this.connecting = true;
|
|
4029
|
+
const protocols = await this.source.protocols();
|
|
4030
|
+
if (this.destroyed || this.browserCount === 0) return;
|
|
4031
|
+
const webSocket = new import_ws.default(this.source.url, protocols);
|
|
4032
|
+
this.webSocket = webSocket;
|
|
4033
|
+
webSocket.on("open", () => {
|
|
4034
|
+
for (const data of this.pendingInput.splice(0)) webSocket.send(data);
|
|
4035
|
+
src_default.info({ vmId: this.vmId }, "[Serial] Lambda shell connected");
|
|
4036
|
+
});
|
|
4037
|
+
webSocket.on("message", (data) => {
|
|
4038
|
+
const chunk = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
4039
|
+
this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: chunk });
|
|
4040
|
+
});
|
|
4041
|
+
webSocket.on("error", (error) => {
|
|
4042
|
+
src_default.error({ vmId: this.vmId, err: error }, "[Serial] Lambda shell error");
|
|
4043
|
+
});
|
|
4044
|
+
webSocket.on("close", () => {
|
|
4045
|
+
if (this.webSocket === webSocket) this.webSocket = null;
|
|
4046
|
+
src_default.info({ vmId: this.vmId }, "[Serial] Lambda shell disconnected");
|
|
4047
|
+
this.scheduleWebSocketReconnect();
|
|
4048
|
+
});
|
|
4049
|
+
} catch (error) {
|
|
4050
|
+
src_default.error({ vmId: this.vmId, err: error }, "[Serial] Lambda shell connect failed");
|
|
4051
|
+
this.scheduleWebSocketReconnect();
|
|
4052
|
+
} finally {
|
|
4053
|
+
this.connecting = false;
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
scheduleWebSocketReconnect() {
|
|
4057
|
+
if (this.destroyed || this.browserCount === 0 || this.reconnectTimer) return;
|
|
4058
|
+
this.reconnectTimer = setTimeout(() => {
|
|
4059
|
+
this.reconnectTimer = null;
|
|
4060
|
+
void this.connectWebSocket();
|
|
4061
|
+
}, 1e3);
|
|
4062
|
+
}
|
|
4063
|
+
disconnectWebSocket() {
|
|
4064
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
4065
|
+
this.reconnectTimer = null;
|
|
4066
|
+
this.pendingInput = [];
|
|
4067
|
+
const webSocket = this.webSocket;
|
|
4068
|
+
this.webSocket = null;
|
|
4069
|
+
webSocket?.close();
|
|
4070
|
+
}
|
|
3732
4071
|
destroy() {
|
|
4072
|
+
this.destroyed = true;
|
|
3733
4073
|
if (this.inputHandler) this.socket.off(WorkerEvent.TERMINAL_INPUT, this.inputHandler);
|
|
4074
|
+
if (this.attachHandler) this.socket.off(WorkerEvent.TERMINAL_ATTACH, this.attachHandler);
|
|
4075
|
+
if (this.detachHandler) this.socket.off(WorkerEvent.TERMINAL_DETACH, this.detachHandler);
|
|
3734
4076
|
try {
|
|
3735
4077
|
this.conn?.destroy();
|
|
3736
4078
|
} catch {
|
|
3737
4079
|
}
|
|
3738
4080
|
this.conn = null;
|
|
4081
|
+
this.disconnectWebSocket();
|
|
3739
4082
|
src_default.info({ vmId: this.vmId }, "[Serial] relay destroyed");
|
|
3740
4083
|
}
|
|
3741
4084
|
};
|
|
@@ -3743,9 +4086,8 @@ var SerialRelay = class {
|
|
|
3743
4086
|
// src/console-relays.ts
|
|
3744
4087
|
function attachConsoleRelays(executor, sandboxSocket) {
|
|
3745
4088
|
const relays = /* @__PURE__ */ new Map();
|
|
3746
|
-
const
|
|
3747
|
-
|
|
3748
|
-
const handle = await originalCreate(spec);
|
|
4089
|
+
const attach = (handle) => {
|
|
4090
|
+
if (relays.has(handle.vmId)) return;
|
|
3749
4091
|
const entry = {};
|
|
3750
4092
|
if (handle.vncPort) {
|
|
3751
4093
|
const vnc = new VncRelay(handle.vmId, "127.0.0.1", handle.vncPort, sandboxSocket);
|
|
@@ -3759,8 +4101,22 @@ function attachConsoleRelays(executor, sandboxSocket) {
|
|
|
3759
4101
|
entry.serial = serial;
|
|
3760
4102
|
}
|
|
3761
4103
|
if (entry.vnc || entry.serial) relays.set(handle.vmId, entry);
|
|
4104
|
+
};
|
|
4105
|
+
const originalCreate = executor.create.bind(executor);
|
|
4106
|
+
executor.create = async (spec) => {
|
|
4107
|
+
const handle = await originalCreate(spec);
|
|
4108
|
+
attach(handle);
|
|
3762
4109
|
return handle;
|
|
3763
4110
|
};
|
|
4111
|
+
const originalReattachAll = executor.reattachAll.bind(executor);
|
|
4112
|
+
executor.reattachAll = async (records) => {
|
|
4113
|
+
const externallyTerminated = await originalReattachAll(records);
|
|
4114
|
+
for (const record of records) {
|
|
4115
|
+
const handle = executor.get(record.vmId);
|
|
4116
|
+
if (handle) attach(handle);
|
|
4117
|
+
}
|
|
4118
|
+
return externallyTerminated;
|
|
4119
|
+
};
|
|
3764
4120
|
const originalDestroy = executor.destroy.bind(executor);
|
|
3765
4121
|
executor.destroy = async (vmId) => {
|
|
3766
4122
|
const entry = relays.get(vmId);
|
|
@@ -3855,68 +4211,111 @@ async function startSandboxHealthServer(options) {
|
|
|
3855
4211
|
}
|
|
3856
4212
|
|
|
3857
4213
|
// src/cli.ts
|
|
4214
|
+
process.on("unhandledRejection", (reason) => {
|
|
4215
|
+
src_default.error({ err: reason }, "unhandled rejection \u2014 sandbox worker continues running");
|
|
4216
|
+
});
|
|
4217
|
+
process.on("uncaughtException", (error) => {
|
|
4218
|
+
src_default.error({ err: error }, "uncaught exception \u2014 sandbox worker continues running");
|
|
4219
|
+
});
|
|
3858
4220
|
var program = new import_commander.Command();
|
|
3859
4221
|
program.name("sandbox").description("Run and manage a Multiplayer sandbox worker").version(version);
|
|
3860
|
-
program.command("start").description("start a worker and connect it to the sandbox control plane").option("--token <token>", "worker authentication token (env: WORKER_TOKEN)").option("--worker-id <id>", "worker pool/authentication ID (env: WORKER_ID; default: hostname)").option(
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
4222
|
+
program.command("start").description("start a worker and connect it to the sandbox control plane").option("--token <token>", "worker authentication token (env: WORKER_TOKEN)").option("--worker-id <id>", "worker pool/authentication ID (env: WORKER_ID; default: hostname)").option(
|
|
4223
|
+
"--sandbox-url <url>",
|
|
4224
|
+
"control-plane base URL (env: SANDBOX_URL; default: https://api.sandbox.filingramp.com/v0)"
|
|
4225
|
+
).option("--max-vms <count>", "maximum concurrent VMs (env: MAX_VMS)").action(
|
|
4226
|
+
async (opts) => {
|
|
4227
|
+
const config = loadWorkerConfigFromEnv({
|
|
4228
|
+
...process.env,
|
|
4229
|
+
...opts.token ? { WORKER_TOKEN: opts.token } : {},
|
|
4230
|
+
...opts.workerId ? { WORKER_ID: opts.workerId } : {},
|
|
4231
|
+
...opts.sandboxUrl ? { SANDBOX_URL: opts.sandboxUrl } : {},
|
|
4232
|
+
...opts.maxVms ? { MAX_VMS: opts.maxVms } : {}
|
|
4233
|
+
});
|
|
4234
|
+
const socketRef = {};
|
|
4235
|
+
const healthServer = await startSandboxHealthServer({
|
|
4236
|
+
host: config.healthHost,
|
|
4237
|
+
port: config.healthPort,
|
|
4238
|
+
pathPrefix: config.healthPathPrefix,
|
|
4239
|
+
isReady: () => socketRef.current?.connected === true
|
|
4240
|
+
});
|
|
4241
|
+
await prepareWorkerImages(config);
|
|
4242
|
+
const executor = createExecutor(config);
|
|
4243
|
+
const socket = connectToSandbox(config, executor);
|
|
4244
|
+
socketRef.current = socket;
|
|
4245
|
+
attachConsoleRelays(executor, socket);
|
|
4246
|
+
let stopping = false;
|
|
4247
|
+
const stop = async (signal) => {
|
|
4248
|
+
if (stopping) return;
|
|
4249
|
+
stopping = true;
|
|
4250
|
+
console.log(`
|
|
3885
4251
|
${signal} received; shutting down sandbox worker...`);
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
});
|
|
4252
|
+
socket.disconnect();
|
|
4253
|
+
healthServer.close();
|
|
4254
|
+
const results = await Promise.allSettled(
|
|
4255
|
+
executor.list().map((vm) => executor.destroy(vm.vmId))
|
|
4256
|
+
);
|
|
4257
|
+
const failed = results.filter((result) => result.status === "rejected");
|
|
4258
|
+
if (failed.length) console.error(`Failed to destroy ${failed.length} VM(s) during shutdown`);
|
|
4259
|
+
process.exit(failed.length ? 1 : 0);
|
|
4260
|
+
};
|
|
4261
|
+
process.once("SIGINT", () => {
|
|
4262
|
+
void stop("SIGINT");
|
|
4263
|
+
});
|
|
4264
|
+
process.once("SIGTERM", () => {
|
|
4265
|
+
void stop("SIGTERM");
|
|
4266
|
+
});
|
|
4267
|
+
src_default.info(`Starting sandbox worker ${config.workerId} \u2192 ${config.sandboxUrl}`);
|
|
4268
|
+
}
|
|
4269
|
+
);
|
|
3901
4270
|
program.command("detect").description("probe this host and report which micro-VM engines it can run").action(() => {
|
|
3902
|
-
const probes = probeEngines(
|
|
4271
|
+
const probes = probeEngines(
|
|
4272
|
+
loadWorkerConfigFromEnv({ ...process.env, WORKER_ID: "-", WORKER_TOKEN: "-" })
|
|
4273
|
+
);
|
|
3903
4274
|
for (const probe of probes) {
|
|
3904
4275
|
const status = probe.available ? "available" : `unavailable \u2014 ${probe.reason}`;
|
|
3905
4276
|
console.log(`${probe.engine.padEnd(15)} ${status}`);
|
|
3906
4277
|
}
|
|
3907
4278
|
});
|
|
3908
4279
|
var images = program.command("images").description("manage engine base images (kernel + initramfs for QEMU/Firecracker)");
|
|
3909
|
-
images.command("pull").description(
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
4280
|
+
images.command("pull").description(
|
|
4281
|
+
"download per-stack base images from S3 or an HTTPS mirror into the standard location"
|
|
4282
|
+
).requiredOption(
|
|
4283
|
+
"--from <location>",
|
|
4284
|
+
"s3://bucket[/prefix] or https:// base URL (default derived from S3_BUCKET_ARTIFACTS)",
|
|
4285
|
+
artifactImageLocation()
|
|
4286
|
+
).option("--arch <arch>", "image architecture", hostArch()).option("--stack <stack...>", "stacks to pull (default: all)").option("--dest <dir>", "destination directory (default: ~/.multiplayer/vm-images/<arch>)").option(
|
|
4287
|
+
"--endpoint <url>",
|
|
4288
|
+
"custom S3 endpoint, e.g. a MinIO URL (env: S3_ARTIFACTS_ENDPOINT)",
|
|
4289
|
+
process.env.S3_ARTIFACTS_ENDPOINT
|
|
4290
|
+
).action(
|
|
4291
|
+
async (opts) => {
|
|
4292
|
+
const dest = opts.dest ?? defaultImagesDir(opts.arch);
|
|
4293
|
+
const files = await pullImages(
|
|
4294
|
+
{ location: opts.from, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint },
|
|
4295
|
+
dest
|
|
4296
|
+
);
|
|
4297
|
+
for (const file of files) console.log(`pulled ${file}`);
|
|
4298
|
+
console.log('\nbase images ready \u2014 "sandbox detect" shows which engines they enable');
|
|
4299
|
+
}
|
|
4300
|
+
);
|
|
4301
|
+
images.command("push").description("upload locally built per-stack base images (scripts/setup-vm-images.sh) to S3").requiredOption(
|
|
4302
|
+
"--to <location>",
|
|
4303
|
+
"s3://bucket[/prefix] (default derived from S3_BUCKET_ARTIFACTS)",
|
|
4304
|
+
artifactImageLocation()
|
|
4305
|
+
).option("--arch <arch>", "image architecture", hostArch()).option("--stack <stack...>", "stacks to push (default: all found locally)").option("--src <dir>", "source directory (default: ~/.multiplayer/vm-images/<arch>)").option(
|
|
4306
|
+
"--endpoint <url>",
|
|
4307
|
+
"custom S3 endpoint, e.g. a MinIO URL (env: S3_ARTIFACTS_ENDPOINT)",
|
|
4308
|
+
process.env.S3_ARTIFACTS_ENDPOINT
|
|
4309
|
+
).action(
|
|
4310
|
+
async (opts) => {
|
|
4311
|
+
const src = opts.src ?? defaultImagesDir(opts.arch);
|
|
4312
|
+
const uploaded = await pushImages(
|
|
4313
|
+
{ location: opts.to, arch: opts.arch, stacks: opts.stack, endpoint: opts.endpoint },
|
|
4314
|
+
src
|
|
4315
|
+
);
|
|
4316
|
+
for (const uri of uploaded) console.log(`pushed ${uri}`);
|
|
4317
|
+
}
|
|
4318
|
+
);
|
|
3920
4319
|
program.parseAsync().catch((error) => {
|
|
3921
4320
|
console.error(`Error: ${error.message}`);
|
|
3922
4321
|
process.exit(1);
|