From 77a822f0b3d9bb2b7c2fe2271d718ecee7a978db Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:11:40 -0700 Subject: [PATCH] Make WSL Codex path canonicalization async to avoid blocking main proces - readlink -f over wsl.exe could stall the Electron main process on a cold or wedged distro, freezing the UI on every Codex WSL launch - Replace execFileSync with execFile + a resolution cache so lookups return immediately (null until resolved) and results are cached per (distro, path) once available - Add unit tests covering caching, in-flight dedup, and retry after failed resolution --- .../codex/codex-wsl-hook-install-plan.test.ts | 81 +++++++++++++++++++ src/main/codex/codex-wsl-hook-install-plan.ts | 68 ++++++++++++---- 2 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 src/main/codex/codex-wsl-hook-install-plan.test.ts diff --git a/src/main/codex/codex-wsl-hook-install-plan.test.ts b/src/main/codex/codex-wsl-hook-install-plan.test.ts new file mode 100644 index 00000000000..f16a6701126 --- /dev/null +++ b/src/main/codex/codex-wsl-hook-install-plan.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock +})) + +import { _internals } from './codex-wsl-hook-install-plan' + +const originalPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +beforeEach(() => { + execFileMock.mockReset() + _internals.resetWslCanonicalPathCache() +}) + +afterEach(() => { + setPlatform(originalPlatform) +}) + +describe('canonicalizeWslLinuxPath', () => { + it('returns the path unchanged off Windows without spawning wsl.exe', () => { + setPlatform('linux') + expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alice')).toBe('/home/alice') + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('never blocks: returns null and schedules an async resolution on first call', () => { + setPlatform('win32') + const result = _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias') + + expect(result).toBeNull() + expect(execFileMock).toHaveBeenCalledTimes(1) + const [file, args] = execFileMock.mock.calls[0] + expect(file).toBe('wsl.exe') + expect(args).toEqual(['-d', 'Ubuntu', '--', 'readlink', '-f', '--', '/home/alias']) + }) + + it('caches the resolved canonical path and stops spawning wsl.exe', () => { + setPlatform('win32') + expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull() + + const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void + callback(null, '/home/alice\n') + + expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBe('/home/alice') + expect(execFileMock).toHaveBeenCalledTimes(1) + }) + + it('does not spawn a second subprocess while one is in flight', () => { + setPlatform('win32') + _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias') + _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias') + + expect(execFileMock).toHaveBeenCalledTimes(1) + }) + + it('retries after a failed resolution rather than caching the failure', () => { + setPlatform('win32') + _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias') + const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void + callback(new Error('wsl unreachable'), '') + + expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull() + expect(execFileMock).toHaveBeenCalledTimes(2) + }) + + it('ignores non-absolute readlink output', () => { + setPlatform('win32') + _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias') + const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void + callback(null, 'readlink: missing operand\n') + + expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull() + }) +}) diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index e7878777bff..00774a26ccb 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -1,4 +1,4 @@ -import { execFileSync } from 'node:child_process' +import { execFile } from 'node:child_process' import { win32 as pathWin32 } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' @@ -29,24 +29,56 @@ function toDefaultWslLinuxPath(windowsPath: string): string { return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2].replace(/\\/g, '/')}` } +const WSL_CANONICALIZE_TIMEOUT_MS = 5000 + +// Why: `readlink -f` over wsl.exe stalls up to the timeout on a cold or wedged +// distro. Running it synchronously on the Electron main process froze the UI on +// every Codex WSL launch, so resolve it off-thread and cache the stable result. +const canonicalWslPathCache = new Map() +const inFlightWslCanonicalizations = new Set() + +function wslCanonicalizeCacheKey(distro: string, linuxPath: string): string { + return `${distro}\x00${linuxPath}` +} + +function scheduleWslLinuxPathCanonicalization(distro: string, linuxPath: string): void { + const key = wslCanonicalizeCacheKey(distro, linuxPath) + // Why: cap the wedged-WSL blast radius to a single background subprocess per + // (distro, path); the cache holds the answer once one resolution succeeds. + if (canonicalWslPathCache.has(key) || inFlightWslCanonicalizations.has(key)) { + return + } + inFlightWslCanonicalizations.add(key) + execFile( + 'wsl.exe', + ['-d', distro, '--', 'readlink', '-f', '--', linuxPath], + { encoding: 'utf-8', timeout: WSL_CANONICALIZE_TIMEOUT_MS, windowsHide: true }, + (error, stdout) => { + inFlightWslCanonicalizations.delete(key) + if (error) { + return + } + const canonicalPath = stdout.trim() + if (canonicalPath.startsWith('/')) { + canonicalWslPathCache.set(key, canonicalPath) + } + } + ) +} + function canonicalizeWslLinuxPath(distro: string, linuxPath: string): string | null { if (process.platform !== 'win32') { return linuxPath } - try { - const canonicalPath = execFileSync( - 'wsl.exe', - ['-d', distro, '--', 'readlink', '-f', '--', linuxPath], - { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 5000 - } - ).trim() - return canonicalPath.startsWith('/') ? canonicalPath : null - } catch { - return null + const cached = canonicalWslPathCache.get(wslCanonicalizeCacheKey(distro, linuxPath)) + if (cached) { + return cached } + // Why: no canonical path resolved yet — start the off-thread resolution and + // let the caller use the logical path for now (identical unless HOME crosses a + // symlink). The next launch reads the cached canonical path. + scheduleWslLinuxPathCanonicalization(distro, linuxPath) + return null } export function createCodexWslRuntimeHookInstallPlan( @@ -85,3 +117,11 @@ export function createCodexWslRuntimeHookInstallPlan( trustConfigPath: `${linuxRuntimeHome}/hooks.json` } } + +export const _internals = { + canonicalizeWslLinuxPath, + resetWslCanonicalPathCache(): void { + canonicalWslPathCache.clear() + inFlightWslCanonicalizations.clear() + } +}