mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -166,7 +166,7 @@ export function writeWslHostDeployment(
|
||||
)
|
||||
}
|
||||
|
||||
function hasNonWslOwner(environment: KnownRuntimeEnvironment): boolean {
|
||||
export function hasNonWslOwner(environment: KnownRuntimeEnvironment): boolean {
|
||||
return Boolean(
|
||||
environment.orcadDeployment ||
|
||||
environment.sshAccess ||
|
||||
|
||||
Reference in New Issue
Block a user