diff --git a/src/main/ipc/wsl-host-handlers-startup-reconcile.test.ts b/src/main/ipc/wsl-host-handlers-startup-reconcile.test.ts new file mode 100644 index 00000000000..bb3d6e2c11d --- /dev/null +++ b/src/main/ipc/wsl-host-handlers-startup-reconcile.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const order = vi.hoisted(() => ({ calls: new Array(), serving: true })) +vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: () => [] }, + ipcMain: { removeHandler: vi.fn(), handle: vi.fn() } +})) +vi.mock('../wsl/wsl-host-gate', async (importOriginal) => ({ + ...(await importOriginal>()), + wslHostsServing: () => order.serving +})) +vi.mock('../../shared/runtime-environment-wsl-host-store', async (importOriginal) => ({ + ...(await importOriginal>()), + reconcileWslHostBridgePorts: () => { + order.calls.push('reconcile') + return [] + } +})) +vi.mock('../wsl/wsl-host-connector', () => ({ createWslHostConnector: vi.fn() })) +vi.mock('../wsl/wsl-host-e2e-fake-connector', () => ({ + readFakeWslHostsConfig: () => ({ serverFiles: 'present' }), + createFakeWslHostConnector: () => { + order.calls.push('connector') + return { + connect: async () => { + order.calls.push('connect') + } + } + } +})) +vi.mock('../wsl/wsl-orphan-holder-release', () => ({ + releaseUnwantedWslHolders: async () => {}, + wslHolderKeepPolicy: () => () => true +})) +vi.mock('./wsl-host-automation-wake-wiring', () => ({ createWslHostAutomationWake: () => null })) +vi.mock('../wsl/wsl-host-service', () => ({ + WslHostService: class { + connector: { connect: (distro: string) => Promise } | null = null + attach(connector: { connect: (distro: string) => Promise }): void { + this.connector = connector + } + handleConnectionChange(): void {} + isKnownDistro(): boolean { + return true + } + hasUserVisibleWork(): boolean { + return false + } + // Startup's first bridge work: adopting holders, then any reconnect. + async start(): Promise { + order.calls.push('start') + await this.connector?.connect('Ubuntu') + } + } +})) +const instance = vi.hoisted((): { service: unknown } => ({ service: null })) +vi.mock('../wsl/wsl-host-service-instance', () => ({ + getWslHostService: () => instance.service, + setWslHostService: (service: unknown) => { + instance.service = service + } +})) + +import { registerWslHostHandlers } from './wsl-host-handlers' + +function register(): void { + registerWslHostHandlers({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: startup reads settings only through onSettingsChanged. + store: { getSettings: () => ({}), onSettingsChanged: vi.fn() } as never, + getUserDataPath: () => '/user-data', + invalidateTransport: vi.fn() + }) +} + +// A crash between a pairing save's two writes hides the host unless its record is realigned first. +describe('WSL host startup: the bridge-port reconcile', () => { + afterEach(() => { + order.calls = [] + order.serving = true + instance.service = null + }) + + it('realigns saved records before the connector exists or any bridge connects', async () => { + register() + await vi.waitFor(() => expect(order.calls).toContain('connect')) + expect(order.calls).toEqual(['reconcile', 'connector', 'start', 'connect']) + }) + + it('reads and writes no record while WSL hosts are not serving', () => { + order.serving = false + register() + expect(order.calls).toEqual([]) + expect(instance.service).toBeNull() + }) +}) diff --git a/src/main/ipc/wsl-host-handlers.ts b/src/main/ipc/wsl-host-handlers.ts index bf411330dc2..1fddb4c9b65 100644 --- a/src/main/ipc/wsl-host-handlers.ts +++ b/src/main/ipc/wsl-host-handlers.ts @@ -30,6 +30,7 @@ import { getRuntimeEnvironmentStatus } from './runtime-environments/runtime-envi import { callRuntimeEnvironment } from './runtime-environments/runtime-environment-transport-routing' import { isRemoteRuntimeConnectionReady } from './runtime-environments/runtime-environment-request-connections' import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { reconcileWslHostBridgePorts } from '../../shared/runtime-environment-wsl-host-store' import { getPreferredPairedRuntimeConnection } from '../../shared/runtime-environments' import { requestOrcadTerminalCensus } from '../ssh/orcad-terminal-census-client' import type { WslHostRuntimeAccess } from '../wsl/wsl-host-server-stop' @@ -121,6 +122,14 @@ function createService(args: WslHostHandlerArgs): WslHostService | null { if (process.platform !== 'win32' && !fake) { return null } + // Before any bridge connects or the window lists servers: a crash mid-save must not hide a host. + try { + for (const environmentId of reconcileWslHostBridgePorts(args.getUserDataPath())) { + console.warn(`[wsl-hosts] Realigned ${environmentId}'s bridge port with its pairing.`) + } + } catch (error) { + console.warn('[wsl-hosts] Could not check WSL host records against their pairings:', error) + } let automationWake: WslHostAutomationWake | null = null const created = new WslHostService({ userDataPath: args.getUserDataPath, diff --git a/src/main/ipc/wsl-move-undo-host-connect.test.ts b/src/main/ipc/wsl-move-undo-host-connect.test.ts new file mode 100644 index 00000000000..30a42ab3592 --- /dev/null +++ b/src/main/ipc/wsl-move-undo-host-connect.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { wslMoveCutoverFixture } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { createWslMoveUndoPorts } from './wsl-move-undo-wiring' + +// The real connectWslMoveHostService runs here; only the host's records and runtime are faked. +const host = vi.hoisted(() => ({ + pendingStop: false, + connect: vi.fn(async () => {}), + call: vi.fn(async (..._args: unknown[]): Promise => ({ + ok: true, + result: { terminals: [], truncated: false } + })) +})) +vi.mock('./runtime-environments/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: (...args: unknown[]) => host.call(...args) +})) +vi.mock('../../shared/runtime-environment-store', () => ({ + listEnvironments: () => [{ id: 'wsl:Ubuntu' }] +})) +vi.mock('../../shared/runtime-environment-wsl-host-store', () => ({ + findWslHostDeployment: () => + host.pendingStop ? { environmentId: 'wsl:ubuntu', pendingStop: { at: 1 } } : null +})) +vi.mock('../wsl/wsl-host-service-instance', () => ({ + getWslHostService: () => ({ + recordForEnvironment: () => ({ environmentId: 'wsl:ubuntu', distro: 'Ubuntu' }), + getStatuses: () => [], + connect: host.connect, + ensureForRuntimeCall: vi.fn() + }) +})) + +const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), + patchWorkspaceSession: vi.fn(), + listAutomations: () => [], + updateAutomation: vi.fn() +} + +describe("undo's stop of the host's terminals: connecting the host", () => { + const move = wslMoveCutoverFixture('move-1', 'retained', {}, { repoIds: ['repo-a'] }) + + it('connects a host that is not serving, as the user’s act', async () => { + host.pendingStop = false + host.connect.mockClear() + expect(await createWslMoveUndoPorts(store, '/user-data').stopHostCopies(move)).toBe('stopped') + expect(host.connect).toHaveBeenCalledTimes(1) + }) + + it('never connects a host whose Remove is pending (E11)', async () => { + host.pendingStop = true + host.connect.mockClear() + host.call.mockClear() + await createWslMoveUndoPorts(store, '/user-data').stopHostCopies(move) + expect(host.connect).not.toHaveBeenCalled() + // It got past the removed-host check, so the skip is the pending Remove's. + expect(host.call).toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/wsl-move-undo-wiring.test.ts b/src/main/ipc/wsl-move-undo-wiring.test.ts index b95d843ef04..b992ff8416a 100644 --- a/src/main/ipc/wsl-move-undo-wiring.test.ts +++ b/src/main/ipc/wsl-move-undo-wiring.test.ts @@ -17,7 +17,8 @@ const host = vi.hoisted(() => ({ enabledOnHost: new Map(), listed: true, unreadable: false, - removalPending: false + removalPending: false, + connect: vi.fn(async (..._args: unknown[]): Promise => true) })) vi.mock('../../shared/runtime-environment-store', () => ({ listEnvironments: () => { @@ -28,8 +29,10 @@ vi.mock('../../shared/runtime-environment-store', () => ({ } })) vi.mock('./wsl-move-host-connect', () => ({ - wslHostRemovalPending: () => host.removalPending + wslHostRemovalPending: () => host.removalPending, + connectWslMoveHostService: (...args: unknown[]) => host.connect(...args) })) +vi.mock('../wsl/wsl-host-service-instance', () => ({ getWslHostService: () => ({}) })) vi.mock('./wsl-host-automation-disable', () => ({ readWslHostAutomationStates: async (_userData: string, _environmentId: string, ids: string[]) => Object.fromEntries(ids.map((id) => [id, host.enabledOnHost.get(id) ?? false])), @@ -277,3 +280,42 @@ describe("undo's stop of the host's terminals", () => { }) }) }) + +// After a relaunch the host has no open work, so a background-only reconnect refused every undo. +describe("undo's connect to the host", () => { + const move = wslMoveCutoverFixture('move-1', 'retained', {}, { repoIds: ['repo-a'] }) + const store = { + getWorkspaceSession: () => getDefaultWorkspaceSession(), + patchWorkspaceSession: vi.fn(), + listAutomations: () => [], + updateAutomation: vi.fn() + } + + it('connects the host as the user’s act before asking it anything', async () => { + host.listed = true + host.connect.mockClear() + rpc.call.mockReset() + rpc.call.mockResolvedValue({ ok: true, result: { terminals: [], truncated: false } }) + const ports = createWslMoveUndoPorts(store, '/user-data') + expect((await ports.stopHostAutomations?.(move))?.outcome).toBe('stopped') + expect(await ports.stopHostCopies(move)).toBe('stopped') + expect(host.connect.mock.calls.map(([, , distro, mode]) => [distro, mode])).toEqual([ + [move.manifest.source.distro, 'user'], + [move.manifest.source.distro, 'user'] + ]) + }) + + it('refuses as unreachable, asking the host nothing, when it does not connect', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + host.listed = true + host.disabled = [] + host.connect.mockRejectedValueOnce(new Error('distro failed to start')) + host.connect.mockRejectedValueOnce(new Error('distro failed to start')) + rpc.call.mockReset() + const ports = createWslMoveUndoPorts(store, '/user-data') + expect(await ports.stopHostAutomations?.(move)).toEqual({ outcome: 'unreachable' }) + expect(await ports.stopHostCopies(move)).toBe('unreachable') + expect(host.disabled).toEqual([]) + expect(rpc.call).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/wsl-move-undo-wiring.ts b/src/main/ipc/wsl-move-undo-wiring.ts index d1fa804bc34..27ede2a57b5 100644 --- a/src/main/ipc/wsl-move-undo-wiring.ts +++ b/src/main/ipc/wsl-move-undo-wiring.ts @@ -22,7 +22,8 @@ import { type WslMoveHostAutomationStop } from '../persistence/moving-wsl-projects/wsl-move-undo-host-automations' import { scheduleWslMoveRewriteRetry } from '../persistence/moving-wsl-projects/wsl-move-rewrite-retry' -import { wslHostRemovalPending } from './wsl-move-host-connect' +import { connectWslMoveHostService, wslHostRemovalPending } from './wsl-move-host-connect' +import { getWslHostService } from '../wsl/wsl-host-service-instance' import { disableWslHostAutomations, readWslHostAutomationStates, @@ -54,6 +55,21 @@ const HOST_TERMINAL_LIST_LIMIT = 10_000 const SLEEP_TIMEOUT_MS = 60_000 const LIST_TIMEOUT_MS = 10_000 +/** Undo is the user's act: after a relaunch a host with no open work connects first, as Connect would. */ +async function connectUndoHost(userDataPath: string, move: WslMoveCutover): Promise { + const service = getWslHostService() + if (!service) { + return true + } + try { + await connectWslMoveHostService(service, userDataPath, move.manifest.source.distro, 'user') + return true + } catch (error) { + console.warn('[wsl-move] The host did not connect for undo:', error) + return false + } +} + export function createWslMoveUndoPorts( store: Pick< Store, @@ -72,6 +88,9 @@ export function createWslMoveUndoPorts( const hostIds = [...new Set(Object.keys(local.tabsByWorktree).filter(scope.inScope))].map( (id) => scope.worktreeId(id) ) + if (!(await connectUndoHost(userDataPath, move))) { + return 'unreachable' + } const call = async (method: string, params: unknown, timeoutMs: number) => { try { return await callRuntimeEnvironment( @@ -139,6 +158,9 @@ export function createWslMoveUndoPorts( if (wslHostRemovalPending(userDataPath, move.manifest.source.distro)) { return { outcome: 'removal-pending' } } + if (!(await connectUndoHost(userDataPath, move))) { + return { outcome: 'unreachable' } + } const environmentId = move.destinationEnvironmentId const stop = await stopWslMoveHostAutomations( userDataPath, diff --git a/src/shared/runtime-environment-wsl-host-store.test.ts b/src/shared/runtime-environment-wsl-host-store.test.ts index 25f95441985..4889251c25c 100644 --- a/src/shared/runtime-environment-wsl-host-store.test.ts +++ b/src/shared/runtime-environment-wsl-host-store.test.ts @@ -7,6 +7,7 @@ import { listEnvironments } from './runtime-environment-store' import { addWslHostEnvironment, findWslHostDeployment, + reconcileWslHostBridgePorts, refreshWslHostPairing, removeWslHostEnvironment } from './runtime-environment-wsl-host-store' @@ -172,6 +173,64 @@ describe('WSL host environment store', () => { expect(listEnvironments(userDataPath)[0]?.orcadDeployment).toMatchObject({ kind: 'wsl' }) }) + it('heals a crash between saving a moved bridge and its pairing, keeping the saved pairing', () => { + addWslHostEnvironment(userDataPath, { + id: 'wsl:ubuntu', + name: 'Ubuntu (WSL)', + pairingCode: code(42000), + record, + now: 100 + }) + const saved = findWslHostDeployment(userDataPath, 'wsl:ubuntu') + if (!saved) { + throw new Error('record missing') + } + // refreshWslHostPairing's record write landed; its pairing write never did. + writeWslHostDeployment(userDataPath, 'wsl:ubuntu', { ...saved, localPort: 42001 }) + const [crashed] = listEnvironments(userDataPath) + expect(crashed?.orcadDeployment).toBeUndefined() + + expect(reconcileWslHostBridgePorts(userDataPath)).toEqual(['wsl:ubuntu']) + const [healed] = listEnvironments(userDataPath) + expect(healed?.orcadDeployment).toMatchObject({ kind: 'wsl', localPort: 42000 }) + expect(healed && getPreferredPairingOffer(healed)).toEqual( + crashed && getPreferredPairingOffer(crashed) + ) + expect(reconcileWslHostBridgePorts(userDataPath)).toEqual([]) + + // The next connect saves both. + refreshWslHostPairing(userDataPath, 'wsl:ubuntu', { + pairingCode: code(42002, 'token-2'), + localPort: 42002, + remotePort: 6768 + }) + expect(listEnvironments(userDataPath)[0]?.orcadDeployment).toMatchObject({ localPort: 42002 }) + }) + + it('leaves a record written for a different pairing, or with no server, alone', () => { + addWslHostEnvironment(userDataPath, { + id: 'wsl:ubuntu', + name: 'Ubuntu (WSL)', + pairingCode: code(42000), + record, + now: 100 + }) + const saved = findWslHostDeployment(userDataPath, 'wsl:ubuntu') + if (!saved) { + throw new Error('record missing') + } + const otherKey = Buffer.alloc(32, 3).toString('base64') + writeWslHostDeployment(userDataPath, 'wsl:ubuntu', { + ...saved, + localPort: 42001, + pairingPublicKeyB64: otherKey, + previousPairingPublicKeyB64: otherKey + }) + writeWslHostDeployment(userDataPath, 'wsl:orphan', { ...saved, environmentId: 'wsl:orphan' }) + expect(reconcileWslHostBridgePorts(userDataPath)).toEqual([]) + expect(findWslHostDeployment(userDataPath, 'wsl:ubuntu')?.localPort).toBe(42001) + }) + it('removes the environment and its record', () => { addWslHostEnvironment(userDataPath, { id: 'wsl:ubuntu', diff --git a/src/shared/runtime-environment-wsl-host-store.ts b/src/shared/runtime-environment-wsl-host-store.ts index fac7cd89f6f..648c9142cf5 100644 --- a/src/shared/runtime-environment-wsl-host-store.ts +++ b/src/shared/runtime-environment-wsl-host-store.ts @@ -16,6 +16,7 @@ import { import { resolveEnvironmentFromStore } from './runtime-environment-store' import { findCollidingEnvironmentName } from './runtime-environment-name-collision' import { + hasNonWslOwner, readWslHostDeployments, writeWslHostDeployment, type WslHostDeploymentRecord, @@ -165,6 +166,39 @@ export function refreshWslHostPairing( } } +/** + * A crash between refreshWslHostPairing's writes leaves the record's bridge port off the saved + * pairing, so the overlay drops the WSL link. The record follows the pairing: its port is only + * where the next connect listens, and that connect saves both. Returns the repaired ids. + */ +export function reconcileWslHostBridgePorts(userDataPath: string): string[] { + const environments = readEnvironmentStore(userDataPath).environments + const repaired: string[] = [] + for (const record of readWslHostDeployments(userDataPath).deployments) { + const environment = environments.find((entry) => entry.id === record.environmentId) + // A link that overlaid cleanly reads as an owner too, and needs nothing. + if (!environment || hasNonWslOwner(environment)) { + continue + } + // A record written for a different pairing stays ignored, not rebound to this one. + const key = getPreferredPairingOffer(environment).publicKeyB64 + if ( + record.pairingPublicKeyB64 !== undefined && + key !== record.pairingPublicKeyB64 && + key !== record.previousPairingPublicKeyB64 + ) { + continue + } + const port = getPreferredLoopbackRuntimePort(environment) + if (port === null || port === record.localPort) { + continue + } + writeWslHostDeployment(userDataPath, record.environmentId, { ...record, localPort: port }) + repaired.push(record.environmentId) + } + return repaired +} + /** Marks (or with null, clears) a Remove that could not prove the host's server stopped. */ export function setWslHostPendingStop( userDataPath: string, diff --git a/src/shared/wsl-host-deployment-store.ts b/src/shared/wsl-host-deployment-store.ts index 1cfbb8811f5..1b18cc75260 100644 --- a/src/shared/wsl-host-deployment-store.ts +++ b/src/shared/wsl-host-deployment-store.ts @@ -166,7 +166,7 @@ export function writeWslHostDeployment( ) } -function hasNonWslOwner(environment: KnownRuntimeEnvironment): boolean { +export function hasNonWslOwner(environment: KnownRuntimeEnvironment): boolean { return Boolean( environment.orcadDeployment || environment.sshAccess ||