@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/dist/index.js CHANGED
@@ -58,7 +58,7 @@ var import_os2 = __toESM(require("os"));
58
58
  var import_path2 = __toESM(require("path"));
59
59
 
60
60
  // package.json
61
- var version = "7.0.0";
61
+ var version = "7.0.2";
62
62
 
63
63
  // src/images.ts
64
64
  var import_fs = __toESM(require("fs"));
@@ -77,6 +77,14 @@ var WorkerEvent = {
77
77
  // worker → sandbox, fire-and-forget like HEARTBEAT — sent once when a 'job' VM's
78
78
  // entrypoint exits on its own (the worker has already torn the resource down by then).
79
79
  VM_RUN_EXITED: "vm:run-exited",
80
+ // worker → sandbox after an asynchronous VM_CREATE finishes. Socket.IO acknowledgements
81
+ // cannot be relied on across the detached Redis-adapter emitter used by the API.
82
+ VM_CREATE_RESULT: "vm:create-result",
83
+ // worker → sandbox, fire-and-forget — sent when periodic reconciliation finds the
84
+ // underlying resource gone without this worker ever issuing a destroy (e.g. AWS's own
85
+ // idle-suspend/max-duration policies terminating a Lambda MicroVM). Only engines whose
86
+ // VMs can disappear outside an explicit command from this process emit it.
87
+ VM_EXTERNALLY_TERMINATED: "vm:externally-terminated",
80
88
  // sandbox → worker, generated by the control plane and reused by this worker process
81
89
  // on Socket.IO reconnects. This keeps duplicate configured WORKER_ID values isolated.
82
90
  ASSIGNED_ID: "worker:assigned-id",
@@ -104,6 +112,8 @@ var WorkerEvent = {
104
112
  // sandbox → worker (browser input forwarded downstream)
105
113
  VNC_MOUSE: "vnc:mouse",
106
114
  VNC_KEYBOARD: "vnc:keyboard",
115
+ TERMINAL_ATTACH: "terminal:attach",
116
+ TERMINAL_DETACH: "terminal:detach",
107
117
  TERMINAL_INPUT: "terminal:input"
108
118
  };
109
119
  var VM_ACTION_EVENTS = {
@@ -148,7 +158,20 @@ function s3Key(prefix, arch, stack, file) {
148
158
  async function makeS3Client(endpoint) {
149
159
  const { S3Client } = await import("@aws-sdk/client-s3");
150
160
  return new S3Client({
151
- ...endpoint ? { endpoint, forcePathStyle: true } : {},
161
+ ...endpoint ? {
162
+ endpoint,
163
+ forcePathStyle: true,
164
+ // A custom endpoint means a local/self-hosted store (MinIO, etc.), not real AWS.
165
+ // Use its own static credentials explicitly rather than the ambient default
166
+ // provider chain — otherwise, whenever AWS_PROFILE is also set (e.g. for the
167
+ // lambda-microvm engine's real AWS calls), the SDK unconditionally skips static env
168
+ // credentials process-wide and sends the real AWS SSO session to this local store
169
+ // instead, which rejects it since it only recognizes its own configured user.
170
+ credentials: {
171
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "minioadmin",
172
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "minioadmin"
173
+ }
174
+ } : {},
152
175
  ...process.env.AWS_REGION ? {} : { region: "us-east-1" },
153
176
  // aws-chunked checksum streaming breaks older S3-compatible stores (MinIO)
154
177
  requestChecksumCalculation: "WHEN_REQUIRED"
@@ -169,7 +192,10 @@ async function downloadFromHttp(opts, stack, file, dest) {
169
192
  if (!response.ok || !response.body) {
170
193
  throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`);
171
194
  }
172
- await (0, import_promises.pipeline)(import_stream.Readable.fromWeb(response.body), import_fs.default.createWriteStream(dest));
195
+ await (0, import_promises.pipeline)(
196
+ import_stream.Readable.fromWeb(response.body),
197
+ import_fs.default.createWriteStream(dest)
198
+ );
173
199
  }
174
200
  async function pullImages(opts, destDir) {
175
201
  const isS3 = opts.location.startsWith("s3://");
@@ -204,11 +230,13 @@ async function pushImages(opts, srcDir) {
204
230
  throw new Error(`${src} not found \u2014 run scripts/setup-vm-images.sh first`);
205
231
  }
206
232
  const key = s3Key(prefix, opts.arch, stack, file);
207
- await client.send(new PutObjectCommand({
208
- Bucket: bucket,
209
- Key: key,
210
- Body: import_fs.default.readFileSync(src)
211
- }));
233
+ await client.send(
234
+ new PutObjectCommand({
235
+ Bucket: bucket,
236
+ Key: key,
237
+ Body: import_fs.default.readFileSync(src)
238
+ })
239
+ );
212
240
  uploaded.push(`s3://${bucket}/${key}`);
213
241
  }
214
242
  }
@@ -233,25 +261,28 @@ function parseArnList(raw) {
233
261
  const arns = raw.split(",").map((s) => s.trim()).filter(Boolean);
234
262
  return arns.length ? arns : void 0;
235
263
  }
236
- function resolveSandboxEndpoint(rawUrl, explicitSocketPath) {
264
+ function resolveSandboxEndpoint(rawUrl) {
237
265
  const url = new URL(rawUrl);
238
- const basePath = url.pathname.replace(/\/+$/, "") || "/v0";
266
+ const basePath = url.pathname.replace(/\/+$/, "");
239
267
  return {
240
268
  // Socket.IO treats a URL pathname as a namespace, so connect to the origin
241
- // and pass the derived HTTP transport path separately.
269
+ // and pass the derived HTTP transport path separately. SANDBOX_URL's pathname is the
270
+ // sole source of truth for the socket path — it must include whatever base the control
271
+ // plane expects (e.g. "/v0/gapi" or "/internal/v0/gapi"), and "/workers/ws" is appended.
242
272
  sandboxUrl: url.origin,
243
- workerSocketPath: explicitSocketPath ?? `${basePath}/sapi/workers/ws`
273
+ workerSocketPath: `${basePath}/workers/ws`
244
274
  };
245
275
  }
246
276
  function loadWorkerConfigFromEnv(env = process.env) {
247
277
  const workerId = env.WORKER_ID?.trim() || import_os2.default.hostname();
278
+ const workerIdFile = env.WORKER_ID_FILE?.trim() || import_path2.default.join(import_os2.default.tmpdir(), `multiplayer-sandbox-worker-${workerId}.id`);
248
279
  const token = env.WORKER_TOKEN?.trim() || void 0;
249
280
  const sandboxEndpoint = resolveSandboxEndpoint(
250
- env.SANDBOX_URL ?? "https://api.sandbox.filingramp.com/v0",
251
- env.WORKER_SOCKET_PATH
281
+ env.SANDBOX_URL ?? "https://api.sandbox.filingramp.com/v0/gapi"
252
282
  );
253
283
  return {
254
284
  workerId,
285
+ workerIdFile,
255
286
  token,
256
287
  sandboxUrl: sandboxEndpoint.sandboxUrl,
257
288
  workerSocketPath: sandboxEndpoint.workerSocketPath,
@@ -286,7 +317,10 @@ function loadWorkerConfigFromEnv(env = process.env) {
286
317
  // 0 disables auto-suspend; the sandbox drives pause/resume explicitly by default
287
318
  maxIdleSeconds: Number(env.MICROVM_MAX_IDLE_SECONDS ?? 900),
288
319
  suspendedDurationSeconds: Number(env.MICROVM_SUSPENDED_DURATION_SECONDS ?? 28800),
289
- maximumDurationInSeconds: Number(env.MICROVM_MAX_DURATION_SECONDS ?? 28800)
320
+ maximumDurationInSeconds: Number(env.MICROVM_MAX_DURATION_SECONDS ?? 28800),
321
+ // A real cold MicroVM boot can comfortably exceed the old 25s hardcoded default —
322
+ // must stay well under the api's own VM_CREATE ack timeout (see vm.service.ts).
323
+ createTimeoutMs: Number(env.MICROVM_CREATE_TIMEOUT_MS ?? 6e4)
290
324
  },
291
325
  fargate: {
292
326
  region: env.FARGATE_AWS_REGION ?? env.AWS_REGION,
@@ -2491,8 +2525,8 @@ var RemoteExecutor = class {
2491
2525
  body: result.body ? Buffer.from(result.body, "base64") : Buffer.alloc(0)
2492
2526
  };
2493
2527
  }
2494
- request(method, path7, body) {
2495
- const url = new URL(this.base + path7);
2528
+ request(method, path8, body) {
2529
+ const url = new URL(this.base + path8);
2496
2530
  const lib = url.protocol === "https:" ? import_https.default : import_http5.default;
2497
2531
  const payload = body !== void 0 ? JSON.stringify(body) : void 0;
2498
2532
  return new Promise((resolve, reject) => {
@@ -2513,7 +2547,7 @@ var RemoteExecutor = class {
2513
2547
  res.on("end", () => {
2514
2548
  const text = Buffer.concat(chunks).toString();
2515
2549
  if (res.statusCode && res.statusCode >= 400) {
2516
- reject(new Error(`vm-agent ${method} ${path7} \u2192 ${res.statusCode}: ${text}`));
2550
+ reject(new Error(`vm-agent ${method} ${path8} \u2192 ${res.statusCode}: ${text}`));
2517
2551
  return;
2518
2552
  }
2519
2553
  if (res.statusCode === 204 || !text) {
@@ -2540,6 +2574,23 @@ var import_https2 = __toESM(require("https"));
2540
2574
  var import_client_lambda_microvms = require("@aws-sdk/client-lambda-microvms");
2541
2575
  var AUTH_TOKEN_TTL_MINUTES = 60;
2542
2576
  var AUTH_TOKEN_REFRESH_MARGIN_MS = 2 * 6e4;
2577
+ var SHELL_PORT = 8022;
2578
+ function managedConnectorArn(imageIdentifier, name) {
2579
+ const [arn, partition, service, region] = imageIdentifier.split(":");
2580
+ if (arn !== "arn" || !partition || service !== "lambda" || !region) {
2581
+ throw new Error(`cannot derive Lambda MicroVM connector ARN from image "${imageIdentifier}"`);
2582
+ }
2583
+ return `arn:${partition}:lambda:${region}:aws:network-connector:aws-network-connector:${name}`;
2584
+ }
2585
+ function runtimeEnv(spec) {
2586
+ const env = {
2587
+ ...spec.env,
2588
+ // A release URL is unique and short-lived, so it belongs in per-VM run data rather than
2589
+ // the image-level environment shared by every MicroVM launched from that image.
2590
+ ...spec.appUrl ? { APP_URL: spec.appUrl } : {}
2591
+ };
2592
+ return Object.keys(env).length ? env : void 0;
2593
+ }
2543
2594
  var LambdaMicrovmExecutor = class {
2544
2595
  constructor(config) {
2545
2596
  this.config = config;
@@ -2549,10 +2600,15 @@ var LambdaMicrovmExecutor = class {
2549
2600
  async create(spec) {
2550
2601
  const imageIdentifier = this.config.stackImageArns?.[spec.stack] ?? this.config.imageArn;
2551
2602
  const maxIdle = this.config.maxIdleSeconds ?? 900;
2603
+ const shellConnectorArn = managedConnectorArn(imageIdentifier, "SHELL_INGRESS");
2604
+ const ingressNetworkConnectors = [
2605
+ .../* @__PURE__ */ new Set([...this.config.ingressConnectorArns ?? [], shellConnectorArn])
2606
+ ];
2552
2607
  const run = await this.client.send(
2553
2608
  new import_client_lambda_microvms.RunMicrovmCommand({
2554
2609
  imageIdentifier,
2555
- ingressNetworkConnectors: this.config.ingressConnectorArns,
2610
+ // Required by CreateMicrovmShellAuthToken and the /shell WebSocket used by vm-console.
2611
+ ingressNetworkConnectors,
2556
2612
  egressNetworkConnectors: this.config.egressConnectorArns,
2557
2613
  executionRoleArn: this.config.executionRoleArn,
2558
2614
  maximumDurationInSeconds: this.config.maximumDurationInSeconds ?? 28800,
@@ -2569,9 +2625,11 @@ var LambdaMicrovmExecutor = class {
2569
2625
  vmId: spec.vmId,
2570
2626
  sessionId: spec.sessionId,
2571
2627
  stack: spec.stack,
2572
- appUrl: spec.appUrl,
2573
- env: spec.env
2574
- })
2628
+ env: runtimeEnv(spec)
2629
+ }),
2630
+ // VM_CREATE may be delivered more than once by the Redis-backed control plane.
2631
+ // AWS returns the original request result instead of provisioning another MicroVM.
2632
+ clientToken: spec.vmId
2575
2633
  })
2576
2634
  );
2577
2635
  if (!run.microvmId || !run.endpoint) {
@@ -2634,6 +2692,31 @@ var LambdaMicrovmExecutor = class {
2634
2692
  list() {
2635
2693
  return [...this.vms.values()].map((v) => v.handle);
2636
2694
  }
2695
+ getSerialSource(vmId) {
2696
+ const vm = this.vms.get(vmId);
2697
+ if (!vm) return void 0;
2698
+ return {
2699
+ type: "websocket",
2700
+ url: `wss://${vm.endpoint}/shell`,
2701
+ protocols: async () => {
2702
+ const response = await this.client.send(
2703
+ new import_client_lambda_microvms.CreateMicrovmShellAuthTokenCommand({
2704
+ microvmIdentifier: vm.microvmId,
2705
+ expirationInMinutes: AUTH_TOKEN_TTL_MINUTES
2706
+ })
2707
+ );
2708
+ const token = Object.entries(response.authToken ?? {}).find(
2709
+ ([key]) => key.toLowerCase() === "x-aws-proxy-auth"
2710
+ )?.[1];
2711
+ if (!token) throw new Error("create-microvm-shell-auth-token returned no auth token");
2712
+ return [
2713
+ "lambda-microvms",
2714
+ `lambda-microvms.authentication.${token}`,
2715
+ `lambda-microvms.port.${SHELL_PORT}`
2716
+ ];
2717
+ }
2718
+ };
2719
+ }
2637
2720
  async proxyHttp(vmId, request) {
2638
2721
  const vm = this.mustGet(vmId);
2639
2722
  const auth = await this.getAuthHeaders(vm);
@@ -2701,6 +2784,47 @@ var LambdaMicrovmExecutor = class {
2701
2784
  );
2702
2785
  return handle;
2703
2786
  }
2787
+ /**
2788
+ * AWS can suspend or terminate a MicroVM on its own (idle-suspend policy, the suspended-
2789
+ * duration cap, the hard maximumDurationInSeconds lifetime) without this process ever
2790
+ * issuing a command — nothing else here polls for that. Called periodically by the
2791
+ * worker: re-describes every tracked VM, quietly reconciles running/suspended drift, and
2792
+ * reports (then forgets) any that AWS has actually terminated so the control plane's
2793
+ * record doesn't stay stuck saying "running" forever.
2794
+ */
2795
+ async reconcile() {
2796
+ const externallyTerminated = [];
2797
+ for (const [vmId, vm] of [...this.vms.entries()]) {
2798
+ try {
2799
+ const described = await this.client.send(
2800
+ new import_client_lambda_microvms.GetMicrovmCommand({ microvmIdentifier: vm.microvmId })
2801
+ );
2802
+ if (described.state === "TERMINATED" || described.state === "TERMINATING") {
2803
+ this.vms.delete(vmId);
2804
+ externallyTerminated.push(vmId);
2805
+ src_default.warn(
2806
+ { vmId, microvmId: vm.microvmId, state: described.state },
2807
+ "[LambdaMicrovm] vm terminated outside our control"
2808
+ );
2809
+ } else if (typeof described.state === "string") {
2810
+ const running = described.state === "RUNNING";
2811
+ if (running !== vm.handle.running) vm.handle = { ...vm.handle, running };
2812
+ }
2813
+ } catch (error) {
2814
+ if (error instanceof Error && error.name === "ResourceNotFoundException") {
2815
+ this.vms.delete(vmId);
2816
+ externallyTerminated.push(vmId);
2817
+ src_default.warn(
2818
+ { vmId, microvmId: vm.microvmId },
2819
+ "[LambdaMicrovm] vm no longer exists in AWS"
2820
+ );
2821
+ } else {
2822
+ src_default.error({ vmId, err: error }, "[LambdaMicrovm] reconcile check failed");
2823
+ }
2824
+ }
2825
+ }
2826
+ return externallyTerminated;
2827
+ }
2704
2828
  mustGet(vmId) {
2705
2829
  const vm = this.vms.get(vmId);
2706
2830
  if (!vm) throw new Error(`vm ${vmId} not found`);
@@ -3273,6 +3397,8 @@ function detectEngines(config) {
3273
3397
  }
3274
3398
 
3275
3399
  // src/executor/index.ts
3400
+ var VmResourceGoneError = class extends Error {
3401
+ };
3276
3402
  function findNotAttachedVms(executor, records) {
3277
3403
  return records.filter((record) => !executor.get(record.vmId));
3278
3404
  }
@@ -3302,11 +3428,25 @@ var MultiEngineExecutor = class {
3302
3428
  constructor(executors, defaultEngine) {
3303
3429
  this.executors = executors;
3304
3430
  this.defaultEngine = defaultEngine;
3431
+ this.creations = /* @__PURE__ */ new Map();
3305
3432
  }
3306
3433
  get engines() {
3307
3434
  return [...this.executors.keys()];
3308
3435
  }
3309
3436
  async create(spec) {
3437
+ const existing = this.get(spec.vmId);
3438
+ if (existing) return existing;
3439
+ const pending = this.creations.get(spec.vmId);
3440
+ if (pending) return pending;
3441
+ const creation = this.createOnce(spec);
3442
+ this.creations.set(spec.vmId, creation);
3443
+ try {
3444
+ return await creation;
3445
+ } finally {
3446
+ this.creations.delete(spec.vmId);
3447
+ }
3448
+ }
3449
+ async createOnce(spec) {
3310
3450
  const engine = spec.engine ?? this.defaultEngine;
3311
3451
  const executor = this.executors.get(engine);
3312
3452
  if (!executor) {
@@ -3317,22 +3457,27 @@ var MultiEngineExecutor = class {
3317
3457
  const handle = await executor.create(spec);
3318
3458
  return { ...handle, engine };
3319
3459
  }
3320
- pause(vmId) {
3460
+ // async here isn't a no-op: route() throws synchronously when the vmId isn't tracked in any
3461
+ // executor's in-memory map (e.g. worker restarted and hasn't reattached this VM yet). Without
3462
+ // `async`, that throw escapes as a real exception instead of a rejected promise — which,
3463
+ // called from socket-client.ts's wrap(), skips its .catch() entirely and leaves the command
3464
+ // un-acked until the caller's timeout, looking like the action "does nothing".
3465
+ async pause(vmId) {
3321
3466
  return this.route(vmId).pause(vmId);
3322
3467
  }
3323
- resume(vmId) {
3468
+ async resume(vmId) {
3324
3469
  return this.route(vmId).resume(vmId);
3325
3470
  }
3326
- stop(vmId) {
3471
+ async stop(vmId) {
3327
3472
  return this.route(vmId).stop(vmId);
3328
3473
  }
3329
- restore(vmId) {
3474
+ async restore(vmId) {
3330
3475
  return this.route(vmId).restore(vmId);
3331
3476
  }
3332
- destroy(vmId) {
3477
+ async destroy(vmId) {
3333
3478
  return this.route(vmId).destroy(vmId);
3334
3479
  }
3335
- proxyHttp(vmId, request) {
3480
+ async proxyHttp(vmId, request) {
3336
3481
  return this.route(vmId).proxyHttp(vmId, request);
3337
3482
  }
3338
3483
  get(vmId) {
@@ -3351,15 +3496,14 @@ var MultiEngineExecutor = class {
3351
3496
  }
3352
3497
  return void 0;
3353
3498
  }
3354
- exec(vmId, request) {
3499
+ async exec(vmId, request) {
3355
3500
  const executor = this.route(vmId);
3356
- if (!executor.exec) return Promise.reject(new Error("executor does not support guest exec"));
3501
+ if (!executor.exec) throw new Error("executor does not support guest exec");
3357
3502
  return executor.exec(vmId, request);
3358
3503
  }
3359
- writeFiles(vmId, files) {
3504
+ async writeFiles(vmId, files) {
3360
3505
  const executor = this.route(vmId);
3361
- if (!executor.writeFiles)
3362
- return Promise.reject(new Error("executor does not support guest file writes"));
3506
+ if (!executor.writeFiles) throw new Error("executor does not support guest file writes");
3363
3507
  return executor.writeFiles(vmId, files);
3364
3508
  }
3365
3509
  onExit(vmId, handler) {
@@ -3367,6 +3511,15 @@ var MultiEngineExecutor = class {
3367
3511
  if (!executor.onExit) throw new Error("executor does not support job run mode");
3368
3512
  executor.onExit(vmId, handler);
3369
3513
  }
3514
+ /** Fans out to every sub-executor that supports it (currently just lambda-microvm). */
3515
+ async reconcile() {
3516
+ const externallyTerminated = [];
3517
+ for (const executor of this.executors.values()) {
3518
+ if (!executor.reconcile) continue;
3519
+ externallyTerminated.push(...await executor.reconcile());
3520
+ }
3521
+ return externallyTerminated;
3522
+ }
3370
3523
  /**
3371
3524
  * Recover VMs sandbox says this workerId owns but that aren't currently tracked (a worker
3372
3525
  * restart, most likely). Runs in parallel — probing many VMs serially would needlessly
@@ -3375,9 +3528,16 @@ var MultiEngineExecutor = class {
3375
3528
  * never thrown — a VM that can't be reattached just stays unavailable until something
3376
3529
  * touches it again (create/proxy/lifecycle), which already errors gracefully today.
3377
3530
  */
3531
+ /**
3532
+ * Returns vmIds found genuinely gone (executor.reattach resolved undefined — the
3533
+ * resource itself no longer exists, e.g. AWS already terminated it while this worker
3534
+ * was down) as distinct from any other reattach failure (engine unavailable, no reattach
3535
+ * support, a transient describe error, ...), which leave the VM merely unavailable
3536
+ * rather than confirmed gone.
3537
+ */
3378
3538
  async reattachAll(records) {
3379
3539
  const pending = findNotAttachedVms(this, records);
3380
- if (!pending.length) return;
3540
+ if (!pending.length) return [];
3381
3541
  const results = await Promise.allSettled(
3382
3542
  pending.map(async (record) => {
3383
3543
  const executor = record.engine ? this.executors.get(record.engine) : void 0;
@@ -3386,18 +3546,25 @@ var MultiEngineExecutor = class {
3386
3546
  if (!executor.reattach)
3387
3547
  throw new Error(`engine "${record.engine}" does not support reattach`);
3388
3548
  const handle = await executor.reattach(record);
3389
- if (!handle) throw new Error("resource no longer exists");
3549
+ if (!handle) throw new VmResourceGoneError();
3390
3550
  })
3391
3551
  );
3552
+ const externallyTerminated = [];
3392
3553
  results.forEach((result, i) => {
3393
3554
  const vmId = pending[i].vmId;
3394
- if (result.status === "fulfilled") src_default.info({ vmId }, "vm reattached");
3395
- else
3555
+ if (result.status === "fulfilled") {
3556
+ src_default.info({ vmId }, "vm reattached");
3557
+ } else if (result.reason instanceof VmResourceGoneError) {
3558
+ externallyTerminated.push(vmId);
3559
+ src_default.warn({ vmId }, "vm reattach found resource already gone");
3560
+ } else {
3396
3561
  src_default.warn(
3397
3562
  { vmId, err: result.reason },
3398
3563
  "vm reattach failed \u2014 staying unavailable until next touch"
3399
3564
  );
3565
+ }
3400
3566
  });
3567
+ return externallyTerminated;
3401
3568
  }
3402
3569
  route(vmId) {
3403
3570
  for (const executor of this.executors.values()) {
@@ -3419,8 +3586,36 @@ function createExecutor(config) {
3419
3586
  // src/socket-client.ts
3420
3587
  var import_os9 = __toESM(require("os"));
3421
3588
  var import_socket = require("socket.io-client");
3589
+
3590
+ // src/worker-identity.ts
3591
+ var import_fs3 = __toESM(require("fs"));
3592
+ var import_path7 = __toESM(require("path"));
3593
+ function loadPersistedWorkerId(filePath) {
3594
+ try {
3595
+ const id = import_fs3.default.readFileSync(filePath, "utf8").trim();
3596
+ return id || void 0;
3597
+ } catch {
3598
+ return void 0;
3599
+ }
3600
+ }
3601
+ function savePersistedWorkerId(filePath, workerId) {
3602
+ try {
3603
+ import_fs3.default.mkdirSync(import_path7.default.dirname(filePath), { recursive: true });
3604
+ import_fs3.default.writeFileSync(filePath, workerId, "utf8");
3605
+ } catch (error) {
3606
+ src_default.warn(
3607
+ { err: error, filePath },
3608
+ "failed to persist assigned worker id \u2014 reattach after a restart may not find its VMs"
3609
+ );
3610
+ }
3611
+ }
3612
+
3613
+ // src/socket-client.ts
3422
3614
  var HEARTBEAT_INTERVAL_MS = 1e4;
3615
+ var RECONCILE_INTERVAL_MS = 6e4;
3423
3616
  var REATTACH_FALLBACK_MS = 5e3;
3617
+ var RECONNECTION_DELAY_MS = 1e3;
3618
+ var RECONNECTION_DELAY_MAX_MS = 1e4;
3424
3619
  function primaryIp() {
3425
3620
  for (const ifaces of Object.values(import_os9.default.networkInterfaces())) {
3426
3621
  for (const iface of ifaces ?? []) {
@@ -3449,7 +3644,11 @@ function wrap(handler) {
3449
3644
  return (payload, callback) => {
3450
3645
  handler(payload).then((result) => callback({ ok: true, result })).catch((error) => {
3451
3646
  src_default.error({ err: error }, "command failed");
3452
- callback({ ok: false, error: error.message });
3647
+ try {
3648
+ callback({ ok: false, error: error.message });
3649
+ } catch (ackError) {
3650
+ src_default.error({ err: ackError }, "failed to send command failure ack");
3651
+ }
3453
3652
  });
3454
3653
  };
3455
3654
  }
@@ -3457,20 +3656,32 @@ function registerCommandHandlers(socket, executor) {
3457
3656
  socket.on(
3458
3657
  WorkerEvent.VM_CREATE,
3459
3658
  wrap(async (spec) => {
3460
- const handle = await executor.create(spec);
3461
- if (spec.runMode === "job") {
3462
- try {
3659
+ try {
3660
+ const handle = await executor.create(spec);
3661
+ if (spec.runMode === "job") {
3463
3662
  executor.onExit(
3464
3663
  spec.vmId,
3465
- (result) => socket.emit(WorkerEvent.VM_RUN_EXITED, { vmId: spec.vmId, ...result })
3664
+ (result2) => socket.emit(WorkerEvent.VM_RUN_EXITED, { vmId: spec.vmId, ...result2 })
3466
3665
  );
3467
- } catch (error) {
3468
- await executor.destroy(spec.vmId).catch(() => {
3469
- });
3470
- throw error;
3471
3666
  }
3667
+ const result = {
3668
+ vmId: spec.vmId,
3669
+ ok: true,
3670
+ metadata: handle.metadata
3671
+ };
3672
+ socket.emit(WorkerEvent.VM_CREATE_RESULT, result);
3673
+ return handle;
3674
+ } catch (error) {
3675
+ if (spec.runMode === "job") await executor.destroy(spec.vmId).catch(() => {
3676
+ });
3677
+ const result = {
3678
+ vmId: spec.vmId,
3679
+ ok: false,
3680
+ error: error instanceof Error ? error.message : String(error)
3681
+ };
3682
+ socket.emit(WorkerEvent.VM_CREATE_RESULT, result);
3683
+ throw error;
3472
3684
  }
3473
- return handle;
3474
3685
  })
3475
3686
  );
3476
3687
  socket.on(
@@ -3522,7 +3733,7 @@ function registerCommandHandlers(socket, executor) {
3522
3733
  );
3523
3734
  }
3524
3735
  function connectToSandbox(config, executor) {
3525
- let assignedWorkerId;
3736
+ let assignedWorkerId = loadPersistedWorkerId(config.workerIdFile);
3526
3737
  const socket = (0, import_socket.io)(config.sandboxUrl, {
3527
3738
  path: config.workerSocketPath,
3528
3739
  auth: (callback) => callback({
@@ -3531,12 +3742,17 @@ function connectToSandbox(config, executor) {
3531
3742
  ...config.token ? { token: config.token } : {}
3532
3743
  }),
3533
3744
  transports: ["websocket"],
3534
- reconnection: true
3745
+ reconnection: true,
3746
+ reconnectionAttempts: Infinity,
3747
+ reconnectionDelay: RECONNECTION_DELAY_MS,
3748
+ reconnectionDelayMax: RECONNECTION_DELAY_MAX_MS
3535
3749
  });
3536
3750
  let heartbeatTimer;
3751
+ let reconcileTimer;
3537
3752
  let reattachFallbackTimer;
3538
3753
  socket.on(WorkerEvent.ASSIGNED_ID, ({ workerId }) => {
3539
3754
  assignedWorkerId = workerId;
3755
+ savePersistedWorkerId(config.workerIdFile, workerId);
3540
3756
  src_default.info({ workerId: config.workerId, assignedWorkerId }, "worker runtime ID assigned");
3541
3757
  });
3542
3758
  const sendHeartbeat = () => socket.emit(WorkerEvent.HEARTBEAT, buildHeartbeat(config, executor));
@@ -3545,6 +3761,17 @@ function connectToSandbox(config, executor) {
3545
3761
  sendHeartbeat();
3546
3762
  heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
3547
3763
  }
3764
+ const runReconcile = () => {
3765
+ executor.reconcile().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 }, "vm reconcile pass failed"));
3770
+ };
3771
+ function startReconcile() {
3772
+ if (reconcileTimer) return;
3773
+ reconcileTimer = setInterval(runReconcile, RECONCILE_INTERVAL_MS);
3774
+ }
3548
3775
  socket.on("connect", () => {
3549
3776
  src_default.info(
3550
3777
  { workerId: config.workerId, assignedWorkerId, url: config.sandboxUrl },
@@ -3557,10 +3784,26 @@ function connectToSandbox(config, executor) {
3557
3784
  "no reattach handshake from sandbox \u2014 starting heartbeat anyway"
3558
3785
  );
3559
3786
  startHeartbeat();
3787
+ startReconcile();
3560
3788
  }, REATTACH_FALLBACK_MS);
3561
3789
  socket.once(WorkerEvent.REATTACH, (records) => {
3562
3790
  clearTimeout(reattachFallbackTimer);
3563
- executor.reattachAll(records).catch((error) => src_default.error({ err: error }, "reattach batch failed")).finally(() => {
3791
+ executor.reattachAll(records).then((externallyTerminated) => {
3792
+ for (const vmId of externallyTerminated) {
3793
+ socket.emit(WorkerEvent.VM_EXTERNALLY_TERMINATED, { vmId });
3794
+ }
3795
+ }).catch((error) => src_default.error({ err: error }, "reattach batch failed")).finally(() => {
3796
+ for (const record of records) {
3797
+ const handle = executor.get(record.vmId);
3798
+ if (record.state === "creating" && handle) {
3799
+ const result = {
3800
+ vmId: record.vmId,
3801
+ ok: true,
3802
+ metadata: handle.metadata
3803
+ };
3804
+ socket.emit(WorkerEvent.VM_CREATE_RESULT, result);
3805
+ }
3806
+ }
3564
3807
  for (const record of records) {
3565
3808
  if (record.runMode !== "job" || !executor.get(record.vmId)) continue;
3566
3809
  try {
@@ -3576,21 +3819,36 @@ function connectToSandbox(config, executor) {
3576
3819
  }
3577
3820
  }
3578
3821
  startHeartbeat();
3822
+ startReconcile();
3579
3823
  });
3580
3824
  });
3581
3825
  });
3582
3826
  socket.on("disconnect", (reason) => {
3583
3827
  src_default.warn({ workerId: config.workerId, reason }, "disconnected from sandbox");
3584
3828
  clearInterval(heartbeatTimer);
3829
+ clearInterval(reconcileTimer);
3585
3830
  clearTimeout(reattachFallbackTimer);
3586
3831
  heartbeatTimer = void 0;
3832
+ reconcileTimer = void 0;
3587
3833
  socket.off(WorkerEvent.REATTACH);
3588
3834
  });
3589
3835
  socket.on("connect_error", (error) => {
3590
- src_default.error({
3591
- err: error,
3592
- sandboxUrl: config.sandboxUrl
3593
- }, "sandbox connection error");
3836
+ src_default.error(
3837
+ {
3838
+ err: error,
3839
+ sandboxUrl: config.sandboxUrl
3840
+ },
3841
+ "sandbox connection error"
3842
+ );
3843
+ });
3844
+ socket.io.on("reconnect_attempt", (attempt) => {
3845
+ src_default.warn({ workerId: config.workerId, attempt }, "reconnecting to sandbox");
3846
+ });
3847
+ socket.io.on("reconnect_error", (error) => {
3848
+ src_default.warn({ workerId: config.workerId, err: error }, "sandbox reconnect attempt failed");
3849
+ });
3850
+ socket.io.on("reconnect", (attempt) => {
3851
+ src_default.info({ workerId: config.workerId, attempt }, "reconnected to sandbox");
3594
3852
  });
3595
3853
  registerCommandHandlers(socket, executor);
3596
3854
  return socket;
@@ -3700,19 +3958,42 @@ var VncRelay = class {
3700
3958
 
3701
3959
  // src/services/serial-relay.ts
3702
3960
  var import_net3 = __toESM(require("net"));
3961
+ var import_ws = __toESM(require("ws"));
3703
3962
  var SerialRelay = class {
3704
3963
  constructor(vmId, source, socket) {
3705
3964
  this.vmId = vmId;
3706
3965
  this.source = source;
3707
3966
  this.socket = socket;
3708
3967
  this.conn = null;
3968
+ this.webSocket = null;
3969
+ this.reconnectTimer = null;
3970
+ this.connecting = false;
3971
+ this.browserCount = 0;
3972
+ this.pendingInput = [];
3973
+ this.destroyed = false;
3709
3974
  this.inputHandler = null;
3975
+ this.attachHandler = null;
3976
+ this.detachHandler = null;
3710
3977
  }
3711
3978
  connect() {
3979
+ this.destroyed = false;
3712
3980
  if (this.source.type === "tcp") {
3713
3981
  this.connectTcp();
3714
- } else {
3982
+ } else if (this.source.type === "pipe") {
3715
3983
  this.connectPipe();
3984
+ } else {
3985
+ this.attachHandler = ({ vmId }) => {
3986
+ if (vmId !== this.vmId) return;
3987
+ this.browserCount += 1;
3988
+ if (this.browserCount === 1) void this.connectWebSocket();
3989
+ };
3990
+ this.detachHandler = ({ vmId }) => {
3991
+ if (vmId !== this.vmId) return;
3992
+ this.browserCount = Math.max(0, this.browserCount - 1);
3993
+ if (this.browserCount === 0) this.disconnectWebSocket();
3994
+ };
3995
+ this.socket.on(WorkerEvent.TERMINAL_ATTACH, this.attachHandler);
3996
+ this.socket.on(WorkerEvent.TERMINAL_DETACH, this.detachHandler);
3716
3997
  }
3717
3998
  this.inputHandler = (e) => {
3718
3999
  if (e.vmId !== this.vmId) return;
@@ -3721,6 +4002,15 @@ var SerialRelay = class {
3721
4002
  } else if (this.source.type === "pipe") {
3722
4003
  const proc = this.source.process;
3723
4004
  proc.stdin?.write(e.data);
4005
+ } else if (this.source.type === "websocket") {
4006
+ if (this.webSocket?.readyState === import_ws.default.OPEN) {
4007
+ this.webSocket.send(e.data);
4008
+ } else {
4009
+ this.browserCount = Math.max(1, this.browserCount);
4010
+ this.pendingInput.push(e.data);
4011
+ if (this.pendingInput.length > 100) this.pendingInput.shift();
4012
+ void this.connectWebSocket();
4013
+ }
3724
4014
  }
3725
4015
  };
3726
4016
  this.socket.on(WorkerEvent.TERMINAL_INPUT, this.inputHandler);
@@ -3739,7 +4029,9 @@ var SerialRelay = class {
3739
4029
  sock.on("end", () => src_default.info({ vmId: this.vmId }, "[Serial] TCP ended"));
3740
4030
  sock.on("error", (err) => src_default.error({ vmId: this.vmId, err }, "[Serial] TCP error"));
3741
4031
  });
3742
- sock.once("error", () => setTimeout(attempt, 1e3));
4032
+ sock.once("error", () => {
4033
+ if (!this.destroyed) setTimeout(attempt, 1e3);
4034
+ });
3743
4035
  };
3744
4036
  attempt();
3745
4037
  }
@@ -3754,13 +4046,65 @@ var SerialRelay = class {
3754
4046
  (d) => this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: d })
3755
4047
  );
3756
4048
  }
4049
+ async connectWebSocket() {
4050
+ if (this.destroyed || this.browserCount === 0 || this.source.type !== "websocket" || this.webSocket || this.connecting) {
4051
+ return;
4052
+ }
4053
+ try {
4054
+ this.connecting = true;
4055
+ const protocols = await this.source.protocols();
4056
+ if (this.destroyed || this.browserCount === 0) return;
4057
+ const webSocket = new import_ws.default(this.source.url, protocols);
4058
+ this.webSocket = webSocket;
4059
+ webSocket.on("open", () => {
4060
+ for (const data of this.pendingInput.splice(0)) webSocket.send(data);
4061
+ src_default.info({ vmId: this.vmId }, "[Serial] Lambda shell connected");
4062
+ });
4063
+ webSocket.on("message", (data) => {
4064
+ const chunk = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data);
4065
+ this.socket.emit(WorkerEvent.TERMINAL_DATA, { vmId: this.vmId, data: chunk });
4066
+ });
4067
+ webSocket.on("error", (error) => {
4068
+ src_default.error({ vmId: this.vmId, err: error }, "[Serial] Lambda shell error");
4069
+ });
4070
+ webSocket.on("close", () => {
4071
+ if (this.webSocket === webSocket) this.webSocket = null;
4072
+ src_default.info({ vmId: this.vmId }, "[Serial] Lambda shell disconnected");
4073
+ this.scheduleWebSocketReconnect();
4074
+ });
4075
+ } catch (error) {
4076
+ src_default.error({ vmId: this.vmId, err: error }, "[Serial] Lambda shell connect failed");
4077
+ this.scheduleWebSocketReconnect();
4078
+ } finally {
4079
+ this.connecting = false;
4080
+ }
4081
+ }
4082
+ scheduleWebSocketReconnect() {
4083
+ if (this.destroyed || this.browserCount === 0 || this.reconnectTimer) return;
4084
+ this.reconnectTimer = setTimeout(() => {
4085
+ this.reconnectTimer = null;
4086
+ void this.connectWebSocket();
4087
+ }, 1e3);
4088
+ }
4089
+ disconnectWebSocket() {
4090
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
4091
+ this.reconnectTimer = null;
4092
+ this.pendingInput = [];
4093
+ const webSocket = this.webSocket;
4094
+ this.webSocket = null;
4095
+ webSocket?.close();
4096
+ }
3757
4097
  destroy() {
4098
+ this.destroyed = true;
3758
4099
  if (this.inputHandler) this.socket.off(WorkerEvent.TERMINAL_INPUT, this.inputHandler);
4100
+ if (this.attachHandler) this.socket.off(WorkerEvent.TERMINAL_ATTACH, this.attachHandler);
4101
+ if (this.detachHandler) this.socket.off(WorkerEvent.TERMINAL_DETACH, this.detachHandler);
3759
4102
  try {
3760
4103
  this.conn?.destroy();
3761
4104
  } catch {
3762
4105
  }
3763
4106
  this.conn = null;
4107
+ this.disconnectWebSocket();
3764
4108
  src_default.info({ vmId: this.vmId }, "[Serial] relay destroyed");
3765
4109
  }
3766
4110
  };
@@ -3768,9 +4112,8 @@ var SerialRelay = class {
3768
4112
  // src/console-relays.ts
3769
4113
  function attachConsoleRelays(executor, sandboxSocket) {
3770
4114
  const relays = /* @__PURE__ */ new Map();
3771
- const originalCreate = executor.create.bind(executor);
3772
- executor.create = async (spec) => {
3773
- const handle = await originalCreate(spec);
4115
+ const attach = (handle) => {
4116
+ if (relays.has(handle.vmId)) return;
3774
4117
  const entry = {};
3775
4118
  if (handle.vncPort) {
3776
4119
  const vnc = new VncRelay(handle.vmId, "127.0.0.1", handle.vncPort, sandboxSocket);
@@ -3784,8 +4127,22 @@ function attachConsoleRelays(executor, sandboxSocket) {
3784
4127
  entry.serial = serial;
3785
4128
  }
3786
4129
  if (entry.vnc || entry.serial) relays.set(handle.vmId, entry);
4130
+ };
4131
+ const originalCreate = executor.create.bind(executor);
4132
+ executor.create = async (spec) => {
4133
+ const handle = await originalCreate(spec);
4134
+ attach(handle);
3787
4135
  return handle;
3788
4136
  };
4137
+ const originalReattachAll = executor.reattachAll.bind(executor);
4138
+ executor.reattachAll = async (records) => {
4139
+ const externallyTerminated = await originalReattachAll(records);
4140
+ for (const record of records) {
4141
+ const handle = executor.get(record.vmId);
4142
+ if (handle) attach(handle);
4143
+ }
4144
+ return externallyTerminated;
4145
+ };
3789
4146
  const originalDestroy = executor.destroy.bind(executor);
3790
4147
  executor.destroy = async (vmId) => {
3791
4148
  const entry = relays.get(vmId);