mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(wsl-host): uninstall and startup release WSL holders; a partial Remove keeps the host pending (#33843)
* fix(wsl-host): uninstall and startup release the WSL distro holders Orca left running The detached wsl.exe holder outlives the app, so an uninstall left its distro, orcad and shells running. The uninstaller now runs Orca with --orca-uninstall-release-wsl-holders after killing the app and its daemon; it ends only the holders this profile recorded, each verified by pid, start time and command line, and keeps any it cannot verify. At startup the WSL host service now releases holders whose distro is no longer a host (a failed add or remove), and every holder while WSL hosts are off; a closed gate releases them too. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-host): a Remove that can't prove the server stopped keeps the host, pending A partial Remove dropped the host anyway, leaving a server whose live terminals block the next add's activation. Remove now drops the host only on a proven stop; otherwise it records a pending stop on the WSL record, keeps the host listed and says why (live or unverifiable). Remove again retries without ever booting a stopped distro (it says to start it); Connect or Add on the same distro keeps the host; a distro that is no longer registered finishes. Nothing reconnects a pending host on its own. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-host): E11 review — keep holders when records are unreadable; finish removals a stopped or renamed distro proves - Startup keeps every holder when the WSL host records can't all be read (unverifiable), and no longer re-adopts a pending host's holder. - Remove matches a renamed distro by GUID (isWslDistroRegistered), and a stopped distro finishes a pending Remove: the cold probe proves the stop. - Only the user's Connect or Add withdraws a pending Remove; other connects are refused, and re-adding respects startIfStopped. - Settings' Remove carries the kept result, so it warns instead of 'Removed'. - A holder record without a start time is never killed on its command line alone, except the one this process just spawned. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-host): E11 review 2 — a closed gate keeps live hosts' holders; stop a renamed distro by its current name - With the gate closed, a launch releases only orphan holders (no host record, or a pending Remove), under the same keep rule as startup; a user with no holder records costs one folder check, nothing else. - The keep rule re-reads host records per holder and keeps an Add in flight. - Remove resolves the distro's current name from its GUID and stops it there. - The uninstall command arms its exit timer first and exits 1 if setup throws; NSIS launches it only when a userData holder folder exists. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-host): never boot a host whose Remove is pending at the next launch Early wake built its host list from every record, so a host focused when its Remove ended pending was booted at each launch, undoing the stop's proof. It now skips pending records, and Remove forgets the host's early wake whatever the outcome. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-host): no wake for a pending host; the e2e fake stops an unbridged server like the real one - mayWake skips a host whose Remove is pending, so a due automation can't boot it (focus already couldn't). - The e2e fake connector read a host with no bridge as stopped. Since E9 a re-enable connects nothing, so Remove after off/on left the host's shells running. Like the real connector, it now reconnects the always-running fake distro to stop its server. - Status bookkeeping moves to WslHostStatusBoard, and the pending-host connect rule to admitConnectToPendingHost, to keep the service under max-lines. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+8
-4
@@ -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) {
|
||||
|
||||
@@ -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<RuntimeEnvironmentRemoval> => {
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<unknown> | null {
|
||||
): Promise<WslHostServerStop> | null {
|
||||
const host = classifyRuntimeEnvironmentHost(environment)
|
||||
switch (host.kind) {
|
||||
case 'wsl':
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<WslDistroHolderAdoption[]>): 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"/
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<number> {
|
||||
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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -59,6 +59,8 @@ export type WslDistroHolder = {
|
||||
adoptHolders(): Promise<WslDistroHolderAdoption[]>
|
||||
/** Explicit stop only; the distro then idles out on WSL's own schedule. */
|
||||
release(distro: string): Promise<WslDistroHolderState>
|
||||
/** Every recorded holder (uninstall, hosts turned off); kills only verified holders. */
|
||||
releaseAll(): Promise<WslDistroHolderAdoption[]>
|
||||
holderState(distro: string): Promise<WslDistroHolderState>
|
||||
}
|
||||
|
||||
@@ -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<WslDistroHolderState> {
|
||||
async function stopRecordedHolder(
|
||||
record: WslDistroHolderRecord,
|
||||
{ spawnedHere = false } = {}
|
||||
): Promise<WslDistroHolderState> {
|
||||
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<boolean> {
|
||||
@@ -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<WslDistroHolderState> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string | false | null> {
|
||||
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<string, { at: number; id: Promise<string | null> }>()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>
|
||||
/** Ends the host's terminals, orcad and its terminal daemon; never boots a stopped distro. */
|
||||
stopServer(distro: string, environmentId: string): Promise<WslHostServerStop>
|
||||
/** The current name by GUID (a rename) or name; false when gone, null when unreadable. Never boots. */
|
||||
resolveDistroName(distro: string, distroId?: string): Promise<string | false | null>
|
||||
releaseHolder(distro: string): Promise<void>
|
||||
adoptHolders(): Promise<void>
|
||||
/** Startup: keeps the holders an earlier run left and releases those `isWanted` refuses. */
|
||||
adoptHolders(isWanted: (distro: string) => boolean): Promise<void>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
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> | void
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -48,6 +48,32 @@ export function createFakeWslHostConnector(
|
||||
const port = offer ? Number(new URL(offer.endpoint).port) : 0
|
||||
const active = new Map<string, WslOrcadConnection>()
|
||||
const key = (distro: string): string => distro.toLowerCase()
|
||||
const connect = async (distro: string): Promise<WslOrcadConnection> => {
|
||||
// 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 () => {
|
||||
|
||||
@@ -106,10 +106,11 @@ export function wakeRememberedWslHosts(
|
||||
}
|
||||
let registered: Set<string>
|
||||
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 []
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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<WslHostRemoval> {
|
||||
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<WslHostDistroCandidate[]>
|
||||
keep: () => Promise<void>
|
||||
environmentId: string
|
||||
}
|
||||
): Promise<WslHostAddResult> {
|
||||
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)
|
||||
}
|
||||
@@ -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<OrcadDecommissionResult> => ({
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,6 +43,8 @@ function describeLeftovers(leftovers: readonly WslHostTerminalLeftover[]): strin
|
||||
export async function stopWslHostServer(steps: WslHostServerStopSteps): Promise<WslHostServerStop> {
|
||||
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. */
|
||||
|
||||
@@ -59,8 +59,11 @@ export function makeConnector(distros: WslHostDistroCandidate[]) {
|
||||
stopped: true
|
||||
})
|
||||
),
|
||||
resolveDistroName: vi.fn(
|
||||
async (distro: string, _distroId?: string): Promise<string | false | null> => 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 })
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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> | void
|
||||
}
|
||||
export type {
|
||||
WslHostConnector,
|
||||
WslHostReachability,
|
||||
WslHostServiceDependencies
|
||||
} from './wsl-host-connector-contract'
|
||||
|
||||
export class WslHostService {
|
||||
private connector: WslHostConnector | null = null
|
||||
private readonly statuses = new Map<string, WslHostStatus>()
|
||||
private readonly board = new WslHostStatusBoard((status) => this.deps.publish(status))
|
||||
private readonly adding = new Map<string, Promise<WslHostAddResult>>()
|
||||
private visible = new Set<string>()
|
||||
private readonly parked = new Set<string>()
|
||||
private readonly removing = new Set<string>()
|
||||
// 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<WslHostDistro[]> {
|
||||
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<WslHostAddResult> {
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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[] {
|
||||
|
||||
@@ -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<string, WslHostStatus>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ function holder(): WslDistroHolder {
|
||||
}),
|
||||
adoptHolders: vi.fn(),
|
||||
release: vi.fn(),
|
||||
releaseAll: vi.fn(),
|
||||
holderState: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<WslDistroHolderAdoption[]> {
|
||||
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) }))
|
||||
)
|
||||
}
|
||||
@@ -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<VerifyAndAddRuntimeEnvironmentResult>
|
||||
resolve: (args: { selector: string }) => Promise<PublicKnownRuntimeEnvironment>
|
||||
remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }>
|
||||
remove: (args: { selector: string }) => Promise<RuntimeEnvironmentRemoval>
|
||||
disconnect: (args: {
|
||||
selector: string
|
||||
}) => Promise<{ disconnected: PublicKnownRuntimeEnvironment }>
|
||||
|
||||
@@ -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<PublicKnownRuntimeEnvironment> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:resolve', args),
|
||||
remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> =>
|
||||
remove: (args: { selector: string }): Promise<RuntimeEnvironmentRemoval> =>
|
||||
ipcRenderer.invoke('runtimeEnvironments:remove', args),
|
||||
disconnect: (args: {
|
||||
selector: string
|
||||
|
||||
+23
-3
@@ -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 }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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}}.",
|
||||
|
||||
@@ -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<WslHostDeploymentRecord, 'localPort' | 'remotePort'>
|
||||
@@ -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)
|
||||
|
||||
@@ -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<KnownRuntimeEnvironment, 'endpo
|
||||
hostKeyFingerprint?: string
|
||||
}
|
||||
|
||||
/** `serverStop` only for a host whose owning service stops a server (a WSL host, E11). */
|
||||
export type RuntimeEnvironmentRemoval = {
|
||||
removed: PublicKnownRuntimeEnvironment
|
||||
serverStop?: WslHostServerStop
|
||||
}
|
||||
|
||||
export function redactRuntimeEnvironment(
|
||||
environment: KnownRuntimeEnvironment
|
||||
): PublicKnownRuntimeEnvironment {
|
||||
|
||||
@@ -18,6 +18,15 @@ const WSL_HOST_DEPLOYMENTS_FILE = 'orca-wsl-host-deployments.json'
|
||||
const MAX_WSL_HOST_DEPLOYMENTS_FILE_BYTES = 256 * 1024
|
||||
|
||||
/** A managed orcad in a WSL distro. The environment's name is its label; runtime state lives elsewhere. */
|
||||
/** A Remove that could not prove the host's server stopped (E11); the host stays until one does. */
|
||||
export const WslHostPendingStopSchema = z.object({
|
||||
since: z.number().finite(),
|
||||
reason: z.string().max(4_096),
|
||||
verdict: z.enum(['live', 'unverifiable'])
|
||||
})
|
||||
|
||||
export type WslHostPendingStop = z.infer<typeof WslHostPendingStopSchema>
|
||||
|
||||
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<typeof WslHostDeploymentRecordSchema>
|
||||
@@ -194,6 +204,7 @@ export function overlayWslHostDeployments(
|
||||
const {
|
||||
environmentId: _environmentId,
|
||||
createdAt: _createdAt,
|
||||
pendingStop: _pendingStop,
|
||||
pairingPublicKeyB64,
|
||||
previousPairingPublicKeyB64,
|
||||
...link
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user