mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
co-authored by
Syed Zohaib Hussain
parent
53bf77682c
commit
3fd65b4f97
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'), '')
|
||||
|
||||
Reference in New Issue
Block a user