Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/codev-sandbox/src/wsl/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
25 changes: 20 additions & 5 deletions packages/codev-sandbox/src/wsl/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; spawnImpl?: typeof spawn },
opts?: { wslCommand?: string; env?: Record<string, string>; spawnImpl?: typeof spawn; timeoutMs?: number },
): Promise<WslResult> {
const spawnImpl = opts?.spawnImpl ?? spawn
const child = spawnImpl(opts?.wslCommand ?? "wsl.exe", args, {
Expand All @@ -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)),
}),
)
})
})
})
}

Expand Down
16 changes: 12 additions & 4 deletions packages/codev-sandbox/src/wsl/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string>) =>
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"])
Expand All @@ -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}`}`,
Expand All @@ -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}`}`)
}
Expand Down
Loading