mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user