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 : []) }) }