mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(wsl): run wsl.exe probes from the system folder, never the profile (#29853)
* fix(wsl): run wsl.exe probes from the system root, never the profile WSL holds wsl.exe's working directory busy for about its idle timeout after the probe exits. Since #29209 the orcad startup probe ran in the profile, so a temporary orca serve profile could not be deleted for about a minute (diagnosed in #29845). * test(orcad): drop the WSL profile-hold wait from serve parity Reverts #29845's extended EBUSY retry: wsl.exe no longer runs in the profile, so nothing outside the test's process tree holds it. * build(cli): list the WSL spawn-directory module in the CLI typecheck project
This commit is contained in:
@@ -199,6 +199,7 @@
|
||||
"../src/main/codex/codex-trust-grant-ledger.ts",
|
||||
"../src/main/codex/codex-user-hook-trust-moves.ts",
|
||||
"../src/main/codex/codex-wsl-hook-install-plan.ts",
|
||||
"../src/main/wsl-interop-spawn-directory.ts",
|
||||
"../src/main/codex/config-settings-baseline.ts",
|
||||
"../src/main/codex/config-settings-conflict-resolution.ts",
|
||||
"../src/main/codex/config-plugin-registration-promotion.ts",
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { CodexRuntimeHomeService } from './runtime-home-service'
|
||||
import type { Store } from '../persistence'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import { buildEncodedWslBashCommand } from '../wsl-bash-command'
|
||||
import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory'
|
||||
import { admitSelfInitiatedTreeKill } from '../own-chromium-tree-kill-guard'
|
||||
import type { CodexAccountSelectionTarget } from './runtime-selection'
|
||||
import { CodexAccountIdentity, type ResolvedCodexIdentity } from './codex-account-identity'
|
||||
@@ -102,7 +103,7 @@ export class CodexAccountService {
|
||||
execFileSync(
|
||||
'wsl.exe',
|
||||
['-d', distro, '--exec', 'bash', '-lc', buildEncodedWslBashCommand(script)],
|
||||
{ windowsHide: true, encoding: 'utf-8', timeout: 5000 }
|
||||
{ windowsHide: true, encoding: 'utf-8', timeout: 5000, cwd: resolveWslInteropSpawnCwd() }
|
||||
)
|
||||
)
|
||||
this.managedHomes = new CodexManagedHomeLifecycle(this.managedHomePaths)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../provider-process/provider-process-supervisor'
|
||||
import type { CodexAppServerSpawn } from './codex-app-server-process-tree-kill'
|
||||
import { stopSupervisedChildProcess } from '../provider-process/supervised-child-process-stop'
|
||||
import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory'
|
||||
|
||||
const BACKFILL_RECOVERY_KILL_SITE = 'codex-state-db-backfill-recovery'
|
||||
// Codex drains on its stdin end, as the Codex connection's root-only close asks; one value
|
||||
@@ -36,7 +37,8 @@ export function spawnCodexBackfillRecoveryProcess(
|
||||
{
|
||||
stdio: ['pipe', 'ignore', 'ignore'],
|
||||
windowsHide: true,
|
||||
env: process.env
|
||||
env: process.env,
|
||||
cwd: resolveWslInteropSpawnCwd()
|
||||
}
|
||||
)
|
||||
return { child, supervised: false }
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type CodexTrustGrantBinaryStamp,
|
||||
type CodexTrustGrantLedgerHome
|
||||
} from './codex-trust-grant-ledger'
|
||||
import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory'
|
||||
|
||||
// Why: WSL pays cold-distro and login-shell startup, but stays hard-bounded on launch prep.
|
||||
const WSL_GRANT_TIMEOUT_MS = 30_000
|
||||
@@ -65,6 +66,7 @@ async function buildWslCodexBinaryStamp(
|
||||
const result = await runProcess({
|
||||
program: 'wsl.exe',
|
||||
args: probe.args,
|
||||
cwd: resolveWslInteropSpawnCwd(),
|
||||
timeoutMs: WSL_CODEX_AVAILABILITY_TIMEOUT_MS
|
||||
})
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { posix as pathPosix, win32 as pathWin32 } from 'node:path'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory'
|
||||
|
||||
export type CodexWslRuntimeHookTarget = {
|
||||
runtime?: 'host' | 'wsl'
|
||||
@@ -118,7 +119,12 @@ function scheduleWslLinuxPathCanonicalization(
|
||||
execFile(
|
||||
'wsl.exe',
|
||||
args,
|
||||
{ encoding: 'utf-8', timeout: WSL_CANONICALIZE_TIMEOUT_MS, windowsHide: true },
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: WSL_CANONICALIZE_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
cwd: resolveWslInteropSpawnCwd()
|
||||
},
|
||||
(error, stdout) => {
|
||||
const canonicalPath = stdout.trim()
|
||||
const resolvedPath = !error && canonicalPath.startsWith('/') ? canonicalPath : null
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildWslCapturedLoginShellCommand,
|
||||
buildWslExecArgs
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory'
|
||||
|
||||
export type WslGitReadEnvironment = { gitPath: string; home: string; path: string }
|
||||
|
||||
@@ -92,7 +93,8 @@ function probeWslGitReadEnvironment(distro: string): Promise<ProbeOutcome> {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: PROBE_MAX_BUFFER,
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
windowsHide: true,
|
||||
cwd: resolveWslInteropSpawnCwd()
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
|
||||
@@ -249,28 +249,16 @@ export async function killProfileDaemons(userData: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A full orcad on Windows probes WSL with `wsl.exe` run in its profile, and the WSL
|
||||
* service, outside the test's process tree, holds that directory busy for ~60s after.
|
||||
*/
|
||||
export const WINDOWS_WSL_PROFILE_HOLD_MS = 75_000
|
||||
|
||||
/**
|
||||
* Removes a test root, retrying a late writer's ENOTEMPTY/EBUSY on every platform.
|
||||
* `windowsBusyHoldMs` keeps retrying a Windows EBUSY that long once attempts run out.
|
||||
*/
|
||||
export async function removeTestRoot(root: string, windowsBusyHoldMs = 0): Promise<void> {
|
||||
const holdDeadline = Date.now() + (process.platform === 'win32' ? windowsBusyHoldMs : 0)
|
||||
/** Removes a test root, retrying a late writer's ENOTEMPTY/EBUSY on every platform. */
|
||||
export async function removeTestRoot(root: string): Promise<void> {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
await removeTree(root)
|
||||
return
|
||||
} catch (error) {
|
||||
const code = errorCode(error) ?? ''
|
||||
const held = code === 'EBUSY' && Date.now() < holdDeadline
|
||||
if (
|
||||
(attempt >= REMOVE_ATTEMPTS && !held) ||
|
||||
!['ENOTEMPTY', 'EBUSY', 'EPERM'].includes(code)
|
||||
attempt >= REMOVE_ATTEMPTS ||
|
||||
!['ENOTEMPTY', 'EBUSY', 'EPERM'].includes(errorCode(error) ?? '')
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { removeTree } from '../../shared/windows-transient-lock-removal'
|
||||
import { removeTestRoot } from './orcad-daemon-teardown-fixture'
|
||||
|
||||
vi.mock('../../shared/windows-transient-lock-removal', () => ({ removeTree: vi.fn() }))
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
|
||||
}
|
||||
|
||||
function failTimes(times: number, code: string): void {
|
||||
let calls = 0
|
||||
vi.mocked(removeTree).mockImplementation(async () => {
|
||||
calls += 1
|
||||
if (calls <= times) {
|
||||
throw Object.assign(new Error(code), { code })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function settle(removal: Promise<void>): Promise<unknown> {
|
||||
const outcome = removal.then(
|
||||
() => 'removed',
|
||||
(error: unknown) => error
|
||||
)
|
||||
await vi.runAllTimersAsync()
|
||||
return outcome
|
||||
}
|
||||
|
||||
describe('removeTestRoot Windows busy hold', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(removeTree).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
setPlatform(originalPlatform)
|
||||
})
|
||||
|
||||
it('outlasts an EBUSY hold longer than its attempts within the hold window', async () => {
|
||||
setPlatform('win32')
|
||||
failTimes(40, 'EBUSY')
|
||||
expect(await settle(removeTestRoot('root', 10_000))).toBe('removed')
|
||||
expect(removeTree).toHaveBeenCalledTimes(41)
|
||||
})
|
||||
|
||||
it('still fails an EBUSY that outlives the hold window', async () => {
|
||||
setPlatform('win32')
|
||||
failTimes(Number.POSITIVE_INFINITY, 'EBUSY')
|
||||
expect(await settle(removeTestRoot('root', 1_000))).toMatchObject({ code: 'EBUSY' })
|
||||
})
|
||||
|
||||
it('keeps the attempt bound for ENOTEMPTY, a late writer rather than a held directory', async () => {
|
||||
setPlatform('win32')
|
||||
failTimes(Number.POSITIVE_INFINITY, 'ENOTEMPTY')
|
||||
expect(await settle(removeTestRoot('root', 10_000))).toMatchObject({ code: 'ENOTEMPTY' })
|
||||
expect(removeTree).toHaveBeenCalledTimes(8)
|
||||
})
|
||||
|
||||
it('ignores the hold off Windows', async () => {
|
||||
setPlatform('linux')
|
||||
failTimes(Number.POSITIVE_INFINITY, 'EBUSY')
|
||||
expect(await settle(removeTestRoot('root', 10_000))).toMatchObject({ code: 'EBUSY' })
|
||||
expect(removeTree).toHaveBeenCalledTimes(8)
|
||||
})
|
||||
})
|
||||
@@ -24,8 +24,7 @@ import {
|
||||
killProfileDaemons,
|
||||
recordProfileDaemons,
|
||||
removeTestRoot,
|
||||
waitForProcessExit,
|
||||
WINDOWS_WSL_PROFILE_HOLD_MS
|
||||
waitForProcessExit
|
||||
} from './orcad-daemon-teardown-fixture'
|
||||
|
||||
const slotDir = resolve('out/orcad')
|
||||
@@ -36,7 +35,6 @@ const skip = skipForMissingInputs('artifact', runtime ? [] : ['a Node orcad slot
|
||||
const roots: string[] = []
|
||||
const cleanupDiagnostics = new Set<ServeCleanupDiagnostics>()
|
||||
|
||||
// Past the 60s hook default: daemon cleanup plus the Windows WSL profile hold.
|
||||
afterEach(async () => {
|
||||
const failures: unknown[] = []
|
||||
// Stop every profile before removing any project root it may still use.
|
||||
@@ -61,7 +59,7 @@ afterEach(async () => {
|
||||
const diagnostics = [...cleanupDiagnostics].find((entry) => entry.owns(root))
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
await removeTestRoot(root, WINDOWS_WSL_PROFILE_HOLD_MS)
|
||||
await removeTestRoot(root)
|
||||
roots.splice(roots.indexOf(root), 1)
|
||||
if (diagnostics?.rootRemoved(root)) {
|
||||
cleanupDiagnostics.delete(diagnostics)
|
||||
@@ -76,7 +74,7 @@ afterEach(async () => {
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'Test root removal incomplete')
|
||||
}
|
||||
}, 150_000)
|
||||
})
|
||||
|
||||
/** Without Vitest's markers: daemon-entry.js does not start its server under VITEST. */
|
||||
function serveEnv(userData: string): NodeJS.ProcessEnv {
|
||||
|
||||
@@ -44,6 +44,9 @@ vi.mock('../codex-cli/command', () => ({
|
||||
resolveCodexCommand: resolveCodexCommandMock
|
||||
}))
|
||||
|
||||
vi.mock('../wsl-interop-spawn-directory', () => ({
|
||||
resolveWslInteropSpawnCwd: () => 'C:\\Windows'
|
||||
}))
|
||||
vi.mock('node-pty', () => ({
|
||||
spawn: ptySpawnMock
|
||||
}))
|
||||
@@ -678,7 +681,7 @@ describe('fetchCodexRateLimits', () => {
|
||||
expect(shellCommand).not.toContain('wsl-codex-path')
|
||||
expect(spawnOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringContaining('rate-limit-pty-cwd'),
|
||||
cwd: 'C:\\Windows',
|
||||
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
|
||||
})
|
||||
)
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
resolveHiddenRateLimitPtyCwd
|
||||
} from './hidden-rate-limit-pty-cwd'
|
||||
import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell'
|
||||
import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory'
|
||||
|
||||
const RPC_TIMEOUT_MS = 10_000
|
||||
const WSL_RPC_TIMEOUT_MS = 25_000
|
||||
@@ -96,7 +97,8 @@ async function fetchViaRpc(options?: CodexRateLimitFetchOptions): Promise<Provid
|
||||
const child = spawnProcessOnWorkerThread({
|
||||
program: wslCodex ? wslCodex.command : codexCommand,
|
||||
args: wslCodex ? wslCodex.args : codexArgs,
|
||||
cwd: resolveHiddenRateLimitPtyCwd(),
|
||||
// Why: WSL holds wsl.exe's cwd busy ~60s, so never hand it a folder inside the profile (#29845).
|
||||
cwd: wslCodex ? resolveWslInteropSpawnCwd() : resolveHiddenRateLimitPtyCwd(),
|
||||
env: withCliRuntimeOnPath(codexCommand, {
|
||||
...(wslCodex ? processEnvWithoutCodexHome() : process.env),
|
||||
// WSL probes keep the guest's own proxy env; a Windows proxy address may not resolve there.
|
||||
|
||||
@@ -8,12 +8,8 @@ import {
|
||||
resolveWslInteropSpawnCwd
|
||||
} from './wsl-interop-spawn-directory'
|
||||
|
||||
// Regression coverage for #16463 ("Removing the worktree Orca was launched from
|
||||
// breaks every wsl.exe spawn for the rest of the session"). The WSL command
|
||||
// builders passed `cwd: undefined` meaning "the directory is inside the
|
||||
// command", but CreateProcessW reads NULL as "inherit the parent's" — and the
|
||||
// parent's was a `\\wsl.localhost\...` worktree Linux had just deleted. 1805 of
|
||||
// 1806 git calls then failed `spawn wsl.exe ENOENT` until the app restarted.
|
||||
// #16463: an inherited cwd Linux had deleted broke every later `wsl.exe` spawn.
|
||||
// #29845: naming the profile instead let WSL hold it busy ~60s after Orca exited.
|
||||
|
||||
const createdRoots: string[] = []
|
||||
|
||||
@@ -23,7 +19,15 @@ function makeExistingDirectory(): string {
|
||||
return dir
|
||||
}
|
||||
|
||||
const ENV_KEYS = ['ORCA_USER_DATA_PATH', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH'] as const
|
||||
const ENV_KEYS = [
|
||||
'ORCA_USER_DATA_PATH',
|
||||
'USERPROFILE',
|
||||
'HOMEDRIVE',
|
||||
'HOMEPATH',
|
||||
'SystemRoot',
|
||||
'windir',
|
||||
'SystemDrive'
|
||||
] as const
|
||||
const savedEnv = new Map<string, string | undefined>()
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -50,40 +54,41 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('resolveWslInteropSpawnCwd', () => {
|
||||
it('names the app-owned directory first, so no worktree can be the answer', () => {
|
||||
const userData = makeExistingDirectory()
|
||||
process.env.ORCA_USER_DATA_PATH = userData
|
||||
it('names the system root, never the profile or the user folder', () => {
|
||||
const systemRoot = makeExistingDirectory()
|
||||
process.env.SystemRoot = systemRoot
|
||||
process.env.ORCA_USER_DATA_PATH = makeExistingDirectory()
|
||||
process.env.USERPROFILE = makeExistingDirectory()
|
||||
|
||||
expect(resolveWslInteropSpawnCwd()).toBe(userData)
|
||||
expect(resolveWslInteropSpawnCwd()).toBe(systemRoot)
|
||||
})
|
||||
|
||||
it('never falls back to the profile or user folder when no system directory resolves', () => {
|
||||
const profile = makeExistingDirectory()
|
||||
const userFolder = makeExistingDirectory()
|
||||
process.env.ORCA_USER_DATA_PATH = profile
|
||||
process.env.USERPROFILE = userFolder
|
||||
|
||||
expect([profile, userFolder]).not.toContain(resolveWslInteropSpawnCwd())
|
||||
})
|
||||
|
||||
it('skips a candidate that does not resolve instead of naming it', () => {
|
||||
process.env.ORCA_USER_DATA_PATH = join(tmpdir(), 'orca-wsl-spawn-cwd-never-created')
|
||||
const profile = makeExistingDirectory()
|
||||
process.env.USERPROFILE = profile
|
||||
process.env.SystemRoot = join(tmpdir(), 'orca-wsl-spawn-cwd-never-created')
|
||||
const windir = makeExistingDirectory()
|
||||
process.env.windir = windir
|
||||
|
||||
expect(resolveWslInteropSpawnCwd()).toBe(profile)
|
||||
})
|
||||
|
||||
it('always names some directory rather than letting the spawn inherit one', () => {
|
||||
// Why: inheriting is the failure mode. With no configured candidate at all
|
||||
// the home directory and system root still stand between a spawn and the
|
||||
// parent's cwd.
|
||||
expect(resolveWslInteropSpawnCwd()).toEqual(expect.any(String))
|
||||
expect(resolveWslInteropSpawnCwd()).toBe(windir)
|
||||
})
|
||||
|
||||
it('re-answers after the directory it memoized goes away mid-session', () => {
|
||||
// This is the incident: the chosen directory was valid when the process
|
||||
// started and was deleted underneath it hours later. A memo that is never
|
||||
// re-validated reproduces the original bug one layer up.
|
||||
// A memo that is never re-validated reproduces #16463 one layer up.
|
||||
const doomed = makeExistingDirectory()
|
||||
process.env.ORCA_USER_DATA_PATH = doomed
|
||||
process.env.SystemRoot = doomed
|
||||
const survivor = makeExistingDirectory()
|
||||
expect(resolveWslInteropSpawnCwd()).toBe(doomed)
|
||||
|
||||
rmSync(doomed, { recursive: true, force: true })
|
||||
process.env.ORCA_USER_DATA_PATH = survivor
|
||||
process.env.SystemRoot = survivor
|
||||
|
||||
expect(resolveWslInteropSpawnCwd()).toBe(survivor)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { statSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
/**
|
||||
* A Windows directory that is safe to hand `wsl.exe` as its working directory.
|
||||
@@ -19,6 +19,10 @@ import { homedir } from 'node:os'
|
||||
* git works. It is never the cwd the command runs in: WSL invocations carry
|
||||
* their Linux directory in `git -C`, a `cd` inside `bash -c`, or the `sh -c`
|
||||
* wrapper `withGuestCwd` builds.
|
||||
*
|
||||
* Why a system directory and never the profile or user folder (#29845): WSL
|
||||
* holds this directory busy for about its ~60s idle timeout after `wsl.exe`
|
||||
* exits, so a profile named here cannot be deleted for a minute after Orca exits.
|
||||
*/
|
||||
|
||||
let cachedSpawnCwd: string | null = null
|
||||
@@ -40,22 +44,18 @@ export function resetWslInteropSpawnDirectoryCache(): void {
|
||||
}
|
||||
|
||||
export function resolveWslInteropSpawnCwd(): string | undefined {
|
||||
// Why re-validate: the answer is only useful while it still resolves, and the
|
||||
// user's profile directory can go away on a roaming/mapped-drive host.
|
||||
// Why re-validate: the answer is only useful while it still resolves.
|
||||
if (isExistingDirectory(cachedSpawnCwd)) {
|
||||
return cachedSpawnCwd
|
||||
}
|
||||
const env = process.env
|
||||
// Why this order: an app-owned directory first (it outlives every worktree),
|
||||
// then the user's profile, then the system root as a floor that always exists.
|
||||
// A root is fine here — nothing scans this directory, it is only the value
|
||||
// `CreateProcessW` receives for `lpCurrentDirectory`.
|
||||
const candidates: (string | undefined)[] = [
|
||||
env.ORCA_USER_DATA_PATH,
|
||||
env.USERPROFILE,
|
||||
env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined,
|
||||
homedir(),
|
||||
env.SystemDrive ? `${env.SystemDrive}\\` : 'C:\\'
|
||||
env.SystemRoot,
|
||||
env.windir,
|
||||
env.SystemDrive ? `${env.SystemDrive}\\` : undefined,
|
||||
'C:\\',
|
||||
// Why last: unreachable on a real Windows host; it keeps POSIX test hosts naming a directory.
|
||||
tmpdir()
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (isExistingDirectory(candidate)) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProcessResult } from '@orca/process-host/process-spec'
|
||||
|
||||
@@ -17,11 +20,20 @@ import {
|
||||
listRunningWslHomeDirsAsync
|
||||
} from './wsl'
|
||||
import { filterPathsToRunningWslDistrosAsync } from './wsl-running-path-filter'
|
||||
import { resetWslInteropSpawnDirectoryCache } from './wsl-interop-spawn-directory'
|
||||
import {
|
||||
observeWslTranscriptRunningState,
|
||||
resetWslTranscriptRunningObserverForTests
|
||||
} from './native-chat/wsl-transcript-running-observer'
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
async function withPlatform<T>(value: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
|
||||
const original = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value })
|
||||
@@ -56,6 +68,28 @@ describe('running WSL distro discovery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('runs the startup probe from the system root, never the profile (#29845)', async () => {
|
||||
runProcessMock.mockResolvedValue(exited('Ubuntu\n'))
|
||||
const saved = { SystemRoot: process.env.SystemRoot, profile: process.env.ORCA_USER_DATA_PATH }
|
||||
const systemRoot = mkdtempSync(join(tmpdir(), 'orca-wsl-probe-system-root-'))
|
||||
const profile = mkdtempSync(join(tmpdir(), 'orca-wsl-probe-profile-'))
|
||||
process.env.SystemRoot = systemRoot
|
||||
process.env.ORCA_USER_DATA_PATH = profile
|
||||
resetWslInteropSpawnDirectoryCache()
|
||||
try {
|
||||
await withPlatform('win32', () => listRunningWslDistrosAsync({ requireConfirmed: true }))
|
||||
expect(runProcessMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ args: ['--list', '--running', '--quiet'], cwd: systemRoot })
|
||||
)
|
||||
} finally {
|
||||
restoreEnv('SystemRoot', saved.SystemRoot)
|
||||
restoreEnv('ORCA_USER_DATA_PATH', saved.profile)
|
||||
resetWslInteropSpawnDirectoryCache()
|
||||
rmSync(systemRoot, { recursive: true, force: true })
|
||||
rmSync(profile, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('confirms an empty list when no distro runs', async () => {
|
||||
// Captured on WSL 2.7: with nothing running, `--quiet` exits 0 with empty stdout in both
|
||||
// encodings; only the non-quiet form prints the localized message and exits -1.
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { ProcessResult } from '@orca/process-host/process-spec'
|
||||
const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() }))
|
||||
|
||||
vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock }))
|
||||
vi.mock('./wsl-interop-spawn-directory', () => ({
|
||||
resolveWslInteropSpawnCwd: () => 'C:\\Windows'
|
||||
}))
|
||||
|
||||
function exited(code: number, stderr = ''): ProcessResult {
|
||||
return { code, signal: null, stdout: '', stderr, timedOut: false }
|
||||
@@ -58,7 +61,7 @@ describe('tryDeleteWslUncPath', () => {
|
||||
expect(timeoutMs).toBe(30000)
|
||||
// Why a concrete directory (#16463): this deletes worktrees, so the cwd it
|
||||
// would otherwise inherit is the very directory about to disappear.
|
||||
expect(cwd).toEqual(expect.any(String))
|
||||
expect(cwd).toEqual('C:\\Windows')
|
||||
expect(spawnArgs).toEqual([
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
|
||||
@@ -10,6 +10,9 @@ vi.mock('@orca/process-host', () => ({
|
||||
runProcess: runProcessMock,
|
||||
runProcessSync: runProcessSyncMock
|
||||
}))
|
||||
vi.mock('./wsl-interop-spawn-directory', () => ({
|
||||
resolveWslInteropSpawnCwd: () => 'C:\\Windows'
|
||||
}))
|
||||
|
||||
function exited(stdout: string, code: number | null = 0): ProcessResult {
|
||||
return { code, signal: null, stdout, stderr: '', timedOut: false }
|
||||
@@ -391,7 +394,7 @@ describe('WSL availability cache', () => {
|
||||
program: 'wsl.exe',
|
||||
args: ['--status'],
|
||||
timeoutMs: 5000,
|
||||
cwd: expect.any(String)
|
||||
cwd: 'C:\\Windows'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildWslExecArgs
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
import { resolveWslExecutablePath } from './wsl-executable-path'
|
||||
import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory'
|
||||
|
||||
/**
|
||||
* A distro's login-shell environment, probed once and cached.
|
||||
@@ -91,6 +92,7 @@ async function probeGuestEnvironment(
|
||||
// spawns wsl.exe itself, so without it wsl.exe's own errors arrive UTF-16LE
|
||||
// and the NUL-separated payload below is read through NUL-riddled text.
|
||||
env: { ...process.env, WSL_UTF8: '1' },
|
||||
cwd: resolveWslInteropSpawnCwd(),
|
||||
timeoutMs: Math.min(PROBE_TIMEOUT_MS, budgetMs),
|
||||
maxOutputBytes: PROBE_MAX_OUTPUT_BYTES
|
||||
})
|
||||
|
||||
@@ -2,6 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const runProcessMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock }))
|
||||
vi.mock('../wsl-interop-spawn-directory', () => ({
|
||||
resolveWslInteropSpawnCwd: () => 'C:\\Windows'
|
||||
}))
|
||||
vi.mock('./wsl-executable-path', () => ({
|
||||
resolveWslExecutablePath: () => 'C:\\Windows\\System32\\wsl.exe'
|
||||
}))
|
||||
@@ -244,7 +247,7 @@ describe('guest cwd', () => {
|
||||
// The contract is that the GUEST path never becomes wsl.exe's Windows cwd,
|
||||
// not that there is no cwd: inheriting one is its own bug (#16463).
|
||||
expect(runProcessMock.mock.calls.at(-1)?.[0].cwd).not.toBe('/home/u/repo')
|
||||
expect(runProcessMock.mock.calls.at(-1)?.[0].cwd).toEqual(expect.any(String))
|
||||
expect(runProcessMock.mock.calls.at(-1)?.[0].cwd).toEqual('C:\\Windows')
|
||||
expect(lastArgv()).toContain('/home/u/repo')
|
||||
expect(lastArgv()).toContain('sh')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user