diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index 4c4532a9658..bd00a056bbd 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -16,13 +16,13 @@ import { codexHookService } from '../codex/hook-service' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { PluginSources } from '../../wsl-guest/plugin-overlay' import { - isWslDistroRunning, resolveWslHookRelayBundle, runWslInstallProcess, spawnWslRelayProcess } from './wsl-hook-relay-launch' import { waitForWslRelaySentinel } from './wsl-hook-relay-sentinel' import { listWslDistrosAsync } from '../wsl' +import { isWslDistroRunning } from '../wsl-background-distro-gate' import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' // Why: fresh WSL intermittently throws "Catastrophic failure (E_UNEXPECTED)" @@ -60,6 +60,7 @@ export type WslHookRelayManagerDeps = { resolveBundle: typeof resolveWslHookRelayBundle readBundle: (jsPath: string) => Buffer listDistros: () => Promise + /** Restart timers check this so relay recovery never resurrects a VM the user shut down. */ isDistroRunning: typeof isWslDistroRunning spawnRelay: typeof spawnWslRelayProcess runInstall: typeof runWslInstallProcess diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index c60f4364bf2..295cfeea718 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -17,7 +17,6 @@ import { import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import { runWslProcess } from '../wsl/wsl-runner' import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' -import { listRunningWslDistrosAsync } from '../wsl' import { WSL_HOOK_RELAY_BUNDLE_NAME, WSL_HOOK_RELAY_DIR, @@ -129,20 +128,6 @@ export function spawnWslRelayProcess( }) } -/** True when the distro shows in `wsl --list --running`. Listing does NOT - * boot anything — unlike `wsl -d`, which starts a stopped distro. The - * restart timer must check this so relay recovery never resurrects a VM the - * user shut down with `wsl --shutdown`. Fails CLOSED (false) on probe - * errors: booting a VM the user shut down is worse than a skipped restart - * (the next WSL PTY spawn re-ensures), and a wsl.exe too wedged to list - * distros would not have launched the relay anyway. */ -export function isWslDistroRunning(distro: string): Promise { - const wanted = distro.trim().toLowerCase() - return listRunningWslDistrosAsync().then((running) => - running.some((candidate) => candidate.toLowerCase() === wanted) - ) -} - export async function runWslInstallProcess( distro: string, script: string, diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index 25ecbab4a84..ed9a278bed4 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -10,6 +10,7 @@ const fakes = vi.hoisted(() => ({ }, wsl: { prepareLaunch: async (distro: string) => ({ runtime: 'wsl', wslDistro: distro }), + preparation: async (distro: string) => ({ runtime: 'wsl', wslDistro: distro }), publish: async (_distro: string): Promise => {}, runningDistros: async (): Promise => [] } @@ -39,10 +40,14 @@ vi.mock('./claude-profile-installed-router', () => ({ import { ClaudeRuntimeAuthService } from './runtime-auth-service' const platform = Object.getOwnPropertyDescriptor(process, 'platform')! -function service(platformName: NodeJS.Platform, overrides: Partial = {}) { +function service( + platformName: NodeJS.Platform, + overrides: Partial = {}, + isDistroRunning: (distro: string) => Promise = async () => true +) { Object.defineProperty(process, 'platform', { configurable: true, value: platformName }) const settings = { ...getDefaultSettings('/home/user'), ...overrides } - return new ClaudeRuntimeAuthService({ getSettings: () => settings }) + return new ClaudeRuntimeAuthService({ getSettings: () => settings }, isDistroRunning) } beforeEach(() => { @@ -54,6 +59,20 @@ afterEach(() => { }) describe('ClaudeRuntimeAuthService', () => { + it('skips a quota preparation for a stopped distro instead of booting it', async () => { + const preparation = vi.spyOn(fakes.wsl, 'preparation') + const auth = service('win32', {}, async () => false) + await expect( + auth.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).resolves.toMatchObject({ usageError: 'WSL distro Ubuntu is not running.' }) + expect(preparation).not.toHaveBeenCalled() + + const running = service('win32') + await expect( + running.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + ).resolves.toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + }) + it('keeps a stale WSL account setting on the host off Windows', async () => { const auth = service('darwin', { localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' }) expect(auth.getRuntimeConfigDir()).toBe('/host/.claude') diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 66659ddb53f..d84fe4bbdba 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -3,6 +3,7 @@ import { getAppEnvironment } from '../../shared/app-environment' import { toWindowsWslPath } from '../../shared/wsl-paths' import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import type { Store } from '../persistence' +import { isWslDistroRunning } from '../wsl-background-distro-gate' import { getDefaultWslDistro, getWslHome } from '../wsl' import { ClaudeProfileRouter } from './claude-profile-router' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' @@ -25,7 +26,10 @@ export class ClaudeRuntimeAuthService { private readonly dataRoot = getAppEnvironment().getPath('userData') private mutationQueue: Promise = Promise.resolve() - constructor(private readonly store: Pick) { + constructor( + private readonly store: Pick, + private readonly isDistroRunning: (distro: string) => Promise = isWslDistroRunning + ) { const args = { getSettings: () => store.getSettings(), dataRoot: this.dataRoot } this.router = new ClaudeProfileRouter(args) installClaudeProfileRouter(this.router) @@ -47,6 +51,15 @@ export class ClaudeRuntimeAuthService { target?: ClaudeAccountSelectionTarget ): Promise { const wsl = this.wslRouteFor(target) + // Why: a quota poll is background work and must not boot a stopped distro. + if (wsl && !(await this.isDistroRunning(wsl.distro))) { + return { + configDir: '', + envPatch: {}, + provenance: 'profile:unavailable', + usageError: `WSL distro ${wsl.distro} is not running.` + } + } try { return await (wsl ? wsl.router.preparation(wsl.distro) : this.router.preparation()) } catch (error) { diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index a728e0acafe..43188e60739 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -437,6 +437,7 @@ describe('WslCliInstaller', () => { isPackaged: true, userDataPath: '/user-data', listDistros: async () => ['Ubuntu'], + listRunningDistros: async () => ['Ubuntu'], registry: { getCandidates: async () => ['Ubuntu'], recordObservations: async () => undefined diff --git a/src/main/cli/wsl-cli-registration-reconciliation.test.ts b/src/main/cli/wsl-cli-registration-reconciliation.test.ts index 48b75dd207d..44f4aa36b30 100644 --- a/src/main/cli/wsl-cli-registration-reconciliation.test.ts +++ b/src/main/cli/wsl-cli-registration-reconciliation.test.ts @@ -24,6 +24,7 @@ describe('reconcileManagedWslCliRegistrations', () => { isPackaged: true, userDataPath: '/user-data', listDistros: async () => ['Ubuntu', 'Debian', 'Fedora'], + listRunningDistros: async () => ['Ubuntu', 'Debian', 'Fedora'], registry, createInstaller: (distro) => { if (distro === 'Ubuntu') { @@ -62,6 +63,7 @@ describe('reconcileManagedWslCliRegistrations', () => { userDataPath: '/user-data', appVersion: '1.4.138', listDistros: async () => ['Ubuntu'], + listRunningDistros: async () => ['Ubuntu'], getHostLauncherTarget: async () => 'C:\\Orca\\resources\\bin\\orca.exe', registry, createInstaller: () => ({ @@ -98,6 +100,7 @@ describe('reconcileManagedWslCliRegistrations', () => { isPackaged: true, userDataPath: '/user-data', listDistros: async () => ['Broken Distro', 'No Interop'], + listRunningDistros: async () => ['Broken Distro', 'No Interop'], registry, createInstaller: (distro) => ({ repairManagedRegistration: async () => { @@ -133,6 +136,7 @@ describe('reconcileManagedWslCliRegistrations', () => { isPackaged: true, userDataPath: '/user-data', listDistros: async () => ['Ubuntu'], + listRunningDistros: async () => ['Ubuntu'], registry: { getCandidates: async () => ['Ubuntu'], recordObservations: async () => { @@ -192,6 +196,7 @@ describe('reconcileManagedWslCliRegistrations', () => { isPackaged: true, userDataPath: '/user-data', listDistros: async () => ['Ubuntu'], + listRunningDistros: async () => ['Ubuntu'], registry, createInstaller: () => ({ repairManagedRegistration: async () => { @@ -226,4 +231,71 @@ describe('reconcileManagedWslCliRegistrations', () => { 'settings-remove' ]) }) + + it('defers stopped candidates without booting them and reconciles each once it runs', async () => { + const registry = { + getCandidates: vi.fn(async (available: string[]) => + ['Ubuntu', 'Debian'].filter((distro) => available.includes(distro)) + ), + recordObservations: vi.fn(async () => undefined) + } + const repaired: string[] = [] + const deferred = new Map unknown>() + let running = ['Ubuntu'] + const options = { + platform: 'win32' as const, + isPackaged: true, + userDataPath: '/user-data', + listDistros: async () => ['Ubuntu', 'Debian'], + listRunningDistros: async () => running, + deferUntilRunning: (distro: string, _key: string, task: () => unknown) => { + deferred.set(distro, task) + }, + registry, + createInstaller: (distro: string) => ({ + repairManagedRegistration: async () => { + repaired.push(distro) + return { changed: false, managed: true, status: { state: 'installed' as const } } + } + }) + } + + await expect(reconcileManagedWslCliRegistrations(options)).resolves.toEqual([ + { distro: 'Ubuntu', outcome: 'unchanged', state: 'installed', managed: true }, + { distro: 'Debian', outcome: 'deferred' } + ]) + expect(repaired).toEqual(['Ubuntu']) + expect([...deferred.keys()]).toEqual(['Debian']) + + running = ['Ubuntu', 'Debian'] + await deferred.get('Debian')?.() + expect(repaired).toEqual(['Ubuntu', 'Debian']) + expect(registry.getCandidates).toHaveBeenLastCalledWith(['Debian'], { + currentTarget: null, + appVersion: '' + }) + }) + + it('defers every candidate when no distro is running', async () => { + const repair = vi.fn() + const deferUntilRunning = vi.fn() + const results = await reconcileManagedWslCliRegistrations({ + platform: 'win32', + isPackaged: true, + userDataPath: '/user-data', + listDistros: async () => ['Ubuntu'], + listRunningDistros: async () => [], + deferUntilRunning, + registry: { getCandidates: async () => ['Ubuntu'], recordObservations: vi.fn() }, + createInstaller: () => ({ repairManagedRegistration: repair }) + }) + + expect(results).toEqual([{ distro: 'Ubuntu', outcome: 'deferred' }]) + expect(repair).not.toHaveBeenCalled() + expect(deferUntilRunning).toHaveBeenCalledWith( + 'Ubuntu', + expect.any(String), + expect.any(Function) + ) + }) }) diff --git a/src/main/cli/wsl-cli-registration-reconciliation.ts b/src/main/cli/wsl-cli-registration-reconciliation.ts index b4c4933bcd1..d550d2d91ab 100644 --- a/src/main/cli/wsl-cli-registration-reconciliation.ts +++ b/src/main/cli/wsl-cli-registration-reconciliation.ts @@ -1,5 +1,9 @@ import type { CliInstallState, CliInstallStatus } from '../../shared/cli-install-types' import { listWslDistrosAsync } from '../wsl' +import { + deferUntilWslDistroRunning, + listConfirmedRunningWslDistros +} from '../wsl-background-distro-gate' import { CliInstaller } from './cli-installer' import { getWslCliRegistrationCandidates, @@ -9,9 +13,9 @@ import { import { WslCliInstaller } from './wsl-cli-installer' import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation' -// Why: candidate distros can each boot a stopped WSL VM; a small cap staggers -// those boots instead of spiking RAM/CPU for every distro at once at startup. +// Why: each repair spawns a guest shell; a small cap bounds the startup fan-out. const MAX_CONCURRENT_DISTRO_REPAIRS = 2 +const DEFERRED_RECONCILIATION_KEY = 'wsl-cli-registration-reconciliation' type ManagedWslCliInstaller = { repairManagedRegistration: () => Promise<{ @@ -43,6 +47,8 @@ type WslCliRegistrationReconciliationOptions = { createInstaller?: (distro: string) => ManagedWslCliInstaller getHostLauncherTarget?: () => Promise registry?: WslCliRegistrationRegistry + listRunningDistros?: () => Promise + deferUntilRunning?: (distro: string, key: string, task: () => unknown) => void } export type WslCliRegistrationReconciliationResult = @@ -57,6 +63,10 @@ export type WslCliRegistrationReconciliationResult = outcome: 'failed' error: string } + | { + distro: string + outcome: 'deferred' + } export async function reconcileManagedWslCliRegistrations( options: WslCliRegistrationReconciliationOptions @@ -101,10 +111,34 @@ export async function reconcileManagedWslCliRegistrations( ? await getHostLauncherTarget().catch(() => null) : null const appVersion = options.appVersion ?? '' - const distros = await registry.getCandidates(availableDistros, { currentTarget, appVersion }) - if (distros.length === 0) { + const candidates = await registry.getCandidates(availableDistros, { currentTarget, appVersion }) + if (candidates.length === 0) { return [] } + // Why: background reconciliation must not boot a stopped distro; it re-runs for that distro + // once it is next seen running, and the registry keeps it a candidate on later startups. + const running = new Set( + (await (options.listRunningDistros ?? listConfirmedRunningWslDistros)()).map((distro) => + distro.toLowerCase() + ) + ) + const distros = candidates.filter((distro) => running.has(distro.toLowerCase())) + const deferUntilRunning = options.deferUntilRunning ?? deferUntilWslDistroRunning + const deferred: WslCliRegistrationReconciliationResult[] = [] + for (const distro of candidates) { + if (running.has(distro.toLowerCase())) { + continue + } + deferred.push({ distro, outcome: 'deferred' }) + deferUntilRunning(distro, DEFERRED_RECONCILIATION_KEY, () => + reconcileManagedWslCliRegistrations({ ...options, listDistros: async () => [distro] }).then( + logWslCliReconciliationResults + ) + ) + } + if (distros.length === 0) { + return deferred + } const reconcileDistro = async ( distro: string @@ -171,5 +205,19 @@ export async function reconcileManagedWslCliRegistrations( } }) ) - return results + return [...results, ...deferred] +} + +export function logWslCliReconciliationResults( + results: readonly WslCliRegistrationReconciliationResult[] +): void { + for (const result of results) { + if (result.outcome === 'failed') { + console.warn( + `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}` + ) + } else if (result.outcome === 'repaired') { + console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) + } + } } diff --git a/src/main/codex-accounts/runtime-home-restart-stopped-distro.test.ts b/src/main/codex-accounts/runtime-home-restart-stopped-distro.test.ts new file mode 100644 index 00000000000..ee44ae2ffee --- /dev/null +++ b/src/main/codex-accounts/runtime-home-restart-stopped-distro.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { CodexAccountSelectionTarget } from './runtime-selection' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createStore, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' + +const { isWslDistroRunningMock } = vi.hoisted(() => ({ + isWslDistroRunningMock: vi.fn<(distro: string) => Promise>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) +vi.mock('../wsl-background-distro-gate', () => ({ + isWslDistroRunning: isWslDistroRunningMock +})) + +function wslAccount(id: string, distro: string): CodexManagedAccount { + return { + id, + email: `${id}@example.com`, + managedHomePath: `\\\\wsl.localhost\\${distro}\\home\\user\\.codex-${id}`, + managedHomeRuntime: 'wsl', + wslDistro: distro, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } +} + +describe('Codex WSL auth preservation before restart', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + + beforeEach(() => { + setupRuntimeHomeTest() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + isWslDistroRunningMock.mockImplementation(async (distro) => distro === 'Ubuntu') + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + teardownRuntimeHomeTest() + }) + + it('drains only running distros so a restart never boots a stopped one', async () => { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const drained: (string | null | undefined)[] = [] + class DrainRecorder extends CodexRuntimeHomeService { + protected override startLegacyWslAuthDrain( + target: CodexAccountSelectionTarget + ): Promise { + drained.push(target.wslDistro) + return Promise.resolve() + } + } + const store = createStore( + createSettings({ + codexManagedAccounts: [wslAccount('a', 'Ubuntu'), wslAccount('b', 'Debian')], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'a', Debian: 'b' } } + }) + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness store implements the subset the service reads. + const service = new DrainRecorder(store as never) + + await service.syncActiveWslSelectionsBeforeRestart() + + expect(drained).toEqual(['Ubuntu']) + expect(isWslDistroRunningMock).toHaveBeenCalledWith('Debian') + }) +}) diff --git a/src/main/codex-accounts/runtime-home-service-home-routing.ts b/src/main/codex-accounts/runtime-home-service-home-routing.ts index ca68b322667..cfb630dd29d 100644 --- a/src/main/codex-accounts/runtime-home-service-home-routing.ts +++ b/src/main/codex-accounts/runtime-home-service-home-routing.ts @@ -2,6 +2,7 @@ import { posix as pathPosix } from 'node:path' import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { getDefaultWslDistro, getWslHome } from '../wsl' +import { isWslDistroRunning } from '../wsl-background-distro-gate' import { getSystemCodexHomePath, resolveOrcaManagedCodexHomePath, @@ -191,7 +192,14 @@ export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHom ? account.wslDistro?.trim() || null : selectedDistroKey.trim() || null if (distro) { - drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) + // Why: a stopped distro has no live legacy pane to drain, and the next launch prep drains anyway. + drains.push( + isWslDistroRunning(distro).then((running) => + running + ? this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro }) + : undefined + ) + ) } } await Promise.all(drains) diff --git a/src/main/ipc/pty/ipc/spawn-execute.ts b/src/main/ipc/pty/ipc/spawn-execute.ts index 0746b7e989a..b13daf35115 100644 --- a/src/main/ipc/pty/ipc/spawn-execute.ts +++ b/src/main/ipc/pty/ipc/spawn-execute.ts @@ -1,4 +1,5 @@ import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import { noteWslDistrosRunning } from '../../../wsl-background-distro-gate' import { classifyError } from '../../../telemetry/classify-error' import { track } from '../../../telemetry/client' import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' @@ -96,10 +97,13 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { } } ensureWslHookRelayForReattach(ctx.result) - ctx.deps.runtime?.preparePtyExecutionContext?.( - ctx.result.id, + const spawnedWslDistro = ctx.result.wslDistro === undefined ? ctx.expectedWslDistro : ctx.result.wslDistro - ) + if (spawnedWslDistro && !args.connectionId) { + // A local WSL terminal is user-initiated, so background work held for this distro may run. + noteWslDistrosRunning([spawnedWslDistro]) + } + ctx.deps.runtime?.preparePtyExecutionContext?.(ctx.result.id, spawnedWslDistro) ctx.spawnTiming.mark('provider_spawn') } catch (err) { if ( diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 46540384be6..6c4fe9d41a9 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -3,6 +3,7 @@ import { ptyIncarnationById } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' import { tryGetProviderForPty } from '../provider/registry' import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import { noteWslDistrosRunning } from '../../../wsl-background-distro-gate' import { agentSessionOwners, assertSpawnReplyWasLive, @@ -187,10 +188,13 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise } } ensureWslHookRelayForReattach(ctx.result) - ctx.deps.runtime?.preparePtyExecutionContext?.( - ctx.result.id, + const spawnedWslDistro = ctx.result.wslDistro === undefined ? ctx.expectedWslDistro : ctx.result.wslDistro - ) + if (spawnedWslDistro && !args.connectionId) { + // A local WSL terminal is user-initiated, so background work held for this distro may run. + noteWslDistrosRunning([spawnedWslDistro]) + } + ctx.deps.runtime?.preparePtyExecutionContext?.(ctx.result.id, spawnedWslDistro) } catch (err) { if ( (ctx.isNewDaemonSession || ctx.preparedProvisionalExecutionContext) && diff --git a/src/main/kimi/kimi-runtime-home.test.ts b/src/main/kimi/kimi-runtime-home.test.ts index a1977e8d857..3dd5c51f482 100644 --- a/src/main/kimi/kimi-runtime-home.test.ts +++ b/src/main/kimi/kimi-runtime-home.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const wslMocks = vi.hoisted(() => ({ listWslDistrosAsync: vi.fn<() => Promise>(), + listRunningWslDistrosAsync: vi.fn<() => Promise>(), getWslHomeAsync: vi.fn<(distro: string) => Promise>() })) @@ -53,6 +54,7 @@ describe('resolveKimiHome', () => { beforeEach(() => { delete process.env.KIMI_CODE_HOME wslMocks.listWslDistrosAsync.mockReset().mockResolvedValue(['Ubuntu']) + wslMocks.listRunningWslDistrosAsync.mockReset().mockResolvedValue(['Ubuntu']) wslMocks.getWslHomeAsync.mockReset().mockResolvedValue('\\\\wsl.localhost\\Ubuntu\\home\\neil') }) @@ -95,6 +97,16 @@ describe('resolveKimiHome', () => { expect(wslMocks.getWslHomeAsync).toHaveBeenCalledWith('Ubuntu') }) + it('reports no path without probing the home while the distro is stopped', async () => { + wslMocks.listRunningWslDistrosAsync.mockResolvedValue([]) + expect(await resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32')).toEqual({ + runtime: 'wsl', + wslDistro: 'Ubuntu', + path: null + }) + expect(wslMocks.getWslHomeAsync).not.toHaveBeenCalled() + }) + it('reports no path when the distro home cannot be probed', async () => { wslMocks.getWslHomeAsync.mockResolvedValue(null) expect(await resolveKimiHome({ runtime: 'wsl', wslDistro: 'Ubuntu' }, 'win32')).toEqual({ diff --git a/src/main/kimi/kimi-runtime-home.ts b/src/main/kimi/kimi-runtime-home.ts index 709cb755879..475b1851647 100644 --- a/src/main/kimi/kimi-runtime-home.ts +++ b/src/main/kimi/kimi-runtime-home.ts @@ -7,9 +7,10 @@ import { } from '../../shared/local-account-runtime' import type { GlobalSettings } from '../../shared/global-settings-types' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' +import { isWslDistroRunning } from '../wsl-background-distro-gate' export type KimiHomeResolution = LocalAccountRuntimeTarget & { - /** null when the WSL distro's home could not be probed (distro missing/stopped). */ + /** null when the distro is missing or stopped, or its home could not be probed. */ path: string | null } @@ -50,6 +51,10 @@ export async function resolveKimiHome( if (!distro) { return { runtime: 'wsl', wslDistro: null, path: null } } + // Why: a quota poll is background work and must not boot a stopped distro. + if (!(await isWslDistroRunning(distro))) { + return { runtime: 'wsl', wslDistro: distro, path: null } + } const home = await getWslHomeAsync(distro) // KIMI_CODE_HOME describes the Windows host process, never the distro's home. return { runtime: 'wsl', wslDistro: distro, path: home ? joinKimiHome(home) : null } diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index acab66c9f4f..f0f8bc99951 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -609,6 +609,38 @@ describe('RateLimitService', () => { expect.arrayContaining([expect.objectContaining({ accountId: 'wsl-account-1' })]) ) }) + + // Regression: the Codex home resolver is async, and a reset never waits on isFetching. + it('does not apply a stale full-refresh cycle codex result over a newer reset', async () => { + const service = new RateLimitService() + const staleHome = deferred<{ kind: 'ready'; codexHomePath: string | null }>() + const resolver = vi + .fn() + .mockReturnValueOnce(staleHome.promise) + .mockResolvedValue({ kind: 'ready', codexHomePath: '/tmp/approved-selection' }) + service.setCodexHomePathResolver(resolver) + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits) + .mockResolvedValueOnce(okProvider('codex', 0, Date.now())) + .mockResolvedValueOnce(okProvider('codex', 100, Date.now())) + vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset') + + const staleRefresh = service.refresh() + await vi.waitFor(() => expect(resolver).toHaveBeenCalledTimes(1)) + + await expect( + service.consumeCodexRateLimitResetCredit({ + idempotencyKey: '55555555-5555-4555-8555-555555555555', + target: { runtime: 'host', wslDistro: null }, + codexHomePath: '/tmp/approved-selection' + }) + ).resolves.toMatchObject({ outcome: 'reset' }) + + staleHome.resolve({ kind: 'ready', codexHomePath: '/tmp/approved-selection' }) + await staleRefresh + + expect(service.getState().codex?.session?.usedPercent).not.toBe(100) + }) }) function inactiveCodexAccount(id: string, managedHomePath: string) { diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 1c089f0701c..a315a172054 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -348,7 +348,7 @@ describe('RateLimitService', () => { const refresh = service.refresh().then(() => { refreshResolved = true }) - await flushMicrotasks() + await flushMicrotasks(8) const pendingGrokState = service.getState() expect(pendingGrokState.claude?.status).toBe('ok') diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 8cbb2591503..a37d18d0bd9 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -147,8 +147,9 @@ describe('RateLimitService', () => { expect(fetchCodexRateLimits).not.toHaveBeenCalled() window.emit('focus') - await Promise.resolve() - await Promise.resolve() + for (let tick = 0; tick < 3; tick += 1) { + await Promise.resolve() + } expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts index 7f9fb882e6d..c387a1b442a 100644 --- a/src/main/rate-limits/service/service-fetch-targets.ts +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -44,11 +44,11 @@ function codexResetUsageVisible( } export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResultPolicy { - protected resolveCodexHome(target?: CodexAccountSelectionTarget): { + protected async resolveCodexHome(target?: CodexAccountSelectionTarget): Promise<{ skip: boolean homePath: string | null - } { - const resolution = this.codexHomePathResolver?.(target) + }> { + const resolution = await this.codexHomePathResolver?.(target) if (!resolution) { return { skip: false, homePath: null } } @@ -151,7 +151,7 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul } const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex) - const currentCodexHome = this.resolveCodexHome(target) + const currentCodexHome = await this.resolveCodexHome(target) // Why: a skip has no provenance to compare, so treat it as no longer active // rather than publishing this result against the system-default lane. const stillActive = diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 7181b0ddf9c..bebf32a5ed6 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -128,7 +128,7 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ status: 'error' } satisfies ProviderRateLimits) - const latestCodexHome = this.resolveCodexHome(codexTarget) + const latestCodexHome = await this.resolveCodexHome(codexTarget) const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) if (signal.aborted) { return diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index c6ad83fe654..91be654443f 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -72,10 +72,12 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' const codexTarget = this.codexFetchTarget + // Why: capture before the resolver await; a reset during it bumps the generation and this cycle's result is stale. + const codexGeneration = this.codexFetchGeneration const previousState = this.state // Why: a skipped Codex poll must not stop the other providers' cycle, so gate // only the Codex slot instead of returning early (#STA-4422). - const codexHome = this.resolveCodexHome(codexTarget) + const codexHome = await this.resolveCodexHome(codexTarget) const codexFetchGated = codexHome.skip const codexHomePath = codexHome.homePath const codexStateBeforeFetch = @@ -83,7 +85,6 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ const codexProvenance = codexFetchGated ? null : this.getCodexProvenance(codexTarget, codexHomePath) - const codexGeneration = this.codexFetchGeneration const openCodeGoConfig = this.resolveOpenCodeGoConfig() const cookie = openCodeGoConfig.sessionCookie const workspaceIdOverride = openCodeGoConfig.workspaceIdOverride diff --git a/src/main/rate-limits/service/service-provider-cycles.ts b/src/main/rate-limits/service/service-provider-cycles.ts index 0932d6fa7a7..a011d19e2d8 100644 --- a/src/main/rate-limits/service/service-provider-cycles.ts +++ b/src/main/rate-limits/service/service-provider-cycles.ts @@ -12,7 +12,7 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul } const codexTarget = this.codexFetchTarget const codexGeneration = this.codexFetchGeneration - const codexHome = this.resolveCodexHome(codexTarget) + const codexHome = await this.resolveCodexHome(codexTarget) // Why: return before the "fetching" mark — a skipped cycle never settles it (#STA-4422). if (codexHome.skip) { if ( @@ -56,7 +56,7 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul return } - const latestCodexHome = this.resolveCodexHome(codexTarget) + const latestCodexHome = await this.resolveCodexHome(codexTarget) if (latestCodexHome.skip && codexGeneration === this.codexFetchGeneration) { this.updateState({ ...this.state, diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index eec4ffd50cb..e2bbc612b4a 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -36,7 +36,7 @@ export type InactiveCodexAccountInfo = { export type CodexHomePathResolver = ( target?: CodexAccountSelectionTarget -) => CodexRateLimitHomeResolution +) => CodexRateLimitHomeResolution | Promise export type KimiHomeResolver = () => Promise export type ClaudeAuthPreparationResolver = ( target?: ClaudeAccountSelectionTarget diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 18d4fa45d45..bb54e610d02 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -13,6 +13,7 @@ import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' import { getInitialCodexRateLimitTarget } from '../rate-limits/codex-rate-limit-target' import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limit-target' import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home' +import { isWslDistroRunningForBackgroundWork } from '../wsl-background-distro-gate' import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' import { readMiniMaxApiKey } from '../minimax/minimax-api-key-store' import { readZcodePlanApiKey } from '../zcode/zcode-plan-api-key-store' @@ -78,8 +79,12 @@ export function initializeMainProcessAccountServices(): void { state.codexSessionMigration.scheduleInitialRun() state.claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) state.claudeAccounts = new ClaudeAccountService(store, state.rateLimits, state.claudeRuntimeAuth) - state.rateLimits.setCodexHomePathResolver((target) => - state.codexRuntimeHome!.prepareForRateLimitFetch(target) + // Why: quota polls are background work and must not boot a stopped distro; user-driven + // resets call prepareForRateLimitFetch directly. + state.rateLimits.setCodexHomePathResolver(async (target) => + target?.runtime === 'wsl' && !(await isWslDistroRunningForBackgroundWork(target.wslDistro)) + ? { kind: 'skip' } + : state.codexRuntimeHome!.prepareForRateLimitFetch(target) ) state.rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) // Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 9439ee3adc1..09201cc2537 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -41,7 +41,10 @@ import { writeHttp1CompatibilityMarker } from './http1-compatibility-marker' import { mainProcessState as state } from './main-process-state' import { syncMacMenuBarIcon } from './main-window-actions' import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' -import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' +import { + logWslCliReconciliationResults, + reconcileManagedWslCliRegistrations +} from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' @@ -105,15 +108,7 @@ export async function initializeReadyFoundation(): Promise { appVersion: app.getVersion() }) .then((results) => { - for (const result of results) { - if (result.outcome === 'failed') { - console.warn( - `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}` - ) - } else if (result.outcome === 'repaired') { - console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) - } - } + logWslCliReconciliationResults(results) state.managedWslCliReconciliationStatus = 'settled' }) .catch((error) => { diff --git a/src/main/terminal-history-deletion.ts b/src/main/terminal-history-deletion.ts index 97650a78ac3..545f707089b 100644 --- a/src/main/terminal-history-deletion.ts +++ b/src/main/terminal-history-deletion.ts @@ -11,6 +11,7 @@ import { } from './terminal-history-paths' import { hashWorktreeId } from './terminal-history-id' import { deleteWslFishHistoryFile } from './wsl-fish-history-cleanup' +import { deferUntilWslDistroRunning } from './wsl-background-distro-gate' const pendingHistoryTreeRemovals = new Map>() export const MAX_PENDING_HISTORY_TREE_REMOVALS = 64 @@ -20,6 +21,8 @@ export const HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS = [30_000, 120_000] const historyTreeRemovalAttempts = new Map() const historyTreeRemovalRetryTimers = new Map>() const wslDistroByTombstone = new Map() +// Tombstones whose fish cleanup waits for their stopped distro; they hold no admission slot. +const tombstonesAwaitingWslDistro = new Set() /** One root's undrained tombstone names from the last enumeration, plus its in-flight re-read. */ type HistoryRemovalQueue = { names: string[]; refill: Promise | null } @@ -45,7 +48,11 @@ function historyTreeRemovalsAtCapacity(): boolean { } function isHistoryTreeRemovalTracked(dir: string): boolean { - return pendingHistoryTreeRemovals.has(dir) || historyTreeRemovalRetryTimers.has(dir) + return ( + pendingHistoryTreeRemovals.has(dir) || + historyTreeRemovalRetryTimers.has(dir) || + tombstonesAwaitingWslDistro.has(dir) + ) } /** Admit already-enumerated tombstones up to the admission cap; the rest stay queued for a later slot. */ @@ -186,7 +193,7 @@ function scheduleHistoryTreeRemovalRetry(dir: string): void { } function scheduleHistoryTreeRemoval(dir: string, wslDistro?: string): void { - if (pendingHistoryTreeRemovals.has(dir)) { + if (pendingHistoryTreeRemovals.has(dir) || tombstonesAwaitingWslDistro.has(dir)) { return } // Leave excess tombstones on disk; admission is intentionally bounded. @@ -202,6 +209,7 @@ function scheduleHistoryTreeRemoval(dir: string, wslDistro?: string): void { wslDistroByTombstone.set(dir, wslDistro) } let removalSucceeded = false + let awaitingDistro = false const cleanupDistro = wslDistroByTombstone.get(dir) const meta = cleanupDistro ? readHistoryMeta(dir) : null const cleanup = @@ -215,13 +223,24 @@ function scheduleHistoryTreeRemoval(dir: string, wslDistro?: string): void { console.warn( `[pty:history] Failed to delete WSL fish history: ${err instanceof Error ? err.message : String(err)}` ) + return undefined }) : null - const removal = (cleanup ? cleanup.then(() => removeHostTree(dir)) : removeHostTree(dir)) + const removal = ( + cleanup + ? cleanup.then((outcome) => { + // Why keep the tombstone: its meta.json is the only record of the guest-side file. + awaitingDistro = outcome === 'distro-stopped' + return awaitingDistro ? undefined : removeHostTree(dir) + }) + : removeHostTree(dir) + ) .then(() => { - removalSucceeded = true - historyTreeRemovalAttempts.delete(dir) - wslDistroByTombstone.delete(dir) + removalSucceeded = !awaitingDistro + if (removalSucceeded) { + historyTreeRemovalAttempts.delete(dir) + wslDistroByTombstone.delete(dir) + } }) .catch((err: unknown) => { console.warn( @@ -233,7 +252,15 @@ function scheduleHistoryTreeRemoval(dir: string, wslDistro?: string): void { if (pendingHistoryTreeRemovals.get(dir) === removal) { pendingHistoryTreeRemovals.delete(dir) } - if (removalSucceeded) { + if (awaitingDistro && cleanupDistro) { + tombstonesAwaitingWslDistro.add(dir) + deferUntilWslDistroRunning(cleanupDistro, `history-tombstone:${dir}`, () => { + if (tombstonesAwaitingWslDistro.delete(dir)) { + scheduleHistoryTreeRemoval(dir) + } + }) + } + if (removalSucceeded || awaitingDistro) { replenishHistoryTreeRemovals(historyRootForTombstone(dir)) } }) @@ -289,6 +316,7 @@ export function cancelPendingHistoryTreeRemovalRetries(): void { historyTreeRemovalRetryTimers.clear() historyTreeRemovalAttempts.clear() wslDistroByTombstone.clear() + tombstonesAwaitingWslDistro.clear() } /** Drain every history root's tombstones and await the in-flight removals. Tests only: production diff --git a/src/main/terminal-history-tombstone-retry.test.ts b/src/main/terminal-history-tombstone-retry.test.ts index cbfb10cc2b7..575d81415a9 100644 --- a/src/main/terminal-history-tombstone-retry.test.ts +++ b/src/main/terminal-history-tombstone-retry.test.ts @@ -8,7 +8,7 @@ let userDataDir: string const { removeHostTreeMock, deleteWslFishHistoryFileMock } = vi.hoisted(() => ({ removeHostTreeMock: vi.fn<(dir: string) => Promise>(), - deleteWslFishHistoryFileMock: vi.fn<(distro: string, session: string) => Promise>() + deleteWslFishHistoryFileMock: vi.fn<(distro: string, session: string) => Promise>() })) vi.mock('./host-tree-removal', () => ({ @@ -29,6 +29,10 @@ import { HISTORY_TREE_REMOVAL_RETRY_DELAYS_MS, schedulePendingHistoryTreeRemovals } from './terminal-history-deletion' +import { + _resetWslBackgroundDistroGateForTests, + noteWslDistrosRunning +} from './wsl-background-distro-gate' // Captured before any fake clock replaces the global: refilling the removal queue reads the // tombstone directory for real, and only a turn of the actual event loop can land that read. @@ -57,6 +61,7 @@ describe('tombstoned history removal retries', () => { afterEach(() => { cancelPendingHistoryTreeRemovalRetries() + _resetWslBackgroundDistroGateForTests() vi.useRealTimers() rmSync(userDataDir, { recursive: true, force: true }) }) @@ -214,4 +219,34 @@ describe('tombstoned history removal retries', () => { expect(removeHostTreeMock).toHaveBeenCalled() expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(0) }) + + it('keeps the tombstone while its distro is stopped and finishes once the distro runs', async () => { + const distroRoot = join(userDataDir, 'terminal-history-wsl', 'Ubuntu') + mkdirSync(distroRoot, { recursive: true }) + deleteWslFishHistoryFileMock.mockResolvedValueOnce('distro-stopped') + const worktreeId = 'repo-1::/path/stopped-distro' + const dir = join(distroRoot, hashWorktreeId(worktreeId)) + mkdirSync(dir, { recursive: true }) + const fishSession = fishHistorySessionName(hashWorktreeId(worktreeId)) + writeFileSync(join(dir, 'meta.json'), JSON.stringify({ worktreeId, fishSession })) + + deleteWorktreeHistoryDir(worktreeId) + await flushPendingWorktreeHistoryDeletions() + + expect(removeHostTreeMock).not.toHaveBeenCalled() + expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(1) + + // A rescan must not re-probe a tombstone that already waits for its distro. + await schedulePendingHistoryTreeRemovals(distroRoot) + await settleTombstoneDirectoryReads() + expect(deleteWslFishHistoryFileMock).toHaveBeenCalledTimes(1) + + deleteWslFishHistoryFileMock.mockResolvedValue('deleted') + noteWslDistrosRunning(['ubuntu']) + await flushPendingWorktreeHistoryDeletions() + + expect(deleteWslFishHistoryFileMock).toHaveBeenLastCalledWith('Ubuntu', fishSession) + expect(removeHostTreeMock).toHaveBeenCalledTimes(1) + expect(readdirSync(join(distroRoot, '.pending-delete'))).toHaveLength(0) + }) }) diff --git a/src/main/wsl-background-distro-gate.test.ts b/src/main/wsl-background-distro-gate.test.ts new file mode 100644 index 00000000000..bec3bfcf503 --- /dev/null +++ b/src/main/wsl-background-distro-gate.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const wslMocks = vi.hoisted(() => ({ + listRunningWslDistrosAsync: + vi.fn<(options?: { requireConfirmed?: boolean }) => Promise>(), + listWslDistrosAsync: vi.fn<() => Promise>() +})) +vi.mock('./wsl', () => wslMocks) + +import { + _resetWslBackgroundDistroGateForTests, + deferUntilWslDistroRunning, + isWslDistroRunning, + isWslDistroRunningForBackgroundWork, + noteWslDistrosRunning +} from './wsl-background-distro-gate' +import { + _resetRunningWslDistroCacheForTests, + resolveRunningWslDistros +} from './wsl-running-distro-cache' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslMocks.listRunningWslDistrosAsync.mockResolvedValue(['Ubuntu']) + wslMocks.listWslDistrosAsync.mockResolvedValue(['Debian', 'Ubuntu']) +}) + +afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + _resetWslBackgroundDistroGateForTests() + _resetRunningWslDistroCacheForTests() + vi.restoreAllMocks() + vi.resetAllMocks() +}) + +describe('isWslDistroRunning', () => { + it('matches running distros case-insensitively', async () => { + await expect(isWslDistroRunning(' ubuntu ')).resolves.toBe(true) + await expect(isWslDistroRunning('Debian')).resolves.toBe(false) + }) + + it('reads a distro as stopped when the probe fails, not as its last-known state', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let probe: () => Promise = async () => ['Ubuntu'] + wslMocks.listRunningWslDistrosAsync.mockImplementation((options) => + resolveRunningWslDistros(() => probe(), options) + ) + await expect(isWslDistroRunning('Ubuntu')).resolves.toBe(true) + + probe = () => Promise.reject(new Error('wsl.exe wedged')) + await expect(isWslDistroRunning('Ubuntu')).resolves.toBe(false) + // Inside the backoff window the cache still holds Ubuntu; the gate must not trust it. + await expect(isWslDistroRunning('Ubuntu')).resolves.toBe(false) + }) +}) + +describe('isWslDistroRunningForBackgroundWork', () => { + it('checks the default distro when none is named', async () => { + await expect(isWslDistroRunningForBackgroundWork(null)).resolves.toBe(false) + wslMocks.listWslDistrosAsync.mockResolvedValue(['Ubuntu']) + await expect(isWslDistroRunningForBackgroundWork(undefined)).resolves.toBe(true) + }) + + it('treats a host with no distros, or no WSL at all, as not running', async () => { + wslMocks.listWslDistrosAsync.mockResolvedValue([]) + await expect(isWslDistroRunningForBackgroundWork(null)).resolves.toBe(false) + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + await expect(isWslDistroRunningForBackgroundWork('Ubuntu')).resolves.toBe(false) + expect(wslMocks.listRunningWslDistrosAsync).not.toHaveBeenCalled() + }) +}) + +describe('deferUntilWslDistroRunning', () => { + it('runs deferred work once, when its distro is seen running', () => { + const ubuntuTask = vi.fn() + const debianTask = vi.fn() + deferUntilWslDistroRunning('Ubuntu', 'work', ubuntuTask) + deferUntilWslDistroRunning('Debian', 'work', debianTask) + + noteWslDistrosRunning(['UBUNTU']) + noteWslDistrosRunning(['Ubuntu']) + + expect(ubuntuTask).toHaveBeenCalledTimes(1) + expect(debianTask).not.toHaveBeenCalled() + }) + + it('keeps only the latest task per key', () => { + const first = vi.fn() + const second = vi.fn() + deferUntilWslDistroRunning('Ubuntu', 'same', first) + deferUntilWslDistroRunning('Ubuntu', 'same', second) + + noteWslDistrosRunning(['Ubuntu']) + + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + }) + + it('drains on a confirmed running-distro probe but not on a failed one', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const task = vi.fn() + deferUntilWslDistroRunning('Ubuntu', 'work', task) + + await resolveRunningWslDistros(() => Promise.reject(new Error('wsl.exe wedged'))) + expect(task).not.toHaveBeenCalled() + + _resetRunningWslDistroCacheForTests() + await resolveRunningWslDistros(async () => ['Ubuntu']) + expect(task).toHaveBeenCalledTimes(1) + }) + + it('isolates a failing task from the rest of the drain', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const after = vi.fn() + deferUntilWslDistroRunning('Ubuntu', 'throws', () => { + throw new Error('boom') + }) + deferUntilWslDistroRunning('Ubuntu', 'rejects', () => Promise.reject(new Error('later'))) + deferUntilWslDistroRunning('Ubuntu', 'after', after) + + noteWslDistrosRunning(['Ubuntu']) + await vi.waitFor(() => expect(warn).toHaveBeenCalledTimes(2)) + + expect(after).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/wsl-background-distro-gate.ts b/src/main/wsl-background-distro-gate.ts new file mode 100644 index 00000000000..9505f199b4b --- /dev/null +++ b/src/main/wsl-background-distro-gate.ts @@ -0,0 +1,92 @@ +import { listRunningWslDistrosAsync, listWslDistrosAsync } from './wsl' +import { onConfirmedRunningWslDistros } from './wsl-running-distro-cache' + +// Why this gate exists: any `wsl.exe -d` (or `\\wsl.localhost` access) boots a stopped distro, +// costing 1-2s of VM boot, and on WSL < 3.0.1 that distro's later idle-stop wipes the VM-wide +// WSLInterop binfmt entry, breaking .exe launches in every other running distro +// (microsoft/WSL#13885). Rule: work the user did not just ask for (startup reconciliation, +// GC, quota polls) runs only in already-running distros; user-initiated work may boot. + +type DeferredTask = () => unknown + +const deferredTasksByDistro = new Map>() +let unsubscribeObservations: (() => void) | null = null + +function distroKey(distro: string): string { + return distro.trim().toLowerCase() +} + +/** Distros a fresh `wsl --list --running` (which never boots anything) confirmed. Why not the + * last-known-good fallback: a distro that idle-stopped since would read as running and get + * booted, so an unknown state reads as stopped and deferred work waits for a confirmed probe. */ +export async function listConfirmedRunningWslDistros(): Promise { + return listRunningWslDistrosAsync({ requireConfirmed: true }).catch(() => []) +} + +export async function isWslDistroRunning(distro: string): Promise { + const wanted = distroKey(distro) + const running = await listConfirmedRunningWslDistros() + return running.some((candidate) => distroKey(candidate) === wanted) +} + +/** Background-work check; a missing distro means the default one, as `wsl.exe` would pick. */ +export async function isWslDistroRunningForBackgroundWork( + distro: string | null | undefined +): Promise { + if (process.platform !== 'win32') { + return false + } + const target = distro?.trim() || (await listWslDistrosAsync())[0] + return target ? isWslDistroRunning(target) : false +} + +/** + * Queue background work for a stopped distro; it runs once the distro is next seen running + * (a confirmed running-list probe or a WSL terminal spawn). Same key replaces, so a rescan + * cannot queue duplicates. In-memory only: callers must re-derive the work on next startup. + */ +export function deferUntilWslDistroRunning(distro: string, key: string, task: DeferredTask): void { + const keyForDistro = distroKey(distro) + if (!keyForDistro) { + return + } + unsubscribeObservations ??= onConfirmedRunningWslDistros(noteWslDistrosRunning) + let tasks = deferredTasksByDistro.get(keyForDistro) + if (!tasks) { + tasks = new Map() + deferredTasksByDistro.set(keyForDistro, tasks) + } + tasks.set(key, task) +} + +/** Runs work deferred for these distros; callers must know they are running now. */ +export function noteWslDistrosRunning(distros: readonly string[]): void { + for (const distro of distros) { + const keyForDistro = distroKey(distro) + const tasks = deferredTasksByDistro.get(keyForDistro) + if (!tasks) { + continue + } + deferredTasksByDistro.delete(keyForDistro) + for (const task of tasks.values()) { + try { + void Promise.resolve(task()).catch(logDeferredTaskFailure) + } catch (error) { + logDeferredTaskFailure(error) + } + } + } +} + +function logDeferredTaskFailure(error: unknown): void { + console.warn( + '[wsl] deferred background work failed:', + error instanceof Error ? error.message : String(error) + ) +} + +export function _resetWslBackgroundDistroGateForTests(): void { + deferredTasksByDistro.clear() + unsubscribeObservations?.() + unsubscribeObservations = null +} diff --git a/src/main/wsl-fish-history-cleanup.test.ts b/src/main/wsl-fish-history-cleanup.test.ts index ede30393049..48c5128c314 100644 --- a/src/main/wsl-fish-history-cleanup.test.ts +++ b/src/main/wsl-fish-history-cleanup.test.ts @@ -6,6 +6,7 @@ import { } from './wsl-fish-history-cleanup' const okResult = { environmentResolved: true, code: 0, stdout: '', stderr: '', timedOut: false } +const running = async (): Promise => true // Why reset on BOTH sides: the cleanup queue is module state, so one test's // pending work would otherwise serialize behind — or deadlock — the next. @@ -22,7 +23,7 @@ describe('deleteWslFishHistoryFile', () => { it('uses direct argv and bounds a distro cleanup subprocess', async () => { const run = vi.fn().mockResolvedValue(okResult) - await deleteWslFishHistoryFile('Ubuntu Test', 'orca_0123456789abcdef', run) + await deleteWslFishHistoryFile('Ubuntu Test', 'orca_0123456789abcdef', run, running) expect(run).toHaveBeenCalledWith({ distro: 'Ubuntu Test', @@ -36,7 +37,7 @@ describe('deleteWslFishHistoryFile', () => { it('rejects an unsafe session before spawning', async () => { const run = vi.fn() - await deleteWslFishHistoryFile('Ubuntu', '../../user-history', run) + await deleteWslFishHistoryFile('Ubuntu', '../../user-history', run, running) expect(run).not.toHaveBeenCalled() }) @@ -52,11 +53,11 @@ describe('deleteWslFishHistoryFile', () => { ) const session = 'orca_0123456789abcdef' - const first = deleteWslFishHistoryFile('Ubuntu', session, run) - const second = deleteWslFishHistoryFile('Ubuntu', session, run) + const first = deleteWslFishHistoryFile('Ubuntu', session, run, running) + const second = deleteWslFishHistoryFile('Ubuntu', session, run, running) expect(second).toBe(first) - expect(run).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)) settle() await Promise.all([first, second]) }) @@ -65,8 +66,8 @@ describe('deleteWslFishHistoryFile', () => { const run = vi.fn().mockResolvedValue(okResult) await Promise.all([ - deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run), - deleteWslFishHistoryFile('Debian', 'orca_0123456789abcdef', run) + deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run, running), + deleteWslFishHistoryFile('Debian', 'orca_0123456789abcdef', run, running) ]) expect(run).toHaveBeenCalledTimes(2) @@ -79,24 +80,35 @@ describe('deleteWslFishHistoryFile', () => { .mockResolvedValue(okResult) const session = 'orca_0123456789abcdef' - await expect(deleteWslFishHistoryFile('Ubuntu', session, run)).rejects.toThrow('distro offline') - await expect(deleteWslFishHistoryFile('Ubuntu', session, run)).resolves.toBeUndefined() + await expect(deleteWslFishHistoryFile('Ubuntu', session, run, running)).rejects.toThrow( + 'distro offline' + ) + await expect(deleteWslFishHistoryFile('Ubuntu', session, run, running)).resolves.toBe('deleted') expect(run).toHaveBeenCalledTimes(2) }) it('treats a non-zero exit as a failed cleanup even though the runner resolved', async () => { const run = vi.fn().mockResolvedValue({ ...okResult, code: 1 }) - await expect(deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run)).rejects.toThrow( - 'wsl fish history cleanup failed' - ) + await expect( + deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run, running) + ).rejects.toThrow('wsl fish history cleanup failed') }) it('treats a runner timeout as a failed cleanup', async () => { const run = vi.fn().mockResolvedValue({ ...okResult, code: null, timedOut: true }) - await expect(deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run)).rejects.toThrow( - 'wsl fish history cleanup failed' - ) + await expect( + deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run, running) + ).rejects.toThrow('wsl fish history cleanup failed') + }) + + it('does not spawn into a stopped distro and reports it for a later retry', async () => { + const run = vi.fn().mockResolvedValue(okResult) + + await expect( + deleteWslFishHistoryFile('Ubuntu', 'orca_0123456789abcdef', run, async () => false) + ).resolves.toBe('distro-stopped') + expect(run).not.toHaveBeenCalled() }) }) diff --git a/src/main/wsl-fish-history-cleanup.ts b/src/main/wsl-fish-history-cleanup.ts index cedbe4b999e..aba89e0459c 100644 --- a/src/main/wsl-fish-history-cleanup.ts +++ b/src/main/wsl-fish-history-cleanup.ts @@ -1,5 +1,9 @@ import { runWslProcess } from './wsl/wsl-runner' import { isSafeFishHistorySession } from './fish-history-session' +import { isWslDistroRunning } from './wsl-background-distro-gate' + +/** 'distro-stopped': nothing ran, because GC must not boot a stopped distro; retry once it runs. */ +export type WslFishHistoryCleanupOutcome = 'deleted' | 'skipped' | 'distro-stopped' /** Deduped by distro+session, so a rescan that re-queues the same tombstone * joins the running call instead of spawning a second `wsl.exe`. @@ -9,7 +13,7 @@ import { isSafeFishHistorySession } from './fish-history-session' * but can still be dozens of `wsl.exe` launches during a startup GC drain on a * machine with many dead WSL fish worktrees. Narrowing that belongs with the * tombstone scheduler that owns the admission, not here. */ -const cleanupsInFlight = new Map>() +const cleanupsInFlight = new Map>() /** Why run this INSIDE the distro: the history file lives on the distro's own * filesystem under its `$XDG_DATA_HOME`/`$HOME`, which only a shell in there @@ -26,17 +30,18 @@ function fishCleanupScript(session: string): string { export function deleteWslFishHistoryFile( distro: string, session: string, - run: typeof runWslProcess = runWslProcess -): Promise { + run: typeof runWslProcess = runWslProcess, + isRunning: (distro: string) => Promise = isWslDistroRunning +): Promise { if (!distro.trim() || !isSafeFishHistorySession(session)) { - return Promise.resolve() + return Promise.resolve('skipped') } const key = `${distro}\0${session}` const existing = cleanupsInFlight.get(key) if (existing) { return existing } - const cleanup = runCleanup(distro, session, run).finally(() => { + const cleanup = runCleanup(distro, session, run, isRunning).finally(() => { cleanupsInFlight.delete(key) }) cleanupsInFlight.set(key, cleanup) @@ -46,8 +51,12 @@ export function deleteWslFishHistoryFile( async function runCleanup( distro: string, session: string, - run: typeof runWslProcess -): Promise { + run: typeof runWslProcess, + isRunning: (distro: string) => Promise +): Promise { + if (!(await isRunning(distro))) { + return 'distro-stopped' + } const result = await run({ distro, // 'preferred', not 'none': `fish` is a bare name, so it is a PATH lookup. @@ -65,6 +74,7 @@ async function runCleanup( `wsl fish history cleanup failed for ${distro}: code=${result.code} timedOut=${result.timedOut}` ) } + return 'deleted' } /** Test-only: drop in-flight state so one test's pending work cannot reach the next. */ @@ -78,7 +88,7 @@ export function __resetWslFishHistoryCleanups(): void { * make `while (size > 0)` re-await a resolved promise forever, starving the * event loop instead of returning. */ export async function flushWslFishHistoryCleanups(): Promise { - const awaited = new Set>() + const awaited = new Set>() let pending = [...cleanupsInFlight.values()] while (pending.length > 0) { for (const cleanup of pending) { diff --git a/src/main/wsl-running-distro-cache.ts b/src/main/wsl-running-distro-cache.ts index 7d2059b2f20..610ee32e0a1 100644 --- a/src/main/wsl-running-distro-cache.ts +++ b/src/main/wsl-running-distro-cache.ts @@ -37,6 +37,28 @@ function notifyStopped(previous: string[] | null, running: string[]): void { } } +const confirmedObservationListeners = new Set<(distros: readonly string[]) => void>() + +/** Notified with every confirmed (successful) probe result; never with a fallback list. */ +export function onConfirmedRunningWslDistros( + listener: (distros: readonly string[]) => void +): () => void { + confirmedObservationListeners.add(listener) + return () => { + confirmedObservationListeners.delete(listener) + } +} + +function notifyConfirmedObservation(distros: readonly string[]): void { + for (const listener of confirmedObservationListeners) { + try { + listener(distros) + } catch (error) { + console.warn('[wsl] running-distro listener failed', error) + } + } +} + function armRetryAfterFailure(): void { const now = Date.now() // Concurrent completions belong to the retry window already armed by the first result. @@ -82,6 +104,7 @@ function observeRunningWslDistros( retryAfterMs = 0 failureStreak = 0 notifyStopped(previous, distros) + notifyConfirmedObservation(distros) return { distros, confirmed: true } }) .catch((error: unknown) => { diff --git a/src/main/wsl-running-distros.test.ts b/src/main/wsl-running-distros.test.ts index 4f80e5eb5c2..3da2481e451 100644 --- a/src/main/wsl-running-distros.test.ts +++ b/src/main/wsl-running-distros.test.ts @@ -57,6 +57,22 @@ describe('running WSL distro discovery', () => { }) }) + it('confirms an empty list when no distro runs', async () => { + // Captured on WSL 2.7: with nothing running, `--quiet` exits 0 with empty stdout in both + // encodings; only the non-quiet form prints the localized message and exits -1. + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, 'Ubuntu\r\n') + }) + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, '') + }) + + await withPlatform('win32', async () => { + await expect(listRunningWslDistrosAsync()).resolves.toEqual(['Ubuntu']) + await expect(listRunningWslDistrosAsync({ requireConfirmed: true })).resolves.toEqual([]) + }) + }) + it('fails closed when running-distro discovery fails', async () => { execFileMock.mockImplementation((_command, _args, _options, callback) => { callback(new Error('wsl unavailable'), '')