@multiplayer-app/sandbox 7.0.0 → 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 +21 -11
- package/dist/cli.js +511 -111
- 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 +416 -59
- 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 +6 -2
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
|
}
|
|
@@ -207,25 +235,28 @@ function parseArnList(raw) {
|
|
|
207
235
|
const arns = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
208
236
|
return arns.length ? arns : void 0;
|
|
209
237
|
}
|
|
210
|
-
function resolveSandboxEndpoint(rawUrl
|
|
238
|
+
function resolveSandboxEndpoint(rawUrl) {
|
|
211
239
|
const url = new URL(rawUrl);
|
|
212
|
-
const basePath = url.pathname.replace(/\/+$/, "")
|
|
240
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
213
241
|
return {
|
|
214
242
|
// Socket.IO treats a URL pathname as a namespace, so connect to the origin
|
|
215
|
-
// and pass the derived HTTP transport path separately.
|
|
243
|
+
// and pass the derived HTTP transport path separately. SANDBOX_URL's pathname is the
|
|
244
|
+
// sole source of truth for the socket path — it must include whatever base the control
|
|
245
|
+
// plane expects (e.g. "/v0/gapi" or "/internal/v0/gapi"), and "/workers/ws" is appended.
|
|
216
246
|
sandboxUrl: url.origin,
|
|
217
|
-
workerSocketPath:
|
|
247
|
+
workerSocketPath: `${basePath}/workers/ws`
|
|
218
248
|
};
|
|
219
249
|
}
|
|
220
250
|
function loadWorkerConfigFromEnv(env = process.env) {
|
|
221
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`);
|
|
222
253
|
const token = env.WORKER_TOKEN?.trim() || void 0;
|
|
223
254
|
const sandboxEndpoint = resolveSandboxEndpoint(
|
|
224
|
-
env.SANDBOX_URL ?? "https://api.sandbox.filingramp.com/v0"
|
|
225
|
-
env.WORKER_SOCKET_PATH
|
|
255
|
+
env.SANDBOX_URL ?? "https://api.sandbox.filingramp.com/v0/gapi"
|
|
226
256
|
);
|
|
227
257
|
return {
|
|
228
258
|
workerId,
|
|
259
|
+
workerIdFile,
|
|
229
260
|
token,
|
|
230
261
|
sandboxUrl: sandboxEndpoint.sandboxUrl,
|
|
231
262
|
workerSocketPath: sandboxEndpoint.workerSocketPath,
|
|
@@ -260,7 +291,10 @@ function loadWorkerConfigFromEnv(env = process.env) {
|
|
|
260
291
|
// 0 disables auto-suspend; the sandbox drives pause/resume explicitly by default
|
|
261
292
|
maxIdleSeconds: Number(env.MICROVM_MAX_IDLE_SECONDS ?? 900),
|
|
262
293
|
suspendedDurationSeconds: Number(env.MICROVM_SUSPENDED_DURATION_SECONDS ?? 28800),
|
|
263
|
-
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)
|
|
264
298
|
},
|
|
265
299
|
fargate: {
|
|
266
300
|
region: env.FARGATE_AWS_REGION ?? env.AWS_REGION,
|
|
@@ -2465,8 +2499,8 @@ var RemoteExecutor = class {
|
|
|
2465
2499
|
body: result.body ? Buffer.from(result.body, "base64") : Buffer.alloc(0)
|
|
2466
2500
|
};
|
|
2467
2501
|
}
|
|
2468
|
-
request(method,
|
|
2469
|
-
const url = new URL(this.base +
|
|
2502
|
+
request(method, path8, body) {
|
|
2503
|
+
const url = new URL(this.base + path8);
|
|
2470
2504
|
const lib = url.protocol === "https:" ? import_https.default : import_http5.default;
|
|
2471
2505
|
const payload = body !== void 0 ? JSON.stringify(body) : void 0;
|
|
2472
2506
|
return new Promise((resolve, reject) => {
|
|
@@ -2487,7 +2521,7 @@ var RemoteExecutor = class {
|
|
|
2487
2521
|
res.on("end", () => {
|
|
2488
2522
|
const text = Buffer.concat(chunks).toString();
|
|
2489
2523
|
if (res.statusCode && res.statusCode >= 400) {
|
|
2490
|
-
reject(new Error(`vm-agent ${method} ${
|
|
2524
|
+
reject(new Error(`vm-agent ${method} ${path8} \u2192 ${res.statusCode}: ${text}`));
|
|
2491
2525
|
return;
|
|
2492
2526
|
}
|
|
2493
2527
|
if (res.statusCode === 204 || !text) {
|
|
@@ -2514,6 +2548,23 @@ var import_https2 = __toESM(require("https"));
|
|
|
2514
2548
|
var import_client_lambda_microvms = require("@aws-sdk/client-lambda-microvms");
|
|
2515
2549
|
var AUTH_TOKEN_TTL_MINUTES = 60;
|
|
2516
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
|
+
}
|
|
2517
2568
|
var LambdaMicrovmExecutor = class {
|
|
2518
2569
|
constructor(config) {
|
|
2519
2570
|
this.config = config;
|
|
@@ -2523,10 +2574,15 @@ var LambdaMicrovmExecutor = class {
|
|
|
2523
2574
|
async create(spec) {
|
|
2524
2575
|
const imageIdentifier = this.config.stackImageArns?.[spec.stack] ?? this.config.imageArn;
|
|
2525
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
|
+
];
|
|
2526
2581
|
const run = await this.client.send(
|
|
2527
2582
|
new import_client_lambda_microvms.RunMicrovmCommand({
|
|
2528
2583
|
imageIdentifier,
|
|
2529
|
-
|
|
2584
|
+
// Required by CreateMicrovmShellAuthToken and the /shell WebSocket used by vm-console.
|
|
2585
|
+
ingressNetworkConnectors,
|
|
2530
2586
|
egressNetworkConnectors: this.config.egressConnectorArns,
|
|
2531
2587
|
executionRoleArn: this.config.executionRoleArn,
|
|
2532
2588
|
maximumDurationInSeconds: this.config.maximumDurationInSeconds ?? 28800,
|
|
@@ -2543,9 +2599,11 @@ var LambdaMicrovmExecutor = class {
|
|
|
2543
2599
|
vmId: spec.vmId,
|
|
2544
2600
|
sessionId: spec.sessionId,
|
|
2545
2601
|
stack: spec.stack,
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
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
|
|
2549
2607
|
})
|
|
2550
2608
|
);
|
|
2551
2609
|
if (!run.microvmId || !run.endpoint) {
|
|
@@ -2608,6 +2666,31 @@ var LambdaMicrovmExecutor = class {
|
|
|
2608
2666
|
list() {
|
|
2609
2667
|
return [...this.vms.values()].map((v) => v.handle);
|
|
2610
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
|
+
}
|
|
2611
2694
|
async proxyHttp(vmId, request) {
|
|
2612
2695
|
const vm = this.mustGet(vmId);
|
|
2613
2696
|
const auth = await this.getAuthHeaders(vm);
|
|
@@ -2675,6 +2758,47 @@ var LambdaMicrovmExecutor = class {
|
|
|
2675
2758
|
);
|
|
2676
2759
|
return handle;
|
|
2677
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
|
+
}
|
|
2678
2802
|
mustGet(vmId) {
|
|
2679
2803
|
const vm = this.vms.get(vmId);
|
|
2680
2804
|
if (!vm) throw new Error(`vm ${vmId} not found`);
|
|
@@ -3247,6 +3371,8 @@ function detectEngines(config) {
|
|
|
3247
3371
|
}
|
|
3248
3372
|
|
|
3249
3373
|
// src/executor/index.ts
|
|
3374
|
+
var VmResourceGoneError = class extends Error {
|
|
3375
|
+
};
|
|
3250
3376
|
function findNotAttachedVms(executor, records) {
|
|
3251
3377
|
return records.filter((record) => !executor.get(record.vmId));
|
|
3252
3378
|
}
|
|
@@ -3276,11 +3402,25 @@ var MultiEngineExecutor = class {
|
|
|
3276
3402
|
constructor(executors, defaultEngine) {
|
|
3277
3403
|
this.executors = executors;
|
|
3278
3404
|
this.defaultEngine = defaultEngine;
|
|
3405
|
+
this.creations = /* @__PURE__ */ new Map();
|
|
3279
3406
|
}
|
|
3280
3407
|
get engines() {
|
|
3281
3408
|
return [...this.executors.keys()];
|
|
3282
3409
|
}
|
|
3283
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) {
|
|
3284
3424
|
const engine = spec.engine ?? this.defaultEngine;
|
|
3285
3425
|
const executor = this.executors.get(engine);
|
|
3286
3426
|
if (!executor) {
|
|
@@ -3291,22 +3431,27 @@ var MultiEngineExecutor = class {
|
|
|
3291
3431
|
const handle = await executor.create(spec);
|
|
3292
3432
|
return { ...handle, engine };
|
|
3293
3433
|
}
|
|
3294
|
-
|
|
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) {
|
|
3295
3440
|
return this.route(vmId).pause(vmId);
|
|
3296
3441
|
}
|
|
3297
|
-
resume(vmId) {
|
|
3442
|
+
async resume(vmId) {
|
|
3298
3443
|
return this.route(vmId).resume(vmId);
|
|
3299
3444
|
}
|
|
3300
|
-
stop(vmId) {
|
|
3445
|
+
async stop(vmId) {
|
|
3301
3446
|
return this.route(vmId).stop(vmId);
|
|
3302
3447
|
}
|
|
3303
|
-
restore(vmId) {
|
|
3448
|
+
async restore(vmId) {
|
|
3304
3449
|
return this.route(vmId).restore(vmId);
|
|
3305
3450
|
}
|
|
3306
|
-
destroy(vmId) {
|
|
3451
|
+
async destroy(vmId) {
|
|
3307
3452
|
return this.route(vmId).destroy(vmId);
|
|
3308
3453
|
}
|
|
3309
|
-
proxyHttp(vmId, request) {
|
|
3454
|
+
async proxyHttp(vmId, request) {
|
|
3310
3455
|
return this.route(vmId).proxyHttp(vmId, request);
|
|
3311
3456
|
}
|
|
3312
3457
|
get(vmId) {
|
|
@@ -3325,15 +3470,14 @@ var MultiEngineExecutor = class {
|
|
|
3325
3470
|
}
|
|
3326
3471
|
return void 0;
|
|
3327
3472
|
}
|
|
3328
|
-
exec(vmId, request) {
|
|
3473
|
+
async exec(vmId, request) {
|
|
3329
3474
|
const executor = this.route(vmId);
|
|
3330
|
-
if (!executor.exec)
|
|
3475
|
+
if (!executor.exec) throw new Error("executor does not support guest exec");
|
|
3331
3476
|
return executor.exec(vmId, request);
|
|
3332
3477
|
}
|
|
3333
|
-
writeFiles(vmId, files) {
|
|
3478
|
+
async writeFiles(vmId, files) {
|
|
3334
3479
|
const executor = this.route(vmId);
|
|
3335
|
-
if (!executor.writeFiles)
|
|
3336
|
-
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");
|
|
3337
3481
|
return executor.writeFiles(vmId, files);
|
|
3338
3482
|
}
|
|
3339
3483
|
onExit(vmId, handler) {
|
|
@@ -3341,6 +3485,15 @@ var MultiEngineExecutor = class {
|
|
|
3341
3485
|
if (!executor.onExit) throw new Error("executor does not support job run mode");
|
|
3342
3486
|
executor.onExit(vmId, handler);
|
|
3343
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
|
+
}
|
|
3344
3497
|
/**
|
|
3345
3498
|
* Recover VMs sandbox says this workerId owns but that aren't currently tracked (a worker
|
|
3346
3499
|
* restart, most likely). Runs in parallel — probing many VMs serially would needlessly
|
|
@@ -3349,9 +3502,16 @@ var MultiEngineExecutor = class {
|
|
|
3349
3502
|
* never thrown — a VM that can't be reattached just stays unavailable until something
|
|
3350
3503
|
* touches it again (create/proxy/lifecycle), which already errors gracefully today.
|
|
3351
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
|
+
*/
|
|
3352
3512
|
async reattachAll(records) {
|
|
3353
3513
|
const pending = findNotAttachedVms(this, records);
|
|
3354
|
-
if (!pending.length) return;
|
|
3514
|
+
if (!pending.length) return [];
|
|
3355
3515
|
const results = await Promise.allSettled(
|
|
3356
3516
|
pending.map(async (record) => {
|
|
3357
3517
|
const executor = record.engine ? this.executors.get(record.engine) : void 0;
|
|
@@ -3360,18 +3520,25 @@ var MultiEngineExecutor = class {
|
|
|
3360
3520
|
if (!executor.reattach)
|
|
3361
3521
|
throw new Error(`engine "${record.engine}" does not support reattach`);
|
|
3362
3522
|
const handle = await executor.reattach(record);
|
|
3363
|
-
if (!handle) throw new
|
|
3523
|
+
if (!handle) throw new VmResourceGoneError();
|
|
3364
3524
|
})
|
|
3365
3525
|
);
|
|
3526
|
+
const externallyTerminated = [];
|
|
3366
3527
|
results.forEach((result, i) => {
|
|
3367
3528
|
const vmId = pending[i].vmId;
|
|
3368
|
-
if (result.status === "fulfilled")
|
|
3369
|
-
|
|
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 {
|
|
3370
3535
|
src_default.warn(
|
|
3371
3536
|
{ vmId, err: result.reason },
|
|
3372
3537
|
"vm reattach failed \u2014 staying unavailable until next touch"
|
|
3373
3538
|
);
|
|
3539
|
+
}
|
|
3374
3540
|
});
|
|
3541
|
+
return externallyTerminated;
|
|
3375
3542
|
}
|
|
3376
3543
|
route(vmId) {
|
|
3377
3544
|
for (const executor of this.executors.values()) {
|
|
@@ -3393,8 +3560,36 @@ function createExecutor(config) {
|
|
|
3393
3560
|
// src/socket-client.ts
|
|
3394
3561
|
var import_os9 = __toESM(require("os"));
|
|
3395
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
|
|
3396
3588
|
var HEARTBEAT_INTERVAL_MS = 1e4;
|
|
3589
|
+
var RECONCILE_INTERVAL_MS = 6e4;
|
|
3397
3590
|
var REATTACH_FALLBACK_MS = 5e3;
|
|
3591
|
+
var RECONNECTION_DELAY_MS = 1e3;
|
|
3592
|
+
var RECONNECTION_DELAY_MAX_MS = 1e4;
|
|
3398
3593
|
function primaryIp() {
|
|
3399
3594
|
for (const ifaces of Object.values(import_os9.default.networkInterfaces())) {
|
|
3400
3595
|
for (const iface of ifaces ?? []) {
|
|
@@ -3423,7 +3618,11 @@ function wrap(handler) {
|
|
|
3423
3618
|
return (payload, callback) => {
|
|
3424
3619
|
handler(payload).then((result) => callback({ ok: true, result })).catch((error) => {
|
|
3425
3620
|
src_default.error({ err: error }, "command failed");
|
|
3426
|
-
|
|
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
|
+
}
|
|
3427
3626
|
});
|
|
3428
3627
|
};
|
|
3429
3628
|
}
|
|
@@ -3431,20 +3630,32 @@ function registerCommandHandlers(socket, executor) {
|
|
|
3431
3630
|
socket.on(
|
|
3432
3631
|
WorkerEvent.VM_CREATE,
|
|
3433
3632
|
wrap(async (spec) => {
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3633
|
+
try {
|
|
3634
|
+
const handle = await executor.create(spec);
|
|
3635
|
+
if (spec.runMode === "job") {
|
|
3437
3636
|
executor.onExit(
|
|
3438
3637
|
spec.vmId,
|
|
3439
|
-
(
|
|
3638
|
+
(result2) => socket.emit(WorkerEvent.VM_RUN_EXITED, { vmId: spec.vmId, ...result2 })
|
|
3440
3639
|
);
|
|
3441
|
-
} catch (error) {
|
|
3442
|
-
await executor.destroy(spec.vmId).catch(() => {
|
|
3443
|
-
});
|
|
3444
|
-
throw error;
|
|
3445
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;
|
|
3446
3658
|
}
|
|
3447
|
-
return handle;
|
|
3448
3659
|
})
|
|
3449
3660
|
);
|
|
3450
3661
|
socket.on(
|
|
@@ -3496,7 +3707,7 @@ function registerCommandHandlers(socket, executor) {
|
|
|
3496
3707
|
);
|
|
3497
3708
|
}
|
|
3498
3709
|
function connectToSandbox(config, executor) {
|
|
3499
|
-
let assignedWorkerId;
|
|
3710
|
+
let assignedWorkerId = loadPersistedWorkerId(config.workerIdFile);
|
|
3500
3711
|
const socket = (0, import_socket.io)(config.sandboxUrl, {
|
|
3501
3712
|
path: config.workerSocketPath,
|
|
3502
3713
|
auth: (callback) => callback({
|
|
@@ -3505,12 +3716,17 @@ function connectToSandbox(config, executor) {
|
|
|
3505
3716
|
...config.token ? { token: config.token } : {}
|
|
3506
3717
|
}),
|
|
3507
3718
|
transports: ["websocket"],
|
|
3508
|
-
reconnection: true
|
|
3719
|
+
reconnection: true,
|
|
3720
|
+
reconnectionAttempts: Infinity,
|
|
3721
|
+
reconnectionDelay: RECONNECTION_DELAY_MS,
|
|
3722
|
+
reconnectionDelayMax: RECONNECTION_DELAY_MAX_MS
|
|
3509
3723
|
});
|
|
3510
3724
|
let heartbeatTimer;
|
|
3725
|
+
let reconcileTimer;
|
|
3511
3726
|
let reattachFallbackTimer;
|
|
3512
3727
|
socket.on(WorkerEvent.ASSIGNED_ID, ({ workerId }) => {
|
|
3513
3728
|
assignedWorkerId = workerId;
|
|
3729
|
+
savePersistedWorkerId(config.workerIdFile, workerId);
|
|
3514
3730
|
src_default.info({ workerId: config.workerId, assignedWorkerId }, "worker runtime ID assigned");
|
|
3515
3731
|
});
|
|
3516
3732
|
const sendHeartbeat = () => socket.emit(WorkerEvent.HEARTBEAT, buildHeartbeat(config, executor));
|
|
@@ -3519,6 +3735,17 @@ function connectToSandbox(config, executor) {
|
|
|
3519
3735
|
sendHeartbeat();
|
|
3520
3736
|
heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
|
|
3521
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
|
+
}
|
|
3522
3749
|
socket.on("connect", () => {
|
|
3523
3750
|
src_default.info(
|
|
3524
3751
|
{ workerId: config.workerId, assignedWorkerId, url: config.sandboxUrl },
|
|
@@ -3531,10 +3758,26 @@ function connectToSandbox(config, executor) {
|
|
|
3531
3758
|
"no reattach handshake from sandbox \u2014 starting heartbeat anyway"
|
|
3532
3759
|
);
|
|
3533
3760
|
startHeartbeat();
|
|
3761
|
+
startReconcile();
|
|
3534
3762
|
}, REATTACH_FALLBACK_MS);
|
|
3535
3763
|
socket.once(WorkerEvent.REATTACH, (records) => {
|
|
3536
3764
|
clearTimeout(reattachFallbackTimer);
|
|
3537
|
-
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
|
+
}
|
|
3538
3781
|
for (const record of records) {
|
|
3539
3782
|
if (record.runMode !== "job" || !executor.get(record.vmId)) continue;
|
|
3540
3783
|
try {
|
|
@@ -3550,21 +3793,36 @@ function connectToSandbox(config, executor) {
|
|
|
3550
3793
|
}
|
|
3551
3794
|
}
|
|
3552
3795
|
startHeartbeat();
|
|
3796
|
+
startReconcile();
|
|
3553
3797
|
});
|
|
3554
3798
|
});
|
|
3555
3799
|
});
|
|
3556
3800
|
socket.on("disconnect", (reason) => {
|
|
3557
3801
|
src_default.warn({ workerId: config.workerId, reason }, "disconnected from sandbox");
|
|
3558
3802
|
clearInterval(heartbeatTimer);
|
|
3803
|
+
clearInterval(reconcileTimer);
|
|
3559
3804
|
clearTimeout(reattachFallbackTimer);
|
|
3560
3805
|
heartbeatTimer = void 0;
|
|
3806
|
+
reconcileTimer = void 0;
|
|
3561
3807
|
socket.off(WorkerEvent.REATTACH);
|
|
3562
3808
|
});
|
|
3563
3809
|
socket.on("connect_error", (error) => {
|
|
3564
|
-
src_default.error(
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
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");
|
|
3568
3826
|
});
|
|
3569
3827
|
registerCommandHandlers(socket, executor);
|
|
3570
3828
|
return socket;
|
|
@@ -3674,19 +3932,42 @@ var VncRelay = class {
|
|
|
3674
3932
|
|
|
3675
3933
|
// src/services/serial-relay.ts
|
|
3676
3934
|
var import_net3 = __toESM(require("net"));
|
|
3935
|
+
var import_ws = __toESM(require("ws"));
|
|
3677
3936
|
var SerialRelay = class {
|
|
3678
3937
|
constructor(vmId, source, socket) {
|
|
3679
3938
|
this.vmId = vmId;
|
|
3680
3939
|
this.source = source;
|
|
3681
3940
|
this.socket = socket;
|
|
3682
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;
|
|
3683
3948
|
this.inputHandler = null;
|
|
3949
|
+
this.attachHandler = null;
|
|
3950
|
+
this.detachHandler = null;
|
|
3684
3951
|
}
|
|
3685
3952
|
connect() {
|
|
3953
|
+
this.destroyed = false;
|
|
3686
3954
|
if (this.source.type === "tcp") {
|
|
3687
3955
|
this.connectTcp();
|
|
3688
|
-
} else {
|
|
3956
|
+
} else if (this.source.type === "pipe") {
|
|
3689
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);
|
|
3690
3971
|
}
|
|
3691
3972
|
this.inputHandler = (e) => {
|
|
3692
3973
|
if (e.vmId !== this.vmId) return;
|
|
@@ -3695,6 +3976,15 @@ var SerialRelay = class {
|
|
|
3695
3976
|
} else if (this.source.type === "pipe") {
|
|
3696
3977
|
const proc = this.source.process;
|
|
3697
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
|
+
}
|
|
3698
3988
|
}
|
|
3699
3989
|
};
|
|
3700
3990
|
this.socket.on(WorkerEvent.TERMINAL_INPUT, this.inputHandler);
|
|
@@ -3713,7 +4003,9 @@ var SerialRelay = class {
|
|
|
3713
4003
|
sock.on("end", () => src_default.info({ vmId: this.vmId }, "[Serial] TCP ended"));
|
|
3714
4004
|
sock.on("error", (err) => src_default.error({ vmId: this.vmId, err }, "[Serial] TCP error"));
|
|
3715
4005
|
});
|
|
3716
|
-
sock.once("error", () =>
|
|
4006
|
+
sock.once("error", () => {
|
|
4007
|
+
if (!this.destroyed) setTimeout(attempt, 1e3);
|
|
4008
|
+
});
|
|
3717
4009
|
};
|
|
3718
4010
|
attempt();
|
|
3719
4011
|
}
|
|
@@ -3728,13 +4020,65 @@ var SerialRelay = class {
|
|
|
3728
4020
|
(d) => this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: d })
|
|
3729
4021
|
);
|
|
3730
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
|
+
}
|
|
3731
4071
|
destroy() {
|
|
4072
|
+
this.destroyed = true;
|
|
3732
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);
|
|
3733
4076
|
try {
|
|
3734
4077
|
this.conn?.destroy();
|
|
3735
4078
|
} catch {
|
|
3736
4079
|
}
|
|
3737
4080
|
this.conn = null;
|
|
4081
|
+
this.disconnectWebSocket();
|
|
3738
4082
|
src_default.info({ vmId: this.vmId }, "[Serial] relay destroyed");
|
|
3739
4083
|
}
|
|
3740
4084
|
};
|
|
@@ -3742,9 +4086,8 @@ var SerialRelay = class {
|
|
|
3742
4086
|
// src/console-relays.ts
|
|
3743
4087
|
function attachConsoleRelays(executor, sandboxSocket) {
|
|
3744
4088
|
const relays = /* @__PURE__ */ new Map();
|
|
3745
|
-
const
|
|
3746
|
-
|
|
3747
|
-
const handle = await originalCreate(spec);
|
|
4089
|
+
const attach = (handle) => {
|
|
4090
|
+
if (relays.has(handle.vmId)) return;
|
|
3748
4091
|
const entry = {};
|
|
3749
4092
|
if (handle.vncPort) {
|
|
3750
4093
|
const vnc = new VncRelay(handle.vmId, "127.0.0.1", handle.vncPort, sandboxSocket);
|
|
@@ -3758,8 +4101,22 @@ function attachConsoleRelays(executor, sandboxSocket) {
|
|
|
3758
4101
|
entry.serial = serial;
|
|
3759
4102
|
}
|
|
3760
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);
|
|
3761
4109
|
return handle;
|
|
3762
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
|
+
};
|
|
3763
4120
|
const originalDestroy = executor.destroy.bind(executor);
|
|
3764
4121
|
executor.destroy = async (vmId) => {
|
|
3765
4122
|
const entry = relays.get(vmId);
|
|
@@ -3854,68 +4211,111 @@ async function startSandboxHealthServer(options) {
|
|
|
3854
4211
|
}
|
|
3855
4212
|
|
|
3856
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
|
+
});
|
|
3857
4220
|
var program = new import_commander.Command();
|
|
3858
4221
|
program.name("sandbox").description("Run and manage a Multiplayer sandbox worker").version(version);
|
|
3859
|
-
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(
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
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(`
|
|
3884
4251
|
${signal} received; shutting down sandbox worker...`);
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
});
|
|
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
|
+
);
|
|
3900
4270
|
program.command("detect").description("probe this host and report which micro-VM engines it can run").action(() => {
|
|
3901
|
-
const probes = probeEngines(
|
|
4271
|
+
const probes = probeEngines(
|
|
4272
|
+
loadWorkerConfigFromEnv({ ...process.env, WORKER_ID: "-", WORKER_TOKEN: "-" })
|
|
4273
|
+
);
|
|
3902
4274
|
for (const probe of probes) {
|
|
3903
4275
|
const status = probe.available ? "available" : `unavailable \u2014 ${probe.reason}`;
|
|
3904
4276
|
console.log(`${probe.engine.padEnd(15)} ${status}`);
|
|
3905
4277
|
}
|
|
3906
4278
|
});
|
|
3907
4279
|
var images = program.command("images").description("manage engine base images (kernel + initramfs for QEMU/Firecracker)");
|
|
3908
|
-
images.command("pull").description(
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
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
|
+
);
|
|
3919
4319
|
program.parseAsync().catch((error) => {
|
|
3920
4320
|
console.error(`Error: ${error.message}`);
|
|
3921
4321
|
process.exit(1);
|