From 3d236c3b3a2a293eee0fbb998598e942f7e29bbd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:30:12 -0700 Subject: [PATCH] fix(wsl): don't start a stopped distro to read a repo's default base ref (#35778) * fix(wsl): don't start a stopped distro to read a repo's default base ref Opening a repo's Settings pane ran git for-each-ref and git remote through the repo's WSL distro, which started it after the user had stopped it (E9). Background reads now return the existing unknown result while the distro is stopped; an explicit refresh can pass wakeWslDistro. The runtime RPC that serves the same read to paired clients is gated the same way. * fix(wsl): re-read a refused default base ref once its distro runs A refused read now names the distro and defers a preflight:wslDistroRunning broadcast; Settings and Source Control re-ask on it, so base comparison returns without a repo switch. Drops the unused wakeWslDistro option. --- src/main/ipc/preflight-wsl-refusal-retry.ts | 8 +- .../ipc/repos-remote-base-ref-queries.test.ts | 6 + .../repos/base-ref-default-wsl-gate.test.ts | 181 ++++++++++++++++++ src/main/ipc/repos/base-ref-query-handlers.ts | 8 + .../runtime/runtime-repository-ref-queries.ts | 5 + .../sync/use-base-ref-default.ts | 23 +-- .../src/components/settings/BaseRefPicker.tsx | 42 ++-- ...me-repo-base-ref-default-wsl-retry.test.ts | 86 +++++++++ .../src/runtime/runtime-repo-client.ts | 48 +++++ src/shared/repo-types.ts | 2 + 10 files changed, 364 insertions(+), 45 deletions(-) create mode 100644 src/main/ipc/repos/base-ref-default-wsl-gate.test.ts create mode 100644 src/renderer/src/runtime/runtime-repo-base-ref-default-wsl-retry.test.ts diff --git a/src/main/ipc/preflight-wsl-refusal-retry.ts b/src/main/ipc/preflight-wsl-refusal-retry.ts index 61625541aa0..b9edd8a8543 100644 --- a/src/main/ipc/preflight-wsl-refusal-retry.ts +++ b/src/main/ipc/preflight-wsl-refusal-retry.ts @@ -18,14 +18,18 @@ function broadcastWslDistroRunning(distro: string): void { } } +/** Tells renderers once `distro` runs, so they re-ask a background read it refused (E9). */ +export function broadcastWhenWslDistroRunning(distro: string, key: string): void { + deferUntilWslDistroRunning(distro, key, () => broadcastWslDistroRunning(distro)) +} + /** A refused background check is deferred, not done: renderers re-ask once the distro runs (E9). */ export async function deferRefusedPreflight(run: () => Promise): Promise { try { return await run() } catch (error) { if (error instanceof WslDistroStoppedError || error instanceof WslDistroUnverifiableError) { - const { distro } = error - deferUntilWslDistroRunning(distro, RETRY_KEY, () => broadcastWslDistroRunning(distro)) + broadcastWhenWslDistroRunning(error.distro, RETRY_KEY) } throw error } diff --git a/src/main/ipc/repos-remote-base-ref-queries.test.ts b/src/main/ipc/repos-remote-base-ref-queries.test.ts index 0f5100d768a..05e1d93d730 100644 --- a/src/main/ipc/repos-remote-base-ref-queries.test.ts +++ b/src/main/ipc/repos-remote-base-ref-queries.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GitRunner from '../git/runner' import type * as RepoModule from '../git/repo' +import type * as WslUncAccessGate from '../wsl/wsl-unc-access-gate' const { reposMocks, moduleMocks } = await vi.hoisted(async () => { const moduleMocks = await import('./repos-remote-test-harness') @@ -21,6 +22,11 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) +// The stopped-distro gate has its own tests; here it must not hide the routing under test. +vi.mock('../wsl/wsl-unc-access-gate', async (importOriginal) => ({ + ...(await importOriginal()), + blockedWslDistroForPath: vi.fn(async () => null) +})) import { registerRepoHandlers } from './repos' import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate' diff --git a/src/main/ipc/repos/base-ref-default-wsl-gate.test.ts b/src/main/ipc/repos/base-ref-default-wsl-gate.test.ts new file mode 100644 index 00000000000..dde45c21e4b --- /dev/null +++ b/src/main/ipc/repos/base-ref-default-wsl-gate.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DistroGateModule from '../../wsl-background-distro-gate' +import type { Repo } from '../../../shared/repo-types' + +const mocks = vi.hoisted(() => { + const state: { + distroState: 'running' | 'stopped' | 'unverifiable' + gitOptions: { wslDistro?: string } + } = { distroState: 'stopped', gitOptions: {} } + return { + state, + handlers: new Map Promise>(), + send: vi.fn(), + getBaseRefDefault: vi.fn(async (_path: string, _options?: unknown) => 'origin/main'), + getRemoteCount: vi.fn(async (_path: string, _options?: unknown) => 1) + } +}) + +vi.mock('electron', () => ({ + BrowserWindow: { + getAllWindows: () => [{ isDestroyed: () => false, webContents: { send: mocks.send } }] + }, + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => Promise) => + mocks.handlers.set(channel, handler) + } +})) +vi.mock('../../git/repo', () => ({ + getBaseRefDefault: mocks.getBaseRefDefault, + getRemoteCount: mocks.getRemoteCount, + searchBaseRefDetails: vi.fn(async () => []) +})) +vi.mock('../../project-runtime-git-options', () => ({ + getProjectRuntimeReadGitOptions: () => mocks.state.gitOptions +})) +vi.mock('../../wsl-background-distro-gate', async (importOriginal) => ({ + ...(await importOriginal()), + readWslDistroRunningState: vi.fn(async () => mocks.state.distroState) +})) + +import { registerBaseRefQueryHandlers } from './base-ref-query-handlers' +import { + _resetWslBackgroundDistroGateForTests, + noteWslDistrosRunning +} from '../../wsl-background-distro-gate' +import { RuntimeRepositoryRefQueries } from '../../runtime/runtime-repository-ref-queries' + +const WSL_REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' +const UNKNOWN = { defaultBaseRef: null, remoteCount: 0 } +const REFUSED = { ...UNKNOWN, refusedWslDistro: 'Ubuntu' } +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: WSL_REPO, + displayName: 'repo', + badgeColor: '#000', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +function registerWith(repo: Repo): (args: Record) => Promise { + const store = { getRepo: () => repo, getRepos: () => [repo] } + registerBaseRefQueryHandlers( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only getRepo/getRepos, both provided. + store as unknown as Parameters[0] + ) + const handler = mocks.handlers.get('repos:getBaseRefDefault') + if (!handler) { + throw new Error('repos:getBaseRefDefault was not registered') + } + return (args) => handler(null, { repoId: repo.id, ...args }) +} + +describe('repos:getBaseRefDefault for a stopped WSL distro (E9)', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mocks.handlers.clear() + mocks.send.mockClear() + _resetWslBackgroundDistroGateForTests() + mocks.state.distroState = 'stopped' + mocks.state.gitOptions = {} + mocks.getBaseRefDefault.mockClear() + mocks.getRemoteCount.mockClear() + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it('returns unknown without running Git when the Settings pane opens', async () => { + const getDefault = registerWith(makeRepo()) + + await expect(getDefault({})).resolves.toEqual(REFUSED) + expect(mocks.getBaseRefDefault).not.toHaveBeenCalled() + expect(mocks.getRemoteCount).not.toHaveBeenCalled() + }) + + it('tells renderers to re-ask once the refusing distro runs', async () => { + const getDefault = registerWith(makeRepo()) + await getDefault({}) + expect(mocks.send).not.toHaveBeenCalled() + + noteWslDistrosRunning(['Ubuntu']) + + expect(mocks.send).toHaveBeenCalledWith('preflight:wslDistroRunning', 'Ubuntu') + }) + + it('refuses a host path whose Git runs through wsl.exe in a stopped distro', async () => { + mocks.state.gitOptions = { wslDistro: 'Ubuntu' } + const getDefault = registerWith(makeRepo({ path: 'C:\\src\\repo' })) + + await expect(getDefault({})).resolves.toEqual(REFUSED) + expect(mocks.getBaseRefDefault).not.toHaveBeenCalled() + }) + + it('treats unverifiable like stopped', async () => { + mocks.state.distroState = 'unverifiable' + const getDefault = registerWith(makeRepo()) + + await expect(getDefault({})).resolves.toEqual(REFUSED) + expect(mocks.getBaseRefDefault).not.toHaveBeenCalled() + }) + + it('runs Git when the distro is running', async () => { + mocks.state.distroState = 'running' + const getDefault = registerWith(makeRepo()) + + await expect(getDefault({})).resolves.toEqual({ defaultBaseRef: 'origin/main', remoteCount: 1 }) + }) + + it('leaves Windows-host repos ungated', async () => { + const getDefault = registerWith(makeRepo({ path: 'C:\\src\\repo' })) + + await expect(getDefault({})).resolves.toEqual({ defaultBaseRef: 'origin/main', remoteCount: 1 }) + }) + + it('leaves folder and SSH repos on their existing unknown result', async () => { + const folder = registerWith(makeRepo({ kind: 'folder' })) + await expect(folder({})).resolves.toEqual(UNKNOWN) + const ssh = registerWith(makeRepo({ path: '/home/me/repo', connectionId: 'ssh-1' })) + await expect(ssh({})).resolves.toEqual(UNKNOWN) + expect(mocks.getBaseRefDefault).not.toHaveBeenCalled() + }) +}) + +describe('runtime repo.baseRefDefault for a stopped WSL distro (E9)', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mocks.state.distroState = 'stopped' + mocks.getBaseRefDefault.mockClear() + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it("returns unknown without touching the stopped distro's UNC path", async () => { + const queries = new RuntimeRepositoryRefQueries({ resolveRepo: async () => makeRepo() }) + + await expect(queries.getDefault('id:repo-1')).resolves.toEqual(UNKNOWN) + expect(mocks.getBaseRefDefault).not.toHaveBeenCalled() + }) + + it('reads the default once the distro runs', async () => { + mocks.state.distroState = 'running' + const queries = new RuntimeRepositoryRefQueries({ resolveRepo: async () => makeRepo() }) + + await expect(queries.getDefault('id:repo-1')).resolves.toEqual({ + defaultBaseRef: 'origin/main', + remoteCount: 1 + }) + }) +}) diff --git a/src/main/ipc/repos/base-ref-query-handlers.ts b/src/main/ipc/repos/base-ref-query-handlers.ts index fa6bc294fa6..ffb73eaf427 100644 --- a/src/main/ipc/repos/base-ref-query-handlers.ts +++ b/src/main/ipc/repos/base-ref-query-handlers.ts @@ -14,6 +14,8 @@ import { } from '../../../shared/execution-host' import { getBaseRefDefault, getRemoteCount, searchBaseRefDetails } from '../../git/repo' import { getProjectRuntimeReadGitOptions } from '../../project-runtime-git-options' +import { blockedWslDistroForPath } from '../../wsl/wsl-unc-access-gate' +import { broadcastWhenWslDistroRunning } from '../preflight-wsl-refusal-retry' export function registerBaseRefQueryHandlers(store: Store): void { ipcMain.handle( @@ -33,6 +35,12 @@ export function registerBaseRefQueryHandlers(store: Store): void { } // Where the project's runtime runs Git; a runtime needing repair reads with host Git. const git = getProjectRuntimeReadGitOptions(store, repo) + // Opening Settings must not start a stopped distro (E9); the renderer re-asks once it runs. + const blocked = await blockedWslDistroForPath(repo.path, git.wslDistro) + if (blocked) { + broadcastWhenWslDistroRunning(blocked.distro, `base-ref-default:${repo.id}`) + return { defaultBaseRef: null, remoteCount: 0, refusedWslDistro: blocked.distro } + } const [defaultBaseRef, remoteCount] = await Promise.all([ getBaseRefDefault(repo.path, git), getRemoteCount(repo.path, git) diff --git a/src/main/runtime/runtime-repository-ref-queries.ts b/src/main/runtime/runtime-repository-ref-queries.ts index e239fad386b..1bd63288005 100644 --- a/src/main/runtime/runtime-repository-ref-queries.ts +++ b/src/main/runtime/runtime-repository-ref-queries.ts @@ -8,6 +8,7 @@ import { } from '../../shared/repo-search-limits' import { getBaseRefDefault, getRemoteCount, searchBaseRefDetails } from '../git/repo' import { getRepoRoutingConnectionId } from '../../shared/execution-host' +import { blockedWslDistroForPath } from '../wsl/wsl-unc-access-gate' type RuntimeRepositoryRefQueryDependencies = { resolveRepo: (selector: string) => Promise @@ -53,6 +54,10 @@ export class RuntimeRepositoryRefQueries { if (getRepoRoutingConnectionId(repo)) { return { defaultBaseRef: null, remoteCount: 0 } } + // A client's Settings pane must not start this host's stopped distro over `\\wsl.localhost` (E9). + if (await blockedWslDistroForPath(repo.path)) { + return { defaultBaseRef: null, remoteCount: 0 } + } const [defaultBaseRef, remoteCount] = await Promise.all([ getBaseRefDefault(repo.path), getRemoteCount(repo.path) diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts index 423912d21fe..f24ad663a5f 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-base-ref-default.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' +import { watchRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' import type { ExecutionHostId } from '../../../../../../shared/execution-host' export function useSourceControlBaseRefDefault({ @@ -23,24 +23,15 @@ export function useSourceControlBaseRefDefault({ } // Drop the previous repo’s default while loading to prevent transient invalid-base comparisons. setDefaultBaseRef(null) - let stale = false // Resolve on the repo owner, which may differ from the focused host. - void getRuntimeRepoBaseRefDefault(activeRepoExecutionHostId, activeRepoId) - .then((result) => { - if (!stale) { - setDefaultBaseRef(result.defaultBaseRef) - } - }) - .catch((err) => { + return watchRuntimeRepoBaseRefDefault(activeRepoExecutionHostId, activeRepoId, { + onResult: (result) => setDefaultBaseRef(result.defaultBaseRef), + onError: (err) => { console.error('[SourceControl] getBaseRefDefault failed', err) // Keep failures unknown so comparison/review fetches cannot use a fabricated ref. - if (!stale) { - setDefaultBaseRef(null) - } - }) - return () => { - stale = true - } + setDefaultBaseRef(null) + } + }) }, [ // Only owner changes reset the base; metadata churn must not restart eligibility’s timeout. activeRepoConnectionId, diff --git a/src/renderer/src/components/settings/BaseRefPicker.tsx b/src/renderer/src/components/settings/BaseRefPicker.tsx index ff7c688df76..4ef72de00e8 100644 --- a/src/renderer/src/components/settings/BaseRefPicker.tsx +++ b/src/renderer/src/components/settings/BaseRefPicker.tsx @@ -4,8 +4,8 @@ import { Input } from '../ui/input' import { useAppStore } from '@/store' import { findRepoForHost } from '@/store/slices/repo-host-identity' import { - getRuntimeRepoBaseRefDefault, - searchRuntimeRepoBaseRefs + searchRuntimeRepoBaseRefs, + watchRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' import { isRuntimeRepoRefSearchQueryWithinLimit } from '@/runtime/runtime-repo-search-bounds' import { translate } from '@/i18n/i18n' @@ -61,37 +61,25 @@ export function BaseRefPicker({ }, [baseRefResults.length]) useEffect(() => { - let stale = false - - const loadDefaultBaseRef = async (): Promise => { - try { - if (!ownerHostId) { - return - } - const result = await getRuntimeRepoBaseRefDefault(ownerHostId, repoId) - if (!stale) { - setDefaultBaseRef(result.defaultBaseRef) - setRemoteCount(result.remoteCount) - } - } catch (err) { - console.error('[BaseRefPicker] getBaseRefDefault failed', err) - if (!stale) { - setDefaultBaseRef(null) - setRemoteCount(0) - } - } - } - setBaseRefQuery('') setBaseRefResults([]) // Drop the previous repo’s default before loading so its Following label cannot describe the new repo. setDefaultBaseRef(null) setRemoteCount(0) - void loadDefaultBaseRef() - - return () => { - stale = true + if (!ownerHostId) { + return } + return watchRuntimeRepoBaseRefDefault(ownerHostId, repoId, { + onResult: (result) => { + setDefaultBaseRef(result.defaultBaseRef) + setRemoteCount(result.remoteCount) + }, + onError: (err) => { + console.error('[BaseRefPicker] getBaseRefDefault failed', err) + setDefaultBaseRef(null) + setRemoteCount(0) + } + }) }, [ownerHostId, repoId]) useEffect(() => { diff --git a/src/renderer/src/runtime/runtime-repo-base-ref-default-wsl-retry.test.ts b/src/renderer/src/runtime/runtime-repo-base-ref-default-wsl-retry.test.ts new file mode 100644 index 00000000000..6d8909ffca4 --- /dev/null +++ b/src/renderer/src/runtime/runtime-repo-base-ref-default-wsl-retry.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { watchRuntimeRepoBaseRefDefault } from './runtime-repo-client' + +const getBaseRefDefault = vi.fn() +const distroRunningListeners = new Set<(distro: string) => void>() + +function emitDistroRunning(distro: string): void { + for (const listener of distroRunningListeners) { + listener(distro) + } +} + +beforeEach(() => { + getBaseRefDefault.mockReset() + distroRunningListeners.clear() + vi.stubGlobal('window', { + api: { + repos: { getBaseRefDefault }, + preflight: { + onWslDistroRunning: (listener: (distro: string) => void) => { + distroRunningListeners.add(listener) + return () => distroRunningListeners.delete(listener) + } + } + } + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +const REFUSED = { defaultBaseRef: null, remoteCount: 0, refusedWslDistro: 'Ubuntu' } +const LOADED = { defaultBaseRef: 'origin/main', remoteCount: 1 } + +describe('watchRuntimeRepoBaseRefDefault after a stopped-distro refusal (E9)', () => { + it('loads again once the refusing distro runs', async () => { + getBaseRefDefault.mockResolvedValueOnce(REFUSED).mockResolvedValueOnce(LOADED) + const onResult = vi.fn() + watchRuntimeRepoBaseRefDefault('local', 'repo-1', { onResult, onError: vi.fn() }) + await vi.waitFor(() => expect(onResult).toHaveBeenCalledWith(REFUSED)) + + emitDistroRunning('ubuntu') + + await vi.waitFor(() => expect(onResult).toHaveBeenLastCalledWith(LOADED)) + expect(getBaseRefDefault).toHaveBeenCalledTimes(2) + expect(distroRunningListeners.size).toBe(0) + }) + + it('ignores other distros', async () => { + getBaseRefDefault.mockResolvedValue(REFUSED) + const onResult = vi.fn() + watchRuntimeRepoBaseRefDefault('local', 'repo-1', { onResult, onError: vi.fn() }) + await vi.waitFor(() => expect(distroRunningListeners.size).toBe(1)) + + emitDistroRunning('Debian') + + expect(getBaseRefDefault).toHaveBeenCalledTimes(1) + }) + + it('stops listening when cancelled', async () => { + getBaseRefDefault.mockResolvedValue(REFUSED) + const cancel = watchRuntimeRepoBaseRefDefault('local', 'repo-1', { + onResult: vi.fn(), + onError: vi.fn() + }) + await vi.waitFor(() => expect(distroRunningListeners.size).toBe(1)) + + cancel() + emitDistroRunning('Ubuntu') + + expect(distroRunningListeners.size).toBe(0) + expect(getBaseRefDefault).toHaveBeenCalledTimes(1) + }) + + it('does not listen after a result that was not refused', async () => { + getBaseRefDefault.mockResolvedValue(LOADED) + const onResult = vi.fn() + watchRuntimeRepoBaseRefDefault('local', 'repo-1', { onResult, onError: vi.fn() }) + await vi.waitFor(() => expect(onResult).toHaveBeenCalledWith(LOADED)) + + expect(distroRunningListeners.size).toBe(0) + }) +}) diff --git a/src/renderer/src/runtime/runtime-repo-client.ts b/src/renderer/src/runtime/runtime-repo-client.ts index d043f83853d..53ee5064e9b 100644 --- a/src/renderer/src/runtime/runtime-repo-client.ts +++ b/src/renderer/src/runtime/runtime-repo-client.ts @@ -18,6 +18,7 @@ function repoHostTarget(hostId: ExecutionHostId): RuntimeClientTarget { export type RuntimeRepoBaseRefDefault = { defaultBaseRef: string | null remoteCount: number + refusedWslDistro?: string } export async function getRuntimeRepoBaseRefDefault( @@ -36,6 +37,53 @@ export async function getRuntimeRepoBaseRefDefault( ) } +/** + * Loads the repo's default base ref and, when a stopped WSL distro refused it (E9), loads it again + * once main reports that distro running. Returns a cancel. + */ +export function watchRuntimeRepoBaseRefDefault( + hostId: ExecutionHostId, + repoId: string, + handlers: { + onResult: (result: RuntimeRepoBaseRefDefault) => void + onError: (error: unknown) => void + } +): () => void { + let cancelled = false + let stopRetry: (() => void) | null = null + const load = (): void => { + getRuntimeRepoBaseRefDefault(hostId, repoId).then( + (result) => { + if (cancelled) { + return + } + handlers.onResult(result) + const refused = result.refusedWslDistro?.toLowerCase() + const onDistroRunning = window.api.preflight?.onWslDistroRunning + if (refused && typeof onDistroRunning === 'function') { + stopRetry = onDistroRunning((distro) => { + if (distro.toLowerCase() === refused) { + stopRetry?.() + stopRetry = null + load() + } + }) + } + }, + (error: unknown) => { + if (!cancelled) { + handlers.onError(error) + } + } + ) + } + load() + return () => { + cancelled = true + stopRetry?.() + } +} + export async function searchRuntimeRepoBaseRefs( hostId: ExecutionHostId, repoId: string, diff --git a/src/shared/repo-types.ts b/src/shared/repo-types.ts index 19bf707eb46..acb5304b587 100644 --- a/src/shared/repo-types.ts +++ b/src/shared/repo-types.ts @@ -104,6 +104,8 @@ export type Repo = { export type BaseRefDefaultResult = { defaultBaseRef: string | null remoteCount: number + /** This stopped (or unverifiable) WSL distro refused the read (E9); re-ask once it runs. */ + refusedWslDistro?: string } export type BaseRefSearchResult = {