diff --git a/packages/codev-sandbox/src/wsl/cli.test.ts b/packages/codev-sandbox/src/wsl/cli.test.ts new file mode 100644 index 0000000000..312e136452 --- /dev/null +++ b/packages/codev-sandbox/src/wsl/cli.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test" +import { spawn } from "node:child_process" +import { runWsl } from "./cli" + +// A child that produces no output and never exits — the wedged-wsl.exe shape +// the timeout exists for. +const hangingSpawn = ((command: string, args: string[], opts: unknown) => + spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], opts as never)) as unknown as typeof spawn + +test("kills and rejects a hung wsl.exe after the timeout", async () => { + expect(runWsl(["--list", "--quiet"], { spawnImpl: hangingSpawn, timeoutMs: 250 })).rejects.toThrow( + "timed out after 250ms", + ) +}) + +test("a fast exit clears the timeout and resolves normally", async () => { + const echoSpawn = ((command: string, args: string[], opts: unknown) => + spawn(process.execPath, ["-e", "process.stdout.write('done'); process.exit(3)"], opts as never)) as unknown as typeof spawn + const result = await runWsl(["--version"], { spawnImpl: echoSpawn, timeoutMs: 5_000 }) + expect(result.code).toBe(3) + expect(result.stdout).toBe("done") +}) diff --git a/packages/codev-sandbox/src/wsl/cli.ts b/packages/codev-sandbox/src/wsl/cli.ts index 9868ab7441..82c0604f41 100644 --- a/packages/codev-sandbox/src/wsl/cli.ts +++ b/packages/codev-sandbox/src/wsl/cli.ts @@ -16,9 +16,15 @@ export function decodeWslOutput(buffer: Buffer) { return buffer.toString("utf16le").replace(/\0/g, "") } +// wsl.exe produces no output and never exits when the LXSS service or a distro +// is wedged (a pending first-run prompt, a Windows update mid-flight), so every +// invocation is bounded. 20s covers slow cold starts; long jobs like --import +// pass their own ceiling. +const DEFAULT_WSL_TIMEOUT_MS = 20_000 + export function runWsl( args: string[], - opts?: { wslCommand?: string; env?: Record; spawnImpl?: typeof spawn }, + opts?: { wslCommand?: string; env?: Record; spawnImpl?: typeof spawn; timeoutMs?: number }, ): Promise { const spawnImpl = opts?.spawnImpl ?? spawn const child = spawnImpl(opts?.wslCommand ?? "wsl.exe", args, { @@ -30,14 +36,23 @@ export function runWsl( child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk)) child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk)) return new Promise((resolve, reject) => { - child.once("error", reject) - child.once("close", (code) => + const timeoutMs = opts?.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS + const timeout = setTimeout(() => { + child.kill() + reject(new Error(`wsl ${args.join(" ")} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + child.once("error", (error) => { + clearTimeout(timeout) + reject(error) + }) + child.once("close", (code) => { + clearTimeout(timeout) resolve({ code, stdout: decodeWslOutput(Buffer.concat(stdout)), stderr: decodeWslOutput(Buffer.concat(stderr)), - }), - ) + }) + }) }) } diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index 18403936f8..8a3d67a507 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -20,6 +20,12 @@ import { parseDistroList, reserveLoopbackPort, runWsl, wslEnvSpec } from "./cli" const GUEST_HOME = "/home/codev" const GUEST_BUNDLE = "/opt/codev" +// --import unpacks a multi-hundred-MB rootfs into a fresh vhdx; --unregister +// deletes the distro's disk, which scales with how much the sandbox wrote. +// Everything else keeps runWsl's 20s wedge guard. +const WSL_IMPORT_TIMEOUT_MS = 15 * 60_000 +const WSL_UNREGISTER_TIMEOUT_MS = 5 * 60_000 + export type WslProviderOptions = { dataDir: string cacheDir: string @@ -53,8 +59,8 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider const spawnImpl = options.spawnImpl ?? spawn const fetchImpl = options.fetchImpl ?? fetch const startTimeoutMs = options.startTimeoutMs ?? 120_000 - const wsl = (args: string[], env?: Record) => - runWsl(args, { wslCommand: options.wslCommand, env, spawnImpl }) + const wsl = (args: string[], opts?: { timeoutMs?: number }) => + runWsl(args, { wslCommand: options.wslCommand, spawnImpl, timeoutMs: opts?.timeoutMs }) const registered = async (distro: string) => { const listed = await wsl(["--list", "--quiet"]) @@ -78,7 +84,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider const distro = wslDistroName(config.name) if (await registered(distro)) { await wsl(["--terminate", distro]) - const unregistered = await wsl(["--unregister", distro]) + const unregistered = await wsl(["--unregister", distro], { timeoutMs: WSL_UNREGISTER_TIMEOUT_MS }) if (unregistered.code !== 0) throw new Error( `wsl --unregister failed for ${distro}: ${unregistered.stderr.trim() || `exited ${unregistered.code}`}`, @@ -94,7 +100,9 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider if (await registered(distro)) return const dirs = wslSandboxDirs(options.dataDir, config.name) await mkdir(dirs.install, { recursive: true }) - const imported = await wsl(["--import", distro, dirs.install, tarball, "--version", "2"]) + const imported = await wsl(["--import", distro, dirs.install, tarball, "--version", "2"], { + timeoutMs: WSL_IMPORT_TIMEOUT_MS, + }) if (imported.code !== 0) throw new Error(`wsl --import failed for ${distro}: ${imported.stderr.trim() || `exited ${imported.code}`}`) }