From 52432e99528d9fdbe8e4483741c1c7cbd14201e2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:15:05 -0700 Subject: [PATCH] refactor(wsl): register WSL host IPC only when the WSL host service exists (#28446) * refactor(wsl): register WSL host IPC only when the WSL host service exists With the internal gate closed (or off Windows) the preload exposes no WSL host API, so the seven handlers and the settings listener were registered only to refuse. Register nothing in that case and drop the per-handler gate checks that a live service makes unreachable. * test(wsl): cover the settings listener, re-registration and non-Windows gate-open cases --- src/main/ipc/wsl-host-gate-off.test.ts | 50 ++++++++++----- src/main/ipc/wsl-host-handlers.ts | 87 ++++++++++++-------------- 2 files changed, 75 insertions(+), 62 deletions(-) diff --git a/src/main/ipc/wsl-host-gate-off.test.ts b/src/main/ipc/wsl-host-gate-off.test.ts index 3081cdcd6db..d31b2e59f78 100644 --- a/src/main/ipc/wsl-host-gate-off.test.ts +++ b/src/main/ipc/wsl-host-gate-off.test.ts @@ -76,7 +76,7 @@ vi.mock('./runtime-environment-request-connections', async () => { import { registerRuntimeEnvironmentHandlers } from './runtime-environments' import { channelHandlerLookup, pairingCode } from './runtime-environments-ipc-test-harness' -import { registerWslHostHandlers } from './wsl-host-handlers' +import { registerWslHostHandlers, WSL_HOST_HANDLER_CHANNELS } from './wsl-host-handlers' import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' import { getWslHostService, setWslHostService } from '../wsl/wsl-host-service-instance' import { getSavedRuntimeAiVaultHostInfos } from '../ai-vault/runtime-session-scanner' @@ -105,6 +105,12 @@ function persistWslHost(userDataPath: string): void { }) } +function registeredWslHostChannels(): unknown[] { + return mocks.handle.mock.calls + .map((call) => call[0]) + .filter((channel) => String(channel).startsWith('wslHosts:')) +} + function touchedWslHost(mock: { mock: { calls: unknown[][] } }): boolean { return mock.mock.calls.some((call) => call[0] === WSL_ID) } @@ -154,6 +160,7 @@ describe('WSL hosts with the internal gate closed', () => { startApp() expect(getWslHostService()).toBeNull() + expect(store.onSettingsChanged).not.toHaveBeenCalled() expect(mocks.createConnector).not.toHaveBeenCalled() expect(mocks.createFakeConnector).not.toHaveBeenCalled() expect(getRuntimeEnvironmentStatusSnapshots().map((s) => s.environmentId)).not.toContain(WSL_ID) @@ -165,8 +172,7 @@ describe('WSL hosts with the internal gate closed', () => { expect( getSavedRuntimeAiVaultHostInfos(userDataPath).map((host) => host.environmentId) ).not.toContain(WSL_ID) - expect(await handler('wslHosts:listDistros')(null, undefined)).toEqual([]) - expect(await handler('wslHosts:getStatuses')(null, undefined)).toEqual([]) + expect(registeredWslHostChannels()).toEqual([]) expect(mocks.spawnProcess).not.toHaveBeenCalled() expect(mocks.runProcess).not.toHaveBeenCalled() }) @@ -174,17 +180,7 @@ describe('WSL hosts with the internal gate closed', () => { it('refuses every WSL host operation and leaves the persisted host on disk', async () => { startApp() - expect(await handler('wslHosts:add')(null, { distro: 'Ubuntu' })).toMatchObject({ - ok: false, - kind: 'disabled', - message: GATED - }) - await expect( - Promise.resolve().then(() => handler('wslHosts:connect')(null, { environmentId: WSL_ID })) - ).rejects.toThrow(GATED) - await expect( - Promise.resolve().then(() => handler('wslHosts:remove')(null, { environmentId: WSL_ID })) - ).rejects.toThrow(GATED) + expect(registeredWslHostChannels()).toEqual([]) await expect( handler('runtimeEnvironments:connect')(null, { selector: WSL_ID }) ).rejects.toThrow(GATED) @@ -216,5 +212,31 @@ describe('WSL hosts with the internal gate closed', () => { expect(getWslHostService()).not.toBeNull() expect(mocks.createFakeConnector).toHaveBeenCalledTimes(1) + expect(registeredWslHostChannels().sort()).toEqual([...WSL_HOST_HANDLER_CHANNELS].sort()) + expect(store.onSettingsChanged).toHaveBeenCalledTimes(1) + + // Registering again (another window) reuses the service and its one settings listener. + mocks.handle.mockClear() + startApp() + expect(mocks.createFakeConnector).toHaveBeenCalledTimes(1) + expect(store.onSettingsChanged).toHaveBeenCalledTimes(1) + expect(registeredWslHostChannels().sort()).toEqual([...WSL_HOST_HANDLER_CHANNELS].sort()) + }) + + it('registers nothing with the gate open on a non-Windows host without the fake', () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1') + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'linux' }) + try { + startApp() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(getWslHostService()).toBeNull() + expect(registeredWslHostChannels()).toEqual([]) + expect(store.onSettingsChanged).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/wsl-host-handlers.ts b/src/main/ipc/wsl-host-handlers.ts index ed71510de95..447a2fe2228 100644 --- a/src/main/ipc/wsl-host-handlers.ts +++ b/src/main/ipc/wsl-host-handlers.ts @@ -17,7 +17,7 @@ import { } from '../wsl/wsl-host-e2e-fake-connector' import { hasOrcadTemplate } from '../orcad/orcad-artifact-materializer' import { WslHostService } from '../wsl/wsl-host-service' -import { WSL_HOSTS_GATED_OFF_MESSAGE, wslHostsGateOpen } from '../wsl/wsl-host-gate' +import { wslHostsGateOpen } from '../wsl/wsl-host-gate' import { getWslHostService, setWslHostService } from '../wsl/wsl-host-service-instance' import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup' import { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe' @@ -72,11 +72,13 @@ function wslHostRuntimeAccess(getUserDataPath: () => string): WslHostRuntimeAcce } } -function createService(args: { +type WslHostHandlerArgs = { store: Store getUserDataPath: () => string invalidateTransport: (environmentId: string) => Promise | void -}): WslHostService | null { +} + +function createService(args: WslHostHandlerArgs): WslHostService | null { // Closed gate: no service, so no holder adoption, wsl.exe spawn or reconnect can start. if (!wslHostsGateOpen()) { return null @@ -116,67 +118,56 @@ function createService(args: { return created } -export function registerWslHostHandlers(args: { - store: Store - getUserDataPath: () => string - invalidateTransport: (environmentId: string) => Promise | void -}): void { +function startService(args: WslHostHandlerArgs): WslHostService | null { + const created = createService(args) + if (!created) { + return null + } + setWslHostService(created) + args.store.onSettingsChanged((updates, settings) => { + if ('experimentalWslHosts' in updates) { + void created.enabledChanged(settings.experimentalWslHosts === true).catch((error) => { + console.warn('[wsl-hosts] Could not apply the WSL hosts setting:', error) + }) + } + }) + void created.start().catch((error: unknown) => { + console.warn('[wsl-hosts] Could not adopt WSL distro holders:', error) + }) + return created +} + +export function registerWslHostHandlers(args: WslHostHandlerArgs): void { for (const channel of WSL_HOST_HANDLER_CHANNELS) { ipcMain.removeHandler(channel) } - if (!getWslHostService()) { - const created = createService(args) - setWslHostService(created) - args.store.onSettingsChanged((updates, settings) => { - if ('experimentalWslHosts' in updates) { - void created?.enabledChanged(settings.experimentalWslHosts === true).catch((error) => { - console.warn('[wsl-hosts] Could not apply the WSL hosts setting:', error) - }) - } - }) - void created?.start().catch((error: unknown) => { - console.warn('[wsl-hosts] Could not adopt WSL distro holders:', error) - }) + // Why none at all: without a service (gate closed, or not Windows) the preload exposes no API. + const wsl = getWslHostService() ?? startService(args) + if (!wsl) { + return } - const wsl = getWslHostService() - ipcMain.handle('wslHosts:listDistros', () => wsl?.listDistros() ?? []) + ipcMain.handle('wslHosts:listDistros', () => wsl.listDistros()) ipcMain.handle( 'wslHosts:add', - ( - _event, - request: { distro: string; startIfStopped?: boolean } - ): Promise | WslHostAddResult => { - if (!wslHostsGateOpen()) { - return { ok: false, kind: 'disabled', message: WSL_HOSTS_GATED_OFF_MESSAGE } - } - return wsl - ? wsl.add(request) - : { ok: false, kind: 'failed', message: 'WSL hosts are not available on this computer.' } - } + (_event, request: { distro: string; startIfStopped?: boolean }): Promise => + wsl.add(request) ) ipcMain.handle('wslHosts:remove', async (_event, request: { environmentId: string }) => { - // A closed gate leaves persisted WSL hosts untouched on disk. - if (!wslHostsGateOpen()) { - throw new Error(WSL_HOSTS_GATED_OFF_MESSAGE) - } if (args.store.getSettings().activeRuntimeEnvironmentId === request.environmentId) { throw new Error( 'This server is the default host for new projects. Choose another one in Advanced before removing it.' ) } - return wsl ? wsl.remove(request.environmentId) : { stopped: true } - }) - ipcMain.handle('wslHosts:connect', async (_event, request: { environmentId: string }) => { - if (!wslHostsGateOpen()) { - throw new Error(WSL_HOSTS_GATED_OFF_MESSAGE) - } - await wsl?.connect(request.environmentId) + return wsl.remove(request.environmentId) }) + ipcMain.handle('wslHosts:connect', (_event, request: { environmentId: string }) => + wsl.connect(request.environmentId) + ) ipcMain.handle('wslHosts:getCapability', (): WslHostCapability => ({ - serverFiles: wsl?.hasServerFiles() ?? false + serverFiles: wsl.hasServerFiles() })) - ipcMain.handle('wslHosts:getStatuses', () => wsl?.getStatuses() ?? []) + ipcMain.handle('wslHosts:getStatuses', () => wsl.getStatuses()) ipcMain.handle('wslHosts:setVisibleWork', (_event, request: { environmentIds: string[] }) => { - wsl?.setVisibleWork(Array.isArray(request.environmentIds) ? request.environmentIds : []) + wsl.setVisibleWork(Array.isArray(request.environmentIds) ? request.environmentIds : []) }) }