From 93a5a1a20a2c68fedc21ccff1e3901abe7a0cc78 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:59:01 -0700 Subject: [PATCH] fix(wsl-host): release a failed add's holder and cancel adds the experiment was turned off during (#29889) A failed or deferred first add kept its detached distro holder with no record to find it; an add in flight when WSL hosts were turned off still finished as a ready host. --- src/main/wsl/wsl-host-first-connect.ts | 46 +++++++++++++++++++++++ src/main/wsl/wsl-host-service.test.ts | 51 ++++++++++++++++++++++++++ src/main/wsl/wsl-host-service.ts | 32 ++++++++-------- 3 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 src/main/wsl/wsl-host-first-connect.ts diff --git a/src/main/wsl/wsl-host-first-connect.ts b/src/main/wsl/wsl-host-first-connect.ts new file mode 100644 index 00000000000..9f24a23f29b --- /dev/null +++ b/src/main/wsl/wsl-host-first-connect.ts @@ -0,0 +1,46 @@ +/** + * The connect step of a first WSL host Add. Until the Add persists a record, nothing else (Remove, + * disabling the experiment, startup adoption) can find this distro's connection or detached + * holder, so every way the Add can end without a host must release them here. + */ +import { errorMessage } from '../../shared/error-message' +import type { WslHostAddResult } from '../../shared/wsl-host-types' +import { OrcadHostUnsupportedError } from '../orcad/orcad-host-unavailable' +import type { WslHostConnector } from './wsl-host-service' +import { WslOrcadLaunchDeferredError, type WslOrcadConnection } from './wsl-orcad-connection' + +type WslHostAddRefusal = Extract +type AddConnector = Pick + +export async function abandonWslHostAdd(connector: AddConnector, distro: string): Promise { + await connector.disconnect(distro).catch(() => {}) + await connector.releaseHolder(distro).catch(() => {}) +} + +/** `lateRefusal` is re-read once the connect lands: the setting can change while it runs. */ +export async function connectForWslHostAdd( + connector: AddConnector, + distro: string, + lateRefusal: () => WslHostAddRefusal | null +): Promise<{ connection: WslOrcadConnection } | WslHostAddRefusal> { + let connection: WslOrcadConnection + try { + connection = await connector.connect(distro) + } catch (error) { + await abandonWslHostAdd(connector, distro) + if (error instanceof WslOrcadLaunchDeferredError) { + return { ok: false, kind: 'deferred', message: error.launch.reason } + } + return { + ok: false, + kind: error instanceof OrcadHostUnsupportedError ? 'unsupported-wsl1' : 'failed', + message: errorMessage(error) + } + } + const refusal = lateRefusal() + if (refusal) { + await abandonWslHostAdd(connector, distro) + return refusal + } + return { connection } +} diff --git a/src/main/wsl/wsl-host-service.test.ts b/src/main/wsl/wsl-host-service.test.ts index f53fd27df8b..7df9c2b9e6c 100644 --- a/src/main/wsl/wsl-host-service.test.ts +++ b/src/main/wsl/wsl-host-service.test.ts @@ -424,6 +424,57 @@ describe('WslHostService', () => { expect(connector.connect).not.toHaveBeenCalled() }) + it('releases the holder of a failed or deferred first add, which no record would ever find', async () => { + const connector = makeConnector([UBUNTU]) + connector.connect.mockRejectedValueOnce(new Error('deploy failed')) + const service = makeService(connector) + await expect(service.add({ distro: 'Ubuntu' })).resolves.toMatchObject({ kind: 'failed' }) + expect(connector.disconnect).toHaveBeenCalledWith('Ubuntu') + expect(connector.releaseHolder).toHaveBeenCalledWith('Ubuntu') + + vi.clearAllMocks() + connector.connect.mockRejectedValueOnce( + new WslOrcadLaunchDeferredError({ + outcome: 'deferred', + distro: 'Ubuntu', + candidateVersion: '2.0.0', + code: 'live-terminals', + reason: 'Terminals are still running.', + forceable: true + }) + ) + await expect(service.add({ distro: 'Ubuntu' })).resolves.toMatchObject({ kind: 'deferred' }) + expect(connector.releaseHolder).toHaveBeenCalledWith('Ubuntu') + expect(listEnvironments(userDataPath)).toEqual([]) + }) + + it('an add the experiment was turned off during ends without a host, connection or holder', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + let finishConnect: () => void = () => {} + connector.connect.mockImplementationOnce(async (distro: string) => { + await new Promise((resolve) => { + finishConnect = resolve + }) + return connection(distro) + }) + const adding = service.add({ distro: 'Ubuntu' }) + await vi.waitFor(() => expect(connector.connect).toHaveBeenCalled()) + + // Off and back on before the connect lands: the add started under the old setting. + enabled = false + await service.enabledChanged(false) + enabled = true + await service.enabledChanged(true) + finishConnect() + + await expect(adding).resolves.toMatchObject({ ok: false, kind: 'disabled' }) + expect(listEnvironments(userDataPath)).toEqual([]) + expect(connector.disconnect).toHaveBeenCalledWith('Ubuntu') + expect(connector.releaseHolder).toHaveBeenCalledWith('Ubuntu') + expect(ready).not.toHaveBeenCalled() + }) + it('treats a distro being added as known, so its first connect may spawn', async () => { const connector = makeConnector([UBUNTU]) const service = makeService(connector) diff --git a/src/main/wsl/wsl-host-service.ts b/src/main/wsl/wsl-host-service.ts index aa983f8dca7..49832a52f9d 100644 --- a/src/main/wsl/wsl-host-service.ts +++ b/src/main/wsl/wsl-host-service.ts @@ -19,7 +19,6 @@ import { type WslHostServerStop, type WslHostStatus } from '../../shared/wsl-host-types' -import { OrcadHostUnsupportedError } from '../orcad/orcad-host-unavailable' import type { WslHostDistroCandidate } from './wsl-host-distro-list' import { wslHostSshManagedRefusal } from './wsl-host-ssh-orcad-conflict' import { wslDistroHolderKey } from './wsl-distro-holder-record' @@ -29,9 +28,11 @@ import { sameWslDistro, wslHostNotConnectedMessage, WSL_HOST_REMOVING_MESSAGE, + WSL_HOSTS_DISABLED_MESSAGE, wslHostRunRefusal } from './wsl-host-add-admission' -import { WslOrcadLaunchDeferredError, type WslOrcadConnection } from './wsl-orcad-connection' +import type { WslOrcadConnection } from './wsl-orcad-connection' +import { abandonWslHostAdd, connectForWslHostAdd } from './wsl-host-first-connect' import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status' import { defaultWslOrcadEnvironmentId } from './wsl-orcad-deploy' @@ -70,6 +71,8 @@ export class WslHostService { private readonly adding = new Map>() private visible = new Set() private readonly removing = new Set() + // Bumped on disable so an Add that started before it cannot finish as a ready host. + private disableGeneration = 0 constructor(private readonly deps: WslHostServiceDependencies) {} @@ -167,6 +170,7 @@ export class WslHostService { this.setVisibleWork([...this.visible], new Set()) return } + this.disableGeneration += 1 for (const record of this.records()) { await connector.disconnect(record.distro) await connector.releaseHolder(record.distro) @@ -264,6 +268,7 @@ export class WslHostService { if (refusal) { return refusal } + const generation = this.disableGeneration const admission = admitWslHostDistro({ requested: args.distro, candidates: await this.wsl.listDistros(), @@ -282,19 +287,15 @@ export class WslHostService { return sharing } const environmentId = defaultWslOrcadEnvironmentId(distro.name) - let connection: WslOrcadConnection - try { - connection = await this.wsl.connect(distro.name) - } catch (error) { - if (error instanceof WslOrcadLaunchDeferredError) { - return { ok: false, kind: 'deferred', message: error.launch.reason } - } - return { - ok: false, - kind: error instanceof OrcadHostUnsupportedError ? 'unsupported-wsl1' : 'failed', - message: errorMessage(error) - } + const connected = await connectForWslHostAdd(this.wsl, distro.name, () => + generation === this.disableGeneration + ? wslHostRunRefusal(this.deps) + : { ok: false, kind: 'disabled', message: WSL_HOSTS_DISABLED_MESSAGE } + ) + if (!('connection' in connected)) { + return connected } + const { connection } = connected try { addWslHostEnvironment(userDataPath, { id: environmentId, @@ -309,8 +310,7 @@ export class WslHostService { } }) } catch (error) { - await this.wsl.disconnect(distro.name).catch(() => {}) - await this.wsl.releaseHolder(distro.name).catch(() => {}) + await abandonWslHostAdd(this.wsl, distro.name) return { ok: false, kind: 'failed', message: errorMessage(error) } } const record = this.recordForEnvironment(environmentId)