From 3a68805eeef6f22e9da736a0979bc46fc384b48b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:24:26 -0700 Subject: [PATCH] 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. --- ...sl-cli-registration-reconciliation.test.ts | 36 ++++++++++ .../wsl-cli-registration-reconciliation.ts | 20 +++--- src/main/cli/wsl-cli-status.ts | 13 ++++ src/main/ipc/cli.test.ts | 57 ++++++++++++++- src/main/ipc/cli.ts | 34 ++++++--- ...ell-paths-exist-wsl-stopped-distro.test.ts | 70 +++++++++++++++++++ src/main/ipc/shell.ts | 27 +++++-- src/main/wsl-availability.ts | 1 + src/main/wsl/wsl-interop-health.test.ts | 68 +++++++++++------- src/main/wsl/wsl-interop-health.ts | 32 +++++---- src/preload/api/cli-bridge.ts | 6 +- src/preload/api/cli-install-api.ts | 6 +- src/preload/api/shell-api.ts | 3 +- src/preload/api/shell-bridge.ts | 3 +- .../settings/WslCliRegistration.tsx | 52 ++++++++------ .../terminal-path-existence-batch.test.ts | 17 +++++ .../terminal-path-existence-batch.ts | 13 +++- src/shared/path-existence-batch.ts | 2 + 18 files changed, 365 insertions(+), 95 deletions(-) create mode 100644 src/main/ipc/shell-paths-exist-wsl-stopped-distro.test.ts diff --git a/src/main/cli/wsl-cli-registration-reconciliation.test.ts b/src/main/cli/wsl-cli-registration-reconciliation.test.ts index 791a1c894d0..5067595316c 100644 --- a/src/main/cli/wsl-cli-registration-reconciliation.test.ts +++ b/src/main/cli/wsl-cli-registration-reconciliation.test.ts @@ -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' }) + }) }) diff --git a/src/main/cli/wsl-cli-registration-reconciliation.ts b/src/main/cli/wsl-cli-registration-reconciliation.ts index 2ccece14c6f..1fa6692c7e4 100644 --- a/src/main/cli/wsl-cli-registration-reconciliation.ts +++ b/src/main/cli/wsl-cli-registration-reconciliation.ts @@ -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 registry?: WslCliRegistrationRegistry listRunningDistros?: () => Promise + /** Settled read taken right before each repair spawns. */ + isDistroRunning?: (distro: string) => Promise 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) ) } }) diff --git a/src/main/cli/wsl-cli-status.ts b/src/main/cli/wsl-cli-status.ts index e6d43e41a98..0557b250e0b 100644 --- a/src/main/cli/wsl-cli-status.ts +++ b/src/main/cli/wsl-cli-status.ts @@ -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 diff --git a/src/main/ipc/cli.test.ts b/src/main/ipc/cli.test.ts index e9e81d56c84..29060ab8004 100644 --- a/src/main/ipc/cli.test.ts +++ b/src/main/ipc/cli.test.ts @@ -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 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()), + 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' + }) + }) + }) }) diff --git a/src/main/ipc/cli.ts b/src/main/ipc/cli.ts index e8b0e15f929..b56486bd53d 100644 --- a/src/main/ipc/cli.ts +++ b/src/main/ipc/cli.ts @@ -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 => { + async ( + _event, + args?: { distro?: string | null; wakeWslDistro?: boolean } + ): Promise => { // 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 => { + // 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() } ) diff --git a/src/main/ipc/shell-paths-exist-wsl-stopped-distro.test.ts b/src/main/ipc/shell-paths-exist-wsl-stopped-distro.test.ts new file mode 100644 index 00000000000..dd1ddc9f346 --- /dev/null +++ b/src/main/ipc/shell-paths-exist-wsl-stopped-distro.test.ts @@ -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 Promise>(), + readWslDistroRunningState: vi.fn(), + pathExists: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, fn: (event: unknown, args?: unknown) => Promise) => + mocks.handlers.set(name, fn) + }, + shell: {}, + dialog: {} +})) +vi.mock('../wsl-background-distro-gate', async (importOriginal) => ({ + ...(await importOriginal()), + readWslDistroRunningState: mocks.readWslDistroRunningState +})) +vi.mock('./shell-owned-path-target', async (importOriginal) => ({ + ...(await importOriginal()), + 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 { + 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) + }) +}) diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 2a89deff991..96539f0955f 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -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 => { - validatePathExistenceBatch(paths) - return Promise.all(paths.map(pathExists)) - }) + ipcMain.handle( + 'shell:pathsExist', + async (_event, paths: string[]): Promise => { + 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 => { return pathExists(filePath) diff --git a/src/main/wsl-availability.ts b/src/main/wsl-availability.ts index 1be0e42c9ba..ede887e6c73 100644 --- a/src/main/wsl-availability.ts +++ b/src/main/wsl-availability.ts @@ -110,6 +110,7 @@ async function probeWslAvailability(): Promise { 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 } diff --git a/src/main/wsl/wsl-interop-health.test.ts b/src/main/wsl/wsl-interop-health.test.ts index acdbf3f8ce2..dfe0bc5fc98 100644 --- a/src/main/wsl/wsl-interop-health.test.ts +++ b/src/main/wsl/wsl-interop-health.test.ts @@ -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 = {}) { 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() }) }) diff --git a/src/main/wsl/wsl-interop-health.ts b/src/main/wsl/wsl-interop-health.ts index 1d44754761f..6b849a2173f 100644 --- a/src/main/wsl/wsl-interop-health.ts +++ b/src/main/wsl/wsl-interop-health.ts @@ -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 + /** A settled running read: a list taken while a distro stops can still name it. */ + isRunning(distro: string): Promise resolveDefaultDistro(): Promise 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 { + check(distro: string): Promise { 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 { + private async runCheck(health: DistroHealth): Promise { 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 { 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 }), diff --git a/src/preload/api/cli-bridge.ts b/src/preload/api/cli-bridge.ts index 76b574a2f44..939d8db03e1 100644 --- a/src/preload/api/cli-bridge.ts +++ b/src/preload/api/cli-bridge.ts @@ -6,8 +6,10 @@ export const cliApi = { getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), install: (): Promise => ipcRenderer.invoke('cli:install'), remove: (): Promise => ipcRenderer.invoke('cli:remove'), - getWslInstallStatus: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:getWslInstallStatus', args), + getWslInstallStatus: (args?: { + distro?: string | null + wakeWslDistro?: boolean + }): Promise => ipcRenderer.invoke('cli:getWslInstallStatus', args), installWsl: (args?: { distro?: string | null }): Promise => ipcRenderer.invoke('cli:installWsl', args), removeWsl: (args?: { distro?: string | null }): Promise => diff --git a/src/preload/api/cli-install-api.ts b/src/preload/api/cli-install-api.ts index cadf7f5cfb7..801d5cd8052 100644 --- a/src/preload/api/cli-install-api.ts +++ b/src/preload/api/cli-install-api.ts @@ -4,7 +4,11 @@ export type CliApi = { getInstallStatus: () => Promise install: () => Promise remove: () => Promise - getWslInstallStatus: (args?: { distro?: string | null }) => Promise + /** `wakeWslDistro`: a user act (the refresh button) that may start a stopped distro. */ + getWslInstallStatus: (args?: { + distro?: string | null + wakeWslDistro?: boolean + }) => Promise installWsl: (args?: { distro?: string | null }) => Promise removeWsl: (args?: { distro?: string | null }) => Promise } diff --git a/src/preload/api/shell-api.ts b/src/preload/api/shell-api.ts index 76b328807ef..b3d42ae2cff 100644 --- a/src/preload/api/shell-api.ts +++ b/src/preload/api/shell-api.ts @@ -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 openFilePath: (path: string, ownerHostId: ExecutionHostId) => Promise openFileUri: (uri: string, ownerHostId: ExecutionHostId) => Promise - pathsExist?: (paths: string[]) => Promise + pathsExist?: (paths: string[]) => Promise pathExists: (path: string) => Promise pickAttachment: () => Promise pickAttachments: () => Promise diff --git a/src/preload/api/shell-bridge.ts b/src/preload/api/shell-bridge.ts index d3191084428..759a7e679d2 100644 --- a/src/preload/api/shell-bridge.ts +++ b/src/preload/api/shell-bridge.ts @@ -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 => ipcRenderer.invoke('shell:openFileUri', uri, ownerHostId), - pathsExist: (paths: string[]): Promise => + pathsExist: (paths: string[]): Promise => ipcRenderer.invoke('shell:pathsExist', paths), pathExists: (path: string): Promise => ipcRenderer.invoke('shell:pathExists', path), diff --git a/src/renderer/src/components/settings/WslCliRegistration.tsx b/src/renderer/src/components/settings/WslCliRegistration.tsx index f33e0f6487a..cddc722c9a4 100644 --- a/src/renderer/src/components/settings/WslCliRegistration.tsx +++ b/src/renderer/src/components/settings/WslCliRegistration.tsx @@ -33,30 +33,36 @@ export function WslCliRegistration({ const { wslAvailable } = useWindowsTerminalCapabilities(currentPlatform === 'win32') const showWslCli = currentPlatform === 'win32' && wslAvailable - const refreshStatus = useCallback(async (): Promise => { - 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 => { + 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({