mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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 `<userData>/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<string, unknown>(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<WslDistroHolderRecordRead> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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'
|
||||
}
|
||||
@@ -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<number, FakeProcess>()
|
||||
running = new Set<string>()
|
||||
tableFails = false
|
||||
nextPid = 4000
|
||||
spawned: string[] = []
|
||||
killed: number[] = []
|
||||
children: (EventEmitter & { pid?: number; unref: () => void })[] = []
|
||||
|
||||
query = async (pid: number): Promise<WindowsProcessEvidence> => {
|
||||
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<WslDistroHolderDependencies> = {}) {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
return readdir(wslDistroHolderDirectory(userDataPath)).catch(() => [])
|
||||
}
|
||||
|
||||
function record(overrides: Partial<WslDistroHolderRecord> = {}): 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])
|
||||
})
|
||||
})
|
||||
@@ -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<WindowsProcessEvidence>
|
||||
killProcess?: (pid: number) => void
|
||||
/** Must never boot a distro: `wsl --list --running` only. */
|
||||
listRunningDistros?: () => Promise<string[]>
|
||||
now?: () => number
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
/** 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<WslDistroHolderEnsureResult>
|
||||
/** Startup: keep holders a previous Orca left running, drop records that are provably stale. */
|
||||
adoptHolders(): Promise<WslDistroHolderAdoption[]>
|
||||
/** Explicit stop only; the distro then idles out on WSL's own schedule. */
|
||||
release(distro: string): Promise<WslDistroHolderState>
|
||||
holderState(distro: string): Promise<WslDistroHolderState>
|
||||
}
|
||||
|
||||
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<void>((resolve) => setTimeout(resolve, ms)))
|
||||
const bootTimeoutMs = dependencies.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS
|
||||
|
||||
const tails = new Map<string, Promise<unknown>>()
|
||||
const inFlightEnsures = new Map<string, Promise<WslDistroHolderEnsureResult>>()
|
||||
|
||||
// Ensure, release and adoption of one distro never interleave; other distros run freely.
|
||||
function serialize<T>(distro: string, task: () => Promise<T>): Promise<T> {
|
||||
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<void> {
|
||||
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<WslDistroHolderRecord> {
|
||||
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<void>((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<WslDistroHolderState> {
|
||||
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<WslDistroHolderEnsureResult> => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user