mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(wsl): restore Windows interop after another distro wipes it (#27682)
* fix(wsl): detect and restore a wiped WSLInterop entry in running distros On WSL < 3.0.1 a graceful stop of any systemd distro deletes every binfmt_misc entry VM-wide, so Windows .exe launches (code ., explorer.exe, the orca WSL CLI bridge) fail with Exec format error in every other distro. Check, never boot: 5 s after a WSL terminal env build (gated on the distro being in the confirmed running list, at most once a minute per distro), and whenever a confirmed running-distro probe shows another distro stopped. Repair is automatic and idempotent as root, skipped when the distro's wsl.conf sets [interop] enabled=false, single-flight per distro, with a 10 minute backoff after a failed repair. * fix(wsl): repair interop with a single register write matching WSL's flags Busybox printf writes a trailing newline separately; the kernel registers the entry, then rejects the lone newline, so the repair reported failure after succeeding. Write without the newline and let the re-check decide. Use WSL's own P flags rather than PF, and treat an entry restored between probe and repair as intact. Add a spawn-budget test.
This commit is contained in:
@@ -8,6 +8,7 @@ import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode
|
||||
import { mimoCodeHookService } from '../../../mimo/hook-service'
|
||||
import { agentHookServer } from '../../../agent-hooks/server'
|
||||
import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager'
|
||||
import { wslInteropHealthMonitor } from '../../../wsl/wsl-interop-health'
|
||||
import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service'
|
||||
import { prependOrcaCliDirToChildPath } from '../../../cli/orca-cli-child-path'
|
||||
import { getManagedWslCliDir, getWslCliCommandName } from '../../../cli/wsl-managed-cli'
|
||||
@@ -266,6 +267,7 @@ export function buildPtyHostEnv(
|
||||
delete baseEnv.ORCA_WSL_CLI_DIR
|
||||
// Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance.
|
||||
if (opts.isWsl) {
|
||||
wslInteropHealthMonitor.noteActivity(opts.wslDistro ?? null)
|
||||
baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath
|
||||
// Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim.
|
||||
baseEnv.ORCA_CLI_COMMAND = getWslCliCommandName(opts.isPackaged)
|
||||
|
||||
@@ -10,6 +10,32 @@ let retryAfterMs = 0
|
||||
let failureStreak = 0
|
||||
type RunningDistroObservation = { distros: string[]; confirmed: boolean }
|
||||
let inFlightProbe: Promise<RunningDistroObservation> | null = null
|
||||
type RunningDistroStopListener = (stopped: string[], running: string[]) => void
|
||||
const stopListeners = new Set<RunningDistroStopListener>()
|
||||
|
||||
/** Notified when a confirmed probe no longer lists a distro the previous one did. */
|
||||
export function onRunningWslDistrosStopped(listener: RunningDistroStopListener): () => void {
|
||||
stopListeners.add(listener)
|
||||
return () => stopListeners.delete(listener)
|
||||
}
|
||||
|
||||
function notifyStopped(previous: string[] | null, running: string[]): void {
|
||||
if (!previous || stopListeners.size === 0) {
|
||||
return
|
||||
}
|
||||
const current = new Set(running.map((distro) => distro.toLowerCase()))
|
||||
const stopped = previous.filter((distro) => !current.has(distro.toLowerCase()))
|
||||
if (stopped.length === 0) {
|
||||
return
|
||||
}
|
||||
for (const listener of stopListeners) {
|
||||
try {
|
||||
listener(stopped, running)
|
||||
} catch (error) {
|
||||
console.warn('[wsl] running-distro stop listener failed', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function armRetryAfterFailure(): void {
|
||||
const now = Date.now()
|
||||
@@ -51,9 +77,11 @@ function observeRunningWslDistros(
|
||||
}
|
||||
const result = probe()
|
||||
.then((distros) => {
|
||||
const previous = cache
|
||||
cache = distros
|
||||
retryAfterMs = 0
|
||||
failureStreak = 0
|
||||
notifyStopped(previous, distros)
|
||||
return { distros, confirmed: true }
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
_resetRunningWslDistroCacheForTests,
|
||||
onRunningWslDistrosStopped,
|
||||
resolveRunningWslDistros
|
||||
} from './wsl-running-distro-cache'
|
||||
|
||||
describe('onRunningWslDistrosStopped', () => {
|
||||
afterEach(() => {
|
||||
_resetRunningWslDistroCacheForTests()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports distros a confirmed probe no longer lists', async () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = onRunningWslDistrosStopped(listener)
|
||||
await resolveRunningWslDistros(async () => ['Ubuntu', 'docker-desktop'])
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
await resolveRunningWslDistros(async () => ['ubuntu'])
|
||||
expect(listener).toHaveBeenCalledWith(['docker-desktop'], ['ubuntu'])
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('ignores failed probes', async () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = onRunningWslDistrosStopped(listener)
|
||||
await resolveRunningWslDistros(async () => ['Ubuntu'])
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
await resolveRunningWslDistros(async () => {
|
||||
throw new Error('wsl.exe wedged')
|
||||
})
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../wsl', () => ({
|
||||
listRunningWslDistrosAsync: vi.fn(),
|
||||
listWslDistrosAsync: vi.fn()
|
||||
}))
|
||||
vi.mock('./wsl-runner', () => ({ runWslProcess: vi.fn() }))
|
||||
|
||||
import {
|
||||
ACTIVITY_CHECK_DELAY_MS,
|
||||
ACTIVITY_CHECK_INTERVAL_MS,
|
||||
REPAIR_FAILURE_BACKOFF_MS,
|
||||
STOP_CHECK_FLOOR_MS,
|
||||
WslInteropHealthMonitor,
|
||||
type WslInteropHealthDeps
|
||||
} from './wsl-interop-health'
|
||||
import { WSL_INTEROP_PROBE_SCRIPT, WSL_INTEROP_REPAIR_SCRIPT } from './wsl-interop-registration'
|
||||
|
||||
const PRESENT = 'orca-wsl-interop:present\n'
|
||||
const MISSING = 'orca-wsl-interop:missing\norca-wsl-interop:conf\n'
|
||||
const MISSING_DISABLED = `${MISSING}[interop]\nenabled=false\n`
|
||||
|
||||
function harness(overrides: Partial<WslInteropHealthDeps> = {}) {
|
||||
let now = 1_000_000
|
||||
const timers: { fn: () => void; delayMs: number }[] = []
|
||||
let stopListener: ((stopped: string[], running: string[]) => void) | null = null
|
||||
const deps: WslInteropHealthDeps = {
|
||||
platform: () => 'win32',
|
||||
listRunningDistros: vi.fn(async () => ['Ubuntu']),
|
||||
resolveDefaultDistro: vi.fn(async () => 'Ubuntu'),
|
||||
runScript: vi.fn(async () => ({ code: 0, stdout: PRESENT })),
|
||||
subscribeStops: (listener) => {
|
||||
stopListener = listener
|
||||
return () => {
|
||||
stopListener = null
|
||||
}
|
||||
},
|
||||
now: () => now,
|
||||
schedule: (fn, delayMs) => {
|
||||
timers.push({ fn, delayMs })
|
||||
},
|
||||
log: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
const monitor = new WslInteropHealthMonitor(deps)
|
||||
return {
|
||||
deps,
|
||||
monitor,
|
||||
timers,
|
||||
advance: (ms: number) => {
|
||||
now += ms
|
||||
},
|
||||
runTimers: () => {
|
||||
for (const timer of timers.splice(0)) {
|
||||
timer.fn()
|
||||
}
|
||||
},
|
||||
emitStop: (stopped: string[], running: string[]) => stopListener?.(stopped, running)
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
describe('WslInteropHealthMonitor.check', () => {
|
||||
it('reports intact without repairing', async () => {
|
||||
const h = harness()
|
||||
await expect(h.monitor.check('Ubuntu', { confirmRunning: true })).resolves.toBe('intact')
|
||||
expect(h.deps.runScript).toHaveBeenCalledTimes(1)
|
||||
expect(h.deps.runScript).toHaveBeenCalledWith('Ubuntu', WSL_INTEROP_PROBE_SCRIPT)
|
||||
})
|
||||
|
||||
it('repairs as root when the entry is missing', async () => {
|
||||
const runScript = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ code: 0, stdout: MISSING })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: 'orca-wsl-interop:repaired\n' })
|
||||
const h = harness({ runScript })
|
||||
await expect(h.monitor.check('Ubuntu', { confirmRunning: true })).resolves.toBe('repaired')
|
||||
expect(runScript).toHaveBeenLastCalledWith('Ubuntu', WSL_INTEROP_REPAIR_SCRIPT, 'root')
|
||||
expect(h.deps.log).toHaveBeenCalledWith(expect.stringContaining('wsl --update'))
|
||||
})
|
||||
|
||||
it('respects interop disabled in wsl.conf', async () => {
|
||||
const runScript = vi.fn(async () => ({ code: 0, stdout: MISSING_DISABLED }))
|
||||
const h = harness({ runScript })
|
||||
await expect(h.monitor.check('Ubuntu', { confirmRunning: true })).resolves.toBe(
|
||||
'interop-disabled'
|
||||
)
|
||||
expect(runScript).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never runs inside a distro that is not running', async () => {
|
||||
const h = harness({ listRunningDistros: vi.fn(async () => ['Debian']) })
|
||||
await expect(h.monitor.check('Ubuntu', { confirmRunning: true })).resolves.toBe('not-running')
|
||||
expect(h.deps.runScript).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an unconfirmed running list as not running', async () => {
|
||||
const h = harness({
|
||||
listRunningDistros: vi.fn(async () => {
|
||||
throw new Error('unavailable')
|
||||
})
|
||||
})
|
||||
await expect(h.monitor.check('Ubuntu', { confirmRunning: true })).resolves.toBe('not-running')
|
||||
expect(h.deps.runScript).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is single-flight per distro', async () => {
|
||||
let release: (value: { code: number; stdout: string }) => void = () => undefined
|
||||
const runScript = vi.fn(
|
||||
() =>
|
||||
new Promise<{ code: number; stdout: string }>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
const h = harness({ runScript })
|
||||
const first = h.monitor.check('Ubuntu', { confirmRunning: false })
|
||||
const second = h.monitor.check('ubuntu', { confirmRunning: false })
|
||||
expect(second).toBe(first)
|
||||
await flush()
|
||||
release({ code: 0, stdout: PRESENT })
|
||||
await expect(first).resolves.toBe('intact')
|
||||
expect(runScript).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('backs off after a failed repair', async () => {
|
||||
const runScript = vi.fn(async (_distro: string, script: string) =>
|
||||
script === WSL_INTEROP_PROBE_SCRIPT ? { code: 0, stdout: MISSING } : { code: 1, stdout: '' }
|
||||
)
|
||||
const h = harness({ runScript })
|
||||
await expect(h.monitor.check('Ubuntu', { confirmRunning: false })).resolves.toBe(
|
||||
'repair-failed'
|
||||
)
|
||||
await expect(h.monitor.check('Ubuntu', { confirmRunning: false })).resolves.toBe(
|
||||
'repair-failed'
|
||||
)
|
||||
expect(runScript).toHaveBeenCalledTimes(3)
|
||||
h.advance(REPAIR_FAILURE_BACKOFF_MS)
|
||||
await h.monitor.check('Ubuntu', { confirmRunning: false })
|
||||
expect(runScript).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('reports unknown when the probe throws', async () => {
|
||||
const h = harness({
|
||||
runScript: vi.fn(async () => {
|
||||
throw new Error('timeout')
|
||||
})
|
||||
})
|
||||
await expect(h.monitor.check('Ubuntu', { confirmRunning: false })).resolves.toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WslInteropHealthMonitor triggers', () => {
|
||||
it('does nothing off Windows', async () => {
|
||||
const h = harness({ platform: () => 'darwin' })
|
||||
h.monitor.noteActivity('Ubuntu')
|
||||
await flush()
|
||||
expect(h.timers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('checks once after a delay and throttles repeated activity', async () => {
|
||||
const h = harness()
|
||||
h.monitor.noteActivity(null)
|
||||
h.monitor.noteActivity('Ubuntu')
|
||||
await flush()
|
||||
expect(h.timers).toEqual([{ fn: expect.any(Function), delayMs: ACTIVITY_CHECK_DELAY_MS }])
|
||||
expect(h.deps.runScript).not.toHaveBeenCalled()
|
||||
h.runTimers()
|
||||
await flush()
|
||||
expect(h.deps.listRunningDistros).toHaveBeenCalledTimes(1)
|
||||
expect(h.deps.runScript).toHaveBeenCalledTimes(1)
|
||||
|
||||
h.monitor.noteActivity('Ubuntu')
|
||||
await flush()
|
||||
expect(h.timers).toHaveLength(0)
|
||||
h.advance(ACTIVITY_CHECK_INTERVAL_MS)
|
||||
h.monitor.noteActivity('Ubuntu')
|
||||
await flush()
|
||||
expect(h.timers).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('re-checks used distros that are still running when another distro stops', async () => {
|
||||
const h = harness()
|
||||
h.monitor.noteActivity('Ubuntu')
|
||||
await flush()
|
||||
h.runTimers()
|
||||
await flush()
|
||||
expect(h.deps.runScript).toHaveBeenCalledTimes(1)
|
||||
h.advance(10_000)
|
||||
h.emitStop(['docker-desktop'], ['Ubuntu'])
|
||||
await flush()
|
||||
expect(h.deps.runScript).toHaveBeenCalledTimes(2)
|
||||
// The confirmed list that reported the stop already proves Ubuntu is up.
|
||||
expect(h.deps.listRunningDistros).toHaveBeenCalledTimes(1)
|
||||
h.advance(10_000)
|
||||
h.emitStop(['Ubuntu'], [])
|
||||
await flush()
|
||||
expect(h.deps.runScript).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('bounds probes under bursts of activity and stop events', async () => {
|
||||
const h = harness()
|
||||
for (let i = 0; i < 50; i++) {
|
||||
h.monitor.noteActivity('Ubuntu')
|
||||
}
|
||||
await flush()
|
||||
h.runTimers()
|
||||
await flush()
|
||||
for (let elapsed = 0; elapsed < ACTIVITY_CHECK_INTERVAL_MS - 1_000; elapsed += 1_000) {
|
||||
h.advance(1_000)
|
||||
h.monitor.noteActivity('Ubuntu')
|
||||
await flush()
|
||||
h.runTimers()
|
||||
await flush()
|
||||
}
|
||||
expect(h.deps.runScript).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Stop-triggered checks are rate-floored and skip distros the list no longer shows.
|
||||
h.emitStop(['docker-desktop'], ['Ubuntu'])
|
||||
await flush()
|
||||
expect(h.deps.runScript).toHaveBeenCalledTimes(2)
|
||||
h.advance(STOP_CHECK_FLOOR_MS - 1)
|
||||
h.emitStop(['Debian'], ['Ubuntu'])
|
||||
h.emitStop(['Debian'], ['Ubuntu'])
|
||||
await flush()
|
||||
expect(h.deps.runScript).toHaveBeenCalledTimes(2)
|
||||
h.advance(1)
|
||||
h.emitStop(['Ubuntu'], [])
|
||||
await flush()
|
||||
expect(h.deps.runScript).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('treats an entry restored between probe and repair as intact', async () => {
|
||||
const h = harness({
|
||||
runScript: vi.fn(async (_distro: string, script: string) => ({
|
||||
code: 0,
|
||||
stdout: script === WSL_INTEROP_PROBE_SCRIPT ? MISSING : PRESENT
|
||||
}))
|
||||
})
|
||||
await expect(h.monitor.check('Ubuntu', { confirmRunning: false })).resolves.toBe('intact')
|
||||
expect(h.deps.log).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import { listRunningWslDistrosAsync, listWslDistrosAsync } from '../wsl'
|
||||
import { onRunningWslDistrosStopped } from '../wsl-running-distro-cache'
|
||||
import { runWslProcess } from './wsl-runner'
|
||||
import {
|
||||
parseWslInteropProbeOutput,
|
||||
parseWslInteropRepairOutput,
|
||||
WSL_INTEROP_PROBE_SCRIPT,
|
||||
WSL_INTEROP_REPAIR_SCRIPT
|
||||
} from './wsl-interop-registration'
|
||||
|
||||
export type WslInteropCheckOutcome =
|
||||
| 'intact'
|
||||
| 'repaired'
|
||||
| 'interop-disabled'
|
||||
| 'unsupported'
|
||||
| 'repair-failed'
|
||||
| 'not-running'
|
||||
| 'unknown'
|
||||
|
||||
export type WslInteropHealthDeps = {
|
||||
platform(): NodeJS.Platform
|
||||
listRunningDistros(): Promise<string[]>
|
||||
resolveDefaultDistro(): Promise<string | null>
|
||||
runScript(
|
||||
distro: string,
|
||||
script: string,
|
||||
user?: 'root'
|
||||
): Promise<{ code: number | null; stdout: string }>
|
||||
subscribeStops(listener: (stopped: string[], running: string[]) => void): () => void
|
||||
now(): number
|
||||
schedule(fn: () => void, delayMs: number): void
|
||||
log(message: string): void
|
||||
}
|
||||
|
||||
// Why delayed: the spawn that reported activity is what boots the distro, and the check must never be the one that does.
|
||||
export const ACTIVITY_CHECK_DELAY_MS = 5_000
|
||||
export const ACTIVITY_CHECK_INTERVAL_MS = 60_000
|
||||
// A stop-triggered check bypasses the activity interval but not this floor.
|
||||
export const STOP_CHECK_FLOOR_MS = 5_000
|
||||
export const REPAIR_FAILURE_BACKOFF_MS = 10 * 60_000
|
||||
const PROBE_TIMEOUT_MS = 10_000
|
||||
|
||||
type DistroHealth = {
|
||||
name: string
|
||||
lastCheckAt: number
|
||||
repairFailedAt: number
|
||||
scheduled: boolean
|
||||
inFlight: Promise<WslInteropCheckOutcome> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects and restores a wiped `WSLInterop` entry in distros Orca is using.
|
||||
*
|
||||
* Why automatic: the missing entry is a WSL bug state, not a user choice; the
|
||||
* repair re-registers exactly what WSL's init registers at boot, it is
|
||||
* idempotent, and a prompt would ask users to approve something they cannot
|
||||
* diagnose. `[interop] enabled=false` in the distro's wsl.conf is respected.
|
||||
*/
|
||||
export class WslInteropHealthMonitor {
|
||||
private readonly distros = new Map<string, DistroHealth>()
|
||||
private unsubscribe: (() => void) | null = null
|
||||
|
||||
constructor(private readonly deps: WslInteropHealthDeps) {}
|
||||
|
||||
/** A WSL terminal is starting in `distro` (null: the default distro). */
|
||||
noteActivity(distro: string | null): void {
|
||||
if (this.deps.platform() !== 'win32') {
|
||||
return
|
||||
}
|
||||
this.unsubscribe ??= this.deps.subscribeStops((stopped, running) =>
|
||||
this.onDistrosStopped(stopped, running)
|
||||
)
|
||||
void this.resolveName(distro).then((name) => {
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
const health = this.healthFor(name)
|
||||
if (health.scheduled || this.deps.now() - health.lastCheckAt < ACTIVITY_CHECK_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
health.scheduled = true
|
||||
this.deps.schedule(() => {
|
||||
health.scheduled = false
|
||||
void this.check(name, { confirmRunning: true })
|
||||
}, ACTIVITY_CHECK_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
/** A stopping systemd distro is exactly when the wipe happens; re-check the ones still up. */
|
||||
private onDistrosStopped(_stopped: string[], running: string[]): void {
|
||||
const runningKeys = new Set(running.map((name) => name.toLowerCase()))
|
||||
for (const [key, health] of this.distros) {
|
||||
if (runningKeys.has(key) && this.deps.now() - health.lastCheckAt >= STOP_CHECK_FLOOR_MS) {
|
||||
// The confirmed list that reported the stop also proves this distro is up.
|
||||
void this.check(health.name, { confirmRunning: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check(distro: string, options: { confirmRunning: boolean }): Promise<WslInteropCheckOutcome> {
|
||||
const health = this.healthFor(distro)
|
||||
if (health.inFlight) {
|
||||
return health.inFlight
|
||||
}
|
||||
health.lastCheckAt = this.deps.now()
|
||||
const run = this.runCheck(health, options.confirmRunning)
|
||||
.catch((error: unknown) => {
|
||||
this.deps.log(`[wsl] interop check failed for ${distro}: ${String(error)}`)
|
||||
return 'unknown' as const
|
||||
})
|
||||
.finally(() => {
|
||||
if (health.inFlight === run) {
|
||||
health.inFlight = null
|
||||
}
|
||||
})
|
||||
health.inFlight = run
|
||||
return run
|
||||
}
|
||||
|
||||
private async runCheck(
|
||||
health: DistroHealth,
|
||||
confirmRunning: boolean
|
||||
): Promise<WslInteropCheckOutcome> {
|
||||
const distro = health.name
|
||||
if (confirmRunning && !(await this.isRunning(distro))) {
|
||||
return 'not-running'
|
||||
}
|
||||
const probe = await this.deps.runScript(distro, WSL_INTEROP_PROBE_SCRIPT)
|
||||
const result = parseWslInteropProbeOutput(probe.stdout)
|
||||
if (result.state === 'present') {
|
||||
return 'intact'
|
||||
}
|
||||
if (result.state === 'unmounted') {
|
||||
return 'unsupported'
|
||||
}
|
||||
if (result.state === 'unknown') {
|
||||
return 'unknown'
|
||||
}
|
||||
if (result.interopDisabled) {
|
||||
return 'interop-disabled'
|
||||
}
|
||||
if (this.deps.now() - health.repairFailedAt < REPAIR_FAILURE_BACKOFF_MS) {
|
||||
return 'repair-failed'
|
||||
}
|
||||
const repair = await this.deps.runScript(distro, WSL_INTEROP_REPAIR_SCRIPT, 'root')
|
||||
const repaired = repair.code === 0 ? parseWslInteropRepairOutput(repair.stdout) : 'unknown'
|
||||
if (repaired === 'unknown') {
|
||||
health.repairFailedAt = this.deps.now()
|
||||
this.deps.log(`[wsl] could not restore Windows interop in ${distro} (exit ${repair.code})`)
|
||||
return 'repair-failed'
|
||||
}
|
||||
if (repaired === 'present') {
|
||||
// Another repair (or WSL itself) restored it between probe and repair.
|
||||
return 'intact'
|
||||
}
|
||||
this.deps.log(
|
||||
`[wsl] restored the WSLInterop binfmt entry from ${distro}; another distro's systemd ` +
|
||||
'shutdown removed it (fixed in WSL 3.0.1, run `wsl --update`)'
|
||||
)
|
||||
return 'repaired'
|
||||
}
|
||||
|
||||
private async isRunning(distro: string): Promise<boolean> {
|
||||
try {
|
||||
const running = await this.deps.listRunningDistros()
|
||||
return running.some((name) => name.toLowerCase() === distro.toLowerCase())
|
||||
} catch {
|
||||
// Unconfirmed: `wsl -d` would boot a stopped distro, so skip.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveName(distro: string | null): Promise<string | null> {
|
||||
const trimmed = distro?.trim()
|
||||
if (trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
try {
|
||||
return await this.deps.resolveDefaultDistro()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private healthFor(distro: string): DistroHealth {
|
||||
const key = distro.toLowerCase()
|
||||
let health = this.distros.get(key)
|
||||
if (!health) {
|
||||
health = {
|
||||
name: distro,
|
||||
lastCheckAt: -Infinity,
|
||||
repairFailedAt: -Infinity,
|
||||
scheduled: false,
|
||||
inFlight: null
|
||||
}
|
||||
this.distros.set(key, health)
|
||||
}
|
||||
return health
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.unsubscribe?.()
|
||||
this.unsubscribe = null
|
||||
this.distros.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export const wslInteropHealthMonitor = new WslInteropHealthMonitor({
|
||||
platform: () => process.platform,
|
||||
listRunningDistros: () => listRunningWslDistrosAsync({ requireConfirmed: true }),
|
||||
resolveDefaultDistro: async () => (await listWslDistrosAsync())[0] ?? null,
|
||||
runScript: (distro, script, user) =>
|
||||
runWslProcess({ distro, user, loginPath: 'none', script, timeoutMs: PROBE_TIMEOUT_MS }),
|
||||
subscribeStops: onRunningWslDistrosStopped,
|
||||
now: () => Date.now(),
|
||||
schedule: (fn, delayMs) => {
|
||||
setTimeout(fn, delayMs).unref?.()
|
||||
},
|
||||
log: (message) => console.warn(message)
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isWslConfInteropDisabled,
|
||||
parseWslInteropProbeOutput,
|
||||
parseWslInteropRepairOutput,
|
||||
WSL_INTEROP_PROBE_SCRIPT,
|
||||
WSL_INTEROP_REPAIR_SCRIPT
|
||||
} from './wsl-interop-registration'
|
||||
|
||||
describe('parseWslInteropProbeOutput', () => {
|
||||
it('reads present, unmounted and garbage', () => {
|
||||
expect(parseWslInteropProbeOutput('orca-wsl-interop:present\n')).toEqual({ state: 'present' })
|
||||
expect(parseWslInteropProbeOutput('motd\r\norca-wsl-interop:unmounted\r\n')).toEqual({
|
||||
state: 'unmounted'
|
||||
})
|
||||
expect(parseWslInteropProbeOutput('')).toEqual({ state: 'unknown' })
|
||||
expect(parseWslInteropProbeOutput('Exec format error')).toEqual({ state: 'unknown' })
|
||||
})
|
||||
|
||||
it('carries the wsl.conf interop setting when the entry is missing', () => {
|
||||
expect(
|
||||
parseWslInteropProbeOutput(
|
||||
'orca-wsl-interop:missing\norca-wsl-interop:conf\n[boot]\nsystemd=true\n'
|
||||
)
|
||||
).toEqual({ state: 'missing', interopDisabled: false })
|
||||
expect(
|
||||
parseWslInteropProbeOutput(
|
||||
'orca-wsl-interop:missing\norca-wsl-interop:conf\n[interop]\nenabled=false\n'
|
||||
)
|
||||
).toEqual({ state: 'missing', interopDisabled: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWslConfInteropDisabled', () => {
|
||||
it.each([
|
||||
['[interop]\nenabled=false', true],
|
||||
['[Interop]\n Enabled = FALSE # off', true],
|
||||
['[interop]\nenabled="false"', true],
|
||||
['[interop]\nenabled=0', true],
|
||||
['[interop]\nenabled=true', false],
|
||||
['[interop]\nappendWindowsPath=false', false],
|
||||
['[automount]\nenabled=false', false],
|
||||
['# [interop]\n# enabled=false', false],
|
||||
['[interop]\nenabled=false\nenabled=true', false],
|
||||
['', false]
|
||||
])('%j -> %s', (conf, expected) => {
|
||||
expect(isWslConfInteropDisabled(conf)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scripts', () => {
|
||||
it('accepts either interop entry name and never flushes binfmt', () => {
|
||||
for (const script of [WSL_INTEROP_PROBE_SCRIPT, WSL_INTEROP_REPAIR_SCRIPT]) {
|
||||
expect(script).toContain('/proc/sys/fs/binfmt_misc/WSLInterop-late')
|
||||
expect(script).not.toMatch(/systemd-binfmt|\/status/)
|
||||
// --exec passes argv verbatim, but keep the payload free of guest expansions anyway.
|
||||
expect(script).not.toContain('$')
|
||||
}
|
||||
expect(WSL_INTEROP_REPAIR_SCRIPT).toContain(
|
||||
"printf ':WSLInterop:M::MZ::/init:P' > /proc/sys/fs/binfmt_misc/register"
|
||||
)
|
||||
// A failed write must not decide the verdict; only the re-check does.
|
||||
expect(WSL_INTEROP_REPAIR_SCRIPT).not.toContain('|| exit')
|
||||
})
|
||||
|
||||
it('parses repair results', () => {
|
||||
expect(parseWslInteropRepairOutput('orca-wsl-interop:repaired\n')).toBe('repaired')
|
||||
expect(parseWslInteropRepairOutput('orca-wsl-interop:present\n')).toBe('present')
|
||||
expect(parseWslInteropRepairOutput('sh: permission denied')).toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Guest-side detection and repair of the `WSLInterop` binfmt_misc entry.
|
||||
*
|
||||
* Why: on WSL < 3.0.1 a graceful stop of ANY systemd distro (including the
|
||||
* ~15 s idle stop) writes -1 to binfmt_misc/status, which deletes every entry
|
||||
* VM-wide. Every other running distro then fails to exec Windows binaries
|
||||
* (`code .`, `explorer.exe`, Orca's WSL CLI bridge) with "Exec format error".
|
||||
* Fixed upstream in microsoft/WSL#13885. The registry is shared by all
|
||||
* distros, so one repair restores every one of them.
|
||||
*/
|
||||
|
||||
export type WslInteropProbeResult =
|
||||
| { state: 'present' }
|
||||
/** binfmt_misc is not mounted, so there is nothing Orca can safely restore. */
|
||||
| { state: 'unmounted' }
|
||||
| { state: 'missing'; interopDisabled: boolean }
|
||||
| { state: 'unknown' }
|
||||
|
||||
const MARKER = 'orca-wsl-interop:'
|
||||
const BINFMT_DIR = '/proc/sys/fs/binfmt_misc'
|
||||
|
||||
// Why both names: some WSL builds register `WSLInterop-late` after systemd starts.
|
||||
const PRESENT_TEST = `[ -e ${BINFMT_DIR}/WSLInterop ] || [ -e ${BINFMT_DIR}/WSLInterop-late ]`
|
||||
|
||||
/** Read-only; runs as the default user. wsl.conf is only printed when it matters. */
|
||||
export const WSL_INTEROP_PROBE_SCRIPT = [
|
||||
`if [ ! -e ${BINFMT_DIR}/register ]; then echo '${MARKER}unmounted'`,
|
||||
`elif ${PRESENT_TEST}; then echo '${MARKER}present'`,
|
||||
`else echo '${MARKER}missing'; echo '${MARKER}conf'; cat /etc/wsl.conf 2>/dev/null; fi`
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* Idempotent; runs as root. Re-checks so a concurrent repair (or WSL itself)
|
||||
* never gets a duplicate entry. Never `systemctl restart systemd-binfmt`:
|
||||
* that flushes every entry first. Flags match WSL's own `P` registration; `F`
|
||||
* would pin the repairing distro's /init open for the life of the VM.
|
||||
*/
|
||||
export const WSL_INTEROP_REPAIR_SCRIPT = [
|
||||
`if ${PRESENT_TEST}; then echo '${MARKER}present'; exit 0; fi`,
|
||||
// No trailing newline: busybox printf writes it separately and the kernel rejects that write
|
||||
// with EINVAL after registering. The re-check below, not the write status, is the verdict.
|
||||
`printf ':WSLInterop:M::MZ::/init:P' > ${BINFMT_DIR}/register`,
|
||||
`if ${PRESENT_TEST}; then echo '${MARKER}repaired'; else exit 1; fi`
|
||||
].join('\n')
|
||||
|
||||
function markerLines(stdout: string): string[] {
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith(MARKER))
|
||||
}
|
||||
|
||||
export function parseWslInteropProbeOutput(stdout: string): WslInteropProbeResult {
|
||||
const [first] = markerLines(stdout)
|
||||
if (first === `${MARKER}present`) {
|
||||
return { state: 'present' }
|
||||
}
|
||||
if (first === `${MARKER}unmounted`) {
|
||||
return { state: 'unmounted' }
|
||||
}
|
||||
if (first === `${MARKER}missing`) {
|
||||
const confStart = stdout.indexOf(`${MARKER}conf`)
|
||||
const conf = confStart !== -1 ? stdout.slice(confStart + `${MARKER}conf`.length) : ''
|
||||
return { state: 'missing', interopDisabled: isWslConfInteropDisabled(conf) }
|
||||
}
|
||||
return { state: 'unknown' }
|
||||
}
|
||||
|
||||
export function parseWslInteropRepairOutput(stdout: string): 'present' | 'repaired' | 'unknown' {
|
||||
const [first] = markerLines(stdout)
|
||||
if (first === `${MARKER}present`) {
|
||||
return 'present'
|
||||
}
|
||||
return first === `${MARKER}repaired` ? 'repaired' : 'unknown'
|
||||
}
|
||||
|
||||
const FALSE_VALUES = new Set(['false', '0', 'no', 'off'])
|
||||
|
||||
/** True when /etc/wsl.conf sets `[interop] enabled = false`. */
|
||||
export function isWslConfInteropDisabled(conf: string): boolean {
|
||||
let section = ''
|
||||
let disabled = false
|
||||
for (const rawLine of conf.split(/\r?\n/)) {
|
||||
const line = rawLine.replace(/[#;].*$/, '').trim()
|
||||
const header = /^\[([^\]]*)\]$/.exec(line)
|
||||
if (header) {
|
||||
section = header[1].trim().toLowerCase()
|
||||
continue
|
||||
}
|
||||
if (section !== 'interop') {
|
||||
continue
|
||||
}
|
||||
const entry = /^([^=]+)=(.*)$/.exec(line)
|
||||
if (!entry || entry[1].trim().toLowerCase() !== 'enabled') {
|
||||
continue
|
||||
}
|
||||
// Last assignment wins, as in any INI reader.
|
||||
const value = entry[2]
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.toLowerCase()
|
||||
disabled = FALSE_VALUES.has(value)
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
@@ -73,6 +73,11 @@ describe('separator', () => {
|
||||
await runWslProcess({ loginPath: 'preferred', distro: 'Ubuntu', program: '/bin/true' })
|
||||
expect(lastArgv().slice(0, 3)).toEqual(['-d', 'Ubuntu', '--exec'])
|
||||
})
|
||||
|
||||
it('passes the guest user before --exec', async () => {
|
||||
await runWslProcess({ loginPath: 'none', distro: 'Ubuntu', user: 'root', program: '/bin/true' })
|
||||
expect(lastArgv().slice(0, 5)).toEqual(['-d', 'Ubuntu', '-u', 'root', '--exec'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('probe lane', () => {
|
||||
|
||||
@@ -64,6 +64,8 @@ export type WslCommand =
|
||||
export type WslSpec = WslCommand & {
|
||||
/** Undefined selects the distro's default. */
|
||||
distro?: string
|
||||
/** Guest user; undefined runs as the distro's default user. */
|
||||
user?: 'root'
|
||||
/** Required, with no default: the wrong answer here is the defect this file exists to prevent. */
|
||||
loginPath: WslLoginPath
|
||||
/** Guest (POSIX) path. */
|
||||
@@ -224,7 +226,10 @@ export async function runWslProcess(spec: WslSpec): Promise<WslResult> {
|
||||
const argvForm = buildGuestArgv(environment, spec, 'argv')
|
||||
// Measure what is actually spawned: `wsl.exe` and `-d <distro> --exec` are
|
||||
// prepended after this point and are part of the same budget.
|
||||
const fullLine = [resolveWslExecutablePath(), ...buildWslExecArgs(spec.distro, argvForm)]
|
||||
const fullLine = [
|
||||
resolveWslExecutablePath(),
|
||||
...buildWslExecArgs(spec.distro, argvForm, { user: spec.user })
|
||||
]
|
||||
// Argv is the default, but it has a hard ceiling that stdin does not. A user's
|
||||
// `orca.yaml` hook is the one unbounded script Orca runs, so past the cap the
|
||||
// choice is between failing to spawn at all and accepting the stdin caveat.
|
||||
@@ -239,7 +244,7 @@ export async function runWslProcess(spec: WslSpec): Promise<WslResult> {
|
||||
const remainingMs = Math.max(1, deadline - Date.now())
|
||||
const result = await runProcess({
|
||||
program: resolveWslExecutablePath(),
|
||||
args: buildWslExecArgs(spec.distro, argv),
|
||||
args: buildWslExecArgs(spec.distro, argv, { user: spec.user }),
|
||||
// Name a Windows directory rather than inheriting one: an inherited cwd that
|
||||
// is later deleted (the worktree Orca launched from) fails every later spawn
|
||||
// (#16463). Never the guest cwd -- withGuestCwd still cds inside.
|
||||
|
||||
@@ -17,9 +17,15 @@ export function quotePosixShell(value: string): string {
|
||||
*/
|
||||
export function buildWslExecArgs(
|
||||
distro: string | undefined,
|
||||
shellArgs: readonly string[]
|
||||
shellArgs: readonly string[],
|
||||
options: { user?: string } = {}
|
||||
): string[] {
|
||||
return [...(distro ? ['-d', distro] : []), '--exec', ...shellArgs]
|
||||
return [
|
||||
...(distro ? ['-d', distro] : []),
|
||||
...(options.user ? ['-u', options.user] : []),
|
||||
'--exec',
|
||||
...shellArgs
|
||||
]
|
||||
}
|
||||
|
||||
export function buildWslLoginShellCommand(command: string): string {
|
||||
|
||||
Reference in New Issue
Block a user