fix(wsl): Settings CLI status, CLI repair, interop health and link checks never boot a stopped distro (E9) (#35412)

* fix(wsl): Settings CLI status, startup CLI repair, interop health and link checks never boot a stopped distro (E9)

- cli:getWslInstallStatus: opening Settings takes a settled running read and shows a refreshable
  "not running" status instead of starting the distro; the refresh button passes wakeWslDistro.
- Startup CLI repair re-reads each listed distro (settled) right before its repair spawns and
  defers it otherwise, so a distro stopping mid fan-out is not restarted.
- Interop health: stop-triggered checks no longer trust the stop event's list; every check takes a
  settled read, and again before the root repair.
- shell:pathsExist (chat link and terminal hover existence) refuses a stopped distro's UNC path
  instead of statting it; callers already retry an unverifiable answer.

* docs(wsl): note the missing-kernel availability probe as the known E9 exception

* fix(wsl): shell:pathsExist refuses only the stopped distro's path, not the whole batch

One UNC path in a stopped distro no longer fails the host paths checked with it; the renderer
treats that path alone as unverifiable and retries it.
This commit is contained in:
Neil
2026-10-28 18:24:26 -07:00
committed by GitHub
parent c14c99bb49
commit 3a68805eee
18 changed files with 365 additions and 95 deletions
@@ -25,6 +25,7 @@ describe('reconcileManagedWslCliRegistrations', () => {
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu', 'Debian', 'Fedora'],
listRunningDistros: async () => ['Ubuntu', 'Debian', 'Fedora'],
isDistroRunning: async (distro: string) => ['Ubuntu', 'Debian', 'Fedora'].includes(distro),
registry,
createInstaller: (distro) => {
if (distro === 'Ubuntu') {
@@ -63,6 +64,7 @@ describe('reconcileManagedWslCliRegistrations', () => {
userDataPath: '/user-data',
listDistros: async () => ['Broken Distro', 'No Interop'],
listRunningDistros: async () => ['Broken Distro', 'No Interop'],
isDistroRunning: async (distro: string) => ['Broken Distro', 'No Interop'].includes(distro),
registry,
createInstaller: (distro) => ({
repairManagedRegistration: async () => {
@@ -98,6 +100,7 @@ describe('reconcileManagedWslCliRegistrations', () => {
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu'],
listRunningDistros: async () => ['Ubuntu'],
isDistroRunning: async (distro: string) => ['Ubuntu'].includes(distro),
registry: {
getCandidates: async () => ['Ubuntu'],
recordObservations: async () => {
@@ -141,6 +144,7 @@ describe('reconcileManagedWslCliRegistrations', () => {
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu'],
listRunningDistros: async () => ['Ubuntu'],
isDistroRunning: async (distro: string) => ['Ubuntu'].includes(distro),
registry,
createInstaller: () => ({
repairManagedRegistration: async () => {
@@ -192,6 +196,7 @@ describe('reconcileManagedWslCliRegistrations', () => {
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu', 'Debian'],
listRunningDistros: async () => running,
isDistroRunning: async (distro: string) => running.includes(distro),
deferUntilRunning: (distro: string, _key: string, task: () => unknown) => {
deferred.set(distro, task)
},
@@ -219,4 +224,35 @@ describe('reconcileManagedWslCliRegistrations', () => {
appVersion: ''
})
})
it('defers a listed distro that a settled read finds stopped, never spawning its repair (E9)', async () => {
const repaired: string[] = []
const deferred: string[] = []
const results = await reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu', 'Debian'],
// The unsettled list still names Debian while it stops.
listRunningDistros: async () => ['Ubuntu', 'Debian'],
isDistroRunning: async (distro: string) => distro === 'Ubuntu',
deferUntilRunning: (distro: string) => {
deferred.push(distro)
},
registry: {
getCandidates: async () => ['Ubuntu', 'Debian'],
recordObservations: async () => undefined
},
createInstaller: (distro: string) => ({
repairManagedRegistration: async () => {
repaired.push(distro)
return { changed: false, managed: true, status: { state: 'installed' as const } }
}
})
})
expect(repaired).toEqual(['Ubuntu'])
expect(deferred).toEqual(['Debian'])
expect(results).toContainEqual({ distro: 'Debian', outcome: 'deferred' })
})
})
@@ -2,6 +2,7 @@ import type { CliInstallState, CliInstallStatus } from '../../shared/cli-install
import { listWslDistrosAsync } from '../wsl'
import {
deferUntilWslDistroRunning,
isWslDistroRunning,
listConfirmedRunningWslDistros
} from '../wsl-background-distro-gate'
import { CliInstaller } from './cli-installer'
@@ -48,6 +49,8 @@ type WslCliRegistrationReconciliationOptions = {
getHostLauncherTarget?: () => Promise<string | null>
registry?: WslCliRegistrationRegistry
listRunningDistros?: () => Promise<string[]>
/** Settled read taken right before each repair spawns. */
isDistroRunning?: (distro: string) => Promise<boolean>
deferUntilRunning?: (distro: string, key: string, task: () => unknown) => void
}
@@ -121,18 +124,18 @@ export async function reconcileManagedWslCliRegistrations(
)
const distros = candidates.filter((distro) => running.has(distro.toLowerCase()))
const deferUntilRunning = options.deferUntilRunning ?? deferUntilWslDistroRunning
const deferred: WslCliRegistrationReconciliationResult[] = []
for (const distro of candidates) {
if (running.has(distro.toLowerCase())) {
continue
}
deferred.push({ distro, outcome: 'deferred' })
const isDistroRunning = options.isDistroRunning ?? isWslDistroRunning
const deferReconciliation = (distro: string): WslCliRegistrationReconciliationResult => {
deferUntilRunning(distro, DEFERRED_RECONCILIATION_KEY, () =>
reconcileManagedWslCliRegistrations({ ...options, listDistros: async () => [distro] }).then(
logWslCliReconciliationResults
)
)
return { distro, outcome: 'deferred' }
}
const deferred = candidates
.filter((distro) => !running.has(distro.toLowerCase()))
.map(deferReconciliation)
if (distros.length === 0) {
return deferred
}
@@ -193,8 +196,9 @@ export async function reconcileManagedWslCliRegistrations(
return
}
const distro = distros[index]
results[index] = await runSerializedWslCliRegistrationOperation(distro, () =>
reconcileDistro(distro)
// The list may name a distro still stopping, or one that stopped while earlier repairs ran (E9).
results[index] = await runSerializedWslCliRegistrationOperation(distro, async () =>
(await isDistroRunning(distro)) ? reconcileDistro(distro) : deferReconciliation(distro)
)
}
})
+13
View File
@@ -127,6 +127,19 @@ export function unreachableWslCliStatus(distro: string): CliInstallStatus {
)
}
/** A background status read found the distro stopped (E9); Settings' refresh is a user act that starts it. */
export function notRunningWslCliStatus(
distro: string,
state: 'stopped' | 'unverifiable'
): CliInstallStatus {
return unsupportedWslCliStatus(
'wsl_unreachable',
state === 'stopped'
? `WSL (${distro}) is not running. Refresh to start it and check the CLI.`
: `WSL didn't say whether ${distro} is running. Refresh to try again.`
)
}
export async function resolveReadyWslCliState(args: {
platform: NodeJS.Platform
distro: string | null
+54 -3
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as BackgroundDistroGate from '../wsl-background-distro-gate'
const {
handlers,
@@ -6,8 +7,10 @@ const {
wslInstallerMock,
recordInstalledMock,
recordRemovedMock,
getDefaultWslDistroMock
getDefaultWslDistroMock,
readWslDistroRunningStateMock
} = vi.hoisted(() => ({
readWslDistroRunningStateMock: vi.fn(),
handlers: new Map<string, (...args: unknown[]) => unknown>(),
ipcHandleMock: vi.fn(),
wslInstallerMock: vi.fn(),
@@ -29,11 +32,18 @@ vi.mock('../startup/hydrate-shell-path', () => ({
mergePathSegments: vi.fn()
}))
vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock }))
vi.mock('../wsl-background-distro-gate', async (importOriginal) => ({
...(await importOriginal<typeof BackgroundDistroGate>()),
readWslDistroRunningState: readWslDistroRunningStateMock
}))
import { registerCliHandlers } from './cli'
import { WslCliUnreachableError } from '../cli/wsl-cli-status'
type WslHandler = (event: unknown, args?: { distro?: string | null }) => Promise<{ state: string }>
type WslHandler = (
event: unknown,
args?: { distro?: string | null; wakeWslDistro?: boolean }
) => Promise<{ state: string }>
function getWslHandler(channel: string): WslHandler {
const handler = handlers.get(channel)
@@ -173,4 +183,45 @@ describe('WSL CLI registration IPC', () => {
recordRemovedMock.mock.invocationCallOrder[0]
)
})
describe('on Windows (E9)', () => {
const originalPlatform = process.platform
const getStatus = vi.fn(async () => ({ state: 'installed' }))
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
readWslDistroRunningStateMock.mockReset().mockResolvedValue('stopped')
getStatus.mockClear()
wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
return { getStatus }
})
})
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
})
it('shows a refreshable state instead of starting a stopped distro when Settings opens', async () => {
await expect(getWslHandler('cli:getWslInstallStatus')({})).resolves.toMatchObject({
state: 'unsupported',
unsupportedReason: 'wsl_unreachable',
detail: expect.stringContaining('Ubuntu) is not running')
})
expect(wslInstallerMock).not.toHaveBeenCalled()
})
it('lets the refresh button start the distro to read its status', async () => {
await expect(
getWslHandler('cli:getWslInstallStatus')({}, { wakeWslDistro: true })
).resolves.toEqual({ state: 'installed' })
expect(getStatus).toHaveBeenCalledTimes(1)
})
it('reads a running distro exactly as before', async () => {
readWslDistroRunningStateMock.mockResolvedValue('running')
await expect(getWslHandler('cli:getWslInstallStatus')({})).resolves.toEqual({
state: 'installed'
})
})
})
})
+25 -9
View File
@@ -8,11 +8,16 @@ import {
recordWslCliRegistrationRemoved
} from '../cli/wsl-cli-registration-registry'
import { WslCliInstaller } from '../cli/wsl-cli-installer'
import { unreachableWslCliStatus, WslCliUnreachableError } from '../cli/wsl-cli-status'
import {
notRunningWslCliStatus,
unreachableWslCliStatus,
WslCliUnreachableError
} from '../cli/wsl-cli-status'
import { runSerializedWslCliRegistrationOperation } from '../cli/wsl-cli-registration-operation'
import { getCanonicalUserDataPath } from '../persistence'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
import { getDefaultWslDistro } from '../wsl'
import { blockedWslDistroForExec, runAsWslExecUserAct } from '../wsl/wsl-background-exec-gate'
import { resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity'
const APPIMAGE_REPAIR_RETRY_MS = 30_000
@@ -136,18 +141,29 @@ export function registerCliHandlers(): void {
ipcMain.handle(
'cli:getWslInstallStatus',
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
async (
_event,
args?: { distro?: string | null; wakeWslDistro?: boolean }
): Promise<CliInstallStatus> => {
// Keep status reads independent of long repairs; re-polling corrects transient state.
const distro = resolveWslCliDistro(args)
try {
return await new WslCliInstaller({ distro }).getStatus()
} catch (error) {
// Cold/busy distros are retryable Settings states, not raw IPC failures (#29497).
if (distro && error instanceof WslCliUnreachableError) {
return unreachableWslCliStatus(distro)
const readStatus = async (): Promise<CliInstallStatus> => {
// Opening Settings must not start a stopped distro (E9); its refresh button wakes it.
const blocked = await blockedWslDistroForExec(distro)
if (blocked) {
return notRunningWslCliStatus(blocked.distro, blocked.state)
}
try {
return await new WslCliInstaller({ distro }).getStatus()
} catch (error) {
// Cold/busy distros are retryable Settings states, not raw IPC failures (#29497).
if (distro && error instanceof WslCliUnreachableError) {
return unreachableWslCliStatus(distro)
}
throw error
}
throw error
}
return args?.wakeWslDistro ? runAsWslExecUserAct(distro, readStatus) : readStatus()
}
)
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as BackgroundDistroGate from '../wsl-background-distro-gate'
import type * as ShellOwnedPathTarget from './shell-owned-path-target'
const mocks = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, args?: unknown) => Promise<unknown>>(),
readWslDistroRunningState: vi.fn(),
pathExists: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: (name: string, fn: (event: unknown, args?: unknown) => Promise<unknown>) =>
mocks.handlers.set(name, fn)
},
shell: {},
dialog: {}
}))
vi.mock('../wsl-background-distro-gate', async (importOriginal) => ({
...(await importOriginal<typeof BackgroundDistroGate>()),
readWslDistroRunningState: mocks.readWslDistroRunningState
}))
vi.mock('./shell-owned-path-target', async (importOriginal) => ({
...(await importOriginal<typeof ShellOwnedPathTarget>()),
pathExists: mocks.pathExists
}))
import { registerShellHandlers } from './shell'
const UNC_FILE = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\README.md'
const HOST_FILE = 'C:\\repo\\README.md'
function pathsExist(paths: string[]): Promise<unknown> {
const handler = mocks.handlers.get('shell:pathsExist')
if (!handler) {
throw new Error('shell:pathsExist is not registered')
}
return handler(null, paths)
}
describe('shell:pathsExist never starts a stopped WSL distro (E9)', () => {
const originalPlatform = process.platform
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
mocks.handlers.clear()
mocks.readWslDistroRunningState.mockReset().mockResolvedValue('stopped')
mocks.pathExists.mockReset().mockResolvedValue(true)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: path-existence handlers never read the store.
registerShellHandlers({} as never)
})
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
})
it('refuses only the stopped distro path, never touching it, and answers the rest', async () => {
await expect(pathsExist([HOST_FILE, UNC_FILE])).resolves.toEqual([
true,
{ error: expect.stringContaining("WSL distro 'Ubuntu' is not running") }
])
expect(mocks.pathExists).not.toHaveBeenCalledWith(UNC_FILE)
})
it('answers host paths and running-distro paths as before', async () => {
mocks.readWslDistroRunningState.mockResolvedValue('running')
await expect(pathsExist([HOST_FILE, UNC_FILE])).resolves.toEqual([true, true])
expect(mocks.pathExists).toHaveBeenCalledWith(UNC_FILE)
})
})
+22 -5
View File
@@ -1,5 +1,8 @@
import { translateMain } from '../i18n/main-i18n'
import { validatePathExistenceBatch } from '../../shared/path-existence-batch'
import {
validatePathExistenceBatch,
type ShellPathExistence
} from '../../shared/path-existence-batch'
import { ipcMain, shell, dialog } from 'electron'
import { constants, copyFile, readFile, stat } from 'node:fs/promises'
import { basename, extname, isAbsolute, normalize, posix, win32 } from 'node:path'
@@ -29,6 +32,7 @@ import {
resolveOwnedPathTarget,
validateLocalPathTarget
} from './shell-owned-path-target'
import { guardWslUncPathAccess } from '../wsl/wsl-unc-access-gate'
export { EXTERNAL_EDITOR_CLI_COMMAND }
@@ -194,10 +198,23 @@ export function registerShellHandlers(
await openWithSystemDefault(target.path, ownerHostId)
})
ipcMain.handle('shell:pathsExist', async (_event, paths: string[]): Promise<boolean[]> => {
validatePathExistenceBatch(paths)
return Promise.all(paths.map(pathExists))
})
ipcMain.handle(
'shell:pathsExist',
async (_event, paths: string[]): Promise<ShellPathExistence[]> => {
validatePathExistenceBatch(paths)
// Chat link renders and terminal hovers ask unprompted: a stat of a stopped distro's UNC path
// boots it (E9). Refuse that path alone, so the caller retries it and keeps the rest.
return Promise.all(
paths.map((path) =>
guardWslUncPathAccess(path, () => pathExists(path)).catch(
(error: unknown): ShellPathExistence => ({
error: error instanceof Error ? error.message : String(error)
})
)
)
)
}
)
ipcMain.handle('shell:pathExists', async (_event, filePath: string): Promise<boolean> => {
return pathExists(filePath)
+1
View File
@@ -110,6 +110,7 @@ async function probeWslAvailability(): Promise<WslProbeFailure | null> {
if (status.code === 0) {
return null
}
// Known E9 exception: this `--exec` can start a stopped default distro, but only on a missing kernel.
if (MISSING_WSL2_KERNEL_EXIT_CODES.has(status.code) && (await defaultGuestRuns())) {
return null
}
+43 -25
View File
@@ -1,9 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../wsl', () => ({
listRunningWslDistrosAsync: vi.fn(),
listWslDistrosAsync: vi.fn()
}))
vi.mock('../wsl-background-distro-gate', () => ({ isWslDistroRunning: vi.fn() }))
vi.mock('./wsl-runner', () => ({ runWslProcess: vi.fn() }))
import {
@@ -26,7 +26,7 @@ function harness(overrides: Partial<WslInteropHealthDeps> = {}) {
let stopListener: ((stopped: string[], running: string[]) => void) | null = null
const deps: WslInteropHealthDeps = {
platform: () => 'win32',
listRunningDistros: vi.fn(async () => ['Ubuntu']),
isRunning: vi.fn(async (distro: string) => distro === 'Ubuntu'),
resolveDefaultDistro: vi.fn(async () => 'Ubuntu'),
runScript: vi.fn(async () => ({ code: 0, stdout: PRESENT })),
subscribeStops: (listener) => {
@@ -64,7 +64,7 @@ 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')
await expect(h.monitor.check('Ubuntu')).resolves.toBe('intact')
expect(h.deps.runScript).toHaveBeenCalledTimes(1)
expect(h.deps.runScript).toHaveBeenCalledWith('Ubuntu', WSL_INTEROP_PROBE_SCRIPT)
})
@@ -75,7 +75,7 @@ describe('WslInteropHealthMonitor.check', () => {
.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')
await expect(h.monitor.check('Ubuntu')).resolves.toBe('repaired')
expect(runScript).toHaveBeenLastCalledWith('Ubuntu', WSL_INTEROP_REPAIR_SCRIPT, 'root')
expect(h.deps.log).toHaveBeenCalledWith(expect.stringContaining('wsl --update'))
})
@@ -83,25 +83,23 @@ describe('WslInteropHealthMonitor.check', () => {
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'
)
await expect(h.monitor.check('Ubuntu')).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')
const h = harness({ isRunning: vi.fn(async (distro: string) => distro === 'Debian') })
await expect(h.monitor.check('Ubuntu')).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 () => {
isRunning: vi.fn(async () => {
throw new Error('unavailable')
})
})
await expect(h.monitor.check('Ubuntu', { confirmRunning: true })).resolves.toBe('not-running')
await expect(h.monitor.check('Ubuntu')).resolves.toBe('not-running')
expect(h.deps.runScript).not.toHaveBeenCalled()
})
@@ -114,8 +112,8 @@ describe('WslInteropHealthMonitor.check', () => {
})
)
const h = harness({ runScript })
const first = h.monitor.check('Ubuntu', { confirmRunning: false })
const second = h.monitor.check('ubuntu', { confirmRunning: false })
const first = h.monitor.check('Ubuntu')
const second = h.monitor.check('ubuntu')
expect(second).toBe(first)
await flush()
release({ code: 0, stdout: PRESENT })
@@ -128,15 +126,11 @@ describe('WslInteropHealthMonitor.check', () => {
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'
)
await expect(h.monitor.check('Ubuntu')).resolves.toBe('repair-failed')
await expect(h.monitor.check('Ubuntu')).resolves.toBe('repair-failed')
expect(runScript).toHaveBeenCalledTimes(3)
h.advance(REPAIR_FAILURE_BACKOFF_MS)
await h.monitor.check('Ubuntu', { confirmRunning: false })
await h.monitor.check('Ubuntu')
expect(runScript).toHaveBeenCalledTimes(5)
})
@@ -146,7 +140,7 @@ describe('WslInteropHealthMonitor.check', () => {
throw new Error('timeout')
})
})
await expect(h.monitor.check('Ubuntu', { confirmRunning: false })).resolves.toBe('unknown')
await expect(h.monitor.check('Ubuntu')).resolves.toBe('unknown')
})
})
@@ -167,7 +161,7 @@ describe('WslInteropHealthMonitor triggers', () => {
expect(h.deps.runScript).not.toHaveBeenCalled()
h.runTimers()
await flush()
expect(h.deps.listRunningDistros).toHaveBeenCalledTimes(1)
expect(h.deps.isRunning).toHaveBeenCalledTimes(1)
expect(h.deps.runScript).toHaveBeenCalledTimes(1)
h.monitor.noteActivity('Ubuntu')
@@ -190,8 +184,8 @@ describe('WslInteropHealthMonitor triggers', () => {
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)
// The list that reported the stop can still name a stopping distro: the re-check reads again.
expect(h.deps.isRunning).toHaveBeenCalledTimes(2)
h.advance(10_000)
h.emitStop(['Ubuntu'], [])
await flush()
@@ -230,6 +224,30 @@ describe('WslInteropHealthMonitor triggers', () => {
expect(h.deps.runScript).toHaveBeenCalledTimes(2)
})
it('never probes a distro a stop event lists but a settled read finds stopping (E9)', async () => {
const isRunning = vi.fn(async () => true)
const h = harness({ isRunning })
h.monitor.noteActivity('Ubuntu')
await flush()
h.runTimers()
await flush()
expect(h.deps.runScript).toHaveBeenCalledTimes(1)
h.advance(10_000)
isRunning.mockResolvedValue(false)
h.emitStop(['docker-desktop'], ['Ubuntu'])
await flush()
expect(h.deps.runScript).toHaveBeenCalledTimes(1)
})
it('skips the root repair when the distro stops after the probe (E9)', async () => {
const isRunning = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false)
const runScript = vi.fn(async () => ({ code: 0, stdout: MISSING }))
const h = harness({ isRunning, runScript })
await expect(h.monitor.check('Ubuntu')).resolves.toBe('not-running')
expect(runScript).toHaveBeenCalledTimes(1)
expect(runScript).toHaveBeenCalledWith('Ubuntu', WSL_INTEROP_PROBE_SCRIPT)
})
it('treats an entry restored between probe and repair as intact', async () => {
const h = harness({
runScript: vi.fn(async (_distro: string, script: string) => ({
@@ -237,7 +255,7 @@ describe('WslInteropHealthMonitor triggers', () => {
stdout: script === WSL_INTEROP_PROBE_SCRIPT ? MISSING : PRESENT
}))
})
await expect(h.monitor.check('Ubuntu', { confirmRunning: false })).resolves.toBe('intact')
await expect(h.monitor.check('Ubuntu')).resolves.toBe('intact')
expect(h.deps.log).not.toHaveBeenCalled()
})
})
+17 -15
View File
@@ -1,4 +1,5 @@
import { listRunningWslDistrosAsync, listWslDistrosAsync } from '../wsl'
import { listWslDistrosAsync } from '../wsl'
import { isWslDistroRunning } from '../wsl-background-distro-gate'
import { onRunningWslDistrosStopped } from '../wsl-running-distro-cache'
import { runWslProcess } from './wsl-runner'
import {
@@ -19,7 +20,8 @@ export type WslInteropCheckOutcome =
export type WslInteropHealthDeps = {
platform(): NodeJS.Platform
listRunningDistros(): Promise<string[]>
/** A settled running read: a list taken while a distro stops can still name it. */
isRunning(distro: string): Promise<boolean>
resolveDefaultDistro(): Promise<string | null>
runScript(
distro: string,
@@ -74,7 +76,7 @@ export class WslInteropHealthMonitor {
health.scheduled = true
this.deps.schedule(() => {
health.scheduled = false
void this.check(name, { confirmRunning: true })
void this.check(name)
}, ACTIVITY_CHECK_DELAY_MS)
})
}
@@ -84,19 +86,19 @@ export class WslInteropHealthMonitor {
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 })
// The stop's list can still name a distro that is stopping too; check() takes a settled read (E9).
void this.check(health.name)
}
}
}
check(distro: string, options: { confirmRunning: boolean }): Promise<WslInteropCheckOutcome> {
check(distro: string): 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)
const run = this.runCheck(health)
.catch((error: unknown) => {
this.deps.log(`[wsl] interop check failed for ${distro}: ${String(error)}`)
return 'unknown' as const
@@ -110,12 +112,9 @@ export class WslInteropHealthMonitor {
return run
}
private async runCheck(
health: DistroHealth,
confirmRunning: boolean
): Promise<WslInteropCheckOutcome> {
private async runCheck(health: DistroHealth): Promise<WslInteropCheckOutcome> {
const distro = health.name
if (confirmRunning && !(await this.isRunning(distro))) {
if (!(await this.isRunning(distro))) {
return 'not-running'
}
const probe = await this.deps.runScript(distro, WSL_INTEROP_PROBE_SCRIPT)
@@ -135,6 +134,10 @@ export class WslInteropHealthMonitor {
if (this.deps.now() - health.repairFailedAt < REPAIR_FAILURE_BACKOFF_MS) {
return 'repair-failed'
}
// A missing entry is what a stopping distro's systemd leaves; the repair must not restart it.
if (!(await this.isRunning(distro))) {
return 'not-running'
}
const repair = await this.deps.runScript(distro, WSL_INTEROP_REPAIR_SCRIPT, 'root')
const repaired = repair.code === 0 ? parseWslInteropRepairOutput(repair.stdout) : 'unknown'
if (repaired === 'unknown') {
@@ -155,8 +158,7 @@ export class WslInteropHealthMonitor {
private async isRunning(distro: string): Promise<boolean> {
try {
const running = await this.deps.listRunningDistros()
return running.some((name) => name.toLowerCase() === distro.toLowerCase())
return await this.deps.isRunning(distro)
} catch {
// Unconfirmed: `wsl -d` would boot a stopped distro, so skip.
return false
@@ -200,7 +202,7 @@ export class WslInteropHealthMonitor {
export const wslInteropHealthMonitor = new WslInteropHealthMonitor({
platform: () => process.platform,
listRunningDistros: () => listRunningWslDistrosAsync({ requireConfirmed: true }),
isRunning: (distro) => isWslDistroRunning(distro),
resolveDefaultDistro: async () => (await listWslDistrosAsync())[0] ?? null,
runScript: (distro, script, user) =>
runWslProcess({ distro, user, loginPath: 'none', script, timeoutMs: PROBE_TIMEOUT_MS }),
+4 -2
View File
@@ -6,8 +6,10 @@ export const cliApi = {
getInstallStatus: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:getInstallStatus'),
install: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:install'),
remove: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:remove'),
getWslInstallStatus: (args?: { distro?: string | null }): Promise<CliInstallStatus> =>
ipcRenderer.invoke('cli:getWslInstallStatus', args),
getWslInstallStatus: (args?: {
distro?: string | null
wakeWslDistro?: boolean
}): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:getWslInstallStatus', args),
installWsl: (args?: { distro?: string | null }): Promise<CliInstallStatus> =>
ipcRenderer.invoke('cli:installWsl', args),
removeWsl: (args?: { distro?: string | null }): Promise<CliInstallStatus> =>
+5 -1
View File
@@ -4,7 +4,11 @@ export type CliApi = {
getInstallStatus: () => Promise<CliInstallStatus>
install: () => Promise<CliInstallStatus>
remove: () => Promise<CliInstallStatus>
getWslInstallStatus: (args?: { distro?: string | null }) => Promise<CliInstallStatus>
/** `wakeWslDistro`: a user act (the refresh button) that may start a stopped distro. */
getWslInstallStatus: (args?: {
distro?: string | null
wakeWslDistro?: boolean
}) => Promise<CliInstallStatus>
installWsl: (args?: { distro?: string | null }) => Promise<CliInstallStatus>
removeWsl: (args?: { distro?: string | null }) => Promise<CliInstallStatus>
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { ExecutionHostId } from '../../shared/execution-host'
import type { ShellPathExistence } from '../../shared/path-existence-batch'
import type {
ShellOpenExternalEditorRequest,
ShellOpenExternalEditorResult,
@@ -24,7 +25,7 @@ export type ShellApi = {
openUrl: (url: string) => Promise<void>
openFilePath: (path: string, ownerHostId: ExecutionHostId) => Promise<boolean>
openFileUri: (uri: string, ownerHostId: ExecutionHostId) => Promise<void>
pathsExist?: (paths: string[]) => Promise<boolean[]>
pathsExist?: (paths: string[]) => Promise<ShellPathExistence[]>
pathExists: (path: string) => Promise<boolean>
pickAttachment: () => Promise<string | null>
pickAttachments: () => Promise<string[]>
+2 -1
View File
@@ -5,6 +5,7 @@ import type {
ShellOpenLocalPathResult
} from '../../shared/shell-open-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { ShellPathExistence } from '../../shared/path-existence-batch'
import type { PreloadApi } from '../api-types'
export const shellApi = {
@@ -30,7 +31,7 @@ export const shellApi = {
openFileUri: (uri: string, ownerHostId: ExecutionHostId): Promise<void> =>
ipcRenderer.invoke('shell:openFileUri', uri, ownerHostId),
pathsExist: (paths: string[]): Promise<boolean[]> =>
pathsExist: (paths: string[]): Promise<ShellPathExistence[]> =>
ipcRenderer.invoke('shell:pathsExist', paths),
pathExists: (path: string): Promise<boolean> => ipcRenderer.invoke('shell:pathExists', path),
@@ -33,30 +33,36 @@ export function WslCliRegistration({
const { wslAvailable } = useWindowsTerminalCapabilities(currentPlatform === 'win32')
const showWslCli = currentPlatform === 'win32' && wslAvailable
const refreshStatus = useCallback(async (): Promise<void> => {
setLoading(true)
try {
const next = await window.api.cli.getWslInstallStatus()
if (mountedRef.current) {
setStatus(next)
}
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.WslCliRegistration.26b4b3b00f',
'Failed to load WSL CLI status.'
)
// Mount reads must not start a stopped distro; the refresh button is a user act that may.
const refreshStatus = useCallback(
async (wakeWslDistro = false): Promise<void> => {
setLoading(true)
try {
const next = await window.api.cli.getWslInstallStatus(
wakeWslDistro ? { wakeWslDistro: true } : undefined
)
if (mountedRef.current) {
setStatus(next)
}
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.WslCliRegistration.26b4b3b00f',
'Failed to load WSL CLI status.'
)
)
}
} finally {
if (mountedRef.current) {
setLoading(false)
}
}
} finally {
if (mountedRef.current) {
setLoading(false)
}
}
}, [mountedRef])
},
[mountedRef]
)
useEffect(() => {
if (showWslCli) {
@@ -173,7 +179,7 @@ export function WslCliRegistration({
<Button
variant="ghost"
size="icon-xs"
onClick={() => void refreshStatus()}
onClick={() => void refreshStatus(true)}
disabled={loading || busyAction !== null}
aria-label={translate(
'auto.components.settings.WslCliRegistration.ab6b022a5c',
@@ -24,6 +24,23 @@ it('chunks 257 paths without truncation and preserves each result position', asy
expect(batch.mock.calls.map((call) => call[0].length)).toEqual([128, 128, 1])
expect(results).toEqual(Array.from({ length: 257 }, (_, i) => i % 2 === 0))
})
it('keeps a local batch answered when main refuses one path in it (E9)', async () => {
const batch = vi.fn(async (paths: string[]) =>
paths.map((path) =>
path.startsWith('\\\\wsl.localhost\\')
? { error: "WSL distro 'Ubuntu' is not running." }
: true
)
)
vi.stubGlobal('window', { api: { shell: { pathsExist: batch } } })
const enqueue = createTerminalPathExistenceBatch()
const host = enqueue(context, '/host-file', false)
const wsl = enqueue(context, '\\\\wsl.localhost\\Ubuntu\\home\\me\\a.ts', false)
await expect(host).resolves.toBe(true)
// Unverifiable, never "missing": the caller retries it.
await expect(wsl).rejects.toThrow('is not running')
expect(batch).toHaveBeenCalledTimes(1)
})
it('isolates identical paths by runtime, worktree, connection, and hover turn', async () => {
remote.mockImplementation(async (_context, paths: string[]) =>
paths.map(() => ({ exists: true }))
@@ -97,10 +97,19 @@ export function createTerminalPathExistenceBatch(): (
const values = window.api.shell.pathsExist
? await window.api.shell.pathsExist(paths)
: await Promise.all(paths.map((path) => window.api.shell.pathExists(path)))
if (values.length !== paths.length || values.some((value) => typeof value !== 'boolean')) {
if (values.length !== paths.length) {
throw new Error('Invalid local path existence response')
}
results = values.map((exists) => ({ exists }))
results = values.map((value): PathExistenceResult => {
if (typeof value === 'boolean') {
return { exists: value }
}
// A refused path (e.g. in a stopped WSL distro) is unverifiable, never missing.
if (value && typeof value === 'object' && typeof value.error === 'string') {
return { error: value.error }
}
throw new Error('Invalid local path existence response')
})
}
results.forEach((result, index) => {
if ('exists' in result) {
+2
View File
@@ -1,6 +1,8 @@
export const PATH_EXISTENCE_BATCH_MAX = 128
export const PATH_EXISTENCE_BATCH_CAPABILITY = 'files.pathsExist'
export type PathExistenceResult = { exists: boolean } | { error: string }
/** The desktop shell batch: a boolean per answered path, an error for one it refused. */
export type ShellPathExistence = boolean | { error: string }
export function validatePathExistenceBatch(paths: unknown): asserts paths is string[] {
if (