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
This commit is contained in:
Jinjing
2026-07-10 00:11:40 -07:00
parent 830024dc2e
commit 77a822f0b3
2 changed files with 135 additions and 14 deletions
@@ -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()
})
})
+54 -14
View File
@@ -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<string, string>()
const inFlightWslCanonicalizations = new Set<string>()
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()
}
}