fix(wsl): stop booting stopped distros for background work (#27684)

* fix(wsl): stop booting stopped distros from background work

Any `wsl.exe -d` (or \\wsl.localhost access) boots a stopped distro: 1-2s of
VM boot, and on WSL < 3.0.1 its later idle-stop wipes the VM-wide WSLInterop
binfmt entry for every other running distro (microsoft/WSL#13885).

Add one gate (wsl-background-distro-gate.ts): background work checks
`wsl --list --running` (never boots), and stopped-distro work can be deferred
until the distro is next seen running (a confirmed running probe or a local
WSL terminal spawn).

- Startup CLI registration reconciliation: only running candidates; stopped
  ones are deferred per distro and stay registry candidates for later starts.
- Fish history GC: keeps the tombstone (and its meta.json) while the distro is
  stopped and finishes the cleanup once it runs; no admission slot is held.
- Codex/Claude/Kimi quota polls for WSL targets: skip while stopped (Codex
  keeps its last value). User-driven resets still boot.
- Codex WSL auth drain before an update restart: skip stopped distros (no
  live legacy pane there; the next launch prep drains anyway).

* fix(rate-limits): capture the Codex fetch generation before home resolution

The Codex home resolver is now async (it checks whether the WSL distro is
running), so a reset that lands during that await bumped the generation the
full-refresh cycle then read as its own, letting a stale result overwrite it.

Co-authored-by: Syed Zohaib Hussain <26768511+RoxasZohbi@users.noreply.github.com>

* fix(wsl): read an unconfirmed running-distro probe as stopped in the background gate

* test(wsl): pin the empty running list wsl.exe reports when no distro runs

---------

Co-authored-by: Syed Zohaib Hussain <26768511+RoxasZohbi@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-11 20:03:34 -07:00
committed by GitHub
co-authored by Syed Zohaib Hussain
parent 53bf77682c
commit 3fd65b4f97
31 changed files with 720 additions and 88 deletions
+2 -1
View File
@@ -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<string[]>
/** Restart timers check this so relay recovery never resurrects a VM the user shut down. */
isDistroRunning: typeof isWslDistroRunning
spawnRelay: typeof spawnWslRelayProcess
runInstall: typeof runWslInstallProcess
@@ -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<boolean> {
const wanted = distro.trim().toLowerCase()
return listRunningWslDistrosAsync().then((running) =>
running.some((candidate) => candidate.toLowerCase() === wanted)
)
}
export async function runWslInstallProcess(
distro: string,
script: string,
@@ -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<void> => {},
runningDistros: async (): Promise<string[]> => []
}
@@ -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<GlobalSettings> = {}) {
function service(
platformName: NodeJS.Platform,
overrides: Partial<GlobalSettings> = {},
isDistroRunning: (distro: string) => Promise<boolean> = 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')
@@ -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<unknown> = Promise.resolve()
constructor(private readonly store: Pick<Store, 'getSettings'>) {
constructor(
private readonly store: Pick<Store, 'getSettings'>,
private readonly isDistroRunning: (distro: string) => Promise<boolean> = 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<ClaudeRuntimeAuthPreparation> {
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) {
+1
View File
@@ -437,6 +437,7 @@ describe('WslCliInstaller', () => {
isPackaged: true,
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu'],
listRunningDistros: async () => ['Ubuntu'],
registry: {
getCandidates: async () => ['Ubuntu'],
recordObservations: async () => undefined
@@ -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<string, () => 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)
)
})
})
@@ -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<string | null>
registry?: WslCliRegistrationRegistry
listRunningDistros?: () => Promise<string[]>
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}.`)
}
}
}
@@ -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<boolean>>()
}))
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<void> {
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')
})
})
@@ -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)
+7 -3
View File
@@ -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<void> {
}
}
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 (
+7 -3
View File
@@ -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) &&
+12
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const wslMocks = vi.hoisted(() => ({
listWslDistrosAsync: vi.fn<() => Promise<string[]>>(),
listRunningWslDistrosAsync: vi.fn<() => Promise<string[]>>(),
getWslHomeAsync: vi.fn<(distro: string) => Promise<string | null>>()
}))
@@ -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({
+6 -1
View File
@@ -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 }
@@ -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) {
@@ -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')
@@ -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)
@@ -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 =
@@ -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
@@ -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
@@ -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,
@@ -36,7 +36,7 @@ export type InactiveCodexAccountInfo = {
export type CodexHomePathResolver = (
target?: CodexAccountSelectionTarget
) => CodexRateLimitHomeResolution
) => CodexRateLimitHomeResolution | Promise<CodexRateLimitHomeResolution>
export type KimiHomeResolver = () => Promise<KimiHomeResolution>
export type ClaudeAuthPreparationResolver = (
target?: ClaudeAccountSelectionTarget
@@ -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
@@ -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<void> {
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) => {
+35 -7
View File
@@ -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<string, Promise<void>>()
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<string, number>()
const historyTreeRemovalRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
const wslDistroByTombstone = new Map<string, string>()
// Tombstones whose fish cleanup waits for their stopped distro; they hold no admission slot.
const tombstonesAwaitingWslDistro = new Set<string>()
/** One root's undrained tombstone names from the last enumeration, plus its in-flight re-read. */
type HistoryRemovalQueue = { names: string[]; refill: Promise<void> | 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
@@ -8,7 +8,7 @@ let userDataDir: string
const { removeHostTreeMock, deleteWslFishHistoryFileMock } = vi.hoisted(() => ({
removeHostTreeMock: vi.fn<(dir: string) => Promise<void>>(),
deleteWslFishHistoryFileMock: vi.fn<(distro: string, session: string) => Promise<void>>()
deleteWslFishHistoryFileMock: vi.fn<(distro: string, session: string) => Promise<string | void>>()
}))
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)
})
})
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const wslMocks = vi.hoisted(() => ({
listRunningWslDistrosAsync:
vi.fn<(options?: { requireConfirmed?: boolean }) => Promise<string[]>>(),
listWslDistrosAsync: vi.fn<() => Promise<string[]>>()
}))
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<string[]> = 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)
})
})
+92
View File
@@ -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<string, Map<string, DeferredTask>>()
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<string[]> {
return listRunningWslDistrosAsync({ requireConfirmed: true }).catch(() => [])
}
export async function isWslDistroRunning(distro: string): Promise<boolean> {
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<boolean> {
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
}
+27 -15
View File
@@ -6,6 +6,7 @@ import {
} from './wsl-fish-history-cleanup'
const okResult = { environmentResolved: true, code: 0, stdout: '', stderr: '', timedOut: false }
const running = async (): Promise<boolean> => 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()
})
})
+18 -8
View File
@@ -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<string, Promise<void>>()
const cleanupsInFlight = new Map<string, Promise<WslFishHistoryCleanupOutcome>>()
/** 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<void> {
run: typeof runWslProcess = runWslProcess,
isRunning: (distro: string) => Promise<boolean> = isWslDistroRunning
): Promise<WslFishHistoryCleanupOutcome> {
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<void> {
run: typeof runWslProcess,
isRunning: (distro: string) => Promise<boolean>
): Promise<WslFishHistoryCleanupOutcome> {
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<void> {
const awaited = new Set<Promise<void>>()
const awaited = new Set<Promise<WslFishHistoryCleanupOutcome>>()
let pending = [...cleanupsInFlight.values()]
while (pending.length > 0) {
for (const cleanup of pending) {
+23
View File
@@ -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) => {
+16
View File
@@ -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'), '')