mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user