From 099bb76501f82184820a1f236409e2eacbebb8ef Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:59:13 -0700 Subject: [PATCH] feat(wsl): detached distro holder re-adopted from a pid file (WSL-via-orcad C3) (#27800) * feat(wsl): detached distro holder re-adopted from a pid file (WSL-via-orcad C3) * fix(wsl): observe an early holder exit from spawn, not after the record write * fix(wsl): release polls through a mid-exit unverifiable read until the holder's exit is proven --- src/main/wsl/wsl-distro-holder-command.ts | 26 ++ src/main/wsl/wsl-distro-holder-record.ts | 149 +++++++++++ src/main/wsl/wsl-distro-holder.test.ts | 307 ++++++++++++++++++++++ src/main/wsl/wsl-distro-holder.ts | 301 +++++++++++++++++++++ 4 files changed, 783 insertions(+) create mode 100644 src/main/wsl/wsl-distro-holder-command.ts create mode 100644 src/main/wsl/wsl-distro-holder-record.ts create mode 100644 src/main/wsl/wsl-distro-holder.test.ts create mode 100644 src/main/wsl/wsl-distro-holder.ts diff --git a/src/main/wsl/wsl-distro-holder-command.ts b/src/main/wsl/wsl-distro-holder-command.ts new file mode 100644 index 00000000000..6bf01bce291 --- /dev/null +++ b/src/main/wsl/wsl-distro-holder-command.ts @@ -0,0 +1,26 @@ +import { buildWslExecArgs } from '../../shared/wsl-login-shell-command' + +/** `$0` of the holder's shell; the token that identifies our holder in a process command line. */ +export const WSL_DISTRO_HOLDER_MARKER = 'orca-wsl-distro-holder' + +// Why a loop of short sleeps: busybox `sleep` (Alpine) rejects `infinity`, and one huge +// duration depends on how each `sleep` implementation parses it. +const HOLDER_SCRIPT = 'while :; do sleep 3600; done' + +/** `wsl.exe` argv for a holder: only a Windows-side WSL client keeps a distro from idling out. */ +export function buildWslDistroHolderArgs(distro: string): string[] { + return buildWslExecArgs(distro, ['/bin/sh', '-c', HOLDER_SCRIPT, WSL_DISTRO_HOLDER_MARKER]) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** Whether a Windows command line is our holder for `distro` (distro names are case-insensitive). */ +export function isWslDistroHolderCommandLine(commandLine: string, distro: string): boolean { + if (!commandLine.includes(WSL_DISTRO_HOLDER_MARKER) || !/wsl(\.exe)?"?\s/i.test(commandLine)) { + return false + } + const distroArg = new RegExp(`(^|\\s)-d\\s+("?)${escapeRegExp(distro)}\\2(\\s|$)`, 'i') + return distroArg.test(commandLine) +} diff --git a/src/main/wsl/wsl-distro-holder-record.ts b/src/main/wsl/wsl-distro-holder-record.ts new file mode 100644 index 00000000000..e7e2fc39c1d --- /dev/null +++ b/src/main/wsl/wsl-distro-holder-record.ts @@ -0,0 +1,149 @@ +import { mkdir, readdir, readFile, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { + durableWriteTempPath, + removeStaleDurableWriteTempFiles, + writeFileDurable +} from '../durable-file-write' + +/** + * One file per distro under `/wsl-distro-holders/`. Survives a full Orca quit on + * purpose: the holder does too, and the next launch re-adopts it from this record. + */ +export type WslDistroHolderRecord = { + version: 1 + distro: string + pid: number + /** Process creation time (Unix ms) from the process table; null when the table could not say. */ + creationTimeMs: number | null + startedAtMs: number +} + +const HOLDER_DIRECTORY = 'wsl-distro-holders' +const RECORD_SUFFIX = '.json' + +export function wslDistroHolderKey(distro: string): string { + return distro.trim().toLowerCase() +} + +export function wslDistroHolderDirectory(userDataPath: string): string { + return join(userDataPath, HOLDER_DIRECTORY) +} + +function recordPath(userDataPath: string, distro: string): string { + return join( + wslDistroHolderDirectory(userDataPath), + `${encodeURIComponent(wslDistroHolderKey(distro))}${RECORD_SUFFIX}` + ) +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 +} + +export function parseWslDistroHolderRecord(text: string): WslDistroHolderRecord | null { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + return null + } + if (typeof parsed !== 'object' || parsed === null) { + return null + } + const fields = new Map(Object.entries(parsed)) + const version = fields.get('version') + const distro = fields.get('distro') + const pid = fields.get('pid') + const creationTimeMs = fields.get('creationTimeMs') + const startedAtMs = fields.get('startedAtMs') + if ( + version !== 1 || + typeof distro !== 'string' || + wslDistroHolderKey(distro) === '' || + !isPositiveInteger(pid) || + !(creationTimeMs === null || isPositiveInteger(creationTimeMs)) || + !isPositiveInteger(startedAtMs) + ) { + return null + } + return { version, distro, pid, creationTimeMs, startedAtMs } +} + +export type WslDistroHolderRecordRead = + | { status: 'absent' } + | { status: 'present'; record: WslDistroHolderRecord } + /** Present but not ours to parse, or not readable: the holder it named cannot be identified. */ + | { status: 'corrupt' } + | { status: 'unreadable' } + +export async function readWslDistroHolderRecord( + userDataPath: string, + distro: string +): Promise { + let text: string + try { + text = await readFile(recordPath(userDataPath, distro), 'utf8') + } catch (error) { + return isMissingFileError(error) ? { status: 'absent' } : { status: 'unreadable' } + } + const record = parseWslDistroHolderRecord(text) + if (!record || wslDistroHolderKey(record.distro) !== wslDistroHolderKey(distro)) { + return { status: 'corrupt' } + } + return { status: 'present', record } +} + +export async function writeWslDistroHolderRecord( + userDataPath: string, + record: WslDistroHolderRecord +): Promise { + const finalPath = recordPath(userDataPath, record.distro) + await mkdir(dirname(finalPath), { recursive: true }) + await writeFileDurable(durableWriteTempPath(finalPath), finalPath, `${JSON.stringify(record)}\n`) +} + +export async function removeWslDistroHolderRecord( + userDataPath: string, + distro: string +): Promise { + await rm(recordPath(userDataPath, distro), { force: true }) +} + +/** + * Distro names that have a record file; sweeps temp files a crashed writer left behind. + * Why decode the file name: the record may be corrupt, and its file is still owed a verdict. + */ +export async function listWslDistroHolderRecordDistros(userDataPath: string): Promise { + const directory = wslDistroHolderDirectory(userDataPath) + let entries: string[] + try { + entries = await readdir(directory) + } catch (error) { + if (isMissingFileError(error)) { + return [] + } + throw error + } + const distros: string[] = [] + for (const entry of entries) { + if (!entry.endsWith(RECORD_SUFFIX)) { + continue + } + const path = join(directory, entry) + await removeStaleDurableWriteTempFiles(path, { minimumAgeMs: 60_000 }).catch(() => {}) + try { + const distro = decodeURIComponent(entry.slice(0, -RECORD_SUFFIX.length)) + if (wslDistroHolderKey(distro) !== '') { + distros.push(distro) + } + } catch { + await rm(path, { force: true }) + } + } + return distros +} + +function isMissingFileError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' +} diff --git a/src/main/wsl/wsl-distro-holder.test.ts b/src/main/wsl/wsl-distro-holder.test.ts new file mode 100644 index 00000000000..88f4350094f --- /dev/null +++ b/src/main/wsl/wsl-distro-holder.test.ts @@ -0,0 +1,307 @@ +import { EventEmitter } from 'node:events' +import { mkdtemp, readdir, readFile, rm, writeFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WindowsProcessEvidence } from '../daemon/daemon-incarnation-evidence-types' +import { + buildWslDistroHolderArgs, + isWslDistroHolderCommandLine, + WSL_DISTRO_HOLDER_MARKER +} from './wsl-distro-holder-command' +import { + createWslDistroHolder, + type WslDistroHolderChild, + type WslDistroHolderDependencies +} from './wsl-distro-holder' +import { wslDistroHolderDirectory, type WslDistroHolderRecord } from './wsl-distro-holder-record' + +const HOLDER_COMMAND = (distro: string): string => + `C:\\Windows\\System32\\wsl.exe -d ${distro} --exec /bin/sh -c "while :; do sleep 3600; done" ${WSL_DISTRO_HOLDER_MARKER}` + +type FakeProcess = { commandLine: string | null; startedAtMs: number | null } + +class FakeHost { + processes = new Map() + running = new Set() + tableFails = false + nextPid = 4000 + spawned: string[] = [] + killed: number[] = [] + children: (EventEmitter & { pid?: number; unref: () => void })[] = [] + + query = async (pid: number): Promise => { + if (this.tableFails) { + return { status: 'unavailable' } + } + const row = this.processes.get(pid) + return row ? { status: 'present', ...row } : { status: 'missing' } + } + + spawn = (distro: string): WslDistroHolderChild => { + const pid = this.nextPid++ + this.spawned.push(distro) + const child = Object.assign(new EventEmitter(), { pid, unref: vi.fn() }) + this.children.push(child) + this.processes.set(pid, { commandLine: HOLDER_COMMAND(distro), startedAtMs: 1_000 + pid }) + this.running.add(distro.toLowerCase()) + queueMicrotask(() => child.emit('spawn')) + return child + } + + kill = (pid: number): void => { + this.killed.push(pid) + this.processes.delete(pid) + } +} + +let userDataPath: string +let host: FakeHost + +function makeHolder(overrides: Partial = {}) { + return createWslDistroHolder({ + userDataPath, + spawnHolder: host.spawn, + queryProcess: host.query, + killProcess: host.kill, + listRunningDistros: async () => [...host.running], + sleep: async () => {}, + ...overrides + }) +} + +async function writeRecord(record: WslDistroHolderRecord): Promise { + const directory = wslDistroHolderDirectory(userDataPath) + await mkdir(directory, { recursive: true }) + await writeFile( + join(directory, `${encodeURIComponent(record.distro.toLowerCase())}.json`), + JSON.stringify(record) + ) +} + +async function recordFiles(): Promise { + return readdir(wslDistroHolderDirectory(userDataPath)).catch(() => []) +} + +function record(overrides: Partial = {}): WslDistroHolderRecord { + return { + version: 1, + distro: 'Alpine', + pid: 77, + creationTimeMs: 5_000, + startedAtMs: 5_001, + ...overrides + } +} + +beforeEach(async () => { + userDataPath = await mkdtemp(join(tmpdir(), 'wsl-distro-holder-')) + host = new FakeHost() +}) + +afterEach(async () => { + await rm(userDataPath, { recursive: true, force: true }) +}) + +describe('holder command line', () => { + it('runs a busybox-safe loop through --exec and names the distro', () => { + expect(buildWslDistroHolderArgs('Alpine')).toEqual([ + '-d', + 'Alpine', + '--exec', + '/bin/sh', + '-c', + 'while :; do sleep 3600; done', + WSL_DISTRO_HOLDER_MARKER + ]) + }) + + it('matches only our holder for the same distro', () => { + expect(isWslDistroHolderCommandLine(HOLDER_COMMAND('Alpine'), 'alpine')).toBe(true) + expect(isWslDistroHolderCommandLine(HOLDER_COMMAND('Alpine-2'), 'Alpine')).toBe(false) + expect(isWslDistroHolderCommandLine('wsl.exe -d Alpine --exec sleep 9', 'Alpine')).toBe(false) + expect( + isWslDistroHolderCommandLine(`node x.js -d Alpine ${WSL_DISTRO_HOLDER_MARKER}`, 'Alpine') + ).toBe(false) + }) +}) + +describe('createWslDistroHolder', () => { + it('spawns a detached-style holder, records it, and waits for the distro to run', async () => { + const holder = makeHolder() + const result = await holder.ensureHeld('Alpine') + expect(result.outcome).toBe('spawned') + expect(host.children[0]?.unref).toHaveBeenCalled() + const saved = JSON.parse( + await readFile(join(wslDistroHolderDirectory(userDataPath), 'alpine.json'), 'utf8') + ) + expect(saved).toMatchObject({ version: 1, distro: 'Alpine', pid: 4000, creationTimeMs: 5_000 }) + expect(await holder.holderState('Alpine')).toBe('live') + }) + + it('single-flights concurrent ensures for one distro', async () => { + const holder = makeHolder() + const [a, b] = await Promise.all([holder.ensureHeld('Alpine'), holder.ensureHeld('alpine')]) + expect(a).toBe(b) + expect(host.spawned).toEqual(['Alpine']) + expect((await holder.ensureHeld('Alpine')).outcome).toBe('adopted') + expect(host.spawned).toHaveLength(1) + }) + + it('adopts a valid holder left by a previous run without spawning another', async () => { + host.processes.set(77, { commandLine: HOLDER_COMMAND('Alpine'), startedAtMs: 5_000 }) + await writeRecord(record()) + const holder = makeHolder() + expect(await holder.adoptHolders()).toEqual([{ distro: 'alpine', state: 'live' }]) + expect((await holder.ensureHeld('Alpine')).outcome).toBe('adopted') + expect(host.spawned).toEqual([]) + }) + + it('rejects a reused pid (creation time differs) and drops the record', async () => { + host.processes.set(77, { commandLine: HOLDER_COMMAND('Alpine'), startedAtMs: 9_999 }) + await writeRecord(record()) + const holder = makeHolder() + expect(await holder.adoptHolders()).toEqual([{ distro: 'alpine', state: 'exited' }]) + expect(await recordFiles()).toEqual([]) + expect(host.killed).toEqual([]) + }) + + it('rejects a pid whose command line is not our holder for that distro', async () => { + host.processes.set(77, { commandLine: HOLDER_COMMAND('Ubuntu'), startedAtMs: 5_000 }) + await writeRecord(record()) + expect(await makeHolder().adoptHolders()).toEqual([{ distro: 'alpine', state: 'exited' }]) + expect(await recordFiles()).toEqual([]) + }) + + it('cleans up stale and corrupt records', async () => { + await writeRecord(record({ distro: 'Gone', pid: 12 })) + const directory = wslDistroHolderDirectory(userDataPath) + await writeFile(join(directory, 'broken.json'), '{nope') + const holder = makeHolder() + const adopted = await holder.adoptHolders() + expect(adopted.sort((a, b) => a.distro.localeCompare(b.distro))).toEqual([ + { distro: 'broken', state: 'exited' }, + { distro: 'gone', state: 'exited' } + ]) + expect(await recordFiles()).toEqual([]) + }) + + it('keeps the record and reports unverifiable when the process table fails', async () => { + host.processes.set(77, { commandLine: HOLDER_COMMAND('Alpine'), startedAtMs: 5_000 }) + await writeRecord(record()) + host.tableFails = true + const holder = makeHolder() + expect(await holder.adoptHolders()).toEqual([{ distro: 'alpine', state: 'unverifiable' }]) + expect(await holder.holderState('Alpine')).toBe('unverifiable') + expect((await holder.ensureHeld('Alpine')).outcome).toBe('unverifiable') + expect(await holder.release('Alpine')).toBe('unverifiable') + expect(host.spawned).toEqual([]) + expect(host.killed).toEqual([]) + expect(await recordFiles()).toEqual(['alpine.json']) + }) + + it('reports unverifiable when the command line is unreadable even with a matching pid', async () => { + host.processes.set(77, { commandLine: null, startedAtMs: 5_000 }) + await writeRecord(record()) + expect(await makeHolder().holderState('Alpine')).toBe('unverifiable') + }) + + it('release kills a verified holder and removes its record', async () => { + const holder = makeHolder() + await holder.ensureHeld('Alpine') + expect(await holder.release('Alpine')).toBe('exited') + expect(host.killed).toEqual([4000]) + expect(await recordFiles()).toEqual([]) + expect(await holder.holderState('Alpine')).toBe('exited') + }) + + it('release never kills a pid that is no longer our holder', async () => { + host.processes.set(77, { commandLine: 'C:\\Windows\\notepad.exe', startedAtMs: 5_000 }) + await writeRecord(record()) + expect(await makeHolder().release('Alpine')).toBe('exited') + expect(host.killed).toEqual([]) + }) + + it('fails and cleans up when the holder exits before its record is written', async () => { + const holder = makeHolder({ + listRunningDistros: async () => [], + spawnHolder: (distro) => { + const child = host.spawn(distro) + // Right after 'spawn', before the process-table lookup resolves (a bad distro name). + queueMicrotask(() => { + host.processes.delete(child.pid ?? 0) + child.emit('exit', 1, null) + }) + return child + }, + bootTimeoutMs: 1_000_000 + }) + await expect(holder.ensureHeld('Missing')).rejects.toThrow(/exited before the distro started/) + expect(await recordFiles()).toEqual([]) + }) + + it('stops the holder when the distro never starts', async () => { + let clock = 0 + const holder = makeHolder({ + listRunningDistros: async () => [], + now: () => (clock += 1_000), + bootTimeoutMs: 3_000 + }) + await expect(holder.ensureHeld('Alpine')).rejects.toThrow(/did not start/) + expect(host.killed).toEqual([4000]) + expect(await recordFiles()).toEqual([]) + }) + + it('release keeps polling through an unverifiable window until the exit is proven', async () => { + const holder = makeHolder() + await holder.ensureHeld('Alpine') + // Mid-exit: the pid is still listed but its command line is already unreadable. + let midExitReads = 2 + host.kill = (pid) => { + host.killed.push(pid) + host.processes.set(pid, { commandLine: null, startedAtMs: 5_000 }) + } + const query = host.query + host.query = async (pid) => { + if (host.killed.includes(pid) && midExitReads-- <= 0) { + host.processes.delete(pid) + } + return query(pid) + } + const released = createWslDistroHolder({ + userDataPath, + queryProcess: (pid) => host.query(pid), + killProcess: (pid) => host.kill(pid), + sleep: async () => {} + }) + expect(await released.release('Alpine')).toBe('exited') + expect(midExitReads).toBeLessThan(0) + expect(await recordFiles()).toEqual([]) + expect(await holder.holderState('Alpine')).toBe('exited') + }) + + it('release reports unverifiable and keeps the record when the exit is never proven', async () => { + const holder = makeHolder() + await holder.ensureHeld('Alpine') + let clock = 0 + const released = makeHolder({ + killProcess: (pid) => { + host.killed.push(pid) + host.processes.set(pid, { commandLine: null, startedAtMs: 5_000 }) + }, + now: () => (clock += 1_000) + }) + expect(await released.release('Alpine')).toBe('unverifiable') + expect(host.killed).toEqual([4000]) + expect(await recordFiles()).toEqual(['alpine.json']) + }) + + it('serializes release behind an in-flight ensure', async () => { + const holder = makeHolder() + const ensure = holder.ensureHeld('Alpine') + const release = holder.release('Alpine') + await ensure + expect(await release).toBe('exited') + expect(host.killed).toEqual([4000]) + }) +}) diff --git a/src/main/wsl/wsl-distro-holder.ts b/src/main/wsl/wsl-distro-holder.ts new file mode 100644 index 00000000000..379955dd0a7 --- /dev/null +++ b/src/main/wsl/wsl-distro-holder.ts @@ -0,0 +1,301 @@ +import type { EventEmitter } from 'node:events' +import { spawnProcess } from '@orca/process-host' +import { queryWindowsProcess } from '../daemon/daemon-process-inspection' +import type { WindowsProcessEvidence } from '../daemon/daemon-incarnation-evidence-types' +import { listConfirmedRunningWslDistros } from '../wsl-background-distro-gate' +import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' +import { buildWslDistroHolderArgs, isWslDistroHolderCommandLine } from './wsl-distro-holder-command' +import { + listWslDistroHolderRecordDistros, + readWslDistroHolderRecord, + removeWslDistroHolderRecord, + wslDistroHolderKey, + writeWslDistroHolderRecord, + type WslDistroHolderRecord +} from './wsl-distro-holder-record' +import { resolveWslExecutablePath } from './wsl-executable-path' + +/** Holder liveness, in the execution-host vocabulary: lookup failure is never `exited`. */ +export type WslDistroHolderState = 'live' | 'exited' | 'unverifiable' + +export type WslDistroHolderEnsureResult = + | { outcome: 'adopted'; record: WslDistroHolderRecord } + | { outcome: 'spawned'; record: WslDistroHolderRecord } + /** The recorded holder could not be checked; kept rather than doubled (it may be live). */ + | { outcome: 'unverifiable'; record: WslDistroHolderRecord } + +/** The slice of a ChildProcess the holder reads; `spawn`, `error` and `exit` events. */ +export type WslDistroHolderChild = EventEmitter & { readonly pid?: number; unref(): void } + +export type WslDistroHolderAdoption = { distro: string; state: WslDistroHolderState } + +export type WslDistroHolderDependencies = { + userDataPath: string + /** Test seam; production spawns `wsl.exe` detached through process-host. */ + spawnHolder?: (distro: string) => WslDistroHolderChild + queryProcess?: (pid: number) => Promise + killProcess?: (pid: number) => void + /** Must never boot a distro: `wsl --list --running` only. */ + listRunningDistros?: () => Promise + now?: () => number + sleep?: (ms: number) => Promise + /** How long a spawned holder has to bring its distro up (a cold boot can take ~15 s). */ + bootTimeoutMs?: number +} + +export type WslDistroHolder = { + /** User-initiated only: the one path allowed to boot a stopped distro. */ + ensureHeld(distro: string): Promise + /** Startup: keep holders a previous Orca left running, drop records that are provably stale. */ + adoptHolders(): Promise + /** Explicit stop only; the distro then idles out on WSL's own schedule. */ + release(distro: string): Promise + holderState(distro: string): Promise +} + +const DEFAULT_BOOT_TIMEOUT_MS = 60_000 +const BOOT_POLL_INTERVAL_MS = 250 +const RELEASE_EXIT_TIMEOUT_MS = 5_000 + +function spawnDetachedHolder(distro: string): WslDistroHolderChild { + // Why detached: an attached child sits in libuv's kill-on-close job and dies with Orca, and the + // holder must outlive a full quit. A `taskkill /T` of Orca's tree still reaches it, as for the daemon. + return spawnProcess({ + program: resolveWslExecutablePath(), + args: buildWslDistroHolderArgs(distro), + // Why: outlives every worktree, so it must not pin one as its Win32 cwd. + cwd: resolveWslInteropSpawnCwd(), + env: { ...process.env, WSL_UTF8: '1' }, + detached: true, + stdio: 'ignore' + }) +} + +/** Pid + creation time + command line must all agree; any one contradicting means pid reuse. */ +export function classifyWslDistroHolderProcess( + record: WslDistroHolderRecord, + evidence: WindowsProcessEvidence +): WslDistroHolderState { + if (evidence.status === 'unavailable') { + return 'unverifiable' + } + if (evidence.status === 'missing') { + return 'exited' + } + const timeKnown = record.creationTimeMs !== null && evidence.startedAtMs !== null + if (timeKnown && record.creationTimeMs !== evidence.startedAtMs) { + return 'exited' + } + if (!evidence.commandLine) { + return 'unverifiable' + } + if (!isWslDistroHolderCommandLine(evidence.commandLine, record.distro)) { + return 'exited' + } + // A record written without a creation time never had one to compare; the command line decides. + return timeKnown || record.creationTimeMs === null ? 'live' : 'unverifiable' +} + +export function createWslDistroHolder(dependencies: WslDistroHolderDependencies): WslDistroHolder { + const { userDataPath } = dependencies + const spawnHolder = dependencies.spawnHolder ?? spawnDetachedHolder + const queryProcess = dependencies.queryProcess ?? ((pid: number) => queryWindowsProcess(pid)) + const killProcess = dependencies.killProcess ?? ((pid: number) => process.kill(pid)) + const listRunningDistros = dependencies.listRunningDistros ?? listConfirmedRunningWslDistros + const now = dependencies.now ?? Date.now + const sleep = + dependencies.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + const bootTimeoutMs = dependencies.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS + + const tails = new Map>() + const inFlightEnsures = new Map>() + + // Ensure, release and adoption of one distro never interleave; other distros run freely. + function serialize(distro: string, task: () => Promise): Promise { + const key = wslDistroHolderKey(distro) + const run = (tails.get(key) ?? Promise.resolve()).then(task, task) + const tail = run.catch(() => {}) + tails.set(key, tail) + void tail.then(() => { + if (tails.get(key) === tail) { + tails.delete(key) + } + }) + return run + } + + async function recordedHolderState( + distro: string + ): Promise<{ state: WslDistroHolderState; record: WslDistroHolderRecord | null }> { + const read = await readWslDistroHolderRecord(userDataPath, distro) + if (read.status === 'absent') { + return { state: 'exited', record: null } + } + if (read.status === 'unreadable') { + return { state: 'unverifiable', record: null } + } + if (read.status === 'corrupt') { + // Nothing in it can identify a process, so it holds nothing. + await removeWslDistroHolderRecord(userDataPath, distro) + return { state: 'exited', record: null } + } + const state = classifyWslDistroHolderProcess(read.record, await queryProcess(read.record.pid)) + if (state === 'exited') { + await removeWslDistroHolderRecord(userDataPath, distro) + } + return { state, record: read.record } + } + + type HolderExit = { code: number | null; signal: NodeJS.Signals | null } + + async function waitForDistroRunning( + distro: string, + observedExit: () => HolderExit | null + ): Promise { + const deadline = now() + bootTimeoutMs + const key = wslDistroHolderKey(distro) + for (;;) { + const exit = observedExit() + if (exit) { + throw new Error( + `WSL holder for ${distro} exited before the distro started (code ${exit.code}, signal ${exit.signal})` + ) + } + const running = await listRunningDistros() + if (running.some((candidate) => wslDistroHolderKey(candidate) === key)) { + return + } + if (now() >= deadline) { + throw new Error(`WSL distro ${distro} did not start within ${bootTimeoutMs} ms`) + } + await sleep(BOOT_POLL_INTERVAL_MS) + } + } + + async function spawnAndRecord(distro: string): Promise { + const child = spawnHolder(distro) + // Listen from the start: a bad distro name makes wsl.exe exit before the record is written. + const observed: { exit: HolderExit | null } = { exit: null } + const onExit = (code: number | null, signal: NodeJS.Signals | null): void => { + observed.exit = { code, signal } + } + child.once('exit', onExit) + try { + await new Promise((resolve, reject) => { + child.once('spawn', resolve) + child.once('error', reject) + }) + // The holder must never keep this process alive; it is the other way around. + child.unref() + const pid = child.pid + if (pid === undefined) { + throw new Error(`WSL holder for ${distro} started without a pid`) + } + const evidence = await queryProcess(pid) + const record: WslDistroHolderRecord = { + version: 1, + distro, + pid, + creationTimeMs: evidence.status === 'present' ? evidence.startedAtMs : null, + startedAtMs: now() + } + await writeWslDistroHolderRecord(userDataPath, record) + try { + await waitForDistroRunning(distro, () => observed.exit) + } catch (error) { + await stopRecordedHolder(record) + throw error + } + return record + } finally { + child.off('exit', onExit) + } + } + + async function stopRecordedHolder(record: WslDistroHolderRecord): Promise { + let state = classifyWslDistroHolderProcess(record, await queryProcess(record.pid)) + if (state === 'live') { + try { + // wsl.exe re-launches itself as a child with the same argv; ending the recorded parent ends both (WSL 2.7.10). + killProcess(record.pid) + } catch { + // Raced its own exit, or denied; the re-check below decides. + } + const deadline = now() + RELEASE_EXIT_TIMEOUT_MS + for (;;) { + state = classifyWslDistroHolderProcess(record, await queryProcess(record.pid)) + // A holder mid-exit can read unverifiable (pid present, command line gone); only `exited` is proof. + if (state === 'exited' || now() >= deadline) { + break + } + await sleep(BOOT_POLL_INTERVAL_MS) + } + } + if (state === 'exited') { + await removeWslDistroHolderRecord(userDataPath, record.distro) + } + return state + } + + return { + ensureHeld(distro) { + const key = wslDistroHolderKey(distro) + if (!key) { + return Promise.reject(new Error('A WSL distro name is required')) + } + const existing = inFlightEnsures.get(key) + if (existing) { + return existing + } + const pending = serialize(distro, async (): Promise => { + const { state, record } = await recordedHolderState(distro) + if (record && state === 'live') { + return { outcome: 'adopted', record } + } + if (record && state === 'unverifiable') { + return { outcome: 'unverifiable', record } + } + if (state === 'unverifiable') { + throw new Error(`The WSL holder record for ${distro} could not be read`) + } + return { outcome: 'spawned', record: await spawnAndRecord(distro) } + }).finally(() => { + if (inFlightEnsures.get(key) === pending) { + inFlightEnsures.delete(key) + } + }) + inFlightEnsures.set(key, pending) + return pending + }, + + async adoptHolders() { + const distros = await listWslDistroHolderRecordDistros(userDataPath) + return Promise.all( + distros.map((distro) => + serialize(distro, async () => ({ + distro, + state: (await recordedHolderState(distro)).state + })) + ) + ) + }, + + release(distro) { + return serialize(distro, async () => { + const read = await readWslDistroHolderRecord(userDataPath, distro) + if (read.status === 'unreadable') { + return 'unverifiable' + } + if (read.status !== 'present') { + await removeWslDistroHolderRecord(userDataPath, distro) + return 'exited' + } + return stopRecordedHolder(read.record) + }) + }, + + holderState(distro) { + return serialize(distro, async () => (await recordedHolderState(distro)).state) + } + } +}