fix(wsl): stop the worktree watcher killing a cold distro's boot (root cause of #33145) (#33175)

* fix(wsl): the worktree watcher no longer probes a WSL distro's home synchronously at startup

For a project inside WSL the watcher resolved the workspace root before seeing the
repo is a UNC path it skips. That ran wsl.exe 'echo $HOME' synchronously with a 5 s
timeout: on a cold distro it blocked main for 5 s and killed the distro's first
boot, after which every wsl.exe call failed with Wsl/Service/E_UNEXPECTED. The
watcher now skips WSL repos first and resolves other roots asynchronously.

* revert(wsl): drop the transient E_UNEXPECTED retry from #33145

The error came from the watcher's synchronous home probe killing the distro's first
boot (fixed in the previous commit). Once that happens the distro answers every
wsl.exe call with E_UNEXPECTED for 14+ s, so a 2.5 s retry never recovered it; on
the measuring box it failed every attempt. With the trigger gone the retry is dead
weight.

* fix(wsl): skip mirrored WSL roots without probing; keep the direct-git guard

Review follow-ups: a Windows repo whose workspace root mirrors into WSL also probed
the distro's home (async, still killed at 5 s) only to skip it, so the watcher now
recognizes the mirror and keeps just the Git-common watch. The revert keeps one
piece of #33145: a WSL service failure no longer falls back to the login shell and
switches the direct git read path off.
This commit is contained in:
Neil
2026-10-24 08:05:08 -07:00
committed by GitHub
parent 35157974b9
commit 4e7c3538e5
7 changed files with 144 additions and 262 deletions
+3 -3
View File
@@ -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
@@ -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<string, number>()
// 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<void> {
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<T>(
run: () => Promise<T>,
options: {
retryable: boolean
signal?: AbortSignal
/** False once the caller no longer wants the command (disposed watcher). */
canContinue?: () => boolean
delaysMs?: readonly number[]
now?: () => number
}
): Promise<T> {
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<T>(
args: readonly string[],
options: { signal?: AbortSignal; canStart?: () => boolean },
run: () => Promise<T>
): Promise<T> {
return retryTransientWslServiceFailure(run, {
retryable: classifyGitCommand(args) === 'read',
signal: options.signal,
canContinue: options.canStart
})
return error instanceof Error && WSL_SERVICE_UNEXPECTED.test(error.message)
}
@@ -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<T>(platform: NodeJS.Platform, run: () => Promise<T>): Promise<T> {
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)
})
})
@@ -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<T>(platform: NodeJS.Platform, run: () => Promise<T>): Promise<T> {
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)
})
@@ -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<string | null> => null)
}))
vi.mock('../wsl', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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, {
@@ -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<void> {
// 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()) {
+8
View File
@@ -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 }