From b9996755b35b041bb46450504305be52ab40db59 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:39:49 -0700 Subject: [PATCH] fix(wsl): a missing host gh or glab never boots a stopped default distro (#35709) Cwd-less gh/glab calls whose host CLI is missing fell back to the default WSL distro with no running check, so background work (rate limits, auth status, Settings, task-source probes) booted a stopped distro. The fallback now asks the background exec gate first: stopped or unverifiable refuses with the distro-stopped error, running and user acts fall back as before. --- .../default-wsl-cli-fallback.ts | 25 ++++ src/main/git/command-runner/gh-exec-file.ts | 9 +- src/main/git/command-runner/glab-exec-file.ts | 5 +- .../git/default-wsl-cli-fallback-gate.test.ts | 110 ++++++++++++++++++ src/main/git/runner-wsl-gh-fallback.test.ts | 7 ++ .../check/check-job-log-routing.test.ts | 7 ++ ...tlab-known-host-probe-wsl-fallback.test.ts | 7 ++ 7 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 src/main/git/command-runner/default-wsl-cli-fallback.ts create mode 100644 src/main/git/default-wsl-cli-fallback-gate.test.ts diff --git a/src/main/git/command-runner/default-wsl-cli-fallback.ts b/src/main/git/command-runner/default-wsl-cli-fallback.ts new file mode 100644 index 00000000000..2c4830b0c7d --- /dev/null +++ b/src/main/git/command-runner/default-wsl-cli-fallback.ts @@ -0,0 +1,25 @@ +import { blockedWslDistroForExec } from '../../wsl/wsl-background-exec-gate' +import { WslDistroStoppedError, WslDistroUnverifiableError } from '../../wsl/wsl-unc-access-gate' +import { resolveDefaultWslCli, type ResolvedCommand } from './wsl-command-resolution' + +/** + * The default distro's gh/glab for a cwd-less call whose host CLI is missing. Background work must + * not boot a stopped distro (E9), so a stopped or unverifiable one refuses with its own error rather + * than "not installed"; inside a user act for that distro it may still start it. + */ +export async function resolveDefaultWslCliFallback( + command: 'gh' | 'glab', + args: string[] +): Promise { + const resolved = resolveDefaultWslCli(command, args) + const distro = resolved?.wsl?.distro + const blocked = distro ? await blockedWslDistroForExec(distro) : null + if (blocked?.state === 'stopped') { + // A background retry would refuse again; only a terminal (a user act) starts it. + throw new WslDistroStoppedError(blocked.distro, { retryStartsIt: false }) + } + if (blocked) { + throw new WslDistroUnverifiableError(blocked.distro) + } + return resolved +} diff --git a/src/main/git/command-runner/gh-exec-file.ts b/src/main/git/command-runner/gh-exec-file.ts index 16a3f0f0133..7de2babf9b3 100644 --- a/src/main/git/command-runner/gh-exec-file.ts +++ b/src/main/git/command-runner/gh-exec-file.ts @@ -10,11 +10,8 @@ import { type GhRateLimitBucket } from '../gh-rate-limit-breaker' import { extractExecError, parseRetryAfterMs } from '../exec-error' -import { - resolveCommand, - resolveDefaultWslCli, - type ResolvedCommand -} from './wsl-command-resolution' +import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' +import { resolveDefaultWslCliFallback } from './default-wsl-cli-fallback' import { canFallBackToHostGitHubCli, isHostCommandMissing, @@ -247,7 +244,7 @@ export async function ghExecFileWithScopeAsync( !options.wslDistro && isHostCommandMissing(err, 'gh') ) { - const wslResolved = resolveDefaultWslCli('gh', args) + const wslResolved = await resolveDefaultWslCliFallback('gh', args) if (wslResolved) { // Global calls have no cwd to route WSL-only Windows installs. resolved = wslResolved diff --git a/src/main/git/command-runner/glab-exec-file.ts b/src/main/git/command-runner/glab-exec-file.ts index 4354c947b0f..f7b57478463 100644 --- a/src/main/git/command-runner/glab-exec-file.ts +++ b/src/main/git/command-runner/glab-exec-file.ts @@ -1,6 +1,7 @@ import { addWslEnvKeys } from '../../wsl-env' import { extractExecError, parseRetryAfterMs } from '../exec-error' -import { resolveCommand, resolveDefaultWslCli } from './wsl-command-resolution' +import { resolveCommand } from './wsl-command-resolution' +import { resolveDefaultWslCliFallback } from './default-wsl-cli-fallback' import { isHostCommandMissing } from './github-cli-host-fallback' import { execFileCaptureToTermination } from './exec-file-capture' import { logHostedCliDeadlineKill } from './hosted-cli-deadline-log' @@ -84,7 +85,7 @@ export async function glabExecFileAsync( options.allowDefaultWslFallback !== false && isHostCommandMissing(err, 'glab') ) { - const wslResolved = resolveDefaultWslCli('glab', args) + const wslResolved = await resolveDefaultWslCliFallback('glab', args) if (wslResolved) { resolved = wslResolved attemptedDefaultWslFallback = true diff --git a/src/main/git/default-wsl-cli-fallback-gate.test.ts b/src/main/git/default-wsl-cli-fallback-gate.test.ts new file mode 100644 index 00000000000..58a3a26d77f --- /dev/null +++ b/src/main/git/default-wsl-cli-fallback-gate.test.ts @@ -0,0 +1,110 @@ +// E9: a cwd-less gh/glab call whose host CLI is missing must not boot a stopped default distro from +// background work (rate limits, auth status, Settings, task-source probes). +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fakeSpawnReturning } from '../../shared/__fixtures__/fake-spawned-child' +import type * as WslModule from '../wsl' +import type * as BackgroundDistroGate from '../wsl-background-distro-gate' + +const { spawnMock, getDefaultWslDistroMock, readWslDistroRunningStateMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + getDefaultWslDistroMock: vi.fn(), + readWslDistroRunningStateMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFile: vi.fn(), + execFileSync: vi.fn(), + spawn: spawnMock +})) + +vi.mock('../wsl', async (importOriginal) => ({ + ...(await importOriginal()), + getDefaultWslDistro: getDefaultWslDistroMock +})) + +vi.mock('../wsl-background-distro-gate', async (importOriginal) => ({ + ...(await importOriginal()), + readWslDistroRunningState: readWslDistroRunningStateMock +})) + +import { ghExecFileAsync, glabExecFileAsync, setDefaultWslDistroOverride } from './runner' +import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker' +import { runAsWslExecUserAct } from '../wsl/wsl-background-exec-gate' +import { WslDistroStoppedError, WslDistroUnverifiableError } from '../wsl/wsl-unc-access-gate' + +const originalPlatform = process.platform + +const CLIS = [ + { cli: 'gh', run: () => ghExecFileAsync(['api', 'rate_limit']) }, + { cli: 'glab', run: () => glabExecFileAsync(['api', 'user']) } +] as const + +function hostMissingThenWslAnswers(cli: string): void { + spawnMock + .mockImplementationOnce( + fakeSpawnReturning({ + spawnError: Object.assign(new Error(`spawn ${cli} ENOENT`), { code: 'ENOENT' }) + }) + ) + .mockImplementationOnce(fakeSpawnReturning({ stdout: '{}' })) +} + +function spawnedWsl(): boolean { + return spawnMock.mock.calls.some(([program]) => program === 'wsl.exe') +} + +beforeEach(() => { + spawnMock.mockReset() + getDefaultWslDistroMock.mockReset().mockReturnValue('Ubuntu') + readWslDistroRunningStateMock.mockReset().mockResolvedValue('running') + setDefaultWslDistroOverride(null) + _resetGhRateLimitBreaker() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) +}) + +afterEach(() => { + _resetGhRateLimitBreaker() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +describe.each(CLIS)('$cli default-distro fallback', ({ cli, run }) => { + it('never starts a stopped default distro, and says it is stopped rather than missing', async () => { + readWslDistroRunningStateMock.mockResolvedValue('stopped') + hostMissingThenWslAnswers(cli) + + const error = await run().catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(WslDistroStoppedError) + expect(String(error)).toContain("WSL distro 'Ubuntu' is not running") + expect(String(error)).not.toMatch(/ENOENT|not found/) + expect(spawnedWsl()).toBe(false) + }) + + it('never starts one whose state WSL could not report', async () => { + readWslDistroRunningStateMock.mockResolvedValue('unverifiable') + hostMissingThenWslAnswers(cli) + + await expect(run()).rejects.toBeInstanceOf(WslDistroUnverifiableError) + expect(spawnedWsl()).toBe(false) + }) + + it('falls back to a running default distro as before', async () => { + hostMissingThenWslAnswers(cli) + + await expect(run()).resolves.toEqual({ stdout: '{}', stderr: '' }) + expect(spawnMock).toHaveBeenNthCalledWith( + 2, + 'wsl.exe', + expect.arrayContaining(['-d', 'Ubuntu']), + expect.anything() + ) + }) + + it('still falls back to a stopped one inside a user act for it', async () => { + readWslDistroRunningStateMock.mockResolvedValue('stopped') + hostMissingThenWslAnswers(cli) + + await expect(runAsWslExecUserAct('Ubuntu', run)).resolves.toEqual({ stdout: '{}', stderr: '' }) + expect(spawnedWsl()).toBe(true) + }) +}) diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index e275d4c304e..bd9d5c43496 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as BackgroundDistroGate from '../wsl-background-distro-gate' import { createFakeSpawnedChild, fakeSpawnDispatch, @@ -13,6 +14,12 @@ const { execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() = getDefaultWslDistroMock: vi.fn() })) +// The default-distro fallback asks whether the distro runs before it may start it (E9). +vi.mock('../wsl-background-distro-gate', async (importOriginal) => ({ + ...(await importOriginal()), + readWslDistroRunningState: vi.fn(async () => 'running') +})) + vi.mock('child_process', () => ({ execFile: vi.fn(), execFileSync: execFileSyncMock, diff --git a/src/main/github/client/check/check-job-log-routing.test.ts b/src/main/github/client/check/check-job-log-routing.test.ts index 97d239c8a89..c9b37155563 100644 --- a/src/main/github/client/check/check-job-log-routing.test.ts +++ b/src/main/github/client/check/check-job-log-routing.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as BackgroundDistroGate from '../../../wsl-background-distro-gate' import { fakeSpawnDispatch, createFakeSpawnedChild @@ -9,6 +10,12 @@ import { startCheckJobLogDownload } from './check-job-log-download' import { prCheckLogTailCache } from './check-job-log-tails' import { setDefaultWslDistroOverride } from '../../../git/runner' const { spawnMock, distroMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), distroMock: vi.fn() })) +// The default-distro fallback asks whether the distro runs before it may start it (E9). +vi.mock('../../../wsl-background-distro-gate', async (importOriginal) => ({ + ...(await importOriginal()), + readWslDistroRunningState: vi.fn(async () => 'running') +})) + vi.mock('node:child_process', async (original) => ({ ...(await original()), spawn: spawnMock })) vi.mock('../../../wsl', async (original) => ({ ...(await original()), diff --git a/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts b/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts index 6a89e83f093..0c22f1576b2 100644 --- a/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts +++ b/src/main/gitlab/gitlab-known-host-probe-wsl-fallback.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as BackgroundDistroGate from '../wsl-background-distro-gate' import { fakeSpawnDispatch } from '../../shared/__fixtures__/fake-spawned-child' import type * as WslModule from '../wsl' @@ -8,6 +9,12 @@ const { execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() = getDefaultWslDistroMock: vi.fn() })) +// The default-distro fallback asks whether the distro runs before it may start it (E9). +vi.mock('../wsl-background-distro-gate', async (importOriginal) => ({ + ...(await importOriginal()), + readWslDistroRunningState: vi.fn(async () => 'running') +})) + vi.mock('child_process', () => ({ execFile: vi.fn(), execFileSync: execFileSyncMock,