fix(wsl-move): undo connects the WSL host after a relaunch, and startup heals a crash mid-pairing save (#33279)

* fix(wsl-move): undo connects the WSL host after a relaunch, and startup heals a crash mid-pairing save

Undo is the user's act, so it connects the host the way Connect would before asking it anything;
without that, a relaunched host with no open work refused every undo as unreachable.

A crash between refreshWslHostPairing's two writes leaves the record's bridge port off the saved
pairing, and the overlay then drops the WSL link. Startup realigns the record to the pairing.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(wsl-hosts): startup realigns bridge ports before any bridge connects, and not while gated off

Co-Authored-By: Claude <noreply@anthropic.com>

* test(wsl-move): undo's terminal stop never connects a host whose Remove is pending

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-28 03:35:39 -07:00
committed by GitHub
co-authored by Claude
parent c1ef35bf21
commit 9e1443ecf2
8 changed files with 325 additions and 4 deletions
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const order = vi.hoisted(() => ({ calls: new Array<string>(), serving: true }))
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
ipcMain: { removeHandler: vi.fn(), handle: vi.fn() }
}))
vi.mock('../wsl/wsl-host-gate', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
wslHostsServing: () => order.serving
}))
vi.mock('../../shared/runtime-environment-wsl-host-store', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<void> } | null = null
attach(connector: { connect: (distro: string) => Promise<void> }): 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<void> {
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()
})
})
+9
View File
@@ -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,
@@ -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<unknown> => ({
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()
})
})
+44 -2
View File
@@ -17,7 +17,8 @@ const host = vi.hoisted(() => ({
enabledOnHost: new Map<string, boolean>(),
listed: true,
unreadable: false,
removalPending: false
removalPending: false,
connect: vi.fn(async (..._args: unknown[]): Promise<boolean> => 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()
})
})
+23 -1
View File
@@ -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<boolean> {
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,
@@ -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',
@@ -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,
+1 -1
View File
@@ -166,7 +166,7 @@ export function writeWslHostDeployment(
)
}
function hasNonWslOwner(environment: KnownRuntimeEnvironment): boolean {
export function hasNonWslOwner(environment: KnownRuntimeEnvironment): boolean {
return Boolean(
environment.orcadDeployment ||
environment.sshAccess ||