From 21fdaee9617ebd5e721ce4687b6c8f18a8a5bec0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:56:21 -0700 Subject: [PATCH] fix(wsl): re-read a paired host's refused default base ref, and close the signal race (#35812) A paired host's repo.baseRefDefault now names the stopped distro that refused it. Clients that cannot hear that host's running signal re-ask on window focus and every 30 s while visible, until it answers. Locally, the running listener is attached before the first read, so a signal that lands while main is answering triggers a re-read instead of being lost. --- .../repos/base-ref-default-wsl-gate.test.ts | 4 +- .../runtime/runtime-repository-ref-queries.ts | 12 +- ...me-repo-base-ref-default-wsl-retry.test.ts | 159 +++++++++++++++--- .../src/runtime/runtime-repo-client.ts | 75 +++++++-- 4 files changed, 207 insertions(+), 43 deletions(-) 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 index dde45c21e4b..9ab62173d24 100644 --- a/src/main/ipc/repos/base-ref-default-wsl-gate.test.ts +++ b/src/main/ipc/repos/base-ref-default-wsl-gate.test.ts @@ -162,10 +162,10 @@ describe('runtime repo.baseRefDefault for a stopped WSL distro (E9)', () => { } }) - it("returns unknown without touching the stopped distro's UNC path", async () => { + it('returns unknown, naming the refusing distro, without touching its UNC path', async () => { const queries = new RuntimeRepositoryRefQueries({ resolveRepo: async () => makeRepo() }) - await expect(queries.getDefault('id:repo-1')).resolves.toEqual(UNKNOWN) + await expect(queries.getDefault('id:repo-1')).resolves.toEqual(REFUSED) expect(mocks.getBaseRefDefault).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/runtime-repository-ref-queries.ts b/src/main/runtime/runtime-repository-ref-queries.ts index 1bd63288005..fe0fe61a487 100644 --- a/src/main/runtime/runtime-repository-ref-queries.ts +++ b/src/main/runtime/runtime-repository-ref-queries.ts @@ -1,4 +1,4 @@ -import type { Repo } from '../../shared/repo-types' +import type { BaseRefDefaultResult, Repo } from '../../shared/repo-types' import type { RuntimeRepoSearchRefs } from '../../shared/runtime-types' import { isFolderRepo } from '../../shared/repo-kind' import { @@ -44,9 +44,7 @@ export class RuntimeRepositoryRefQueries { } } - async getDefault( - repoSelector: string - ): Promise<{ defaultBaseRef: string | null; remoteCount: number }> { + async getDefault(repoSelector: string): Promise { const repo = await this.deps.resolveRepo(repoSelector) if (isFolderRepo(repo)) { return { defaultBaseRef: null, remoteCount: 0 } @@ -55,8 +53,10 @@ export class RuntimeRepositoryRefQueries { 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 blocked = await blockedWslDistroForPath(repo.path) + if (blocked) { + // Optional, so older clients read plain unknown; newer ones re-ask while it is set. + return { defaultBaseRef: null, remoteCount: 0, refusedWslDistro: blocked.distro } } const [defaultBaseRef, remoteCount] = await Promise.all([ getBaseRefDefault(repo.path), 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 index 6d8909ffca4..ec76a64785c 100644 --- 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 @@ -1,10 +1,16 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { watchRuntimeRepoBaseRefDefault } from './runtime-repo-client' + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc: rpc })) + +import { REFUSED_BASE_REF_REPOLL_MS, watchRuntimeRepoBaseRefDefault } from './runtime-repo-client' const getBaseRefDefault = vi.fn() const distroRunningListeners = new Set<(distro: string) => void>() +const focusListeners = new Set<() => void>() +const page = { visibilityState: 'visible' } function emitDistroRunning(distro: string): void { for (const listener of distroRunningListeners) { @@ -12,30 +18,58 @@ function emitDistroRunning(distro: string): void { } } -beforeEach(() => { - getBaseRefDefault.mockReset() - distroRunningListeners.clear() +function focusWindow(): void { + for (const listener of focusListeners) { + listener() + } +} + +type PreflightStub = { + onWslDistroRunning?: (listener: (distro: string) => void) => () => void +} + +function stubWindow(preflight: PreflightStub): void { vi.stubGlobal('window', { - api: { - repos: { getBaseRefDefault }, - preflight: { - onWslDistroRunning: (listener: (distro: string) => void) => { - distroRunningListeners.add(listener) - return () => distroRunningListeners.delete(listener) - } + api: { repos: { getBaseRefDefault }, preflight }, + addEventListener: (type: string, listener: () => void) => { + if (type === 'focus') { + focusListeners.add(listener) + } + }, + removeEventListener: (type: string, listener: () => void) => { + if (type === 'focus') { + focusListeners.delete(listener) } } }) +} + +const hearsRunningSignal: PreflightStub = { + onWslDistroRunning: (listener: (distro: string) => void) => { + distroRunningListeners.add(listener) + return () => distroRunningListeners.delete(listener) + } +} + +beforeEach(() => { + getBaseRefDefault.mockReset() + rpc.mockReset() + distroRunningListeners.clear() + focusListeners.clear() + page.visibilityState = 'visible' + vi.stubGlobal('document', page) + stubWindow(hearsRunningSignal) }) afterEach(() => { + vi.useRealTimers() 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)', () => { +describe("watchRuntimeRepoBaseRefDefault on this app's repos (E9)", () => { it('loads again once the refusing distro runs', async () => { getBaseRefDefault.mockResolvedValueOnce(REFUSED).mockResolvedValueOnce(LOADED) const onResult = vi.fn() @@ -46,27 +80,53 @@ describe('watchRuntimeRepoBaseRefDefault after a stopped-distro refusal (E9)', ( await vi.waitFor(() => expect(onResult).toHaveBeenLastCalledWith(LOADED)) expect(getBaseRefDefault).toHaveBeenCalledTimes(2) - expect(distroRunningListeners.size).toBe(0) + }) + + it('re-asks when the distro started while main was still answering', async () => { + let answer: (value: typeof REFUSED) => void = () => {} + getBaseRefDefault + .mockReturnValueOnce(new Promise((resolve) => (answer = resolve))) + .mockResolvedValueOnce(LOADED) + const onResult = vi.fn() + watchRuntimeRepoBaseRefDefault('local', 'repo-1', { onResult, onError: vi.fn() }) + + emitDistroRunning('Ubuntu') + answer(REFUSED) + + await vi.waitFor(() => expect(onResult).toHaveBeenLastCalledWith(LOADED)) + expect(getBaseRefDefault).toHaveBeenCalledTimes(2) }) 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)) + await vi.waitFor(() => expect(onResult).toHaveBeenCalledWith(REFUSED)) emitDistroRunning('Debian') expect(getBaseRefDefault).toHaveBeenCalledTimes(1) }) + it('does not re-ask 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)) + + emitDistroRunning('Ubuntu') + + expect(getBaseRefDefault).toHaveBeenCalledTimes(1) + }) + it('stops listening when cancelled', async () => { getBaseRefDefault.mockResolvedValue(REFUSED) + const onResult = vi.fn() const cancel = watchRuntimeRepoBaseRefDefault('local', 'repo-1', { - onResult: vi.fn(), + onResult, onError: vi.fn() }) - await vi.waitFor(() => expect(distroRunningListeners.size).toBe(1)) + await vi.waitFor(() => expect(onResult).toHaveBeenCalledWith(REFUSED)) cancel() emitDistroRunning('Ubuntu') @@ -74,13 +134,72 @@ describe('watchRuntimeRepoBaseRefDefault after a stopped-distro refusal (E9)', ( expect(distroRunningListeners.size).toBe(0) expect(getBaseRefDefault).toHaveBeenCalledTimes(1) }) +}) - it('does not listen after a result that was not refused', async () => { - getBaseRefDefault.mockResolvedValue(LOADED) +describe('watchRuntimeRepoBaseRefDefault on a paired host (E9)', () => { + const PAIRED = 'runtime:env-1' + + it('re-asks on window focus while the host refuses', async () => { + rpc.mockResolvedValueOnce(REFUSED).mockResolvedValueOnce(LOADED) + const onResult = vi.fn() + watchRuntimeRepoBaseRefDefault(PAIRED, 'repo-1', { onResult, onError: vi.fn() }) + await vi.waitFor(() => expect(onResult).toHaveBeenCalledWith(REFUSED)) + expect(distroRunningListeners.size).toBe(0) + + focusWindow() + + await vi.waitFor(() => expect(onResult).toHaveBeenLastCalledWith(LOADED)) + focusWindow() + expect(rpc).toHaveBeenCalledTimes(2) + }) + + it('re-asks every 30 s while visible, and stops once answered', async () => { + vi.useFakeTimers() + rpc.mockResolvedValueOnce(REFUSED).mockResolvedValueOnce(REFUSED).mockResolvedValueOnce(LOADED) + const onResult = vi.fn() + watchRuntimeRepoBaseRefDefault(PAIRED, 'repo-1', { onResult, onError: vi.fn() }) + await vi.advanceTimersByTimeAsync(0) + + page.visibilityState = 'hidden' + await vi.advanceTimersByTimeAsync(REFUSED_BASE_REF_REPOLL_MS) + expect(rpc).toHaveBeenCalledTimes(1) + + page.visibilityState = 'visible' + await vi.advanceTimersByTimeAsync(REFUSED_BASE_REF_REPOLL_MS) + expect(rpc).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(REFUSED_BASE_REF_REPOLL_MS) + expect(onResult).toHaveBeenLastCalledWith(LOADED) + + await vi.advanceTimersByTimeAsync(REFUSED_BASE_REF_REPOLL_MS * 3) + expect(rpc).toHaveBeenCalledTimes(3) + }) + + it('stops re-asking when cancelled', async () => { + vi.useFakeTimers() + rpc.mockResolvedValue(REFUSED) + const cancel = watchRuntimeRepoBaseRefDefault(PAIRED, 'repo-1', { + onResult: vi.fn(), + onError: vi.fn() + }) + await vi.advanceTimersByTimeAsync(0) + + cancel() + focusWindow() + await vi.advanceTimersByTimeAsync(REFUSED_BASE_REF_REPOLL_MS * 2) + + expect(rpc).toHaveBeenCalledTimes(1) + expect(focusListeners.size).toBe(0) + }) + + it('polls a local-looking host whose preload has no running signal (web client)', async () => { + stubWindow({}) + getBaseRefDefault.mockResolvedValueOnce(REFUSED).mockResolvedValueOnce(LOADED) const onResult = vi.fn() watchRuntimeRepoBaseRefDefault('local', 'repo-1', { onResult, onError: vi.fn() }) - await vi.waitFor(() => expect(onResult).toHaveBeenCalledWith(LOADED)) + await vi.waitFor(() => expect(onResult).toHaveBeenCalledWith(REFUSED)) - expect(distroRunningListeners.size).toBe(0) + focusWindow() + + await vi.waitFor(() => expect(onResult).toHaveBeenLastCalledWith(LOADED)) }) }) diff --git a/src/renderer/src/runtime/runtime-repo-client.ts b/src/renderer/src/runtime/runtime-repo-client.ts index 53ee5064e9b..0f52c138777 100644 --- a/src/renderer/src/runtime/runtime-repo-client.ts +++ b/src/renderer/src/runtime/runtime-repo-client.ts @@ -37,9 +37,13 @@ export async function getRuntimeRepoBaseRefDefault( ) } +/** How often a refused default is re-asked from a host whose running signal cannot reach us. */ +export const REFUSED_BASE_REF_REPOLL_MS = 30_000 + /** - * 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. + * Loads the repo's default base ref and, while a stopped WSL distro refuses it (E9), re-asks once + * that distro runs: on main's running signal for this app's repos, or for a paired host (whose + * signal never reaches this window) on window focus and every 30 s while visible. Returns a cancel. */ export function watchRuntimeRepoBaseRefDefault( hostId: ExecutionHostId, @@ -50,37 +54,78 @@ export function watchRuntimeRepoBaseRefDefault( } ): () => void { let cancelled = false - let stopRetry: (() => void) | null = null - const load = (): void => { + let inFlight = false + let refused: string | null = null + // Running signals that land while main is still answering would otherwise be missed. + let ranDuringRead = new Set() + let repoll: ReturnType | null = null + const onDistroRunning = window.api.preflight?.onWslDistroRunning + const hearsHostSignal = + ipcOrRuntimeTargetForHostId(hostId)?.kind === 'local' && typeof onDistroRunning === 'function' + + const reaskFromPairedHost = (): void => { + if (refused && !inFlight && document.visibilityState === 'visible') { + load() + } + } + const syncRepoll = (): void => { + if (refused && !hearsHostSignal && !repoll) { + repoll = setInterval(reaskFromPairedHost, REFUSED_BASE_REF_REPOLL_MS) + } else if (!refused && repoll) { + clearInterval(repoll) + repoll = null + } + } + function load(): void { + inFlight = true + ranDuringRead = new Set() getRuntimeRepoBaseRefDefault(hostId, repoId).then( (result) => { + inFlight = false 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() - } - }) + refused = result.refusedWslDistro?.toLowerCase() ?? null + syncRepoll() + if (refused && ranDuringRead.has(refused)) { + load() } }, (error: unknown) => { + inFlight = false if (!cancelled) { handlers.onError(error) } } ) } + + // Subscribed before the first read, so no running signal can fall between answer and listener. + const stopListening = + hearsHostSignal && onDistroRunning + ? onDistroRunning((distro) => { + const key = distro.toLowerCase() + if (inFlight) { + ranDuringRead.add(key) + } else if (key === refused) { + load() + } + }) + : null + if (!hearsHostSignal) { + window.addEventListener('focus', reaskFromPairedHost) + } load() return () => { cancelled = true - stopRetry?.() + stopListening?.() + if (!hearsHostSignal) { + window.removeEventListener('focus', reaskFromPairedHost) + } + if (repoll) { + clearInterval(repoll) + } } }