From a98d71b0da17484471507d6f5f111c2ef1ad5d25 Mon Sep 17 00:00:00 2001 From: Shravan Sunder Date: Mon, 13 Apr 2026 22:04:17 -0400 Subject: [PATCH 1/2] feat: add reconnect-ready QEMU transports and VM.attach() for host crash recovery Add support for re-owning a running QEMU VM after a host process crash via deterministic transport identity and a new VM.attach() API. Changes: - Derive all QEMU transport socket paths from a stable runtime ID under a per-VM runtime directory (/tmp/gondolin-runtime/{id}/) - Add reconnect-ms to QEMU chardev/netdev args when QEMU >= 9.2 - Persist runtime metadata (runtime.json) after VM start for discovery - Add VM.attach({ id, ...options }) that validates metadata, verifies the orphaned QEMU is alive, and rebuilds host-side listeners - SandboxController attach mode: skip spawn, verify PID liveness, terminate attached process on close with SIGTERM/SIGKILL escalation - Shared isProcessAlive/signalProcess utilities with proper ESRCH/EPERM discrimination - Guest sandboxd: non-blocking open for virtio ports with errdefer fd cleanup to avoid blocking on dead ports during reconnect window - VMAttachOptions uses Pick to exclude inapplicable fields - VMConstructionOptions as discriminated union for type-safe attach/create Closes #88 --- guest/src/sandboxfs/main.zig | 22 +- host/package.json | 1 + host/scripts/validate-vm-attach-reconnect.ts | 195 +++++++++++++++ host/src/index.ts | 1 + host/src/sandbox/controller.ts | 92 ++++++- host/src/sandbox/krun-controller.ts | 4 + host/src/sandbox/server-options.ts | 88 +++++-- host/src/sandbox/server.ts | 11 + host/src/utils/process.ts | 37 +++ host/src/vm/core.ts | 159 +++++++++++- host/src/vm/types.ts | 21 ++ host/test/helpers/vm-attach-helper.ts | 60 +++++ host/test/qemu-arch-mismatch.test.ts | 63 +++++ host/test/sandbox-controller.test.ts | 132 ++++++++++ host/test/vm-internals.test.ts | 244 +++++++++++++++++++ 15 files changed, 1092 insertions(+), 38 deletions(-) create mode 100644 host/scripts/validate-vm-attach-reconnect.ts create mode 100644 host/src/utils/process.ts create mode 100644 host/test/helpers/vm-attach-helper.ts diff --git a/guest/src/sandboxfs/main.zig b/guest/src/sandboxfs/main.zig index 6c5b8e3a..b4cfb811 100644 --- a/guest/src/sandboxfs/main.zig +++ b/guest/src/sandboxfs/main.zig @@ -1122,16 +1122,30 @@ fn openRpcPort(path: []const u8) ?std.posix.fd_t { const expected = std.fs.path.basename(path); var attempts: usize = 0; while (attempts < 50) : (attempts += 1) { - if (std.posix.open(path, .{ .ACCMODE = .RDWR, .CLOEXEC = true }, 0)) |fd| { + if (tryOpenRpcPath(path) catch null) |fd| { return fd; - } else |_| { - if (openVirtioPortByName(expected)) |fd| return fd; } + if (openVirtioPortByName(expected)) |fd| return fd; std.posix.nanosleep(0, 100 * std.time.ns_per_ms); } return null; } +fn tryOpenRpcPath(path: []const u8) !?std.posix.fd_t { + const fd = std.posix.open(path, .{ .ACCMODE = .RDWR, .NONBLOCK = true, .CLOEXEC = true }, 0) catch |err| switch (err) { + error.FileNotFound, error.NoDevice => return null, + else => return err, + }; + errdefer std.posix.close(fd); + + const original_flags = try std.posix.fcntl(fd, std.posix.F.GETFL, 0); + const nonblock_flag_u32: u32 = @bitCast(std.posix.O{ .NONBLOCK = true }); + const nonblock_flag: usize = @intCast(nonblock_flag_u32); + _ = try std.posix.fcntl(fd, std.posix.F.SETFL, original_flags & ~nonblock_flag); + + return fd; +} + fn openVirtioPortByName(expected: []const u8) ?std.posix.fd_t { var dev_dir = std.fs.openDirAbsolute("/dev", .{ .iterate = true }) catch return null; defer dev_dir.close(); @@ -1142,7 +1156,7 @@ fn openVirtioPortByName(expected: []const u8) ?std.posix.fd_t { if (!std.mem.startsWith(u8, entry.name, "vport")) continue; if (!virtioPortMatches(entry.name, expected)) continue; const path = std.fmt.bufPrint(&path_buf, "/dev/{s}", .{entry.name}) catch continue; - return std.posix.open(path, .{ .ACCMODE = .RDWR, .CLOEXEC = true }, 0) catch continue; + return tryOpenRpcPath(path) catch continue; } return null; diff --git a/host/package.json b/host/package.json index 2c3d9b5d..c1570894 100644 --- a/host/package.json +++ b/host/package.json @@ -23,6 +23,7 @@ "start": "node dist/bin/gondolin.js exec", "dev": "node bin/gondolin.ts exec", "test": "node --test test/*.test.ts", + "test:attach-reconnect": "node ./scripts/validate-vm-attach-reconnect.ts", "test:backend-parity": "node --test test/backend-parity.test.ts", "bash": "node bin/gondolin.ts bash", "gondolin": "node bin/gondolin.ts", diff --git a/host/scripts/validate-vm-attach-reconnect.ts b/host/scripts/validate-vm-attach-reconnect.ts new file mode 100644 index 00000000..f1b79ec5 --- /dev/null +++ b/host/scripts/validate-vm-attach-reconnect.ts @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import net from "node:net"; +import path from "node:path"; + +import { VM } from "../src/vm/core.ts"; +import { __test as serverOptionsTest } from "../src/sandbox/server-options.ts"; +import { shouldSkipVmTests } from "../test/helpers/vm-fixture.ts"; + +function resolveHelperPath(): string { + return path.resolve(import.meta.dirname, "..", "test", "helpers", "vm-attach-helper.ts"); +} + +function waitForLine(child: ReturnType): Promise { + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + + const onStdout = (chunk: Buffer | string) => { + stdout += chunk.toString(); + const newline = stdout.indexOf("\n"); + if (newline === -1) return; + + cleanup(); + resolve(stdout.slice(0, newline)); + }; + + const onStderr = (chunk: Buffer | string) => { + stderr += chunk.toString(); + }; + + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + reject( + new Error( + `helper exited before announcing vm id (code=${String(code)} signal=${String(signal)} stderr=${stderr.trim()})`, + ), + ); + }; + + const cleanup = () => { + child.stdout?.off("data", onStdout); + child.stderr?.off("data", onStderr); + child.off("exit", onExit); + }; + + child.stdout?.on("data", onStdout); + child.stderr?.on("data", onStderr); + child.on("exit", onExit); + }); +} + +function waitForExit(child: ReturnType): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + child.once("exit", () => resolve()); + }); +} + +function disposeChildStreams(child: ReturnType): void { + try { + child.stdout?.destroy(); + } catch { + // ignore + } + try { + child.stderr?.destroy(); + } catch { + // ignore + } +} + +function listenLocalServer(): Promise<{ + close: () => Promise; + port: number; +}> { + return new Promise((resolve, reject) => { + const server = net.createServer((socket) => { + socket.end( + "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Length: 9\r\n" + + "Connection: close\r\n\r\n" + + "attach-ok", + ); + }); + + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("failed to bind local test server")); + return; + } + resolve({ + port: address.port, + close: async () => + await new Promise((done) => { + server.close(() => done()); + }), + }); + }); + }); +} + +async function main(): Promise { + if (shouldSkipVmTests()) { + throw new Error("hardware virtualization unavailable"); + } + + const qemuBinary = process.arch === "arm64" ? "qemu-system-aarch64" : "qemu-system-x86_64"; + if (!(serverOptionsTest as any).qemuSupportsReconnect(qemuBinary)) { + throw new Error(`${qemuBinary} does not support reconnect-ms`); + } + + const httpServer = await listenLocalServer(); + let attachedVm: VM | null = null; + const helper = spawn(process.execPath, [resolveHelperPath(), String(httpServer.port)], { + cwd: path.resolve(import.meta.dirname, ".."), + stdio: ["ignore", "pipe", "pipe"], + }); + + try { + const line = await waitForLine(helper); + const announced = JSON.parse(line) as { id: string }; + assert.ok(typeof announced.id === "string" && announced.id.length > 0); + console.log(`helper vm id: ${announced.id}`); + + helper.kill("SIGKILL"); + await waitForExit(helper); + disposeChildStreams(helper); + + attachedVm = await VM.attach({ + id: announced.id, + sandbox: { + console: "none", + dns: { + mode: "synthetic", + syntheticHostMapping: "per-host", + }, + tcp: { + hosts: { + "local.test:8080": `127.0.0.1:${httpServer.port}`, + }, + }, + }, + vfs: null, + }); + + await attachedVm.start(); + + const marker = await attachedVm.exec(["/bin/cat", "/tmp/reconnect-marker"]); + assert.equal(marker.exitCode, 0); + assert.equal(marker.stdout.trim(), "reconnect-ok"); + + const pidRead = await attachedVm.exec(["/bin/cat", "/tmp/reconnect-worker.pid"]); + assert.equal(pidRead.exitCode, 0); + const workerPid = pidRead.stdout.trim(); + assert.match(workerPid, /^[0-9]+$/u); + + const workerAlive = await attachedVm.exec([ + "/bin/sh", + "-lc", + `kill -0 ${workerPid}`, + ]); + assert.equal(workerAlive.exitCode, 0); + + const networkProbe = await attachedVm.exec([ + "/bin/sh", + "-lc", + "curl -fsS http://local.test:8080/ || wget -qO- http://local.test:8080/", + ]); + assert.equal(networkProbe.exitCode, 0); + assert.equal(networkProbe.stdout.trim(), "attach-ok"); + + console.log("reconnect validation passed"); + } finally { + if (attachedVm) { + await attachedVm.close().catch(() => undefined); + } + helper.kill("SIGKILL"); + await waitForExit(helper); + disposeChildStreams(helper); + await httpServer.close(); + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.stack ?? error.message : String(error); + console.error(message); + process.exit(1); +}); diff --git a/host/src/index.ts b/host/src/index.ts index 8feb1bd5..6293b9d0 100644 --- a/host/src/index.ts +++ b/host/src/index.ts @@ -28,6 +28,7 @@ export { type VmFsWriteFileOptions, type VmFsDeleteOptions, } from "./vm/core.ts"; +export { type VMAttachOptions } from "./vm/types.ts"; export { VmCheckpoint, type VmCheckpointData } from "./checkpoint.ts"; export { type ExecOptions, type ExecResult, type ExecProcess } from "./exec.ts"; diff --git a/host/src/sandbox/controller.ts b/host/src/sandbox/controller.ts index be2c8c3d..4d70e546 100644 --- a/host/src/sandbox/controller.ts +++ b/host/src/sandbox/controller.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "events"; import child_process from "child_process"; import type { ChildProcess } from "child_process"; import fs from "fs"; +import { isProcessAlive, signalProcess } from "../utils/process.ts"; const activeChildren = new Set(); let exitHookRegistered = false; @@ -76,12 +77,18 @@ export type SandboxConfig = { accel?: string; /** qemu cpu model */ cpu?: string; + /** whether qemu client sockets should reconnect to recreated host listeners */ + reconnectCapable?: boolean; + /** reconnect interval for qemu client sockets in `ms` */ + reconnectMs?: number; /** guest console mode */ console?: "stdio" | "none"; /** qemu net socket path */ netSocketPath?: string; /** guest mac address */ netMac?: string; + /** existing qemu pid to re-own instead of spawning a new process */ + attachedPid?: number; /** whether to restart the vm automatically on exit */ autoRestart: boolean; }; @@ -92,6 +99,7 @@ export type SandboxLogStream = "stdout" | "stderr"; export class SandboxController extends EventEmitter { private child: ChildProcess | null = null; + private attachedPid: number | null = null; private state: SandboxState = "stopped"; private restartTimer: NodeJS.Timeout | null = null; private manualStop = false; @@ -100,6 +108,10 @@ export class SandboxController extends EventEmitter { constructor(config: SandboxConfig) { super(); this.config = config; + this.attachedPid = + Number.isInteger(config.attachedPid) && (config.attachedPid ?? 0) > 0 + ? (config.attachedPid ?? null) + : null; } setAppend(append: string) { @@ -110,12 +122,28 @@ export class SandboxController extends EventEmitter { return this.state; } + getRuntimePid() { + return this.child?.pid ?? this.attachedPid; + } + async start() { - if (this.child) return; + if (this.child || (this.attachedPid !== null && this.state === "running")) { + return; + } this.manualStop = false; this.setState("starting"); + if (this.attachedPid !== null) { + if (!isProcessAlive(this.attachedPid)) { + this.attachedPid = null; + this.setState("stopped"); + throw new Error("attached qemu process is not running"); + } + this.setState("running"); + return; + } + const args = buildQemuArgs(this.config); this.child = child_process.spawn(this.config.qemuPath, args, { stdio: ["ignore", "pipe", "pipe"], @@ -155,6 +183,20 @@ export class SandboxController extends EventEmitter { } async close() { + if (this.child === null && this.attachedPid !== null) { + this.manualStop = true; + + if (this.restartTimer) { + clearTimeout(this.restartTimer); + this.restartTimer = null; + } + + await terminateAttachedProcess(this.attachedPid); + this.attachedPid = null; + this.setState("stopped"); + return; + } + if (!this.child) return; const child = this.child; this.child = null; @@ -295,6 +337,39 @@ export class SandboxController extends EventEmitter { } } +async function terminateAttachedProcess(pid: number): Promise { + const closeTimeoutMs = 10_000; + const sigkillAfterMs = 3_000; + const pollIntervalMs = 50; + + if (signalProcess(pid, "SIGTERM") === "missing") { + return; + } + + const startedAt = Date.now(); + let sigkilled = false; + while (Date.now() - startedAt < closeTimeoutMs) { + if (!isProcessAlive(pid)) { + return; + } + + if (!sigkilled && Date.now() - startedAt >= sigkillAfterMs) { + if (signalProcess(pid, "SIGKILL") === "missing") { + return; + } + sigkilled = true; + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + if (isProcessAlive(pid)) { + if (signalProcess(pid, "SIGKILL") === "missing") { + return; + } + } +} + function buildQemuArgs(config: SandboxConfig) { const args: string[] = [ "-nodefaults", @@ -366,23 +441,28 @@ function buildQemuArgs(config: SandboxConfig) { const serialDev = useMmio ? "virtio-serial-device" : "virtio-serial-pci"; const netDev = useMmio ? "virtio-net-device" : "virtio-net-pci"; + const reconnectSuffix = + config.reconnectCapable && config.reconnectMs && config.reconnectMs > 0 + ? `,reconnect-ms=${Math.trunc(config.reconnectMs)}` + : ""; + args.push("-object", "rng-random,filename=/dev/urandom,id=rng0"); args.push("-device", `${rngDev},rng=rng0`); args.push( "-chardev", - `socket,id=virtiocon0,path=${config.virtioSocketPath},server=off`, + `socket,id=virtiocon0,path=${config.virtioSocketPath},server=off${reconnectSuffix}`, ); args.push( "-chardev", - `socket,id=virtiofs0,path=${config.virtioFsSocketPath},server=off`, + `socket,id=virtiofs0,path=${config.virtioFsSocketPath},server=off${reconnectSuffix}`, ); args.push( "-chardev", - `socket,id=virtiossh0,path=${config.virtioSshSocketPath},server=off`, + `socket,id=virtiossh0,path=${config.virtioSshSocketPath},server=off${reconnectSuffix}`, ); args.push( "-chardev", - `socket,id=virtioingress0,path=${config.virtioIngressSocketPath},server=off`, + `socket,id=virtioingress0,path=${config.virtioIngressSocketPath},server=off${reconnectSuffix}`, ); args.push("-device", `${serialDev},id=virtio-serial0`); @@ -406,7 +486,7 @@ function buildQemuArgs(config: SandboxConfig) { if (config.netSocketPath) { args.push( "-netdev", - `stream,id=net0,server=off,addr.type=unix,addr.path=${config.netSocketPath}`, + `stream,id=net0,server=off,addr.type=unix,addr.path=${config.netSocketPath}${reconnectSuffix}`, ); const mac = config.netMac ?? "02:00:00:00:00:01"; args.push("-device", `${netDev},netdev=net0,mac=${mac}`); diff --git a/host/src/sandbox/krun-controller.ts b/host/src/sandbox/krun-controller.ts index 641e8316..d473cdea 100644 --- a/host/src/sandbox/krun-controller.ts +++ b/host/src/sandbox/krun-controller.ts @@ -118,6 +118,10 @@ export class KrunController extends EventEmitter { return this.state; } + getRuntimePid() { + return this.child?.pid ?? null; + } + async start() { if (this.child) return; diff --git a/host/src/sandbox/server-options.ts b/host/src/sandbox/server-options.ts index 2e9ba61d..6338cf9c 100644 --- a/host/src/sandbox/server-options.ts +++ b/host/src/sandbox/server-options.ts @@ -51,6 +51,7 @@ const DEFAULT_MAX_STDIN_BYTES = 64 * 1024; const DEFAULT_MAX_QUEUED_STDIN_BYTES = 8 * 1024 * 1024; const DEFAULT_MAX_TOTAL_QUEUED_STDIN_BYTES = 32 * 1024 * 1024; const DEFAULT_MAX_QUEUED_EXECS = 64; +const DEFAULT_QEMU_RECONNECT_MS = 5000; /** * sandbox server options @@ -202,6 +203,10 @@ export type ResolvedSandboxServerOptions = { virtioIngressSocketPath: string; /** qemu net socket path */ netSocketPath: string; + /** stable runtime identifier */ + runtimeId: string; + /** per-vm runtime directory */ + runtimeDir: string; /** guest mac address */ netMac: string; /** whether networking is enabled */ @@ -217,6 +222,10 @@ export type ResolvedSandboxServerOptions = { accel?: string; /** qemu cpu model */ cpu?: string; + /** whether the local qemu supports reconnect-ready client sockets */ + reconnectCapable: boolean; + /** reconnect interval for qemu client sockets in `ms` */ + reconnectMs?: number; /** guest console mode */ console?: "stdio" | "none"; /** whether to restart the vm automatically on exit */ @@ -768,8 +777,42 @@ function detectGuestArchFromManifest(assets: Partial): { type ResolveSandboxServerOptionsDeps = { /** test-only override for default krun runner resolution */ resolveDefaultKrunRunnerPath?: () => string; + /** internal runtime id override for deterministic transport paths */ + qemuRuntimeId?: string; + /** test-only override for qemu reconnect capability detection */ + qemuSupportsReconnect?: (qemuPath: string) => boolean; }; +function parseQemuVersion(qemuVersionOutput: string): { + major: number; + minor: number; +} | null { + const match = /version\s+(\d+)\.(\d+)/iu.exec(qemuVersionOutput); + if (!match) return null; + + const major = Number(match[1]); + const minor = Number(match[2]); + if (!Number.isInteger(major) || !Number.isInteger(minor)) return null; + + return { major, minor }; +} + +function qemuSupportsReconnect(qemuPath: string): boolean { + try { + const output = execFileSync(qemuPath, ["--version"], { + encoding: "utf8", + stdio: "pipe", + }); + const version = parseQemuVersion(output); + if (!version) return false; + if (version.major > 9) return true; + if (version.major === 9 && version.minor >= 2) return true; + return false; + } catch { + return false; + } +} + export function resolveSandboxServerOptions( options: SandboxServerOptions = {}, assets?: GuestAssets, @@ -807,26 +850,13 @@ export function resolveSandboxServerOptions( // we are running into length limits on macos on the default temp dir const tmpDir = process.platform === "darwin" ? "/tmp" : os.tmpdir(); - const defaultVirtio = path.resolve( - tmpDir, - `gondolin-virtio-${randomUUID().slice(0, 8)}.sock`, - ); - const defaultVirtioFs = path.resolve( - tmpDir, - `gondolin-virtio-fs-${randomUUID().slice(0, 8)}.sock`, - ); - const defaultVirtioSsh = path.resolve( - tmpDir, - `gondolin-virtio-ssh-${randomUUID().slice(0, 8)}.sock`, - ); - const defaultVirtioIngress = path.resolve( - tmpDir, - `gondolin-virtio-ingress-${randomUUID().slice(0, 8)}.sock`, - ); - const defaultNetSock = path.resolve( - tmpDir, - `gondolin-net-${randomUUID().slice(0, 8)}.sock`, - ); + const runtimeId = deps.qemuRuntimeId ?? randomUUID(); + const runtimeDir = path.resolve(tmpDir, "gondolin-runtime", runtimeId); + const defaultVirtio = path.join(runtimeDir, "virtio.sock"); + const defaultVirtioFs = path.join(runtimeDir, "virtio-fs.sock"); + const defaultVirtioSsh = path.join(runtimeDir, "virtio-ssh.sock"); + const defaultVirtioIngress = path.join(runtimeDir, "virtio-ingress.sock"); + const defaultNetSock = path.join(runtimeDir, "net.sock"); const defaultNetMac = "02:00:00:00:00:01"; const hostArch = getHostNodeArchCached(); @@ -849,6 +879,8 @@ export function resolveSandboxServerOptions( const envVmm = normalizeVmm(process.env.GONDOLIN_VMM); const vmm = explicitVmm ?? envVmm ?? "qemu"; let qemuPath = options.qemuPath ?? defaultQemuForHostArch; + const qemuSupportsReconnectFn = + deps.qemuSupportsReconnect ?? qemuSupportsReconnect; const resolveDefaultKrunRunnerPathFn = deps.resolveDefaultKrunRunnerPath ?? resolveDefaultKrunRunnerPath; const krunRunnerPath = @@ -963,6 +995,9 @@ export function resolveSandboxServerOptions( options.maxTotalQueuedStdinBytes ?? DEFAULT_MAX_TOTAL_QUEUED_STDIN_BYTES, maxQueuedStdinBytes, ); + const reconnectCapable = + vmm === "qemu" ? qemuSupportsReconnectFn(qemuPath) : false; + const reconnectMs = reconnectCapable ? DEFAULT_QEMU_RECONNECT_MS : undefined; return { vmm, @@ -982,6 +1017,8 @@ export function resolveSandboxServerOptions( virtioIngressSocketPath: options.virtioIngressSocketPath ?? defaultVirtioIngress, netSocketPath: options.netSocketPath ?? defaultNetSock, + runtimeId, + runtimeDir, netMac: options.netMac ?? defaultNetMac, netEnabled: options.netEnabled ?? true, allowWebSockets: options.allowWebSockets ?? true, @@ -989,6 +1026,8 @@ export function resolveSandboxServerOptions( machineType: options.machineType, accel: options.accel, cpu: options.cpu, + reconnectCapable, + reconnectMs, console: options.console, autoRestart: options.autoRestart ?? false, append: options.append, @@ -1016,10 +1055,11 @@ export function resolveSandboxServerOptions( */ export async function resolveSandboxServerOptionsAsync( options: SandboxServerOptions = {}, + deps: ResolveSandboxServerOptionsDeps = {}, ): Promise { // Explicit object imagePath is already fully resolved. if (options.imagePath && typeof options.imagePath === "object") { - return resolveSandboxServerOptions(options); + return resolveSandboxServerOptions(options, undefined, deps); } // String image selectors may require pulling from the builtin registry. @@ -1028,14 +1068,16 @@ export async function resolveSandboxServerOptionsAsync( return resolveSandboxServerOptions({ ...options, imagePath: resolvedImage.assetDir, - }); + }, undefined, deps); } const assets = await ensureGuestAssets(); - return resolveSandboxServerOptions(options, assets); + return resolveSandboxServerOptions(options, assets, deps); } export const __test = { + parseQemuVersion, + qemuSupportsReconnect, probeKrunRunnerCandidate, resolvePackagedKrunRunnerPath, resolveDefaultKrunRunnerPath, diff --git a/host/src/sandbox/server.ts b/host/src/sandbox/server.ts index 6028ad06..4ca7cf41 100644 --- a/host/src/sandbox/server.ts +++ b/host/src/sandbox/server.ts @@ -124,11 +124,14 @@ type SandboxControllerLike = { error?: Error; }) => void, ): unknown; + getRuntimePid(): number | null; }; type SandboxServerInternalOptions = { /** qemu root disk volatility mode */ qemuRootDiskVolatileMode?: "snapshot"; + /** existing qemu pid to re-own instead of spawning */ + attachedQemuPid?: number; }; export class SandboxServer extends EventEmitter { @@ -268,6 +271,11 @@ export class SandboxServer extends EventEmitter { return this.options.qemuPath; } + /** @internal active vm process pid when known */ + getRuntimePid(): number | null { + return this.controller.getRuntimePid(); + } + /** * Create a SandboxServer, downloading guest assets if needed. * @@ -387,6 +395,9 @@ export class SandboxServer extends EventEmitter { machineType: this.options.machineType, accel: this.options.accel, cpu: this.options.cpu, + reconnectCapable: this.options.reconnectCapable, + reconnectMs: this.options.reconnectMs, + attachedPid: this.internalOptions.attachedQemuPid, console: this.options.console, autoRestart: this.options.autoRestart, }; diff --git a/host/src/utils/process.ts b/host/src/utils/process.ts new file mode 100644 index 00000000..d8e2e84e --- /dev/null +++ b/host/src/utils/process.ts @@ -0,0 +1,37 @@ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : null; + if (code === "ESRCH") { + return false; + } + if (code === "EPERM") { + return true; + } + return false; + } +} + +export function signalProcess( + pid: number, + signal: NodeJS.Signals | number, +): "signaled" | "missing" { + try { + process.kill(pid, signal); + return "signaled"; + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : null; + if (code === "ESRCH") { + return "missing"; + } + throw error; + } +} diff --git a/host/src/vm/core.ts b/host/src/vm/core.ts index 675a3215..9816f287 100644 --- a/host/src/vm/core.ts +++ b/host/src/vm/core.ts @@ -48,7 +48,12 @@ import { unregisterSession, } from "../session-registry.ts"; import { createMitmCaProvider, resolveMitmMounts } from "./mitm-vfs.ts"; -import type { EnvInput, VMOptions, VmVfsOptions } from "./types.ts"; +import type { + EnvInput, + VMAttachOptions, + VMOptions, + VmVfsOptions, +} from "./types.ts"; import { buildShellEnv, envInputToEntries, @@ -57,6 +62,7 @@ import { parseEnvEntry, resolveEnvNumber, } from "../utils/env.ts"; +import { isProcessAlive } from "../utils/process.ts"; import { defaultDebugLog, resolveDebugFlags, @@ -102,6 +108,7 @@ import { const MAX_REQUEST_ID = 0xffffffff; const DEFAULT_STDIN_CHUNK = 32 * 1024; const DEFAULT_VM_START_TIMEOUT_MS = 120000; +const VM_RUNTIME_METADATA_FILE = "runtime.json"; const VM_START_TIMEOUT_MS = resolveEnvNumber( "GONDOLIN_START_TIMEOUT_MS", DEFAULT_VM_START_TIMEOUT_MS, @@ -126,6 +133,93 @@ function normalizeStartTimeoutMs( return Math.max(0, Math.trunc(value)); } + +type VmRuntimeMetadata = { + id: string; + qemuPid: number; + qemuPath: string; + createdAt: string; + reconnectCapable: boolean; +}; + +type VMConstructionOptions = + | { + mode: "attach"; + attachedQemuPid: number; + vmId: string; + } + | { + mode: "create"; + vmId: string; + }; + +function resolveVmRuntimeDir(id: string): string { + const tmpDir = process.platform === "darwin" ? "/tmp" : os.tmpdir(); + return path.resolve(tmpDir, "gondolin-runtime", id); +} + +function runtimeMetadataPath(runtimeDir: string): string { + return path.join(runtimeDir, VM_RUNTIME_METADATA_FILE); +} + +function readVmRuntimeMetadata(id: string): VmRuntimeMetadata { + const runtimeDir = resolveVmRuntimeDir(id); + const metadataPath = runtimeMetadataPath(runtimeDir); + if (!fs.existsSync(metadataPath)) { + throw new Error(`runtime metadata not found for vm '${id}'`); + } + + let parsed: Partial; + try { + parsed = JSON.parse( + fs.readFileSync(metadataPath, "utf8"), + ) as Partial; + } catch { + throw new Error( + `runtime metadata at ${metadataPath} is corrupt for vm '${id}'`, + ); + } + if ( + parsed.id !== id || + !Number.isInteger(parsed.qemuPid) || + (parsed.qemuPid ?? 0) <= 0 || + typeof parsed.qemuPath !== "string" || + typeof parsed.createdAt !== "string" || + typeof parsed.reconnectCapable !== "boolean" + ) { + throw new Error(`runtime metadata is invalid for vm '${id}'`); + } + + return parsed as VmRuntimeMetadata; +} + +function writeVmRuntimeMetadata( + resolved: ResolvedSandboxServerOptions, + qemuPid: number, +): void { + fs.mkdirSync(resolved.runtimeDir, { recursive: true }); + const metadata: VmRuntimeMetadata = { + id: resolved.runtimeId, + qemuPid, + qemuPath: resolved.qemuPath, + createdAt: new Date().toISOString(), + reconnectCapable: resolved.reconnectCapable, + }; + fs.writeFileSync( + runtimeMetadataPath(resolved.runtimeDir), + JSON.stringify(metadata, null, 2) + "\n", + "utf8", + ); +} + +function removeVmRuntimeMetadata(runtimeDir: string): void { + try { + fs.rmSync(runtimeDir, { recursive: true, force: true }); + } catch { + // ignore + } +} + const DEFAULT_VFS_READY_TIMEOUT_MS = 30000; const VFS_READY_SLEEP_SECONDS = resolveEnvNumber( "GONDOLIN_VFS_READY_SLEEP_SECONDS", @@ -278,6 +372,7 @@ export class VM { * @returns A configured VM instance */ static async create(options: VMOptions = {}): Promise { + const vmId = randomUUID(); // Resolve sandbox options with async asset fetching const sandboxOptions: SandboxServerOptions = { ...options.sandbox }; @@ -325,10 +420,40 @@ export class VM { // Resolve options with asset fetching const resolvedSandboxOptions = - await resolveSandboxServerOptionsAsync(sandboxOptions); + await resolveSandboxServerOptionsAsync(sandboxOptions, { + qemuRuntimeId: vmId, + }); // Create VM with pre-resolved options - return new VM(options, resolvedSandboxOptions); + return new VM(options, resolvedSandboxOptions, { + mode: "create", + vmId, + }); + } + + static async attach(options: VMAttachOptions): Promise { + const runtimeMetadata = readVmRuntimeMetadata(options.id); + if (!runtimeMetadata.reconnectCapable) { + throw new Error( + `vm '${options.id}' was created without reconnect support and cannot be attached`, + ); + } + if (!isProcessAlive(runtimeMetadata.qemuPid)) { + throw new Error(`runtime metadata is stale for vm '${options.id}'`); + } + + const resolvedSandboxOptions = await resolveSandboxServerOptionsAsync( + { ...options.sandbox }, + { + qemuRuntimeId: options.id, + }, + ); + + return new VM(options, resolvedSandboxOptions, { + mode: "attach", + attachedQemuPid: runtimeMetadata.qemuPid, + vmId: options.id, + }); } /** @@ -344,8 +469,15 @@ export class VM { constructor( options: VMOptions = {}, resolvedSandboxOptions?: ResolvedSandboxServerOptions, + constructionOptions?: VMConstructionOptions, ) { - this.id = randomUUID(); + const resolvedConstructionOptions = + constructionOptions ?? + ({ + mode: "create", + vmId: resolvedSandboxOptions?.runtimeId ?? randomUUID(), + } satisfies VMConstructionOptions); + this.id = resolvedConstructionOptions.vmId; this.baseOptionsForClone = { ...options }; this.autoStart = options.autoStart ?? true; this.startTimeoutMs = normalizeStartTimeoutMs(options.startTimeoutMs); @@ -471,7 +603,9 @@ export class VM { // Resolve sandbox options (sync) if needed so we can prepare the root disk. const resolved = resolvedSandboxOptions ? ({ ...resolvedSandboxOptions } as ResolvedSandboxServerOptions) - : resolveSandboxServerOptions(sandboxOptions); + : resolveSandboxServerOptions(sandboxOptions, undefined, { + qemuRuntimeId: this.id, + }); // Merge VFS provider into resolved options if (this.vfs) { @@ -597,6 +731,10 @@ export class VM { this.resolvedSandboxOptions = resolved; this.server = new SandboxServer(resolved, { + attachedQemuPid: + resolvedConstructionOptions.mode === "attach" + ? resolvedConstructionOptions.attachedQemuPid + : undefined, qemuRootDiskVolatileMode: this.rootDisk?.snapshot ? "snapshot" : undefined, @@ -1261,6 +1399,16 @@ fi this.ensureStartupGeneration(startupGeneration); await this.ensureSessionIpc(startupGeneration); + this.ensureStartupGeneration(startupGeneration); + const runtimePid = this.server?.getRuntimePid(); + if ( + this.resolvedSandboxOptions.vmm === "qemu" && + runtimePid !== null && + runtimePid !== undefined + ) { + writeVmRuntimeMetadata(this.resolvedSandboxOptions, runtimePid); + } + this.ensureStartupGeneration(startupGeneration); }, "guest readiness", @@ -1435,6 +1583,7 @@ fi if (this.server) { await this.server.close(); } + removeVmRuntimeMetadata(this.resolvedSandboxOptions.runtimeDir); if (this.vfs) { await this.vfs.close(); } diff --git a/host/src/vm/types.ts b/host/src/vm/types.ts index e7e586d0..dc48e6e6 100644 --- a/host/src/vm/types.ts +++ b/host/src/vm/types.ts @@ -63,3 +63,24 @@ export type VMOptions = { /** debug log callback */ debugLog?: DebugLogFn | null; }; + +export type VMAttachOptions = { + /** existing vm session identifier */ + id: string; +} & Pick< + VMOptions, + | "allowWebSockets" + | "debugLog" + | "dns" + | "env" + | "fetch" + | "httpHooks" + | "maxHttpBodyBytes" + | "maxHttpResponseBodyBytes" + | "sandbox" + | "sessionLabel" + | "ssh" + | "startTimeoutMs" + | "tcp" + | "vfs" +>; diff --git a/host/test/helpers/vm-attach-helper.ts b/host/test/helpers/vm-attach-helper.ts new file mode 100644 index 00000000..f806f61b --- /dev/null +++ b/host/test/helpers/vm-attach-helper.ts @@ -0,0 +1,60 @@ +import { VM } from "../../src/vm/core.ts"; + +function parsePort(raw: string | undefined): number { + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0 || value > 65535) { + throw new Error(`invalid port: ${String(raw)}`); + } + return value; +} + +async function main(): Promise { + const mappedPort = parsePort(process.argv[2]); + + const vm = await VM.create({ + sandbox: { + console: "none", + dns: { + mode: "synthetic", + syntheticHostMapping: "per-host", + }, + tcp: { + hosts: { + "local.test:8080": `127.0.0.1:${mappedPort}`, + }, + }, + }, + vfs: null, + }); + + await vm.start(); + + const setup = await vm.exec([ + "/bin/sh", + "-lc", + "echo reconnect-ok > /tmp/reconnect-marker; while true; do sleep 60; done >/dev/null 2>&1 & echo $! > /tmp/reconnect-worker.pid", + ]); + if (setup.exitCode !== 0) { + throw new Error(`guest setup failed: ${setup.stderr}`); + } + + const networkProbe = await vm.exec([ + "/bin/sh", + "-lc", + "curl -fsS http://local.test:8080/ || wget -qO- http://local.test:8080/", + ]); + if (networkProbe.exitCode !== 0 || networkProbe.stdout.trim() !== "attach-ok") { + throw new Error( + `guest network probe failed exit=${networkProbe.exitCode}: ${networkProbe.stderr || networkProbe.stdout}`, + ); + } + + process.stdout.write(`${JSON.stringify({ id: vm.id })}\n`); + setInterval(() => {}, 1000); +} + +main().catch((error) => { + const message = error instanceof Error ? error.stack ?? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exit(1); +}); diff --git a/host/test/qemu-arch-mismatch.test.ts b/host/test/qemu-arch-mismatch.test.ts index a956c893..5428f0e1 100644 --- a/host/test/qemu-arch-mismatch.test.ts +++ b/host/test/qemu-arch-mismatch.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import test from "node:test"; import { resolveSandboxServerOptions } from "../src/sandbox/server-options.ts"; +import { __test as serverOptionsTest } from "../src/sandbox/server-options.ts"; function makeTempAssetsDir( arch: "aarch64" | "x86_64", @@ -93,6 +94,29 @@ test("resolveSandboxServerOptions fails fast on guest/qemu arch mismatch", () => } }); +test("parseQemuVersion accepts 9.2 and newer reconnect-capable versions", () => { + assert.deepEqual( + (serverOptionsTest as any).parseQemuVersion( + "QEMU emulator version 9.2.0\nCopyright", + ), + { major: 9, minor: 2 }, + ); + assert.deepEqual( + (serverOptionsTest as any).parseQemuVersion( + "QEMU emulator version 10.2.2", + ), + { major: 10, minor: 2 }, + ); +}); + +test("parseQemuVersion rejects malformed output", () => { + assert.equal((serverOptionsTest as any).parseQemuVersion("garbage"), null); + assert.equal( + (serverOptionsTest as any).parseQemuVersion("QEMU emulator version 9"), + null, + ); +}); + test("resolveSandboxServerOptions auto-selects qemu binary from guest image arch", () => { const hostArch = process.arch === "arm64" ? "aarch64" : "x86_64"; const guestArch = hostArch === "aarch64" ? "x86_64" : "aarch64"; @@ -177,6 +201,45 @@ test("resolveSandboxServerOptions rejects removed sandbox.rootDiskSnapshot", () } }); +test("resolveSandboxServerOptions derives deterministic runtime socket paths from runtime id", () => { + const hostArch = process.arch === "arm64" ? "aarch64" : "x86_64"; + const dir = makeTempAssetsDir(hostArch); + const tmpDir = process.platform === "darwin" ? "/tmp" : os.tmpdir(); + + try { + const resolved = resolveSandboxServerOptions( + { + imagePath: dir, + }, + undefined, + { + qemuRuntimeId: "vm-reconnect-test", + qemuSupportsReconnect: () => true, + } as any, + ); + + const expectedRuntimeDir = path.resolve( + tmpDir, + "gondolin-runtime", + "vm-reconnect-test", + ); + + assert.equal(resolved.runtimeId, "vm-reconnect-test"); + assert.equal(resolved.runtimeDir, expectedRuntimeDir); + assert.equal(resolved.virtioSocketPath, path.join(expectedRuntimeDir, "virtio.sock")); + assert.equal(resolved.virtioFsSocketPath, path.join(expectedRuntimeDir, "virtio-fs.sock")); + assert.equal(resolved.virtioSshSocketPath, path.join(expectedRuntimeDir, "virtio-ssh.sock")); + assert.equal( + resolved.virtioIngressSocketPath, + path.join(expectedRuntimeDir, "virtio-ingress.sock"), + ); + assert.equal(resolved.netSocketPath, path.join(expectedRuntimeDir, "net.sock")); + assert.equal(resolved.reconnectCapable, true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test("resolveSandboxServerOptions requires manifest krunKernel for vmm=krun", () => { const hostArch = process.arch === "arm64" ? "aarch64" : "x86_64"; const dir = makeTempAssetsDir(hostArch, { includeKrunAssets: false }); diff --git a/host/test/sandbox-controller.test.ts b/host/test/sandbox-controller.test.ts index 6380d290..d18360d2 100644 --- a/host/test/sandbox-controller.test.ts +++ b/host/test/sandbox-controller.test.ts @@ -117,6 +117,26 @@ test("buildQemuArgs: rootDiskVolatileMode=snapshot enables qemu snapshot mode", assert.match(args[driveIndex + 1]!, /snapshot=on/); }); +test("buildQemuArgs: reconnect-capable transports add reconnect-ms to all client sockets", () => { + const args = __test.buildQemuArgs( + makeConfig({ + netSocketPath: "/tmp/net.sock", + reconnectCapable: true as any, + reconnectMs: 5000 as any, + }), + ); + + const chardevs = args.filter((value) => value.includes("socket,id=virtio")); + assert.equal(chardevs.length, 4); + for (const chardev of chardevs) { + assert.match(chardev, /reconnect-ms=5000/); + } + + const netdev = args.find((value) => value.includes("stream,id=net0")); + assert.ok(netdev); + assert.match(netdev!, /reconnect-ms=5000/); +}); + test("SandboxController: start is idempotent while running", async () => { let spawnCalls = 0; const child = new FakeChildProcess(); @@ -134,6 +154,118 @@ test("SandboxController: start is idempotent while running", async () => { assert.equal(spawnCalls, 1); }); +test("SandboxController: attach mode does not spawn qemu and transitions to running", async () => { + let spawnCalls = 0; + mock.method(process, "kill", ((pid: number, signal?: string | number) => { + if (pid === 4242 && signal === 0) { + return true; + } + return true; + }) as typeof process.kill); + mock.method(cp, "spawn", () => { + spawnCalls += 1; + return new FakeChildProcess() as any; + }); + + const controller = new SandboxController(makeConfig({ attachedPid: 4242 })); + + const states: SandboxState[] = []; + controller.on("state", (state) => states.push(state)); + + await controller.start(); + + assert.equal(spawnCalls, 0); + assert.equal(controller.getState(), "running"); + assert.deepEqual(states, ["starting", "running"]); +}); + +test("SandboxController: attach mode close kills the attached qemu pid", async () => { + const killCalls: Array<{ pid: number; signal?: string | number }> = []; + let pidAlive = true; + + mock.method(process, "kill", ((pid: number, signal?: string | number) => { + killCalls.push({ pid, signal }); + if (signal === 0) { + if (pidAlive) return true; + throw new Error("dead"); + } + pidAlive = false; + return true; + }) as typeof process.kill); + + const controller = new SandboxController(makeConfig({ attachedPid: 4242 })); + + await controller.start(); + await controller.close(); + + assert.deepEqual(killCalls[0], { pid: 4242, signal: 0 }); + assert.deepEqual(killCalls[1], { pid: 4242, signal: "SIGTERM" }); + assert.equal(controller.getState(), "stopped"); +}); + +test("SandboxController: attach mode rejects stale pid before reporting running", async () => { + mock.method(process, "kill", ((pid: number, signal?: string | number) => { + if (pid === 4242 && signal === 0) { + const error = new Error("missing") as Error & { code?: string }; + error.code = "ESRCH"; + throw error; + } + return true; + }) as typeof process.kill); + + const controller = new SandboxController(makeConfig({ attachedPid: 4242 })); + await assert.rejects(() => controller.start(), /attached qemu process is not running/); + assert.equal(controller.getState(), "stopped"); +}); + +test("SandboxController: attach mode close escalates to SIGKILL when process survives SIGTERM", async () => { + mock.timers.enable(); + + const killCalls: Array<{ pid: number; signal?: string | number }> = []; + let pidAlive = true; + mock.method(process, "kill", ((pid: number, signal?: string | number) => { + killCalls.push({ pid, signal }); + if (signal === 0) { + if (pidAlive) return true; + const error = new Error("missing") as Error & { code?: string }; + error.code = "ESRCH"; + throw error; + } + if (signal === "SIGKILL") { + pidAlive = false; + } + return true; + }) as typeof process.kill); + + const controller = new SandboxController(makeConfig({ attachedPid: 4242 })); + await controller.start(); + + const closing = controller.close(); + mock.timers.tick(10000); + await closing; + + assert.deepEqual(killCalls[0], { pid: 4242, signal: 0 }); + assert.deepEqual(killCalls[1], { pid: 4242, signal: "SIGTERM" }); + assert.ok( + killCalls.some((entry) => entry.pid === 4242 && entry.signal === "SIGKILL"), + ); +}); + +test("SandboxController: attach mode treats EPERM liveness as alive", async () => { + mock.method(process, "kill", ((pid: number, signal?: string | number) => { + if (pid === 4242 && signal === 0) { + const error = new Error("eperm") as Error & { code?: string }; + error.code = "EPERM"; + throw error; + } + return true; + }) as typeof process.kill); + + const controller = new SandboxController(makeConfig({ attachedPid: 4242 })); + await controller.start(); + assert.equal(controller.getState(), "running"); +}); + test("SandboxController: close sends SIGTERM and does not SIGKILL if child exits quickly", async () => { mock.timers.enable(); diff --git a/host/test/vm-internals.test.ts b/host/test/vm-internals.test.ts index 4b946eb2..6d9feea9 100644 --- a/host/test/vm-internals.test.ts +++ b/host/test/vm-internals.test.ts @@ -16,6 +16,12 @@ function makeTempResolvedServerOptions() { const kernelPath = path.join(dir, "vmlinuz"); const initrdPath = path.join(dir, "initrd"); const rootfsPath = path.join(dir, "rootfs"); + const runtimeId = "test-runtime-id"; + const runtimeDir = path.join( + process.platform === "darwin" ? "/tmp" : os.tmpdir(), + "gondolin-runtime", + runtimeId, + ); fs.writeFileSync(kernelPath, ""); fs.writeFileSync(initrdPath, ""); fs.writeFileSync(rootfsPath, ""); @@ -23,10 +29,13 @@ function makeTempResolvedServerOptions() { return { dir, resolved: { + vmm: "qemu" as const, qemuPath: "qemu-system-aarch64", kernelPath, initrdPath, rootfsPath, + runtimeId, + runtimeDir, memory: "256M", cpus: 1, virtioSocketPath: path.join(dir, "virtio.sock"), @@ -40,6 +49,8 @@ function makeTempResolvedServerOptions() { machineType: "virt", accel: "tcg", cpu: "max", + reconnectCapable: false, + reconnectMs: undefined, console: "none" as const, autoRestart: false, append: "console=ttyAMA0", @@ -100,6 +111,239 @@ function makeVm(options: VMOptions = {}) { }; } +function writeRuntimeMetadata(metadata: Record) { + const runtimeDir = path.join( + process.platform === "darwin" ? "/tmp" : os.tmpdir(), + "gondolin-runtime", + String(metadata.id), + ); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync( + path.join(runtimeDir, "runtime.json"), + JSON.stringify(metadata, null, 2) + "\n", + "utf8", + ); +} + +test("vm internals: constructor derives runtime identity and socket paths from vm id", async () => { + const { dir, resolved } = makeTempResolvedServerOptions(); + + const vm = new VM({ + autoStart: false, + vfs: null, + sandbox: { + imagePath: { + kernelPath: resolved.kernelPath, + initrdPath: resolved.initrdPath, + rootfsPath: resolved.rootfsPath, + }, + }, + }); + + try { + const runtimeOptions = (vm as any).resolvedSandboxOptions; + const expectedRuntimeDir = path.join( + process.platform === "darwin" ? "/tmp" : os.tmpdir(), + "gondolin-runtime", + vm.id, + ); + + assert.equal(runtimeOptions.runtimeId, vm.id); + assert.equal(runtimeOptions.runtimeDir, expectedRuntimeDir); + assert.equal(runtimeOptions.virtioSocketPath, path.join(expectedRuntimeDir, "virtio.sock")); + assert.equal(runtimeOptions.netSocketPath, path.join(expectedRuntimeDir, "net.sock")); + } finally { + await vm.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("vm internals: VM.attach rejects when runtime metadata is missing", async () => { + await assert.rejects( + () => (VM as any).attach({ id: "missing-vm-id", autoStart: false, vfs: null }), + /runtime metadata/i, + ); +}); + +test("vm internals: VM.attach reuses runtime metadata id and attached qemu pid", async () => { + const { dir, resolved } = makeTempResolvedServerOptions(); + const originalKill = process.kill; + let attachedPidAlive = true; + + (process as any).kill = ((pid: number, signal?: string | number) => { + if (pid === 4242 && signal === 0) { + if (attachedPidAlive) return true; + throw new Error("dead"); + } + if (pid === 4242) { + attachedPidAlive = false; + return true; + } + return originalKill.call(process, pid, signal as any); + }) as typeof process.kill; + + writeRuntimeMetadata({ + id: "test-runtime-id", + qemuPid: 4242, + qemuPath: resolved.qemuPath, + createdAt: new Date().toISOString(), + reconnectCapable: true, + }); + + const vm = await VM.attach({ + id: "test-runtime-id", + autoStart: false, + vfs: null, + sandbox: { + imagePath: { + kernelPath: resolved.kernelPath, + initrdPath: resolved.initrdPath, + rootfsPath: resolved.rootfsPath, + }, + }, + }); + + try { + assert.equal(vm.id, "test-runtime-id"); + const runtimeOptions = (vm as any).resolvedSandboxOptions; + assert.equal(runtimeOptions.runtimeId, "test-runtime-id"); + assert.equal((vm as any).server.getRuntimePid(), 4242); + } finally { + await vm.close(); + (process as any).kill = originalKill; + fs.rmSync( + path.join( + process.platform === "darwin" ? "/tmp" : os.tmpdir(), + "gondolin-runtime", + "test-runtime-id", + ), + { recursive: true, force: true }, + ); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("vm internals: VM.attach rejects when runtime metadata is corrupt", async () => { + const runtimeDir = path.join( + process.platform === "darwin" ? "/tmp" : os.tmpdir(), + "gondolin-runtime", + "corrupt-runtime-id", + ); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync(path.join(runtimeDir, "runtime.json"), "{", "utf8"); + + try { + await assert.rejects( + () => VM.attach({ id: "corrupt-runtime-id", vfs: null }), + /is corrupt/, + ); + } finally { + fs.rmSync(runtimeDir, { recursive: true, force: true }); + } +}); + +test("vm internals: VM.attach rejects non-reconnect-capable runtimes", async () => { + const { resolved } = makeTempResolvedServerOptions(); + + writeRuntimeMetadata({ + id: "non-reconnect-runtime-id", + qemuPid: process.pid, + qemuPath: resolved.qemuPath, + createdAt: new Date().toISOString(), + reconnectCapable: false, + }); + + try { + await assert.rejects( + () => + VM.attach({ + id: "non-reconnect-runtime-id", + vfs: null, + }), + /created without reconnect support/, + ); + } finally { + fs.rmSync( + path.join( + process.platform === "darwin" ? "/tmp" : os.tmpdir(), + "gondolin-runtime", + "non-reconnect-runtime-id", + ), + { recursive: true, force: true }, + ); + } +}); + +test("vm internals: VM.attach rejects stale runtime pids", async () => { + const { resolved } = makeTempResolvedServerOptions(); + + writeRuntimeMetadata({ + id: "stale-runtime-id", + qemuPid: 4242, + qemuPath: resolved.qemuPath, + createdAt: new Date().toISOString(), + reconnectCapable: true, + }); + + const originalKill = process.kill; + (process as any).kill = ((pid: number, signal?: string | number) => { + if (pid === 4242 && signal === 0) { + const error = new Error("missing") as Error & { code?: string }; + error.code = "ESRCH"; + throw error; + } + return originalKill.call(process, pid, signal as any); + }) as typeof process.kill; + + try { + await assert.rejects( + () => VM.attach({ id: "stale-runtime-id", vfs: null }), + /metadata is stale/, + ); + } finally { + (process as any).kill = originalKill; + fs.rmSync( + path.join( + process.platform === "darwin" ? "/tmp" : os.tmpdir(), + "gondolin-runtime", + "stale-runtime-id", + ), + { recursive: true, force: true }, + ); + } +}); + +test("vm internals: start writes runtime metadata and close removes it", async () => { + const { vm, cleanup } = makeVm({ + autoStart: false, + vfs: null, + }); + + const runtimeDir = (vm as any).resolvedSandboxOptions.runtimeDir as string; + const metadataPath = path.join(runtimeDir, "runtime.json"); + + (vm as any).ensureVmmAvailable = () => {}; + (vm as any).ensureConnection = async () => {}; + (vm as any).ensureRunning = async () => {}; + (vm as any).ensureVfsReady = async () => {}; + (vm as any).ensureSessionIpc = async () => {}; + (vm as any).server.start = async () => {}; + (vm as any).server.getRuntimePid = () => 4242; + (vm as any).server.close = async () => {}; + (vm as any).disconnect = async () => {}; + + try { + await vm.start(); + assert.equal(fs.existsSync(metadataPath), true); + + await vm.close(); + assert.equal(fs.existsSync(metadataPath), false); + } finally { + fs.rmSync(runtimeDir, { recursive: true, force: true }); + cleanup(); + } +}); + test("vm internals: rootfs readonly mode sets readonly root disk", async () => { const { vm, cleanup } = makeVm({ autoStart: false, From 2a82c50903a19f5dd708297a8ad9b0f288d92cd0 Mon Sep 17 00:00:00 2001 From: Shravan Sunder Date: Tue, 14 Apr 2026 06:28:05 -0400 Subject: [PATCH 2/2] chore: rename utils/process.ts to process-control.ts Avoids ambiguity with Node's built-in process module. --- host/src/sandbox/controller.ts | 2 +- host/src/utils/{process.ts => process-control.ts} | 0 host/src/vm/core.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename host/src/utils/{process.ts => process-control.ts} (100%) diff --git a/host/src/sandbox/controller.ts b/host/src/sandbox/controller.ts index 4d70e546..bbcd173d 100644 --- a/host/src/sandbox/controller.ts +++ b/host/src/sandbox/controller.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "events"; import child_process from "child_process"; import type { ChildProcess } from "child_process"; import fs from "fs"; -import { isProcessAlive, signalProcess } from "../utils/process.ts"; +import { isProcessAlive, signalProcess } from "../utils/process-control.ts"; const activeChildren = new Set(); let exitHookRegistered = false; diff --git a/host/src/utils/process.ts b/host/src/utils/process-control.ts similarity index 100% rename from host/src/utils/process.ts rename to host/src/utils/process-control.ts diff --git a/host/src/vm/core.ts b/host/src/vm/core.ts index 9816f287..190300a9 100644 --- a/host/src/vm/core.ts +++ b/host/src/vm/core.ts @@ -62,7 +62,7 @@ import { parseEnvEntry, resolveEnvNumber, } from "../utils/env.ts"; -import { isProcessAlive } from "../utils/process.ts"; +import { isProcessAlive } from "../utils/process-control.ts"; import { defaultDebugLog, resolveDebugFlags,