diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh index b2ed925004f..08602fd63eb 100644 --- a/config/nsis/orca-installer-hooks.nsh +++ b/config/nsis/orca-installer-hooks.nsh @@ -59,6 +59,36 @@ Pop $0 nsExec::Exec 'taskkill /F /IM "orca-terminal-daemon.exe" $2' Pop $0 + ; WSL hosts: the detached wsl.exe holders outlive the app and keep their distros running. Orca + ; ends only the holders its profile recorded, each checked by pid, start time and command line. + ; Only a userData that has a holder folder launches it: everyone who never ran a WSL host sees + ; nothing here. These are the folders Electron's userData (app.getName()) can be, as the + ; deleteAppData step lists them; a custom --user-data-dir can't be known here. + ; Sync the flag with WSL_HOLDER_UNINSTALL_FLAG in src/main/startup/wsl-holder-uninstall-preflight.ts. + ${if} $installMode == "all" + SetShellVarContext current + ${endIf} + StrCpy $1 "" + ${if} ${FileExists} "$APPDATA\${APP_FILENAME}\wsl-distro-holders\*.json" + StrCpy $1 "1" + ${endIf} + !ifdef APP_PRODUCT_FILENAME + ${if} ${FileExists} "$APPDATA\${APP_PRODUCT_FILENAME}\wsl-distro-holders\*.json" + StrCpy $1 "1" + ${endIf} + !endif + !ifdef APP_PACKAGE_NAME + ${if} ${FileExists} "$APPDATA\${APP_PACKAGE_NAME}\wsl-distro-holders\*.json" + StrCpy $1 "1" + ${endIf} + !endif + ${if} $installMode == "all" + SetShellVarContext all + ${endIf} + ${if} $1 == "1" + nsExec::Exec '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" --orca-uninstall-release-wsl-holders' + Pop $0 + ${endIf} Pop $2 Pop $1 Pop $0 diff --git a/src/main/index.ts b/src/main/index.ts index b6e02ef3acc..7e615b0cd83 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,6 +10,7 @@ import { import { openMainWindow as openMainWindowController } from './startup/main-window-controller' import { mainProcessState as state } from './startup/main-process-state' import { runMainProcessPreflight } from './startup/main-process-preflight' +import { runWslHolderUninstallPreflight } from './startup/wsl-holder-uninstall-preflight' import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-bootstrap' import { initializeMainProcessReady } from './startup/main-process-ready' import { installMainProcessQuitHandlers } from './startup/main-process-quit' @@ -97,10 +98,13 @@ const handleMacAppActivation = createMacAppActivationHandler({ requestActivation: requestDesktopActivation }) -const preflightReady = runMainProcessPreflight({ - focusExistingWindow, - requestDesktopActivation -}) +// The uninstaller's holder release runs instead of the app, never alongside it. +const preflightReady = + !runWslHolderUninstallPreflight() && + runMainProcessPreflight({ + focusExistingWindow, + requestDesktopActivation + }) // Skip userData writes after losing the single-instance lock. if (preflightReady) { diff --git a/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts index 7171be31907..75065160d5d 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts @@ -12,7 +12,8 @@ import { } from '../../../shared/runtime-environment-store' import { redactRuntimeEnvironment, - type PublicKnownRuntimeEnvironment + type PublicKnownRuntimeEnvironment, + type RuntimeEnvironmentRemoval } from '../../../shared/runtime-environments' import { connectThroughOwningService, @@ -110,9 +111,7 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ ( _event, args: { selector: string } - ): - | { removed: PublicKnownRuntimeEnvironment } - | Promise<{ removed: PublicKnownRuntimeEnvironment }> => { + ): RuntimeEnvironmentRemoval | Promise => { const environment = resolveEnvironment(getUserDataPath(), args.selector) if (store.getSettings().activeRuntimeEnvironmentId === environment.id) { throw new Error( @@ -123,7 +122,11 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ assertWslHostReachable(environment) const ownedRemoval = removeThroughOwningService(environment) if (ownedRemoval) { - return ownedRemoval.then(() => ({ removed: redactRuntimeEnvironment(environment) })) + // A WSL host whose server Remove couldn't prove stopped stays listed; say so (E11). + return ownedRemoval.then((serverStop) => ({ + removed: redactRuntimeEnvironment(environment), + serverStop + })) } const removed = removeEnvironment(getUserDataPath(), args.selector) void retireRemovedRuntimeEnvironment( diff --git a/src/main/ipc/runtime-environments/runtime-environment-host-dispatch.test.ts b/src/main/ipc/runtime-environments/runtime-environment-host-dispatch.test.ts index 82deb81c463..4ec8871251b 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-host-dispatch.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-host-dispatch.test.ts @@ -206,6 +206,15 @@ describe('runtimeEnvironments:remove by host kind', () => { expect(mocks.retire).not.toHaveBeenCalled() }) + it('carries what the WSL service kept, so the UI can say the host stays', async () => { + useHost('wsl') + const serverStop = { stopped: false, reason: 'busy', verdict: 'live', kept: true } + mocks.wsl.service!.remove.mockResolvedValueOnce(serverStop) + await expect( + handler('runtimeEnvironments:remove')(null, { selector: 'wsl:ubuntu' }) + ).resolves.toEqual({ removed: redacted('wsl'), serverStop }) + }) + it('removes a WSL host generically when no service exists', () => { useHost('wsl') mocks.wsl.service = null diff --git a/src/main/ipc/runtime-environments/runtime-environment-host-service-ops.ts b/src/main/ipc/runtime-environments/runtime-environment-host-service-ops.ts index f38a1da5951..2d8776491fd 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-host-service-ops.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-host-service-ops.ts @@ -1,5 +1,6 @@ import { classifyRuntimeEnvironmentHost } from '../../../shared/runtime-environment-host-kind' import type { KnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import type { WslHostServerStop } from '../../../shared/wsl-host-types' import { getWslHostService } from '../../wsl/wsl-host-service-instance' /** Explicit connection may boot the WSL distro. */ @@ -19,7 +20,7 @@ export async function connectThroughOwningService( /** Null delegates removal to the generic store path. */ export function removeThroughOwningService( environment: KnownRuntimeEnvironment -): Promise | null { +): Promise | null { const host = classifyRuntimeEnvironmentHost(environment) switch (host.kind) { case 'wsl': diff --git a/src/main/ipc/wsl-host-handlers.ts b/src/main/ipc/wsl-host-handlers.ts index 405d5505a42..d8a18d90a9d 100644 --- a/src/main/ipc/wsl-host-handlers.ts +++ b/src/main/ipc/wsl-host-handlers.ts @@ -7,6 +7,7 @@ import { } from '../../shared/wsl-host-types' import type { Store } from '../persistence' import { createWslHostConnector } from '../wsl/wsl-host-connector' +import { releaseUnwantedWslHolders, wslHolderKeepPolicy } from '../wsl/wsl-orphan-holder-release' import { createFakeWslHostConnector, readFakeWslHostsConfig @@ -184,6 +185,26 @@ function startService(args: WslHostHandlerArgs): WslHostService | null { return created } +let gatedOffHoldersReleased = false + +/** + * With the gate closed, a launch still releases orphans (no host record, or a pending Remove) + * but keeps every host's holder: a tester reopening Orca without the gate must not end a live + * host's sessions. With no holder recorded it touches nothing beyond one folder check. + */ +function releaseHoldersWhileGatedOff(userDataPath: string): void { + if (process.platform !== 'win32' || gatedOffHoldersReleased) { + return + } + gatedOffHoldersReleased = true + void releaseUnwantedWslHolders({ + userDataPath, + isWanted: wslHolderKeepPolicy({ userDataPath, keepHostHolders: true }) + }).catch((error: unknown) => { + console.warn('[wsl-hosts] Could not release orphaned WSL distro holders:', error) + }) +} + export function registerWslHostHandlers(args: WslHostHandlerArgs): void { for (const channel of WSL_HOST_HANDLER_CHANNELS) { ipcMain.removeHandler(channel) @@ -191,6 +212,7 @@ export function registerWslHostHandlers(args: WslHostHandlerArgs): void { // Install no API without a service, matching the preload gate. const wsl = getWslHostService() ?? startService(args) if (!wsl) { + releaseHoldersWhileGatedOff(args.getUserDataPath()) return } ipcMain.handle('wslHosts:listDistros', () => wsl.listDistros()) @@ -210,10 +232,13 @@ export function registerWslHostHandlers(args: WslHostHandlerArgs): void { ipcMain.handle( 'wslHosts:connect', (_event, request: { environmentId: string; intent?: unknown }) => - wsl.connect( - request.environmentId, - request.intent === 'update' || request.intent === 'force' ? { intent: request.intent } : {} - ) + // The Settings Connect is the user's own: it keeps a host whose Remove didn't finish. + wsl.connect(request.environmentId, { + withdrawPendingRemoval: true, + ...(request.intent === 'update' || request.intent === 'force' + ? { intent: request.intent } + : {}) + }) ) ipcMain.handle('wslHosts:getCapability', (): WslHostCapability => ({ serverFiles: wsl.hasServerFiles() diff --git a/src/main/startup/wsl-holder-uninstall-preflight.test.ts b/src/main/startup/wsl-holder-uninstall-preflight.test.ts new file mode 100644 index 00000000000..9665b694f00 --- /dev/null +++ b/src/main/startup/wsl-holder-uninstall-preflight.test.ts @@ -0,0 +1,132 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { WslDistroHolder, WslDistroHolderAdoption } from '../wsl/wsl-distro-holder' + +const electron = vi.hoisted(() => ({ + userDataPath: '/user-data', + exit: vi.fn(), + requestSingleInstanceLock: vi.fn(), + BrowserWindow: vi.fn(), + handle: vi.fn() +})) +const appData = vi.hoisted(() => ({ ensure: vi.fn() })) + +vi.mock('electron', () => ({ + app: { + exit: electron.exit, + getPath: () => electron.userDataPath, + setPath: vi.fn(), + requestSingleInstanceLock: electron.requestSingleInstanceLock + }, + BrowserWindow: electron.BrowserWindow, + ipcMain: { handle: electron.handle } +})) +vi.mock('./windows-app-data-path', () => ({ ensureWindowsAppDataPath: appData.ensure })) + +import { + releaseWslHoldersForUninstall, + runWslHolderUninstallPreflight, + WSL_HOLDER_UNINSTALL_FLAG +} from './wsl-holder-uninstall-preflight' + +function holder(release: () => Promise): WslDistroHolder { + return { + ensureHeld: vi.fn(), + adoptHolders: vi.fn(), + release: vi.fn(), + releaseAll: vi.fn(release), + holderState: vi.fn() + } +} + +describe('WSL holder release on uninstall', () => { + it('succeeds only when every recorded holder is proven gone', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await expect( + releaseWslHoldersForUninstall(holder(async () => [{ distro: 'ubuntu', state: 'exited' }])) + ).resolves.toBe(0) + await expect(releaseWslHoldersForUninstall(holder(async () => []))).resolves.toBe(0) + await expect( + releaseWslHoldersForUninstall( + holder(async () => [ + { distro: 'ubuntu', state: 'exited' }, + { distro: 'debian', state: 'unverifiable' } + ]) + ) + ).resolves.toBe(1) + await expect( + releaseWslHoldersForUninstall( + holder(async () => { + throw new Error('table unreadable') + }) + ) + ).resolves.toBe(1) + }) + + it('runs from the uninstaller only, after the app and its daemon are gone', () => { + const hooks = readFileSync( + new URL('../../../config/nsis/orca-installer-hooks.nsh', import.meta.url), + 'utf8' + ) + const unInstall = hooks.slice(hooks.indexOf('!macro customUnInstall')) + const realUninstall = unInstall.slice( + unInstall.indexOf('${ifNot} ${isUpdated}'), + // The real-uninstall branch ends with the relocated daemon host's cleanup. + unInstall.indexOf('daemon-host-profiles') + ) + const release = realUninstall.indexOf( + `nsExec::Exec '"$INSTDIR\\\${APP_EXECUTABLE_FILENAME}" ${WSL_HOLDER_UNINSTALL_FLAG}'` + ) + expect(release).toBeGreaterThan(-1) + // Killed first, so no running Orca can respawn a holder behind the release. + expect(release).toBeGreaterThan(realUninstall.indexOf('orca-terminal-daemon.exe')) + expect(hooks.split(WSL_HOLDER_UNINSTALL_FLAG)).toHaveLength(2) + }) + + it('exits 0 with no holder records, and opens no window, lock or IPC', async () => { + electron.userDataPath = mkdtempSync(join(tmpdir(), 'orca-uninstall-no-holders-')) + electron.exit.mockClear() + try { + expect(runWslHolderUninstallPreflight(['Orca.exe', WSL_HOLDER_UNINSTALL_FLAG])).toBe(true) + await vi.waitFor(() => expect(electron.exit).toHaveBeenCalledWith(0)) + expect(electron.BrowserWindow).not.toHaveBeenCalled() + expect(electron.requestSingleInstanceLock).not.toHaveBeenCalled() + expect(electron.handle).not.toHaveBeenCalled() + } finally { + rmSync(electron.userDataPath, { recursive: true, force: true }) + } + }) + + it('exits 1 rather than leaving an error box when setup throws', () => { + vi.useFakeTimers() + electron.exit.mockClear() + appData.ensure.mockImplementationOnce(() => { + throw new Error('Orca could not find the Windows roaming AppData folder') + }) + try { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(runWslHolderUninstallPreflight(['Orca.exe', WSL_HOLDER_UNINSTALL_FLAG])).toBe(true) + expect(electron.exit).toHaveBeenCalledExactlyOnceWith(1) + // The timer armed before setup is cleared, so it can't exit a second time. + vi.runAllTimers() + expect(electron.exit).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it('launches from the uninstaller only for a userData that has holder records', () => { + const hooks = readFileSync( + new URL('../../../config/nsis/orca-installer-hooks.nsh', import.meta.url), + 'utf8' + ) + const launch = hooks.indexOf(`--orca-uninstall-release-wsl-holders'`) + const gate = hooks.lastIndexOf('${if} $1 == "1"', launch) + expect(gate).toBeGreaterThan(-1) + expect(hooks.slice(0, gate)).toMatch( + /\$\{FileExists\} "\$APPDATA\\\$\{APP_PACKAGE_NAME\}\\wsl-distro-holders\\\*\.json"/ + ) + }) +}) diff --git a/src/main/startup/wsl-holder-uninstall-preflight.ts b/src/main/startup/wsl-holder-uninstall-preflight.ts new file mode 100644 index 00000000000..dba5a78afa3 --- /dev/null +++ b/src/main/startup/wsl-holder-uninstall-preflight.ts @@ -0,0 +1,51 @@ +import { app } from 'electron' +import { createWslDistroHolder, type WslDistroHolder } from '../wsl/wsl-distro-holder' +import { ensureWindowsAppDataPath } from './windows-app-data-path' + +/** The NSIS uninstaller's flag (config/nsis/orca-installer-hooks.nsh); keep the two in sync. */ +export const WSL_HOLDER_UNINSTALL_FLAG = '--orca-uninstall-release-wsl-holders' + +// Uninstall must finish even if the process table never answers. +const UNINSTALL_RELEASE_TIMEOUT_MS = 30_000 + +/** Exit code 0 only when every recorded holder is proven gone; unverifiable ones are left running. */ +export async function releaseWslHoldersForUninstall(holder: WslDistroHolder): Promise { + try { + const released = await holder.releaseAll() + for (const { distro, state } of released) { + if (state !== 'exited') { + console.warn(`[wsl] uninstall left the ${distro} holder ${state}`) + } + } + return released.every(({ state }) => state === 'exited') ? 0 : 1 + } catch (error) { + console.warn('[wsl] uninstall could not release WSL distro holders:', error) + return 1 + } +} + +/** Uninstall only: ends the `wsl.exe` holders this profile recorded, so its distros idle-stop. */ +export function runWslHolderUninstallPreflight(argv: readonly string[] = process.argv): boolean { + if (!argv.includes(WSL_HOLDER_UNINSTALL_FLAG)) { + return false + } + // Armed first: nothing below may leave the uninstaller waiting on an error box. + const timeout = setTimeout(() => app.exit(1), UNINSTALL_RELEASE_TIMEOUT_MS) + let holder: WslDistroHolder + try { + process.env.ORCA_BACKGROUND_LAUNCH = '1' + // The same userData the app records holders under; before any other preflight step. + ensureWindowsAppDataPath(app) + holder = createWslDistroHolder({ userDataPath: app.getPath('userData') }) + } catch (error) { + console.warn('[wsl] uninstall could not start releasing WSL distro holders:', error) + clearTimeout(timeout) + app.exit(1) + return true + } + void releaseWslHoldersForUninstall(holder).then((code) => { + clearTimeout(timeout) + app.exit(code) + }) + return true +} diff --git a/src/main/wsl/wsl-distro-holder.test.ts b/src/main/wsl/wsl-distro-holder.test.ts index 910ee3d49f5..a769407a415 100644 --- a/src/main/wsl/wsl-distro-holder.test.ts +++ b/src/main/wsl/wsl-distro-holder.test.ts @@ -187,6 +187,38 @@ describe('createWslDistroHolder', () => { expect(await recordFiles()).toEqual([]) }) + it('releases every verified holder it recorded and nothing it cannot verify', async () => { + host.processes.set(77, { commandLine: HOLDER_COMMAND('Alpine'), startedAtMs: 5_000 }) + await writeRecord(record()) + // A reused pid now running something else must never be killed. + host.processes.set(88, { commandLine: 'C:\\Windows\\notepad.exe', startedAtMs: 6_000 }) + await writeRecord(record({ distro: 'Debian', pid: 88, creationTimeMs: 6_000 })) + const released = await makeHolder().releaseAll() + expect(released.sort((a, b) => a.distro.localeCompare(b.distro))).toEqual([ + { distro: 'alpine', state: 'exited' }, + { distro: 'debian', state: 'exited' } + ]) + expect(host.killed).toEqual([77]) + expect(await recordFiles()).toEqual([]) + }) + + it('never kills a holder whose record has no start time to rule out pid reuse', async () => { + host.processes.set(77, { commandLine: HOLDER_COMMAND('Alpine'), startedAtMs: 5_000 }) + await writeRecord(record({ creationTimeMs: null })) + expect(await makeHolder().releaseAll()).toEqual([{ distro: 'alpine', state: 'unverifiable' }]) + expect(host.killed).toEqual([]) + expect(await recordFiles()).toHaveLength(1) + }) + + it('keeps a holder and its record when release cannot verify it', async () => { + host.processes.set(77, { commandLine: HOLDER_COMMAND('Alpine'), startedAtMs: 5_000 }) + await writeRecord(record()) + host.tableFails = true + expect(await makeHolder().releaseAll()).toEqual([{ distro: 'alpine', state: 'unverifiable' }]) + expect(host.killed).toEqual([]) + expect(await recordFiles()).toHaveLength(1) + }) + it('keeps the record and reports unverifiable when the process table fails', async () => { host.processes.set(77, { commandLine: HOLDER_COMMAND('Alpine'), startedAtMs: 5_000 }) await writeRecord(record()) diff --git a/src/main/wsl/wsl-distro-holder.ts b/src/main/wsl/wsl-distro-holder.ts index 3c915637152..eefa4a9f8cf 100644 --- a/src/main/wsl/wsl-distro-holder.ts +++ b/src/main/wsl/wsl-distro-holder.ts @@ -59,6 +59,8 @@ export type WslDistroHolder = { adoptHolders(): Promise /** Explicit stop only; the distro then idles out on WSL's own schedule. */ release(distro: string): Promise + /** Every recorded holder (uninstall, hosts turned off); kills only verified holders. */ + releaseAll(): Promise holderState(distro: string): Promise } @@ -214,7 +216,8 @@ export function createWslDistroHolder(dependencies: WslDistroHolderDependencies) try { await waitForDistroRunning(distro, () => observed.exit) } catch (error) { - await stopRecordedHolder(record) + // This process spawned it moments ago, so its pid is ours even without a start time. + await stopRecordedHolder(record, { spawnedHere: true }) throw error } const { onHolderExit } = dependencies @@ -227,9 +230,14 @@ export function createWslDistroHolder(dependencies: WslDistroHolderDependencies) } } - async function stopRecordedHolder(record: WslDistroHolderRecord): Promise { + async function stopRecordedHolder( + record: WslDistroHolderRecord, + { spawnedHere = false } = {} + ): Promise { let state = classifyWslDistroHolderProcess(record, await queryProcess(record.pid)) - if (state === 'live') { + // A command line alone can't tell our holder from a reused pid running the same argv. + const provenOurs = spawnedHere || record.creationTimeMs !== null + if (state === 'live' && provenOurs) { try { // WSL 2.7.10 relaunches a same-argv child; killing the recorded parent ends both. killProcess(record.pid) @@ -249,7 +257,7 @@ export function createWslDistroHolder(dependencies: WslDistroHolderDependencies) if (state === 'exited') { await removeWslDistroHolderRecord(userDataPath, record.distro) } - return state + return state === 'live' && !provenOurs ? 'unverifiable' : state } async function unverifiableTooLongWhileRunning(distro: string): Promise { @@ -314,22 +322,31 @@ export function createWslDistroHolder(dependencies: WslDistroHolderDependencies) ) }, - release(distro) { - return serialize(distro, async () => { - const read = await readWslDistroHolderRecord(userDataPath, distro) - if (read.status === 'unreadable') { - return 'unverifiable' - } - if (read.status !== 'present') { - await removeWslDistroHolderRecord(userDataPath, distro) - return 'exited' - } - return stopRecordedHolder(read.record) - }) + release: (distro) => releaseRecorded(distro), + + async releaseAll() { + const distros = await listWslDistroHolderRecordDistros(userDataPath) + return Promise.all( + distros.map(async (distro) => ({ distro, state: await releaseRecorded(distro) })) + ) }, holderState(distro) { return serialize(distro, async () => (await recordedHolderState(distro)).state) } } + + function releaseRecorded(distro: string): Promise { + return serialize(distro, async () => { + const read = await readWslDistroHolderRecord(userDataPath, distro) + if (read.status === 'unreadable') { + return 'unverifiable' + } + if (read.status !== 'present') { + await removeWslDistroHolderRecord(userDataPath, distro) + return 'exited' + } + return stopRecordedHolder(read.record) + }) + } } diff --git a/src/main/wsl/wsl-distro-registry-id.test.ts b/src/main/wsl/wsl-distro-registry-id.test.ts index d0c0aa4200f..15ac8a9e419 100644 --- a/src/main/wsl/wsl-distro-registry-id.test.ts +++ b/src/main/wsl/wsl-distro-registry-id.test.ts @@ -3,6 +3,7 @@ import { parseWslDistroRegistrations, readWslDistroRegistryId, readWslDistroRegistryIdShared, + resolveWslDistroName, resetWslDistroRegistryIdSharedReadsForTests } from './wsl-distro-registry-id' @@ -85,3 +86,31 @@ describe('readWslDistroRegistryIdShared', () => { expect(reads).toBe(2) }) }) + +describe('resolveWslDistroName', () => { + const query = async () => ({ code: 0, stdout: REG_OUTPUT }) + + it('finds the distro by name, case-insensitively', async () => { + await expect(resolveWslDistroName('UBUNTU-24.04', undefined, query)).resolves.toBe( + 'Ubuntu-24.04' + ) + }) + + it('names a renamed distro by its recorded GUID, braced or not', async () => { + await expect( + resolveWslDistroName('Ubuntu', '{0283592D-BE56-40D4-B935-3DC18C3AA007}', query) + ).resolves.toBe('Ubuntu-24.04') + }) + + it('reports a distro gone only when neither its name nor its GUID is registered', async () => { + await expect( + resolveWslDistroName('Ubuntu', '99999999-0000-4000-8000-000000000000', query) + ).resolves.toBe(false) + }) + + it('never reports gone when the registrations cannot be read', async () => { + await expect( + resolveWslDistroName('Ubuntu', undefined, async () => ({ code: 1, stdout: '' })) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/wsl/wsl-distro-registry-id.ts b/src/main/wsl/wsl-distro-registry-id.ts index 0c119937887..4af37205bb9 100644 --- a/src/main/wsl/wsl-distro-registry-id.ts +++ b/src/main/wsl/wsl-distro-registry-id.ts @@ -84,6 +84,27 @@ export async function readWslDistroRegistryId( return matches?.length === 1 ? matches[0].id : null } +/** + * The distro's current registered name: by its recorded GUID first (it survives a rename), else + * by name. False when neither is registered; null when the registrations can't be read. + */ +export async function resolveWslDistroName( + distro: string, + distroGuid?: string, + query: RegQuery = defaultRegQuery +): Promise { + const registrations = await readWslDistroRegistrations(query) + if (!registrations) { + return null + } + const guid = distroGuid?.trim().replace(/[{}]/g, '').toLowerCase() + const wanted = distro.trim().toLowerCase() + const match = + (guid && registrations.find((entry) => entry.id === guid)) || + registrations.find((entry) => entry.distro.toLowerCase() === wanted) + return match ? match.distro : false +} + const SHARED_READ_TTL_MS = 30_000 const sharedReads = new Map }>() diff --git a/src/main/wsl/wsl-host-add-admission.ts b/src/main/wsl/wsl-host-add-admission.ts index 6411a3f3cf1..c02a74fc7d6 100644 --- a/src/main/wsl/wsl-host-add-admission.ts +++ b/src/main/wsl/wsl-host-add-admission.ts @@ -19,6 +19,10 @@ export function wslHostStoppedMessage(distro: string): string { return `${distro} was stopped. Switch to one of its projects, or reconnect it in Settings → Remote Orca Servers, to start it again.` } +export function wslHostRemovalPendingMessage(distro: string): string { + return `Orca didn’t finish removing the WSL host ${distro}. Remove it again in Settings → Remote Orca Servers, or connect it to keep it.` +} + /** Why no WSL host may run at all right now; null when they may. */ export function wslHostRunRefusal(gate: { isEnabled: () => boolean diff --git a/src/main/wsl/wsl-host-connection-record.ts b/src/main/wsl/wsl-host-connection-record.ts index b33fc2bcfa7..b554c43d192 100644 --- a/src/main/wsl/wsl-host-connection-record.ts +++ b/src/main/wsl/wsl-host-connection-record.ts @@ -34,14 +34,18 @@ export function toWslHostStatus( environmentId: record.environmentId, distro: record.distro, state: status.state, - detail: status.state === 'failed' || status.state === 'unverifiable' ? status.reason : null, + detail: + status.state === 'failed' || status.state === 'unverifiable' + ? status.reason + : (record.pendingStop?.reason ?? null), // Ready serves the incumbent; deferred serves nothing, so retain its Update/Force explanation. deferredUpdate: status.state === 'ready' ? (connection?.deferredUpdate ?? null) : status.state === 'failed' ? (status.deferredUpdate ?? null) - : null + : null, + ...(record.pendingStop ? { removalPending: true } : {}) } } diff --git a/src/main/wsl/wsl-host-connector-contract.ts b/src/main/wsl/wsl-host-connector-contract.ts index 289ff00da06..b3c9ad28004 100644 --- a/src/main/wsl/wsl-host-connector-contract.ts +++ b/src/main/wsl/wsl-host-connector-contract.ts @@ -1,5 +1,5 @@ -/** What the WSL host service drives: the C3 holder, C5 connections and C6 liveness, or an e2e fake. */ -import type { WslHostServerStop } from '../../shared/wsl-host-types' +/** What the WSL host service drives (the C3 holder, C5 connections and C6 liveness, or an e2e fake), and its dependencies. */ +import type { WslHostServerStop, WslHostStatus } from '../../shared/wsl-host-types' import type { WslHostDistroCandidate } from './wsl-host-distro-list' import type { WslOrcadConnection } from './wsl-orcad-connection' import type { WslOrcadConnectIntent } from './wsl-orcad-connection-status' @@ -26,7 +26,23 @@ export type WslHostConnector = { disconnect(distro: string): Promise /** Ends the host's terminals, orcad and its terminal daemon; never boots a stopped distro. */ stopServer(distro: string, environmentId: string): Promise + /** The current name by GUID (a rename) or name; false when gone, null when unreadable. Never boots. */ + resolveDistroName(distro: string, distroId?: string): Promise releaseHolder(distro: string): Promise - adoptHolders(): Promise + /** Startup: keeps the holders an earlier run left and releases those `isWanted` refuses. */ + adoptHolders(isWanted: (distro: string) => boolean): Promise dispose(): Promise } + +export type WslHostServiceDependencies = { + userDataPath: () => string + isEnabled: () => boolean + /** Whether this build ships the orcad template a WSL host deploys from. */ + hasServerFiles: () => boolean + publish: (status: WslHostStatus) => void + /** The environment's pairing changed: transports that authenticate with the old one retire. */ + pairingChanged: (environmentId: string) => void + /** A connection came up: verify the server now rather than on the status owner's backoff. */ + ready: (environmentId: string) => void + removed: (environmentId: string) => Promise | void +} diff --git a/src/main/wsl/wsl-host-connector.ts b/src/main/wsl/wsl-host-connector.ts index 01fa544a767..b187a3acf24 100644 --- a/src/main/wsl/wsl-host-connector.ts +++ b/src/main/wsl/wsl-host-connector.ts @@ -7,6 +7,7 @@ import { resolveEnvironmentPairingOffer } from '../../shared/runtime-environment import { findWslHostDeployment } from '../../shared/runtime-environment-wsl-host-store' import { createWslDistroHolder, type WslDistroHolder } from './wsl-distro-holder' import { listWslHostDistros } from './wsl-host-distro-list' +import { resolveWslDistroName } from './wsl-distro-registry-id' import type { WslHostConnector, WslHostReachability } from './wsl-host-service' import { readWslDistroOrcadPublicKey } from './wsl-host-ssh-orcad-conflict' import { @@ -165,7 +166,8 @@ export function createWslHostConnector(options: { if (reachability === 'unavailable') { return { stopped: false, - reason: 'The WSL service did not confirm whether the distro runs.' + reason: 'The WSL service did not confirm whether the distro runs.', + verdict: 'unverifiable' } } if (reachability === 'stopped') { @@ -181,11 +183,17 @@ export function createWslHostConnector(options: { decommission: decommissionWslHostOrcad(target, createWslOrcadHostTransport({ target })) }) }, + resolveDistroName: (distro, distroId) => resolveWslDistroName(distro, distroId), releaseHolder: async (distro) => { await holder.release(distro) }, - adoptHolders: async () => { - await holder.adoptHolders() + adoptHolders: async (isWanted) => { + for (const { distro } of await holder.adoptHolders()) { + if (!isWanted(distro)) { + // Verified by pid, start time and command line; an unverifiable holder is kept. + await holder.release(distro) + } + } }, dispose: () => liveness.dispose() } diff --git a/src/main/wsl/wsl-host-e2e-fake-connector.ts b/src/main/wsl/wsl-host-e2e-fake-connector.ts index 98ea826b443..02ab98527f3 100644 --- a/src/main/wsl/wsl-host-e2e-fake-connector.ts +++ b/src/main/wsl/wsl-host-e2e-fake-connector.ts @@ -48,6 +48,32 @@ export function createFakeWslHostConnector( const port = offer ? Number(new URL(offer.endpoint).port) : 0 const active = new Map() const key = (distro: string): string => distro.toLowerCase() + const connect = async (distro: string): Promise => { + // Same guard as the real connector: nothing starts for a host that is gone. + if (!options.isKnownDistro(distro)) { + throw new Error(`${distro} is no longer a WSL host; Orca will not start anything for it.`) + } + if (config.connectLogFile) { + appendFileSync(config.connectLogFile, `${distro}\n`) + } + onChange(distro, null, { state: 'connecting' }) + const connection: WslOrcadConnection = { + distro, + environmentId: defaultWslOrcadEnvironmentId(distro), + localPort: port, + remotePort: port, + fullVersion: 'e2e', + pid: null, + pairingOffer: config.pairingCode, + deferredUpdate: null, + close: async () => { + active.delete(key(distro)) + } + } + active.set(key(distro), connection) + onChange(distro, connection, { state: 'ready', census: null }) + return connection + } return { listDistros: async () => (config.distrosFile @@ -58,32 +84,7 @@ export function createFakeWslHostConnector( id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}` })), readOrcadPublicKey: async () => null, - connect: async (distro) => { - // Same guard as the real connector: nothing starts for a host that is gone. - if (!options.isKnownDistro(distro)) { - throw new Error(`${distro} is no longer a WSL host; Orca will not start anything for it.`) - } - if (config.connectLogFile) { - appendFileSync(config.connectLogFile, `${distro}\n`) - } - onChange(distro, null, { state: 'connecting' }) - const connection: WslOrcadConnection = { - distro, - environmentId: defaultWslOrcadEnvironmentId(distro), - localPort: port, - remotePort: port, - fullVersion: 'e2e', - pid: null, - pairingOffer: config.pairingCode, - deferredUpdate: null, - close: async () => { - active.delete(key(distro)) - } - } - active.set(key(distro), connection) - onChange(distro, connection, { state: 'ready', census: null }) - return connection - }, + connect, get: (distro) => active.get(key(distro)) ?? null, isReachable: async (distro) => active.has(key(distro)), isSettling: () => false, @@ -96,8 +97,10 @@ export function createFakeWslHostConnector( }, // Use real terminal RPCs against the headless fixture server for removal proof; fake only decommission. stopServer: async (distro, environmentId) => { + // As the real connector: the fake distro always runs, so an unbridged server is reconnected + // to stop it, rather than read as stopped (a re-enable connects nothing since E9). if (!active.get(key(distro))) { - return { stopped: true } + await connect(distro) } return stopWslHostServer({ ...wslHostRuntimeStopSteps(options.runtime, environmentId), @@ -113,6 +116,7 @@ export function createFakeWslHostConnector( }) }) }, + resolveDistroName: async (distro) => distro, releaseHolder: async () => {}, adoptHolders: async () => {}, dispose: async () => { diff --git a/src/main/wsl/wsl-host-early-wake.ts b/src/main/wsl/wsl-host-early-wake.ts index 4568e8062f7..6a3829093f7 100644 --- a/src/main/wsl/wsl-host-early-wake.ts +++ b/src/main/wsl/wsl-host-early-wake.ts @@ -106,10 +106,11 @@ export function wakeRememberedWslHosts( } let registered: Set try { + // A host whose Remove is pending must not be booted: that would undo its stop's proof. registered = new Set( - readWslHostDeployments(userDataPath).deployments.map((record) => - wslDistroHolderKey(record.distro) - ) + readWslHostDeployments(userDataPath) + .deployments.filter((record) => !record.pendingStop) + .map((record) => wslDistroHolderKey(record.distro)) ) } catch { return [] diff --git a/src/main/wsl/wsl-host-pending-removal.test.ts b/src/main/wsl/wsl-host-pending-removal.test.ts new file mode 100644 index 00000000000..23fb73a95fc --- /dev/null +++ b/src/main/wsl/wsl-host-pending-removal.test.ts @@ -0,0 +1,273 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { listEnvironments } from '../../shared/runtime-environment-store' +import { + findWslHostDeployment, + setWslHostPendingStop +} from '../../shared/runtime-environment-wsl-host-store' +import type { WslHostStatus } from '../../shared/wsl-host-types' +import { WslHostService, type WslHostConnector } from './wsl-host-service' +import { UBUNTU, UBUNTU_ID, makeConnector } from './wsl-host-service-test-connector' +import { rememberWslHostsToWake, wakeRememberedWslHosts } from './wsl-host-early-wake' + +// E11: a Remove that can't prove the host's server stopped keeps the host, pending. +describe('WslHostService pending removal', () => { + let userDataPath: string + let published: WslHostStatus[] + const removed = vi.fn() + + function makeService(connector: WslHostConnector): WslHostService { + const service = new WslHostService({ + userDataPath: () => userDataPath, + isEnabled: () => true, + hasServerFiles: () => true, + publish: (status) => published.push(status), + pairingChanged: vi.fn(), + ready: vi.fn(), + removed + }) + service.attach(connector) + return service + } + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-wsl-host-pending-removal-')) + published = [] + vi.clearAllMocks() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('keeps a host whose server it could not stop, pending, and nothing reconnects it', async () => { + const connector = makeConnector([UBUNTU]) + connector.stopServer.mockResolvedValueOnce({ + stopped: false, + reason: '1 terminal kept running', + verdict: 'live' + }) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + service.setVisibleWork(['wsl:ubuntu']) + connector.connect.mockClear() + + await expect(service.remove('wsl:ubuntu')).resolves.toEqual({ + stopped: false, + reason: '1 terminal kept running', + verdict: 'live', + kept: true + }) + expect(listEnvironments(userDataPath)).toHaveLength(1) + expect(findWslHostDeployment(userDataPath, 'wsl:ubuntu')?.pendingStop).toMatchObject({ + reason: '1 terminal kept running', + verdict: 'live' + }) + expect(removed).not.toHaveBeenCalled() + expect(connector.releaseHolder).toHaveBeenCalledWith('Ubuntu') + expect(published.at(-1)).toMatchObject({ + state: 'closed', + detail: '1 terminal kept running', + removalPending: true + }) + // Open work, liveness and passive calls leave it alone. + expect(service.hasUserVisibleWork('Ubuntu')).toBe(false) + service.setVisibleWork([]) + service.setVisibleWork(['wsl:ubuntu']) + connector.isReachable.mockResolvedValue(false) + await expect(service.ensureForRuntimeCall('wsl:ubuntu')).rejects.toThrow( + 'didn’t finish removing' + ) + expect(connector.connect).not.toHaveBeenCalled() + + // Remove again: a proven stop finishes it. + await expect(service.remove('wsl:ubuntu')).resolves.toEqual({ stopped: true }) + expect(listEnvironments(userDataPath)).toEqual([]) + expect(removed).toHaveBeenCalledWith('wsl:ubuntu') + }) + + it('keeps a pending stop pending when the stop is unverifiable', async () => { + const connector = makeConnector([UBUNTU]) + const unverifiable = { + stopped: false as const, + reason: 'The terminal daemon did not answer a session count.', + verdict: 'unverifiable' as const + } + connector.stopServer.mockResolvedValue(unverifiable) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + await service.remove('wsl:ubuntu') + await expect(service.remove('wsl:ubuntu')).resolves.toEqual({ ...unverifiable, kept: true }) + expect(connector.stopServer).toHaveBeenCalledTimes(2) + expect(listEnvironments(userDataPath)).toHaveLength(1) + }) + + it('finishes a pending Remove on a stopped distro, which proves the stop, without booting it', async () => { + const connector = makeConnector([UBUNTU]) + connector.stopServer.mockResolvedValueOnce({ stopped: false, reason: 'busy', verdict: 'live' }) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + await service.remove('wsl:ubuntu') + connector.connect.mockClear() + // What the cold probe answers for a stopped distro. + connector.stopServer.mockResolvedValueOnce({ stopped: true }) + + await expect(service.remove('wsl:ubuntu')).resolves.toEqual({ stopped: true }) + expect(connector.connect).not.toHaveBeenCalled() + expect(listEnvironments(userDataPath)).toEqual([]) + }) + + it('finishes removing a host whose distro is gone, which runs nothing', async () => { + const connector = makeConnector([UBUNTU]) + connector.stopServer.mockResolvedValueOnce({ stopped: false, reason: 'busy', verdict: 'live' }) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + await service.remove('wsl:ubuntu') + connector.resolveDistroName.mockResolvedValue(false) + + await expect(service.remove('wsl:ubuntu')).resolves.toEqual({ stopped: true }) + expect(connector.resolveDistroName).toHaveBeenLastCalledWith('Ubuntu', UBUNTU_ID) + expect(connector.stopServer).toHaveBeenCalledTimes(1) + expect(listEnvironments(userDataPath)).toEqual([]) + }) + + it('stops, rather than drops, a host whose registry could not be read', async () => { + const connector = makeConnector([UBUNTU]) + connector.stopServer.mockResolvedValue({ stopped: false, reason: 'busy', verdict: 'live' }) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + connector.resolveDistroName.mockResolvedValue(null) + + await expect(service.remove('wsl:ubuntu')).resolves.toMatchObject({ kept: true }) + expect(listEnvironments(userDataPath)).toHaveLength(1) + }) + + it('stops a renamed distro under its current name, never reading it stopped under the old one', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + connector.resolveDistroName.mockResolvedValue('Ubuntu-Work') + + await service.remove('wsl:ubuntu') + expect(connector.stopServer).toHaveBeenCalledWith('Ubuntu-Work', 'wsl:ubuntu') + }) + + it('re-adding or connecting a pending host keeps it and withdraws the removal', async () => { + const connector = makeConnector([UBUNTU]) + connector.stopServer.mockResolvedValue({ stopped: false, reason: 'busy', verdict: 'live' }) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + await service.remove('wsl:ubuntu') + connector.connect.mockClear() + + await expect(service.add({ distro: 'ubuntu' })).resolves.toEqual({ + ok: true, + environmentId: 'wsl:ubuntu' + }) + expect(connector.connect).toHaveBeenCalledWith('Ubuntu', {}) + expect(findWslHostDeployment(userDataPath, 'wsl:ubuntu')?.pendingStop).toBeUndefined() + + await service.remove('wsl:ubuntu') + // Only the user's own Connect withdraws it; any other caller is refused. + await expect(service.connect('wsl:ubuntu')).rejects.toThrow('didn’t finish removing') + expect(findWslHostDeployment(userDataPath, 'wsl:ubuntu')?.pendingStop).toBeDefined() + await service.connect('wsl:ubuntu', { withdrawPendingRemoval: true }) + expect(findWslHostDeployment(userDataPath, 'wsl:ubuntu')?.pendingStop).toBeUndefined() + }) + + it('re-adding a pending host on a stopped distro asks to start it, as any add does', async () => { + const connector = makeConnector([{ ...UBUNTU, state: 'stopped' }]) + connector.stopServer.mockResolvedValue({ stopped: false, reason: 'busy', verdict: 'live' }) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu', startIfStopped: true }) + await service.remove('wsl:ubuntu') + connector.connect.mockClear() + + await expect(service.add({ distro: 'Ubuntu' })).resolves.toMatchObject({ + ok: false, + kind: 'distro-stopped' + }) + expect(connector.connect).not.toHaveBeenCalled() + expect(findWslHostDeployment(userDataPath, 'wsl:ubuntu')?.pendingStop).toBeDefined() + }) + + it('shows an unfinished removal from launch', async () => { + const connector = makeConnector([UBUNTU]) + connector.stopServer.mockResolvedValueOnce({ stopped: false, reason: 'busy', verdict: 'live' }) + await makeService(connector).add({ distro: 'Ubuntu' }) + await makeService(connector).remove('wsl:ubuntu') + published = [] + await makeService(connector).start() + expect(published).toEqual([ + expect.objectContaining({ state: 'closed', detail: 'busy', removalPending: true }) + ]) + }) + + it('does not keep the holder of a host whose Remove is pending', async () => { + const connector = makeConnector([UBUNTU]) + connector.stopServer.mockResolvedValueOnce({ stopped: false, reason: 'busy', verdict: 'live' }) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + await service.remove('wsl:ubuntu') + await service.start() + const [isWanted] = connector.adoptHolders.mock.calls[0] + expect(isWanted('Ubuntu')).toBe(false) + }) + + it('switching to a pending host’s workspace never reconnects it', async () => { + const connector = makeConnector([UBUNTU]) + connector.stopServer.mockResolvedValueOnce({ stopped: false, reason: 'busy', verdict: 'live' }) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + await service.remove('wsl:ubuntu') + connector.connect.mockClear() + + service.focusHost('wsl:ubuntu') + await new Promise((resolve) => setImmediate(resolve)) + expect(connector.reachability).not.toHaveBeenCalled() + expect(connector.connect).not.toHaveBeenCalled() + }) + + it('never boots a pending host early at launch', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + rememberWslHostsToWake(userDataPath, ['Ubuntu']) + // A Remove left pending by a build that still remembered the host. + setWslHostPendingStop(userDataPath, 'wsl:ubuntu', { since: 1, reason: 'busy', verdict: 'live' }) + const spawn = vi.fn() + + expect(wakeRememberedWslHosts(userDataPath, spawn)).toEqual([]) + expect(spawn).not.toHaveBeenCalled() + }) + + it('forgets the early wake of a host whose Remove ended pending', async () => { + const connector = makeConnector([UBUNTU]) + connector.stopServer.mockResolvedValueOnce({ stopped: false, reason: 'busy', verdict: 'live' }) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + service.focusHost('wsl:ubuntu') + + await service.remove('wsl:ubuntu') + // Withdrawing the removal must not bring the early wake back by itself. + await service.connect('wsl:ubuntu', { withdrawPendingRemoval: true }) + expect(wakeRememberedWslHosts(userDataPath, vi.fn())).toEqual([]) + }) + + it('a due automation never boots a host whose Remove is pending', async () => { + const connector = makeConnector([UBUNTU]) + connector.stopServer.mockResolvedValueOnce({ stopped: false, reason: 'busy', verdict: 'live' }) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + await service.remove('wsl:ubuntu') + connector.connect.mockClear() + connector.reachability.mockClear() + + await service.wakeForAutomation('wsl:ubuntu') + expect(connector.reachability).not.toHaveBeenCalled() + expect(connector.connect).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/wsl/wsl-host-records.ts b/src/main/wsl/wsl-host-records.ts new file mode 100644 index 00000000000..dbee50ea516 --- /dev/null +++ b/src/main/wsl/wsl-host-records.ts @@ -0,0 +1,17 @@ +/** The distro list the UI shows against the WSL host records. */ +import type { WslHostDeploymentRecord } from '../../shared/wsl-host-deployment-store' +import type { WslHostDistro } from '../../shared/wsl-host-types' +import { sameWslDistro } from './wsl-host-add-admission' +import type { WslHostDistroCandidate } from './wsl-host-distro-list' + +/** Each distro with the host it already backs, if any. */ +export function withWslHostIds( + candidates: readonly WslHostDistroCandidate[], + records: readonly WslHostDeploymentRecord[] +): WslHostDistro[] { + return candidates.map(({ id: _id, ...distro }) => ({ + ...distro, + environmentId: + records.find((record) => sameWslDistro(record.distro, distro.name))?.environmentId ?? null + })) +} diff --git a/src/main/wsl/wsl-host-removal.ts b/src/main/wsl/wsl-host-removal.ts new file mode 100644 index 00000000000..871aa21395d --- /dev/null +++ b/src/main/wsl/wsl-host-removal.ts @@ -0,0 +1,96 @@ +/** + * Removing a WSL host (E11). Only a proven stop drops its records; anything else keeps the host + * listed with a pending stop, so the next Remove retries it and Connect or Add can keep the host. + * Dropping it would strand a still-running server that blocks the next add's activation. + */ +import { errorMessage } from '../../shared/error-message' +import { + removeWslHostEnvironment, + setWslHostPendingStop +} from '../../shared/runtime-environment-wsl-host-store' +import type { WslHostDeploymentRecord } from '../../shared/wsl-host-deployment-store' +import type { WslHostAddResult, WslHostServerStop } from '../../shared/wsl-host-types' +import { admitWslHostDistro, wslHostRemovalPendingMessage } from './wsl-host-add-admission' +import type { WslHostDistroCandidate } from './wsl-host-distro-list' +import type { WslHostConnector } from './wsl-host-service' + +export type WslHostRemoval = { serverStop: WslHostServerStop; removed: boolean } + +export async function removeWslHostServer( + userDataPath: string, + connector: WslHostConnector | null, + record: WslHostDeploymentRecord +): Promise { + let serverStop: WslHostServerStop = { stopped: true } + if (connector) { + // A distro that is no longer registered (by GUID or name) runs nothing of this host. + const current = await connector + .resolveDistroName(record.distro, record.distroId) + .catch(() => null) + if (current !== false) { + // Under its current name: a renamed distro never reads as running under the old one. + // Never boots: a stopped distro is itself a proven stop. + serverStop = await connector + .stopServer(current ?? record.distro, record.environmentId) + .catch((error: unknown): WslHostServerStop => ({ + stopped: false, + verdict: 'unverifiable', + reason: errorMessage(error) + })) + } + await connector.disconnect(record.distro) + // Without its holder the distro idles out, pending or not. + await connector.releaseHolder(record.distro) + } + if (serverStop.stopped) { + removeWslHostEnvironment(userDataPath, record.environmentId) + return { serverStop, removed: true } + } + setWslHostPendingStop(userDataPath, record.environmentId, { + since: Date.now(), + reason: serverStop.reason, + verdict: serverStop.verdict ?? 'unverifiable' + }) + return { serverStop: { ...serverStop, kept: true }, removed: false } +} + +/** Adding a pending host again keeps it, under the same stopped-distro rule as any add. */ +export async function readdPendingWslHost( + args: { distro: string; startIfStopped?: boolean }, + host: { + listDistros: () => Promise + keep: () => Promise + environmentId: string + } +): Promise { + try { + const admission = admitWslHostDistro({ + requested: args.distro, + candidates: await host.listDistros(), + alreadyAdded: () => false, + startIfStopped: args.startIfStopped === true + }) + if (!('distro' in admission)) { + return admission + } + await host.keep() + return { ok: true, environmentId: host.environmentId } + } catch (error) { + return { ok: false, kind: 'failed', message: errorMessage(error) } + } +} + +/** A pending host connects only for the user's own Connect or Add, which withdraws its Remove. */ +export function admitConnectToPendingHost( + userDataPath: string, + record: WslHostDeploymentRecord, + withdraw: boolean +): void { + if (!record.pendingStop) { + return + } + if (!withdraw) { + throw new Error(wslHostRemovalPendingMessage(record.distro)) + } + setWslHostPendingStop(userDataPath, record.environmentId, null) +} diff --git a/src/main/wsl/wsl-host-server-stop.test.ts b/src/main/wsl/wsl-host-server-stop.test.ts index 98738f129d8..412f4060fb1 100644 --- a/src/main/wsl/wsl-host-server-stop.test.ts +++ b/src/main/wsl/wsl-host-server-stop.test.ts @@ -78,7 +78,8 @@ describe('stopWslHostServer', () => { expect(result).toEqual({ stopped: false, reason: - 'Terminals that refused to close: term_1 (gone::/home/me/old): selector_not_found 1 terminal session kept running. Orca’s server kept running: 1 terminal is still running on this host.' + 'Terminals that refused to close: term_1 (gone::/home/me/old): selector_not_found 1 terminal session kept running. Orca’s server kept running: 1 terminal is still running on this host.', + verdict: 'live' }) }) @@ -91,7 +92,26 @@ describe('stopWslHostServer', () => { reason: 'the orcad this computer was connected to is not the one the distro records' })) }) - await expect(stopWslHostServer(all)).resolves.toMatchObject({ stopped: false }) + await expect(stopWslHostServer(all)).resolves.toMatchObject({ + stopped: false, + verdict: 'unverifiable' + }) + }) + + it('calls a census that did not answer unverifiable, not live', async () => { + const { all } = steps({ + census: vi.fn(async () => census(null)), + decommission: vi.fn(async (): Promise => ({ + outcome: 'refused', + verdict: 'unverifiable', + code: 'orcad_decommission_census_unavailable', + reason: 'The terminal daemon did not answer a session count.' + })) + }) + await expect(stopWslHostServer(all)).resolves.toMatchObject({ + stopped: false, + verdict: 'unverifiable' + }) }) it('reports a daemon that did not retire', async () => { @@ -103,7 +123,8 @@ describe('stopWslHostServer', () => { }) await expect(stopWslHostServer(all)).resolves.toEqual({ stopped: false, - reason: 'Orca’s server stopped, but its terminal daemon is still running.' + reason: 'Orca’s server stopped, but its terminal daemon is still running.', + verdict: 'live' }) }) }) diff --git a/src/main/wsl/wsl-host-server-stop.ts b/src/main/wsl/wsl-host-server-stop.ts index b5840d9047c..ef4e0abf0b7 100644 --- a/src/main/wsl/wsl-host-server-stop.ts +++ b/src/main/wsl/wsl-host-server-stop.ts @@ -43,6 +43,8 @@ function describeLeftovers(leftovers: readonly WslHostTerminalLeftover[]): strin export async function stopWslHostServer(steps: WslHostServerStopSteps): Promise { const sleep = steps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))) const problems: string[] = [] + // Anything proven still running makes the stop `live`; only doubt leaves it `unverifiable`. + let provenLive = false let leftovers: WslHostTerminalLeftover[] = [] try { leftovers = await steps.endTerminals() @@ -58,8 +60,12 @@ export async function stopWslHostServer(steps: WslHostServerStopSteps): Promise< const servingRuntimeId = await steps.servingRuntimeId().catch(() => null) await steps.closeBridge() if (leftovers.length > 0) { + provenLive = true problems.push(`Terminals that refused to close: ${describeLeftovers(leftovers)}`) } + if (census.liveSessions !== null && census.liveSessions > 0) { + provenLive = true + } if (census.liveSessions !== 0) { problems.push( census.liveSessions === null @@ -70,11 +76,19 @@ export async function stopWslHostServer(steps: WslHostServerStopSteps): Promise< // Decommission still verifies/refuses live sessions; retain its evidence. const result = await steps.decommission(census, servingRuntimeId) if (result.outcome === 'refused') { + provenLive ||= result.verdict === 'live' problems.push(`Orca’s server kept running: ${result.reason}`) } else if (result.retirement !== 'retired') { + provenLive ||= result.retirement === 'live' problems.push('Orca’s server stopped, but its terminal daemon is still running.') } - return problems.length === 0 ? { stopped: true } : { stopped: false, reason: problems.join(' ') } + return problems.length === 0 + ? { stopped: true } + : { + stopped: false, + reason: problems.join(' '), + verdict: provenLive ? 'live' : 'unverifiable' + } } /** Use saved environment pairing and routed RPC for removal, never the bridge's raw pairing offer. */ diff --git a/src/main/wsl/wsl-host-service-test-connector.ts b/src/main/wsl/wsl-host-service-test-connector.ts index 69bb4a6a74a..9b6a0643847 100644 --- a/src/main/wsl/wsl-host-service-test-connector.ts +++ b/src/main/wsl/wsl-host-service-test-connector.ts @@ -59,8 +59,11 @@ export function makeConnector(distros: WslHostDistroCandidate[]) { stopped: true }) ), + resolveDistroName: vi.fn( + async (distro: string, _distroId?: string): Promise => distro + ), releaseHolder: vi.fn(async (_distro: string) => {}), - adoptHolders: vi.fn(async () => {}), + adoptHolders: vi.fn(async (_isWanted: (distro: string) => boolean) => {}), dispose: vi.fn(async () => {}) } satisfies WslHostConnector return Object.assign(connector, { distro }) diff --git a/src/main/wsl/wsl-host-service.test.ts b/src/main/wsl/wsl-host-service.test.ts index 6450b00ed21..559d2df86b4 100644 --- a/src/main/wsl/wsl-host-service.test.ts +++ b/src/main/wsl/wsl-host-service.test.ts @@ -9,6 +9,7 @@ import { } from '../../shared/runtime-environment-store' import { getEnvironmentStorePath } from '../../shared/runtime-environment-store-file' import { findWslHostDeployment } from '../../shared/runtime-environment-wsl-host-store' +import { getWslHostDeploymentStorePath } from '../../shared/wsl-host-deployment-store' import { getPreferredPairingOffer } from '../../shared/runtime-environments' import type { WslHostStatus } from '../../shared/wsl-host-types' import { OrcadHostUnsupportedError } from '../orcad/orcad-host-unavailable' @@ -319,22 +320,6 @@ describe('WslHostService', () => { expect(removed).toHaveBeenCalledWith('wsl:ubuntu') }) - it('reports what removing left running, and removes the host anyway', async () => { - const connector = makeConnector([UBUNTU]) - connector.stopServer.mockResolvedValueOnce({ - stopped: false, - reason: '1 terminal kept running' - }) - const service = makeService(connector) - await service.add({ distro: 'Ubuntu' }) - - await expect(service.remove('wsl:ubuntu')).resolves.toEqual({ - stopped: false, - reason: '1 terminal kept running' - }) - expect(listEnvironments(userDataPath)).toEqual([]) - }) - it('keeps a host whose removal failed usable and removable again', async () => { const connector = makeConnector([UBUNTU]) const service = makeService(connector) @@ -739,15 +724,35 @@ describe('WslHostService', () => { expect(wakeRememberedWslHosts(userDataPath, vi.fn())).toEqual([]) }) - it('adopts holders at startup only when a WSL host exists', async () => { + it('keeps the startup holders of its hosts and releases orphans', async () => { const connector = makeConnector([UBUNTU]) const service = makeService(connector) - await service.start() - expect(connector.adoptHolders).not.toHaveBeenCalled() - await service.add({ distro: 'Ubuntu' }) await service.start() - expect(connector.adoptHolders).toHaveBeenCalledTimes(1) + const [isWanted] = connector.adoptHolders.mock.calls[0] + expect(isWanted('ubuntu')).toBe(true) + // A failed add or remove left a holder for a distro that is no longer a host. + expect(isWanted('Debian')).toBe(false) + }) + + it('keeps every startup holder when the host records cannot all be read', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + writeFileSync(getWslHostDeploymentStorePath(userDataPath), '{not json') + await service.start() + const [isWanted] = connector.adoptHolders.mock.calls[0] + expect(isWanted('Debian')).toBe(true) + }) + + it('releases every startup holder while WSL hosts are off', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + enabled = false + await service.start() + const [isWanted] = connector.adoptHolders.mock.calls[0] + expect(isWanted('Ubuntu')).toBe(false) }) it.each([ diff --git a/src/main/wsl/wsl-host-service.ts b/src/main/wsl/wsl-host-service.ts index beb181a932e..b0db97358fa 100644 --- a/src/main/wsl/wsl-host-service.ts +++ b/src/main/wsl/wsl-host-service.ts @@ -3,10 +3,8 @@ * Add/Connect, or when the user switches to one of its workspaces. After a confirmed stop the host is * parked: polls and visible work keep it down until the user acts or the distro runs again. */ -import { errorMessage } from '../../shared/error-message' import { focusReachability } from './wsl-host-focus-reachability' import { rethrowRetryable, settlingConnectFailure } from './wsl-host-connect-failure' -import { removeWslHostEnvironment } from '../../shared/runtime-environment-wsl-host-store' import type { WslHostDeploymentRecord } from '../../shared/wsl-host-deployment-store' import type { WslHostAddResult, @@ -15,7 +13,7 @@ import type { WslHostServerStop, WslHostStatus } from '../../shared/wsl-host-types' -import type { WslHostConnector } from './wsl-host-connector-contract' +import type { WslHostConnector, WslHostServiceDependencies } from './wsl-host-connector-contract' import { wslDistroHolderKey } from './wsl-distro-holder-record' import { persistWslHostConnection, @@ -31,6 +29,14 @@ import { } from './wsl-host-add-admission' import type { WslOrcadConnection } from './wsl-orcad-connection' import { registerWslHost } from './wsl-host-registration' +import { WslHostStatusBoard } from './wsl-host-status-board' +import { withWslHostIds } from './wsl-host-records' +import { wslHolderKeepPolicy } from './wsl-orphan-holder-release' +import { + admitConnectToPendingHost, + readdPendingWslHost, + removeWslHostServer +} from './wsl-host-removal' import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status' import { forgetWslHostToWake, @@ -38,31 +44,21 @@ import { rememberWslHostsToWake } from './wsl-host-early-wake' -export type { WslHostConnector, WslHostReachability } from './wsl-host-connector-contract' - -export type WslHostServiceDependencies = { - userDataPath: () => string - isEnabled: () => boolean - /** Whether this build ships the orcad template a WSL host deploys from. */ - hasServerFiles: () => boolean - publish: (status: WslHostStatus) => void - /** The environment's pairing changed: transports that authenticate with the old one retire. */ - pairingChanged: (environmentId: string) => void - /** A connection came up: verify the server now rather than on the status owner's backoff. */ - ready: (environmentId: string) => void - removed: (environmentId: string) => Promise | void -} +export type { + WslHostConnector, + WslHostReachability, + WslHostServiceDependencies +} from './wsl-host-connector-contract' export class WslHostService { private connector: WslHostConnector | null = null - private readonly statuses = new Map() + private readonly board = new WslHostStatusBoard((status) => this.deps.publish(status)) private readonly adding = new Map>() private visible = new Set() private readonly parked = new Set() private readonly removing = new Set() // Invalidate in-flight Adds when disabled so they cannot later publish ready hosts. private disableGeneration = 0 - private readonly statusListeners = new Set<() => void>() constructor(private readonly deps: WslHostServiceDependencies) {} @@ -103,31 +99,16 @@ export class WslHostService { } getStatuses(): WslHostStatus[] { - return [...this.statuses.values()] + return this.board.list() } /** Hears every change to what `getStatuses` returns. */ onStatusesChanged(listener: () => void): () => void { - this.statusListeners.add(listener) - return () => { - this.statusListeners.delete(listener) - } - } - - private notifyStatusesChanged(): void { - for (const listener of this.statusListeners) { - listener() - } + return this.board.onChanged(listener) } async listDistros(): Promise { - const records = this.records() - const candidates = await this.wsl.listDistros() - return candidates.map(({ id: _id, ...distro }) => ({ - ...distro, - environmentId: - records.find((record) => sameWslDistro(record.distro, distro.name))?.environmentId ?? null - })) + return withWslHostIds(await this.wsl.listDistros(), this.records()) } add(args: { distro: string; startIfStopped?: boolean }): Promise { @@ -136,6 +117,15 @@ export class WslHostService { if (pending) { return pending } + const unfinishedRemoval = this.recordForDistro(args.distro) + if (unfinishedRemoval?.pendingStop) { + const { environmentId } = unfinishedRemoval + return readdPendingWslHost(args, { + listDistros: () => this.wsl.listDistros(), + keep: () => this.connect(environmentId, { withdrawPendingRemoval: true }), + environmentId + }) + } const operation = registerWslHost( { deps: this.deps, @@ -157,31 +147,25 @@ export class WslHostService { if (!record) { throw new Error('This server is not a WSL host.') } - // Block reconnect from visible work, liveness and calls before stopping. + // Block reconnect from visible work, liveness and calls before stopping; a failed removal + // must leave the host usable and allow another attempt. this.removing.add(environmentId) - const connector = this.connector - let serverStop: WslHostServerStop = { stopped: true } - try { - if (connector) { - serverStop = await connector - .stopServer(record.distro, environmentId) - .catch((error: unknown) => ({ stopped: false, reason: errorMessage(error) })) - await connector.disconnect(record.distro) - // Without its holder the distro idles out. - await connector.releaseHolder(record.distro) - } - removeWslHostEnvironment(this.deps.userDataPath(), environmentId) - forgetWslHostToWake(this.deps.userDataPath(), record.distro) - this.visible.delete(environmentId) - this.parked.delete(environmentId) - } finally { - // Failed removal must leave the host usable and allow another removal attempt. - this.removing.delete(environmentId) + const removal = await removeWslHostServer( + this.deps.userDataPath(), + this.connector, + record + ).finally(() => this.removing.delete(environmentId)) + // Removed or pending, nothing may boot it early at the next launch. + forgetWslHostToWake(this.deps.userDataPath(), record.distro) + if (!removal.removed) { + this.setStatus(this.recordForEnvironment(environmentId) ?? record, { state: 'closed' }) + return removal.serverStop } - this.statuses.delete(environmentId) - this.notifyStatusesChanged() + this.visible.delete(environmentId) + this.parked.delete(environmentId) + this.board.delete(environmentId) await this.deps.removed(environmentId) - return serverStop + return removal.serverStop } /** Disable closes connections/watches and releases holders; reenable resumes only hosts needed by visible work. */ @@ -203,10 +187,16 @@ export class WslHostService { } } - /** The user asked for this host: may boot its distro. An intent retries or forces an update. */ + /** + * The user asked for this host: may boot its distro. An intent retries or forces an update. + * Only the user's own Connect or Add withdraws an unfinished Remove; any other caller is refused. + */ async connect( environmentId: string, - options: { intent?: WslHostConnectIntent } = {} + { + withdrawPendingRemoval = false, + ...options + }: { intent?: WslHostConnectIntent; withdrawPendingRemoval?: boolean } = {} ): Promise { const record = this.recordForEnvironment(environmentId) if (!record) { @@ -216,6 +206,7 @@ export class WslHostService { if (refusal || this.removing.has(environmentId)) { throw new Error(refusal?.message ?? WSL_HOST_REMOVING_MESSAGE) } + admitConnectToPendingHost(this.deps.userDataPath(), record, withdrawPendingRemoval) this.wsl.forgetPairingFailures?.(environmentId) this.parked.delete(environmentId) await this.wsl.connect(record.distro, options) @@ -239,6 +230,8 @@ export class WslHostService { if (refusal || this.removing.has(environmentId)) { throw new Error(refusal?.message ?? WSL_HOST_REMOVING_MESSAGE) } + // A pending host never reconnects for a runtime call. + admitConnectToPendingHost(this.deps.userDataPath(), record, false) if (reachability === 'stopped') { this.parked.add(environmentId) } @@ -280,8 +273,14 @@ export class WslHostService { } } + /** A host whose Remove is pending is never woken, by focus or by a due automation. */ private mayWake(record: WslHostDeploymentRecord): boolean { - return Boolean(this.connector) && this.canRun() && !this.removing.has(record.environmentId) + return ( + !record.pendingStop && + Boolean(this.connector) && + this.canRun() && + !this.removing.has(record.environmentId) + ) } /** A focus entry or a due automation: lifts a park and may boot the distro. */ @@ -315,15 +314,30 @@ export class WslHostService { const record = this.recordForDistro(distro) // Liveness needs visible demand, an enabled gate and no removal in progress. const id = record?.environmentId - return id ? this.canRun() && this.visible.has(id) && !this.removing.has(id) : false + return id && !record.pendingStop + ? this.canRun() && this.visible.has(id) && !this.removing.has(id) + : false } - /** Startup: keep the holders a previous run left, without booting anything. */ + /** + * Startup: keep the holders a previous run left for its hosts, without booting anything. A + * holder for a distro that is no longer a host (a failed add or remove), or any holder while + * hosts are off, would keep its distro running forever, so it is released. + */ async start(): Promise { - if (!this.connector || !this.canRun() || this.records().length === 0) { + if (!this.connector) { return } - await this.connector.adoptHolders() + // An unfinished Remove shows as such from launch; nothing reconnects it. + const pending = this.records().filter((record) => record.pendingStop) + pending.forEach((record) => this.setStatus(record, { state: 'closed' })) + await this.connector.adoptHolders( + wslHolderKeepPolicy({ + userDataPath: this.deps.userDataPath(), + keepHostHolders: this.canRun(), + isAdding: (distro) => this.adding.has(wslDistroHolderKey(distro)) + }) + ) } /** What the UI shows before offering any WSL host action. */ @@ -344,10 +358,7 @@ export class WslHostService { status: WslOrcadConnectionStatus, connection: WslOrcadConnection | null = null ): void { - const next = toWslHostStatus(record, status, connection) - this.statuses.set(record.environmentId, next) - this.deps.publish(next) - this.notifyStatusesChanged() + this.board.set(toWslHostStatus(record, status, connection)) } private records(): WslHostDeploymentRecord[] { diff --git a/src/main/wsl/wsl-host-status-board.ts b/src/main/wsl/wsl-host-status-board.ts new file mode 100644 index 00000000000..46759f8d0f8 --- /dev/null +++ b/src/main/wsl/wsl-host-status-board.ts @@ -0,0 +1,37 @@ +/** The WSL host statuses the UI reads, published as each changes, and who hears every change. */ +import type { WslHostStatus } from '../../shared/wsl-host-types' + +export class WslHostStatusBoard { + private readonly statuses = new Map() + private readonly listeners = new Set<() => void>() + + constructor(private readonly publish: (status: WslHostStatus) => void) {} + + list(): WslHostStatus[] { + return [...this.statuses.values()] + } + + onChanged(listener: () => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + set(status: WslHostStatus): void { + this.statuses.set(status.environmentId, status) + this.publish(status) + this.notify() + } + + delete(environmentId: string): void { + this.statuses.delete(environmentId) + this.notify() + } + + private notify(): void { + for (const listener of this.listeners) { + listener() + } + } +} diff --git a/src/main/wsl/wsl-orcad-deploy.test.ts b/src/main/wsl/wsl-orcad-deploy.test.ts index 8f67c8ac3f8..c80d3baee31 100644 --- a/src/main/wsl/wsl-orcad-deploy.test.ts +++ b/src/main/wsl/wsl-orcad-deploy.test.ts @@ -93,6 +93,7 @@ function holder(): WslDistroHolder { }), adoptHolders: vi.fn(), release: vi.fn(), + releaseAll: vi.fn(), holderState: vi.fn() } } diff --git a/src/main/wsl/wsl-orphan-holder-release.test.ts b/src/main/wsl/wsl-orphan-holder-release.test.ts new file mode 100644 index 00000000000..d86bbdaf737 --- /dev/null +++ b/src/main/wsl/wsl-orphan-holder-release.test.ts @@ -0,0 +1,116 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + addWslHostEnvironment, + setWslHostPendingStop +} from '../../shared/runtime-environment-wsl-host-store' +import { encodePairingOffer } from '../../shared/pairing' +import { getWslHostDeploymentStorePath } from '../../shared/wsl-host-deployment-store' +import type { WslDistroHolder } from './wsl-distro-holder' +import { wslDistroHolderDirectory } from './wsl-distro-holder-record' +import { releaseUnwantedWslHolders, wslHolderKeepPolicy } from './wsl-orphan-holder-release' + +const KEY = Buffer.alloc(32, 7).toString('base64') + +let userDataPath: string + +function addHost(distro: string, port: number): void { + addWslHostEnvironment(userDataPath, { + id: `wsl:${distro.toLowerCase()}`, + name: `${distro} (WSL)`, + pairingCode: encodePairingOffer({ + v: 2, + endpoint: `ws://127.0.0.1:${port}`, + deviceToken: 'token', + publicKeyB64: KEY + }), + record: { kind: 'wsl', distro, localPort: port, remotePort: 6768 } + }) +} + +function recordHolder(distro: string): void { + mkdirSync(wslDistroHolderDirectory(userDataPath), { recursive: true }) + writeFileSync( + join(wslDistroHolderDirectory(userDataPath), `${distro.toLowerCase()}.json`), + JSON.stringify({ version: 1, distro, pid: 7, creationTimeMs: 1, startedAtMs: 1 }) + ) +} + +function fakeHolder() { + const release = vi.fn(async (_distro: string) => 'exited' as const) + const holder = { + ensureHeld: vi.fn(), + adoptHolders: vi.fn(), + release, + releaseAll: vi.fn(), + holderState: vi.fn() + } satisfies WslDistroHolder + return { holder, release, factory: vi.fn(() => holder) } +} + +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-wsl-orphan-holders-')) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +describe('releasing orphaned WSL holders at launch', () => { + it('touches nothing for a user who never ran a WSL host', async () => { + const { factory } = fakeHolder() + await expect( + releaseUnwantedWslHolders({ userDataPath, isWanted: () => false, holder: factory }) + ).resolves.toEqual([]) + // No holder means no process table read and no wsl.exe. + expect(factory).not.toHaveBeenCalled() + }) + + it('keeps a live host’s holder and releases only orphans and pending removals', async () => { + addHost('Ubuntu', 42000) + addHost('Debian', 42001) + setWslHostPendingStop(userDataPath, 'wsl:debian', { + since: 1, + reason: 'busy', + verdict: 'live' + }) + recordHolder('Ubuntu') + recordHolder('Debian') + recordHolder('Alpine') + const { release, factory } = fakeHolder() + + const released = await releaseUnwantedWslHolders({ + userDataPath, + isWanted: wslHolderKeepPolicy({ userDataPath, keepHostHolders: true }), + holder: factory + }) + + expect(released.map(({ distro }) => distro).sort()).toEqual(['alpine', 'debian']) + expect(release).not.toHaveBeenCalledWith('ubuntu') + }) +}) + +describe('wslHolderKeepPolicy', () => { + it('keeps every holder when the host records cannot all be read', () => { + addHost('Ubuntu', 42000) + writeFileSync(getWslHostDeploymentStorePath(userDataPath), '{not json') + expect(wslHolderKeepPolicy({ userDataPath, keepHostHolders: true })('Alpine')).toBe(true) + }) + + it('keeps the holder of a host added after the policy was made', () => { + const isWanted = wslHolderKeepPolicy({ userDataPath, keepHostHolders: true }) + addHost('Ubuntu', 42000) + expect(isWanted('ubuntu')).toBe(true) + }) + + it('keeps an add in flight, and nothing while hosts are off', () => { + expect( + wslHolderKeepPolicy({ userDataPath, keepHostHolders: true, isAdding: () => true })('Alpine') + ).toBe(true) + addHost('Ubuntu', 42000) + expect(wslHolderKeepPolicy({ userDataPath, keepHostHolders: false })('Ubuntu')).toBe(false) + }) +}) diff --git a/src/main/wsl/wsl-orphan-holder-release.ts b/src/main/wsl/wsl-orphan-holder-release.ts new file mode 100644 index 00000000000..88df06b49c2 --- /dev/null +++ b/src/main/wsl/wsl-orphan-holder-release.ts @@ -0,0 +1,67 @@ +/** Which recorded `wsl.exe` holders an Orca launch keeps, and releasing the rest (E11). */ +import { existsSync } from 'node:fs' +import { readWslHostDeployments } from '../../shared/wsl-host-deployment-store' +import { sameWslDistro } from './wsl-host-add-admission' +import { + createWslDistroHolder, + type WslDistroHolder, + type WslDistroHolderAdoption +} from './wsl-distro-holder' +import { + listWslDistroHolderRecordDistros, + wslDistroHolderDirectory +} from './wsl-distro-holder-record' + +/** + * Which holders to keep: none when hosts are off (`keepHostHolders` false); else a host's, re-read per holder so an Add + * that lands meanwhile keeps its own. Unreadable host records keep every holder (unverifiable), + * and a host whose Remove is pending already released its holder, so nothing re-adopts it. + */ +export function wslHolderKeepPolicy(args: { + userDataPath: string + keepHostHolders: boolean + isAdding?: (distro: string) => boolean +}): (distro: string) => boolean { + return (distro) => { + if (!args.keepHostHolders) { + return false + } + if (args.isAdding?.(distro)) { + return true + } + try { + const { deployments, unreadableEnvironmentIds } = readWslHostDeployments(args.userDataPath) + return ( + unreadableEnvironmentIds.size > 0 || + deployments.some((record) => sameWslDistro(record.distro, distro) && !record.pendingStop) + ) + } catch (error) { + console.warn('[wsl-hosts] Could not read WSL host records; keeping the holder:', error) + return true + } + } +} + +/** + * Releases the recorded holders `isWanted` refuses. With no holder ever recorded (every user who + * never ran a WSL host) it only checks that one folder exists: no process table, no `wsl.exe`. + */ +export async function releaseUnwantedWslHolders(args: { + userDataPath: string + isWanted: (distro: string) => boolean + holder?: () => WslDistroHolder +}): Promise { + if (!existsSync(wslDistroHolderDirectory(args.userDataPath))) { + return [] + } + const unwanted = (await listWslDistroHolderRecordDistros(args.userDataPath)).filter( + (distro) => !args.isWanted(distro) + ) + if (unwanted.length === 0) { + return [] + } + const holder = args.holder?.() ?? createWslDistroHolder({ userDataPath: args.userDataPath }) + return Promise.all( + unwanted.map(async (distro) => ({ distro, state: await holder.release(distro) })) + ) +} diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index 36e6fad6c0a..a7564516b87 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -11,7 +11,10 @@ import type { import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import type { SidebarInputsChangedEvent } from '../../shared/sidebar-list/sidebar-inputs-feed-contract' -import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type { + PublicKnownRuntimeEnvironment, + RuntimeEnvironmentRemoval +} from '../../shared/runtime-environments' import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' import type { BrowserClientHostPlacementPreparationRequest, @@ -103,7 +106,7 @@ export type RuntimeApi = { pairingCode: string }) => Promise resolve: (args: { selector: string }) => Promise - remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }> + remove: (args: { selector: string }) => Promise disconnect: (args: { selector: string }) => Promise<{ disconnected: PublicKnownRuntimeEnvironment }> diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index e24002e8aaa..7811b036d88 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -6,7 +6,10 @@ import { import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' import type { RuntimeStatus } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' -import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type { + PublicKnownRuntimeEnvironment, + RuntimeEnvironmentRemoval +} from '../../shared/runtime-environments' import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../shared/remote-runtime/control/remote-runtime-shared-control-types' import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../../shared/runtime-environment-diagnostics' import { RUNTIME_ENVIRONMENT_CATALOG_CHANGED_CHANNEL } from '../../shared/runtime-environment-catalog-channel' @@ -60,7 +63,7 @@ export const runtimeEnvironmentsApi = { ipcRenderer.invoke('runtimeEnvironments:repairFromPairingCode', args), resolve: (args: { selector: string }): Promise => ipcRenderer.invoke('runtimeEnvironments:resolve', args), - remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => + remove: (args: { selector: string }): Promise => ipcRenderer.invoke('runtimeEnvironments:remove', args), disconnect: (args: { selector: string diff --git a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.test.tsx b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.test.tsx index 5c65d5c7722..96ed07e6609 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.test.tsx +++ b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.test.tsx @@ -3,11 +3,14 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' -const { toastSuccess, toastError } = vi.hoisted(() => ({ +const { toastSuccess, toastError, toastWarning } = vi.hoisted(() => ({ toastSuccess: vi.fn(), - toastError: vi.fn() + toastError: vi.fn(), + toastWarning: vi.fn() +})) +vi.mock('sonner', () => ({ + toast: { success: toastSuccess, error: toastError, warning: toastWarning } })) -vi.mock('sonner', () => ({ toast: { success: toastSuccess, error: toastError } })) import { useRuntimeEnvironmentMutationActions } from './use-runtime-environment-mutation-actions' import { getDefaultSettings } from '../../../../shared/constants' @@ -29,6 +32,7 @@ const saved: PublicKnownRuntimeEnvironment = { beforeEach(() => { toastSuccess.mockReset() toastError.mockReset() + toastWarning.mockReset() verifyAndAddFromPairingCode.mockReset() remove.mockReset() Object.defineProperty(window, 'api', { @@ -108,3 +112,19 @@ it("lets main judge a saved server's own name, so its code can refresh it", asyn ) expect(toastError).not.toHaveBeenCalled() }) + +it('says a WSL host it kept was not removed yet, instead of "Removed"', async () => { + remove.mockResolvedValue({ + removed: saved, + serverStop: { stopped: false, reason: '1 terminal kept running.', verdict: 'live', kept: true } + }) + const view = renderActions(async () => true) + await act(async () => { + await view.result.current.removeEnvironment(saved) + }) + expect(toastSuccess).not.toHaveBeenCalled() + expect(toastWarning).toHaveBeenCalledWith( + 'Orca didn’t remove the host yet: 1 terminal kept running. Remove it again to retry, or connect it to keep it.', + { duration: Infinity } + ) +}) diff --git a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts index 10ca630cceb..e50d683e98d 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts +++ b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts @@ -9,6 +9,7 @@ import { isRuntimeEnvironmentRemovalBlocked } from './runtime-environment-host-d import { defaultServerNameForAccessLink } from './runtime-server-default-name' import { forgetCachedRuntimeHostDetails } from './use-runtime-environment-catalog' import type { RuntimeHostAccessFailure } from './RuntimeHostAccessFailureAlert' +import { warnWslHostServerStop } from '../sidebar/wsl-host-removal' type RuntimeEnvironmentMutationActionParams = { settings: GlobalSettings @@ -150,10 +151,11 @@ export function useRuntimeEnvironmentMutationActions({ } return false } - await window.api.runtimeEnvironments.remove({ selector: environment.id }) + const removal = await window.api.runtimeEnvironments.remove({ selector: environment.id }) forgetCachedRuntimeHostDetails(environment.id) await loadEnvironments() - if (mountedRef.current) { + // A WSL host whose Remove couldn't prove its server stopped says so instead of "Removed". + if (mountedRef.current && !warnWslHostServerStop(removal?.serverStop)) { toast.success( translate( 'auto.components.settings.RuntimeEnvironmentsPane.b5b5114cb0', diff --git a/src/renderer/src/components/sidebar/wsl-host-removal.test.ts b/src/renderer/src/components/sidebar/wsl-host-removal.test.ts index 612114077f0..f6bb1ca6f06 100644 --- a/src/renderer/src/components/sidebar/wsl-host-removal.test.ts +++ b/src/renderer/src/components/sidebar/wsl-host-removal.test.ts @@ -80,4 +80,18 @@ describe('removeWslHost', () => { { duration: Infinity } ) }) + + it('says the host stays, and why, when its stop is pending', async () => { + stubApi({ + stopped: false, + reason: 'Start Ubuntu to finish removing it.', + verdict: 'unverifiable', + kept: true + }) + await removeWslHost('wsl:ubuntu') + expect(mocks.warning).toHaveBeenCalledExactlyOnceWith( + 'Orca didn’t remove the host yet: Start Ubuntu to finish removing it. Remove it again to retry, or connect it to keep it.', + { duration: Infinity } + ) + }) }) diff --git a/src/renderer/src/components/sidebar/wsl-host-removal.ts b/src/renderer/src/components/sidebar/wsl-host-removal.ts index 9a4c0cbf88e..468b620e7ce 100644 --- a/src/renderer/src/components/sidebar/wsl-host-removal.ts +++ b/src/renderer/src/components/sidebar/wsl-host-removal.ts @@ -1,5 +1,6 @@ import { toast } from 'sonner' import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { WslHostServerStop } from '../../../../shared/wsl-host-types' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { runEnclosingWorktreeRemoval } from '@/store/active-worktree-deselection-intent' @@ -21,18 +22,36 @@ async function removeWslHostImpl(environmentId: string): Promise { state.setActiveWorktree(null) } state.setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) - // Why `=== false`: an older main answered nothing here. - if (serverStop?.stopped === false) { - // Why it stays up: it names what still runs in the distro, which the user has to act on. + warnWslHostServerStop(serverStop) +} + +/** Says what a WSL host's Remove left behind; true when it warned. Shared by every Remove entry. */ +export function warnWslHostServerStop(serverStop: WslHostServerStop | undefined): boolean { + if (serverStop?.stopped !== false) { + return false + } + if (serverStop.kept) { + // E11: a stop Orca couldn't prove keeps the host listed, so Remove can retry it. toast.warning( translate( - 'auto.components.sidebar.wslHostRemoval.partial', - 'The host was removed, but something in the distro is still running: {{value0}}', + 'auto.components.sidebar.wslHostRemoval.pending', + 'Orca didn’t remove the host yet: {{value0}} Remove it again to retry, or connect it to keep it.', { value0: serverStop.reason } ), { duration: Infinity } ) + return true } + // Why it stays up: it names what still runs in the distro, which the user has to act on. + toast.warning( + translate( + 'auto.components.sidebar.wslHostRemoval.partial', + 'The host was removed, but something in the distro is still running: {{value0}}', + { value0: serverStop.reason } + ), + { duration: Infinity } + ) + return true } export async function removeWslHost(environmentId: string): Promise { diff --git a/src/renderer/src/components/sidebar/wsl-host-row-status.test.ts b/src/renderer/src/components/sidebar/wsl-host-row-status.test.ts index 86b7a485d63..0d1b3ae00e1 100644 --- a/src/renderer/src/components/sidebar/wsl-host-row-status.test.ts +++ b/src/renderer/src/components/sidebar/wsl-host-row-status.test.ts @@ -22,6 +22,9 @@ describe('WSL host row status', () => { it('names the row a WSL distro and adds the state when it is not plain', () => { expect(getWslHostRowDetail(status('ready'))).toBe('WSL distro') expect(getWslHostRowDetail(status('distro-stopped'))).toBe('WSL distro · Distro stopped') + expect(getWslHostRowDetail({ ...status('closed'), removalPending: true })).toBe( + 'WSL distro · Removal didn’t finish' + ) }) it('marks the WSL host’s own row, which connects like an SSH host while stopped', () => { diff --git a/src/renderer/src/components/sidebar/wsl-host-row-status.ts b/src/renderer/src/components/sidebar/wsl-host-row-status.ts index 9f13c3b7c68..71e0a3f39dc 100644 --- a/src/renderer/src/components/sidebar/wsl-host-row-status.ts +++ b/src/renderer/src/components/sidebar/wsl-host-row-status.ts @@ -25,6 +25,12 @@ export function getWslHostRowHealth( } export function getWslHostStatusLabel(status: WslHostStatus | undefined): string | null { + if (status?.removalPending) { + return translate( + 'auto.components.sidebar.wslHostRowStatus.removalPending', + 'Removal didn’t finish' + ) + } switch (status?.state) { case undefined: case 'ready': diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6167f9d050a..65609bc9521 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7154,10 +7154,12 @@ "turnedOff": "WSL hosts are turned off in Settings → Experimental", "turnedOffShort": "Turned off in Settings → Experimental", "serverFilesMissing": "This build of Orca can’t run WSL hosts (server files missing).", - "serverFilesMissingShort": "This build can’t run WSL hosts" + "serverFilesMissingShort": "This build can’t run WSL hosts", + "removalPending": "Removal didn’t finish" }, "wslHostRemoval": { - "partial": "The host was removed, but something in the distro is still running: {{value0}}" + "partial": "The host was removed, but something in the distro is still running: {{value0}}", + "pending": "Orca didn’t remove the host yet: {{value0}} Remove it again to retry, or connect it to keep it." }, "addRepoWslHostFolderPicker": { "otherDistro": "That folder is in {{value0}}, not {{value1}}.", diff --git a/src/shared/runtime-environment-wsl-host-store.ts b/src/shared/runtime-environment-wsl-host-store.ts index def559920aa..fac7cd89f6f 100644 --- a/src/shared/runtime-environment-wsl-host-store.ts +++ b/src/shared/runtime-environment-wsl-host-store.ts @@ -18,7 +18,8 @@ import { findCollidingEnvironmentName } from './runtime-environment-name-collisi import { readWslHostDeployments, writeWslHostDeployment, - type WslHostDeploymentRecord + type WslHostDeploymentRecord, + type WslHostPendingStop } from './wsl-host-deployment-store' type WslHostPorts = Pick @@ -164,6 +165,20 @@ export function refreshWslHostPairing( } } +/** Marks (or with null, clears) a Remove that could not prove the host's server stopped. */ +export function setWslHostPendingStop( + userDataPath: string, + environmentId: string, + pendingStop: WslHostPendingStop | null +): void { + const record = findWslHostDeployment(userDataPath, environmentId) + if (!record) { + throw new RuntimeEnvironmentStoreError('invalid_argument', 'This server is not a WSL host.') + } + const { pendingStop: _previous, ...rest } = record + writeWslHostDeployment(userDataPath, environmentId, pendingStop ? { ...rest, pendingStop } : rest) +} + /** Environment first: a crash between the writes leaves an ownerless record, never a bare server. */ export function removeWslHostEnvironment(userDataPath: string, environmentId: string): void { const store = readPersistedEnvironmentStore(userDataPath) diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts index 36a4fb643b6..c4610ddfe32 100644 --- a/src/shared/runtime-environments.ts +++ b/src/shared/runtime-environments.ts @@ -10,6 +10,7 @@ import { OrcadWslDeploymentLinkSchema, type OrcadWslDeploymentLink } from './wsl-host-deployment-link' +import type { WslHostServerStop } from './wsl-host-types' export const RuntimeAccessEndpointSchema = z.object({ id: z.string().min(1), @@ -162,6 +163,12 @@ export type PublicKnownRuntimeEnvironment = Omit + export const WslHostDeploymentRecordSchema = OrcadWslDeploymentLinkSchema.extend({ environmentId: z.string().min(1), createdAt: z.number().finite(), @@ -27,7 +36,8 @@ export const WslHostDeploymentRecordSchema = OrcadWslDeploymentLinkSchema.extend */ pairingPublicKeyB64: z.string().min(1).optional(), /** The key a refresh replaced, accepted until the pairing write that follows it lands. */ - previousPairingPublicKeyB64: z.string().min(1).optional() + previousPairingPublicKeyB64: z.string().min(1).optional(), + pendingStop: WslHostPendingStopSchema.optional() }) export type WslHostDeploymentRecord = z.infer @@ -194,6 +204,7 @@ export function overlayWslHostDeployments( const { environmentId: _environmentId, createdAt: _createdAt, + pendingStop: _pendingStop, pairingPublicKeyB64, previousPairingPublicKeyB64, ...link diff --git a/src/shared/wsl-host-types.ts b/src/shared/wsl-host-types.ts index f30c17dc618..d5a2157f3b0 100644 --- a/src/shared/wsl-host-types.ts +++ b/src/shared/wsl-host-types.ts @@ -28,6 +28,8 @@ export type WslHostStatus = { detail: string | null /** An app update the running server waits to take (e.g. live terminals); Update/Force retry it. */ deferredUpdate?: WslHostDeferredUpdate | null + /** A Remove that could not prove the server stopped; `detail` says why. Remove retries it. */ + removalPending?: boolean } /** An explicit Update retries a skipped build behind the terminal census; Force also passes it. */ @@ -50,8 +52,14 @@ export type WslHostCapability = { serverFiles: boolean } -/** What removing a host left behind in its distro. */ -export type WslHostServerStop = { stopped: true } | { stopped: false; reason: string } +/** + * What removing a host left behind in its distro, in the census vocabulary: `live` when something + * is proven still running, `unverifiable` when Orca could not tell. `kept` (E11): the host stays + * listed with its stop pending, so Remove can retry; older mains removed it anyway. + */ +export type WslHostServerStop = + | { stopped: true } + | { stopped: false; reason: string; verdict?: 'live' | 'unverifiable'; kept?: boolean } export type WslHostAddFailureKind = | 'unsupported-wsl1' diff --git a/tests/e2e/wsl-host-fake-transport.spec.ts b/tests/e2e/wsl-host-fake-transport.spec.ts index 881351cc02a..ba1a3af88a2 100644 --- a/tests/e2e/wsl-host-fake-transport.spec.ts +++ b/tests/e2e/wsl-host-fake-transport.spec.ts @@ -414,7 +414,9 @@ test('adds a WSL distro as a host, adds a project on it, and runs a terminal the await expect(confirm).toBeHidden({ timeout: 30_000 }) // Closed by handle, so the host-only shell ends too, and nothing is reported left running. await expect.poll(() => hubTerminalsIn(hostOnlyPath), { timeout: 30_000 }).toBe(0) - await expect(page.getByText(/something in the distro is still running/)).toHaveCount(0) + await expect( + page.getByText(/something in the distro is still running|didn’t remove the host yet/) + ).toHaveCount(0) await expect .poll(() => page.evaluate(