diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index d2761760deb..0f86399060b 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -7,7 +7,7 @@ import { } from '../../../shared/git-fetch-head-lock' import { pendingWslDriveCheckoutGitRouting } from '../wsl-drive-checkout-git-routing' import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' -import { annotateWslHostFailure, isTransientWslFailure, retryWslRead } from './wsl-host-failure' +import { annotateWslHostFailure, isTransientWslFailure } from './wsl-host-failure' import type { GitAdmissionTier, GitExecOptions } from './git-exec-options' import { execFileCapture, execFileCaptureToTermination } from './exec-file-capture' import { @@ -118,11 +118,11 @@ async function gitExecFileAsyncUnlocked( const runCapturedCommand = async (): Promise<{ stdout: string; stderr: string }> => { let result: { stdout: string | Buffer; stderr: string | Buffer } try { - result = await retryWslRead(args, options, () => capture(resolved)) + result = await capture(resolved) } catch (error) { if ( directWslGitExitCode(error, resolved) !== null && - // A WSL service hiccup says nothing about the direct git environment. + // A WSL service failure says nothing about the direct git environment. !isTransientWslFailure(error) && !isQuietGitControlFlowExit(error) && !options.signal?.aborted diff --git a/src/main/git/command-runner/wsl-host-failure.ts b/src/main/git/command-runner/wsl-host-failure.ts index 58058a2175c..85105e00e5d 100644 --- a/src/main/git/command-runner/wsl-host-failure.ts +++ b/src/main/git/command-runner/wsl-host-failure.ts @@ -1,5 +1,3 @@ -import { classifyGitCommand } from '../../../shared/git-command-classification' -import { createAbortError } from './abort-error' import type { ResolvedCommand } from './wsl-command-resolution' /** wsl.exe's own launch-failure exit, distinct from any status the guest process can return. */ @@ -46,95 +44,13 @@ export function annotateWslHostFailure(error: unknown, command: ResolvedCommand) return Object.assign(error, { wslHostFailure: true, wslDistro: distro }) } -// Measured on a cold systemd distro: concurrent first launches intermittently fail before the -// guest command starts; a retry a moment later succeeds once the distro is up. -const TRANSIENT_WSL_SERVICE_ERROR = /Wsl\/Service\/E_UNEXPECTED\b/ -const TRANSIENT_WSL_SERVICE_RETRY_DELAYS_MS = [250, 750, 1500] as const -// After one call spends its retries, others on that distro fail fast instead of each sleeping. -const TRANSIENT_WSL_SERVICE_BREAKER_MS = 5_000 -const brokenUntilByDistro = new Map() +// The WSL service's answer while a distro it failed to start is unusable; no guest ran. +const WSL_SERVICE_UNEXPECTED = /Wsl\/Service\/E_UNEXPECTED\b/ -/** wsl.exe failed before the guest ran, with an error the WSL service recovers from by itself. */ +/** wsl.exe failed in the WSL service itself, which says nothing about the guest's git setup. */ export function isTransientWslFailure(error: unknown): boolean { if (!error || typeof error !== 'object' || !('wslHostFailure' in error)) { return false } - return error instanceof Error && TRANSIENT_WSL_SERVICE_ERROR.test(error.message) -} - -function failureDistro(error: unknown): string | null { - return error && - typeof error === 'object' && - 'wslDistro' in error && - typeof error.wslDistro === 'string' - ? error.wslDistro.toLowerCase() - : null -} - -function sleepUnlessAborted(ms: number, signal: AbortSignal | undefined): Promise { - return new Promise((resolve) => { - const done = (): void => { - clearTimeout(timer) - signal?.removeEventListener('abort', done) - resolve() - } - const timer = setTimeout(done, ms) - signal?.addEventListener('abort', done, { once: true }) - }) -} - -/** - * Re-runs a read whose wsl.exe launch hit a transient service error. Reads only: the guest never - * started, but a write must not risk running twice on a host that reports failure late. Each - * attempt has its own timeout; a timeout is not a host failure, so it is never retried. - */ -export async function retryTransientWslServiceFailure( - run: () => Promise, - options: { - retryable: boolean - signal?: AbortSignal - /** False once the caller no longer wants the command (disposed watcher). */ - canContinue?: () => boolean - delaysMs?: readonly number[] - now?: () => number - } -): Promise { - const delays = options.delaysMs ?? TRANSIENT_WSL_SERVICE_RETRY_DELAYS_MS - const now = options.now ?? Date.now - for (let attempt = 0; ; attempt += 1) { - try { - return await run() - } catch (error) { - const distro = failureDistro(error) - if (!options.retryable || !isTransientWslFailure(error) || distro === null) { - throw error - } - const delay = delays[attempt] - if (delay === undefined || (brokenUntilByDistro.get(distro) ?? 0) > now()) { - brokenUntilByDistro.set(distro, now() + TRANSIENT_WSL_SERVICE_BREAKER_MS) - throw error - } - await sleepUnlessAborted(delay, options.signal) - if (options.signal?.aborted || options.canContinue?.() === false) { - throw createAbortError() - } - } - } -} - -export function resetTransientWslServiceBreakerForTests(): void { - brokenUntilByDistro.clear() -} - -/** A git command's first launch, retried per `retryTransientWslServiceFailure` when it is a read. */ -export function retryWslRead( - args: readonly string[], - options: { signal?: AbortSignal; canStart?: () => boolean }, - run: () => Promise -): Promise { - return retryTransientWslServiceFailure(run, { - retryable: classifyGitCommand(args) === 'read', - signal: options.signal, - canContinue: options.canStart - }) + return error instanceof Error && WSL_SERVICE_UNEXPECTED.test(error.message) } diff --git a/src/main/git/command-runner/wsl-service-failure-direct-git.test.ts b/src/main/git/command-runner/wsl-service-failure-direct-git.test.ts new file mode 100644 index 00000000000..3dfe397e492 --- /dev/null +++ b/src/main/git/command-runner/wsl-service-failure-direct-git.test.ts @@ -0,0 +1,82 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: vi.fn(), + spawn: vi.fn() +})) +vi.mock('../../observability/instrumentation', () => ({ + withGitSpan: (_attributes: unknown, run: (span: unknown) => unknown) => + run({ setAttribute: () => {} }) +})) +vi.mock('../../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) + +import { gitExecFileAsync } from '../runner' +import { + peekWslGitReadEnvironment, + resetWslGitReadEnvironmentForTests, + seedWslGitReadEnvironmentForTests +} from '../wsl-git-read-environment' +import { _resetGitAdmissionForTests } from './git-subprocess-admission' + +function hostFailure(stdout: string): Error { + return Object.assign(new Error('Command failed: wsl.exe -d kali-linux --exec sh -lc ...'), { + code: 4294967295, + stdout, + stderr: '' + }) +} + +async function withPlatform(platform: NodeJS.Platform, run: () => Promise): Promise { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await run() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +describe('git reads over wsl.exe when the WSL service fails', () => { + beforeEach(() => { + execFileMock.mockReset() + }) + afterEach(() => { + _resetGitAdmissionForTests() + }) + + const TRANSIENT = 'Catastrophic failure \r\nError code: Wsl/Service/E_UNEXPECTED\r\n' + + function answerWith(results: ({ fail: string } | { stdout: string })[]): void { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + const child = Object.assign(new EventEmitter(), { pid: 4321, kill: () => {} }) + const next = results.shift() ?? { stdout: '' } + queueMicrotask(() => + 'fail' in next + ? callback?.(hostFailure(next.fail), next.fail, '') + : callback?.(null, next.stdout, '') + ) + return child + }) + } + + it('fails a direct read on a WSL service error without the login-shell fallback', async () => { + await withPlatform('win32', async () => { + const environment = { gitPath: '/usr/bin/git', home: '/home/u', path: '/usr/bin:/bin' } + seedWslGitReadEnvironmentForTests('Ubuntu', environment) + answerWith([{ fail: TRANSIENT }, { stdout: 'login-shell fallback' }]) + const failure = await gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + preferWslDirectGit: true, + wslDistro: 'Ubuntu' + }).catch((error: unknown) => error) + expect(String(failure)).toContain('Wsl/Service/E_UNEXPECTED') + // One direct launch and no login-shell fallback, so the fast path stays on. + expect(execFileMock).toHaveBeenCalledOnce() + expect(peekWslGitReadEnvironment('Ubuntu')).toEqual(environment) + }).finally(resetWslGitReadEnvironmentForTests) + }) +}) diff --git a/src/main/git/command-runner/wsl-transient-service-retry.test.ts b/src/main/git/command-runner/wsl-transient-service-retry.test.ts deleted file mode 100644 index 100e09838ca..00000000000 --- a/src/main/git/command-runner/wsl-transient-service-retry.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) - -vi.mock('node:child_process', () => ({ - execFile: execFileMock, - execFileSync: vi.fn(), - spawn: vi.fn() -})) -vi.mock('../../observability/instrumentation', () => ({ - withGitSpan: (_attributes: unknown, run: (span: unknown) => unknown) => - run({ setAttribute: () => {} }) -})) -vi.mock('../../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) - -import { gitExecFileAsync } from '../runner' -import { - peekWslGitReadEnvironment, - resetWslGitReadEnvironmentForTests, - seedWslGitReadEnvironmentForTests -} from '../wsl-git-read-environment' -import { _resetGitAdmissionForTests } from './git-subprocess-admission' -import { resetTransientWslServiceBreakerForTests } from './wsl-host-failure' - -const WSL_DIAGNOSTIC = - 'There is no distribution with the supplied name.\r\nError code: Wsl/Service/WSL_E_DISTRO_NOT_FOUND\r\n' - -function hostFailure(stdout: string): Error { - return Object.assign(new Error('Command failed: wsl.exe -d kali-linux --exec sh -lc ...'), { - code: 4294967295, - stdout, - stderr: '' - }) -} - -async function withPlatform(platform: NodeJS.Platform, run: () => Promise): Promise { - const original = process.platform - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) - try { - return await run() - } finally { - Object.defineProperty(process, 'platform', { configurable: true, value: original }) - } -} - -describe('git reads over wsl.exe on a booting distro', () => { - beforeEach(() => { - execFileMock.mockReset() - resetTransientWslServiceBreakerForTests() - }) - afterEach(() => { - _resetGitAdmissionForTests() - }) - - const TRANSIENT = 'Catastrophic failure \r\nError code: Wsl/Service/E_UNEXPECTED\r\n' - - function answerWith(results: ({ fail: string } | { stdout: string })[]): void { - execFileMock.mockImplementation((_command, _args, _options, callback) => { - const child = Object.assign(new EventEmitter(), { pid: 4321, kill: () => {} }) - const next = results.shift() ?? { stdout: '' } - queueMicrotask(() => - 'fail' in next - ? callback?.(hostFailure(next.fail), next.fail, '') - : callback?.(null, next.stdout, '') - ) - return child - }) - } - - const UNC = '\\\\wsl.localhost\\kali-linux\\home\\paulius\\bugbounty' - const listWorktrees = (options: { signal?: AbortSignal } = {}) => - withPlatform('win32', () => - gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], { cwd: UNC, ...options }).then( - (result) => result.stdout, - (error: unknown) => error - ) - ) - - describe('transient WSL service errors', () => { - beforeEach(() => { - vi.useFakeTimers() - resetTransientWslServiceBreakerForTests() - }) - afterEach(() => { - vi.useRealTimers() - }) - - it('retries a read until the booting distro answers', async () => { - answerWith([{ fail: TRANSIENT }, { fail: TRANSIENT }, { stdout: 'worktree /x\0' }]) - const result = listWorktrees() - await vi.advanceTimersByTimeAsync(1_000) - expect(await result).toBe('worktree /x\0') - expect(execFileMock).toHaveBeenCalledTimes(3) - }) - - it('gives up after the bounded retries, then fails fast on that distro for a while', async () => { - answerWith([ - { fail: TRANSIENT }, - { fail: TRANSIENT }, - { fail: TRANSIENT }, - { fail: TRANSIENT } - ]) - const first = listWorktrees() - await vi.advanceTimersByTimeAsync(2_500) - expect(String(await first)).toContain('Wsl/Service/E_UNEXPECTED') - expect(execFileMock).toHaveBeenCalledTimes(4) - - answerWith([{ fail: TRANSIENT }]) - expect(String(await listWorktrees())).toContain('Wsl/Service/E_UNEXPECTED') - expect(execFileMock).toHaveBeenCalledTimes(5) - }) - - it('stops waiting as soon as the caller aborts', async () => { - answerWith([{ fail: TRANSIENT }, { stdout: 'never' }]) - const controller = new AbortController() - const result = listWorktrees({ signal: controller.signal }) - await vi.advanceTimersByTimeAsync(100) - controller.abort() - expect(await result).toMatchObject({ name: 'AbortError' }) - expect(execFileMock).toHaveBeenCalledTimes(1) - }) - - it('never re-runs a write, or a read that failed for another reason', async () => { - answerWith([{ fail: TRANSIENT }]) - await withPlatform('win32', () => - gitExecFileAsync(['commit', '-m', 'x'], { cwd: UNC }).catch(() => null) - ) - expect(execFileMock).toHaveBeenCalledTimes(1) - - execFileMock.mockReset() - answerWith([{ fail: WSL_DIAGNOSTIC }]) - await listWorktrees() - expect(execFileMock).toHaveBeenCalledTimes(1) - }) - }) - - it('keeps the direct read path when a direct read only hit a transient service error', async () => { - vi.useRealTimers() - await withPlatform('win32', async () => { - const environment = { gitPath: '/usr/bin/git', home: '/home/u', path: '/usr/bin:/bin' } - seedWslGitReadEnvironmentForTests('Ubuntu', environment) - answerWith([ - { fail: TRANSIENT }, - { fail: TRANSIENT }, - { fail: TRANSIENT }, - { fail: TRANSIENT } - ]) - const failure = await gitExecFileAsync(['status', '--short'], { - cwd: String.raw`C:\repo`, - preferWslDirectGit: true, - wslDistro: 'Ubuntu' - }).catch((error: unknown) => error) - expect(String(failure)).toContain('Wsl/Service/E_UNEXPECTED') - // Four direct launches and no login-shell fallback, so the fast path stays on. - expect(execFileMock).toHaveBeenCalledTimes(4) - expect(peekWslGitReadEnvironment('Ubuntu')).toEqual(environment) - }).finally(resetWslGitReadEnvironmentForTests) - }, 15_000) -}) diff --git a/src/main/ipc/worktree-base-directory-watch-targets.test.ts b/src/main/ipc/worktree-base-directory-watch-targets.test.ts index d125b7c3304..6434685839e 100644 --- a/src/main/ipc/worktree-base-directory-watch-targets.test.ts +++ b/src/main/ipc/worktree-base-directory-watch-targets.test.ts @@ -3,10 +3,18 @@ import { join, sep } from 'node:path' import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' -const { readFileMock, realpathMock, statMock } = vi.hoisted(() => ({ +const { readFileMock, realpathMock, statMock, wslHomeMock, wslHomeAsyncMock } = vi.hoisted(() => ({ readFileMock: vi.fn(), realpathMock: vi.fn(), - statMock: vi.fn() + statMock: vi.fn(), + wslHomeMock: vi.fn((): string | null => null), + wslHomeAsyncMock: vi.fn(async (): Promise => null) +})) + +vi.mock('../wsl', async (importOriginal) => ({ + ...(await importOriginal>()), + getWslHome: wslHomeMock, + getWslHomeAsync: wslHomeAsyncMock })) vi.mock('node:fs/promises', () => ({ @@ -172,6 +180,26 @@ describe('worktree base directory watch target resolution', () => { expect(statMock).not.toHaveBeenCalled() }) + // Real host (win-lowspec): the synchronous probe blocked main 5 s on a cold distro and killed its boot. + it('never probes a distro home for a WSL repo or a Windows repo', async () => { + const wsl = makeRepo(0, { path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\project' }) + const mirrored = makeRepo(1, { path: 'C:\\Users\\alice\\orca' }) + + const platform = process.platform + // A WSL path is only recognized on Windows. + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: target resolution reads only settings and repos. + const store = makeStore([wsl, mirrored]) as never + try { + await buildWorktreeBaseDirectoryWatchTargets(store) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + } + + expect(wslHomeMock).not.toHaveBeenCalled() + expect(wslHomeAsyncMock).not.toHaveBeenCalled() + }) + // WSL-mirrored worktrees can have Windows Git metadata; UNC root refusal must preserve that watcher. it('keeps the Windows-side git-common watcher when only the workspace root is a WSL UNC path', async () => { const repo = makeRepo(0, { diff --git a/src/main/ipc/worktree-base-directory-watch-targets.ts b/src/main/ipc/worktree-base-directory-watch-targets.ts index 5f72765bd52..2635f4b6df5 100644 --- a/src/main/ipc/worktree-base-directory-watch-targets.ts +++ b/src/main/ipc/worktree-base-directory-watch-targets.ts @@ -12,7 +12,11 @@ import { import { isWslUncPath } from '../../shared/wsl-paths' import { mapWithConcurrency } from '../../shared/map-with-concurrency' import { getWorktreeMirrorDistro } from '../project-runtime-git-options' -import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic' +import { + computeWorkspaceRoot, + getWorktreePathSettings, + workspaceRootMirrorsIntoWsl +} from './worktree-logic' import { shouldEmitBoundedWarning } from './bounded-warning-dedupe' import { resolveWorktreeCommonGitDirectory } from './worktree-common-git-directory' import type { @@ -71,20 +75,24 @@ async function maybeAddBaseTarget( settings: GlobalSettings, mirrorDistro: string | undefined ): Promise { + // UNC watching is unreliable; distro repos have no native target, but mirrored Windows repos retain their Git-common watch. + // Why before the root: resolving it probes the distro's home, which boots a stopped distro. + if (isWslUncPath(repo.path)) { + warnSkippedWslRoot(repo.id, repo.path) + return + } const pathSettings = getWorktreePathSettings(repo, settings, mirrorDistro) - const workspaceRoot = computeWorkspaceRoot(repo.path, pathSettings) const config = { repoId: repo.id, repoName: getRuntimePathBasename(repo.path).replace(/\.git$/, ''), nestWorkspaces: pathSettings.nestWorkspaces } - // UNC watching is unreliable; distro repos have no native target, but mirrored Windows repos retain their Git-common watch. - if (isWslUncPath(repo.path)) { - warnSkippedWslRoot(repo.id, workspaceRoot) - return - } - if (isWslUncPath(workspaceRoot)) { - warnSkippedWslRoot(repo.id, workspaceRoot) + // A root mirrored into WSL is a UNC path either way; probing the distro's home (which can kill a + // booting distro) would only lead to the same skip, so keep just the Git-common watch. + const mirrorsIntoWsl = workspaceRootMirrorsIntoWsl(repo.path, pathSettings) + const workspaceRoot = mirrorsIntoWsl ? null : computeWorkspaceRoot(repo.path, pathSettings) + if (workspaceRoot === null || isWslUncPath(workspaceRoot)) { + warnSkippedWslRoot(repo.id, workspaceRoot ?? repo.path) } else { try { if ((await stat(workspaceRoot)).isDirectory()) { diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 7da0cd3fa81..fb25a162b49 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -126,6 +126,14 @@ export function computeWorkspaceRoot( ) } +/** Whether the workspace root mirrors into a WSL home, which only a wsl.exe probe can resolve. */ +export function workspaceRootMirrorsIntoWsl( + repoPath: string, + settings: { workspaceDir: string; wslMirrorDistro?: string } +): boolean { + return mirrorDistroForWorkspaceRoot(repoPath, settings) !== undefined +} + function mirrorDistroForWorkspaceRoot( repoPath: string, settings: { workspaceDir: string; wslMirrorDistro?: string }