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 = {