diff --git a/config/main-sync-spawn-baseline.txt b/config/main-sync-spawn-baseline.txt index 645102b49aa..4d5bc37c1ff 100644 --- a/config/main-sync-spawn-baseline.txt +++ b/config/main-sync-spawn-baseline.txt @@ -53,7 +53,6 @@ 1 src/main/plugins/plugin-private-marketplace-ssh-shim.cjs 2 src/main/plugins/plugin-secrets-store.ts 2 src/main/plugins/plugin-storage-store.ts -1 src/main/providers/working-directory-validation.ts 1 src/main/pty/posix-pty-foreground-group.ts 1 src/main/pty/posix-pty-process-groups.ts 1 src/main/pwsh.ts @@ -72,7 +71,7 @@ 1 src/main/startup/linux-secret-service-probe.ts 2 src/main/win32-utils.ts 1 src/main/worktree-runner-script.ts -4 src/main/wsl.ts +3 src/main/wsl.ts 3 src/main/zcode/zcode-plan-api-key-store.ts 2 src/main/zsh-startup-hook-pty-harness.ts 1 src/shared/bounded-secure-json-file.ts diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 14c15704fd9..9fc5dbfa94b 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -1,6 +1,10 @@ import { withFreshOmpLaunch } from '../../shared/omp-fresh-launch' import { describe, expect, it, vi } from 'vitest' -import { piBuildPtyEnvMock, spawnMock } from './pty-ipc-mock-registry' +import { + piBuildPtyEnvMock, + spawnMock, + wslUncDirectoryExistsAsyncMock +} from './pty-ipc-mock-registry' import { TEST_CODEX_HOME } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { LocalPtyProvider } from '../providers/local-pty-provider' @@ -307,6 +311,7 @@ describe('registerPtyHandlers', () => { }) })) __resetPersistedWindowsPathCacheForTests() + wslUncDirectoryExistsAsyncMock.mockResolvedValue(true) try { const provider = new LocalPtyProvider({ diff --git a/src/main/ipc/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup-activity.ts index c7e4cc1fcdc..83638aeacdd 100644 --- a/src/main/ipc/workspace-cleanup-activity.ts +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -96,7 +96,7 @@ async function resolveWorkspaceCleanupActivityAt( } // Why: best-effort only. Win32 stat over \\wsl.localhost (9P) can falsely -// report ENOENT (see wslUncDirectoryExists), so a failed stat degrades to the +// report ENOENT (see wslUncDirectoryExistsAsync), so a failed stat degrades to the // persisted activity timestamp instead of blocking or mislabeling the row. async function getNewestLocalWorktreeActivityAt( worktreePath: string, diff --git a/src/main/providers/local-pty-launch-plan.ts b/src/main/providers/local-pty-launch-plan.ts index 4016e3b5c80..f82dd52e869 100644 --- a/src/main/providers/local-pty-launch-plan.ts +++ b/src/main/providers/local-pty-launch-plan.ts @@ -10,7 +10,7 @@ import { } from './local-pty-launch-helpers' import type { LocalPtyProviderOptions } from './local-pty-provider-types' import type { getShellLaunchConfig } from './local-pty-shell-ready' -import { ensureNodePtySpawnHelperExecutable, validateWorkingDirectory } from './local-pty-utils' +import { ensureNodePtySpawnHelperExecutable } from './local-pty-utils' import { resolveEffectiveWindowsPowerShell, shouldProbeWindowsPowerShellAvailability, @@ -77,9 +77,6 @@ function finalizeLocalPtyLaunchPlan( } ): LocalPtyLaunchPlan { ensureNodePtySpawnHelperExecutable() - if (seed.args.prevalidatedCwd !== shell.validationCwd) { - validateWorkingDirectory(shell.validationCwd) - } const isWslShell = Boolean(seed.wslInfo) || pathWin32.basename(shell.shellPath).toLowerCase() === 'wsl.exe' return { diff --git a/src/main/providers/local-pty-provider-foreground-process.test.ts b/src/main/providers/local-pty-provider-foreground-process.test.ts index b9e65af4e92..1d593e358f0 100644 --- a/src/main/providers/local-pty-provider-foreground-process.test.ts +++ b/src/main/providers/local-pty-provider-foreground-process.test.ts @@ -13,7 +13,7 @@ const { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), @@ -27,7 +27,7 @@ const { readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), createShellPromptReadinessProbeMock: vi.fn() })) @@ -107,7 +107,7 @@ vi.mock('../wsl', () => ({ isWslAvailableAsync: () => isWslAvailableAsyncMock(), // Why: WSL worktree validation now asks the distro; these tests use WSL UNC // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) })) vi.mock('../shell-prompt-readiness-probe', () => ({ @@ -141,7 +141,7 @@ describe('LocalPtyProvider', () => { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock }) diff --git a/src/main/providers/local-pty-provider-io-events.test.ts b/src/main/providers/local-pty-provider-io-events.test.ts index abc1c5dc575..cb3ad3e3f0f 100644 --- a/src/main/providers/local-pty-provider-io-events.test.ts +++ b/src/main/providers/local-pty-provider-io-events.test.ts @@ -13,7 +13,7 @@ const { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), @@ -27,7 +27,7 @@ const { readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), createShellPromptReadinessProbeMock: vi.fn() })) @@ -107,7 +107,7 @@ vi.mock('../wsl', () => ({ isWslAvailableAsync: () => isWslAvailableAsyncMock(), // Why: WSL worktree validation now asks the distro; these tests use WSL UNC // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) })) vi.mock('../shell-prompt-readiness-probe', () => ({ @@ -141,7 +141,7 @@ describe('LocalPtyProvider', () => { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock }) diff --git a/src/main/providers/local-pty-provider-session-inventory.test.ts b/src/main/providers/local-pty-provider-session-inventory.test.ts index 80269558122..9acd241f330 100644 --- a/src/main/providers/local-pty-provider-session-inventory.test.ts +++ b/src/main/providers/local-pty-provider-session-inventory.test.ts @@ -13,7 +13,7 @@ const { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), @@ -27,7 +27,7 @@ const { readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), createShellPromptReadinessProbeMock: vi.fn() })) @@ -107,7 +107,7 @@ vi.mock('../wsl', () => ({ isWslAvailableAsync: () => isWslAvailableAsyncMock(), // Why: WSL worktree validation now asks the distro; these tests use WSL UNC // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) })) vi.mock('../shell-prompt-readiness-probe', () => ({ @@ -141,7 +141,7 @@ describe('LocalPtyProvider', () => { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock }) diff --git a/src/main/providers/local-pty-provider-shell-readiness.test.ts b/src/main/providers/local-pty-provider-shell-readiness.test.ts index 13851cc4020..875a3430418 100644 --- a/src/main/providers/local-pty-provider-shell-readiness.test.ts +++ b/src/main/providers/local-pty-provider-shell-readiness.test.ts @@ -13,7 +13,7 @@ const { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), @@ -27,7 +27,7 @@ const { readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), createShellPromptReadinessProbeMock: vi.fn() })) @@ -107,7 +107,7 @@ vi.mock('../wsl', () => ({ isWslAvailableAsync: () => isWslAvailableAsyncMock(), // Why: WSL worktree validation now asks the distro; these tests use WSL UNC // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) })) vi.mock('../shell-prompt-readiness-probe', () => ({ @@ -142,7 +142,7 @@ describe('LocalPtyProvider', () => { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock }) diff --git a/src/main/providers/local-pty-provider-shutdown.test.ts b/src/main/providers/local-pty-provider-shutdown.test.ts index 950a7e9cbae..a3b4021411b 100644 --- a/src/main/providers/local-pty-provider-shutdown.test.ts +++ b/src/main/providers/local-pty-provider-shutdown.test.ts @@ -13,7 +13,7 @@ const { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), @@ -27,7 +27,7 @@ const { readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), createShellPromptReadinessProbeMock: vi.fn() })) @@ -107,7 +107,7 @@ vi.mock('../wsl', () => ({ isWslAvailableAsync: () => isWslAvailableAsyncMock(), // Why: WSL worktree validation now asks the distro; these tests use WSL UNC // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) })) vi.mock('../shell-prompt-readiness-probe', () => ({ @@ -146,7 +146,7 @@ describe('LocalPtyProvider', () => { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock }) diff --git a/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts b/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts index de793413807..4758d901c70 100644 --- a/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts +++ b/src/main/providers/local-pty-provider-spawn-cwd-safety.test.ts @@ -13,7 +13,7 @@ const { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), @@ -27,7 +27,7 @@ const { readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), createShellPromptReadinessProbeMock: vi.fn() })) @@ -107,7 +107,7 @@ vi.mock('../wsl', () => ({ isWslAvailableAsync: () => isWslAvailableAsyncMock(), // Why: WSL worktree validation now asks the distro; these tests use WSL UNC // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) })) vi.mock('../shell-prompt-readiness-probe', () => ({ @@ -142,7 +142,7 @@ describe('LocalPtyProvider', () => { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock }) diff --git a/src/main/providers/local-pty-provider-spawn-env.test.ts b/src/main/providers/local-pty-provider-spawn-env.test.ts index daf6286b56d..d3ef27088ef 100644 --- a/src/main/providers/local-pty-provider-spawn-env.test.ts +++ b/src/main/providers/local-pty-provider-spawn-env.test.ts @@ -15,7 +15,7 @@ const { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), @@ -29,7 +29,7 @@ const { readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), createShellPromptReadinessProbeMock: vi.fn() })) @@ -109,7 +109,7 @@ vi.mock('../wsl', () => ({ isWslAvailableAsync: () => isWslAvailableAsyncMock(), // Why: WSL worktree validation now asks the distro; these tests use WSL UNC // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) })) vi.mock('../shell-prompt-readiness-probe', () => ({ @@ -143,7 +143,7 @@ describe('LocalPtyProvider', () => { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock }) diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index d283cc188b8..f0dd4870294 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -13,7 +13,7 @@ const { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), @@ -27,7 +27,7 @@ const { readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), createShellPromptReadinessProbeMock: vi.fn() })) @@ -107,7 +107,7 @@ vi.mock('../wsl', () => ({ isWslAvailableAsync: () => isWslAvailableAsyncMock(), // Why: WSL worktree validation now asks the distro; these tests use WSL UNC // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) })) vi.mock('../shell-prompt-readiness-probe', () => ({ @@ -148,7 +148,7 @@ describe('LocalPtyProvider', () => { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock }) diff --git a/src/main/providers/local-pty-provider-test-harness.ts b/src/main/providers/local-pty-provider-test-harness.ts index cf95ae1d962..195b715d409 100644 --- a/src/main/providers/local-pty-provider-test-harness.ts +++ b/src/main/providers/local-pty-provider-test-harness.ts @@ -26,7 +26,7 @@ export type LocalPtyProviderMocks = { readWindowsPtyJobProcessIdsMock: Mock killWithDescendantSweepMock: Mock isWslAvailableAsyncMock: Mock - wslUncDirectoryExistsMock: Mock + wslUncDirectoryExistsAsyncMock: Mock createShellPromptReadinessProbeMock: Mock } @@ -99,8 +99,8 @@ export function applyLocalPtyProviderMockDefaults(mocks: LocalPtyProviderMocks): mocks.readWindowsPtyJobProcessIdsMock.mockReturnValue(null) mocks.isWslAvailableAsyncMock.mockReset() mocks.isWslAvailableAsyncMock.mockResolvedValue(true) - mocks.wslUncDirectoryExistsMock.mockReset() - mocks.wslUncDirectoryExistsMock.mockReturnValue(true) + mocks.wslUncDirectoryExistsAsyncMock.mockReset() + mocks.wslUncDirectoryExistsAsyncMock.mockResolvedValue(true) mocks.createShellPromptReadinessProbeMock.mockReset() } diff --git a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts index 0f985f56151..ace8f935bb2 100644 --- a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts +++ b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts @@ -13,7 +13,7 @@ const { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), @@ -27,7 +27,7 @@ const { readWindowsPtyJobProcessIdsMock: vi.fn(), killWithDescendantSweepMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), - wslUncDirectoryExistsMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), createShellPromptReadinessProbeMock: vi.fn() })) @@ -108,7 +108,7 @@ vi.mock('../wsl', () => ({ isWslAvailableAsync: () => isWslAvailableAsyncMock(), // Why: WSL worktree validation now asks the distro; these tests use WSL UNC // cwds that are meant to exist, so report them present without spawning wsl.exe. - wslUncDirectoryExists: (...args: unknown[]) => wslUncDirectoryExistsMock(...args) + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) })) vi.mock('../shell-prompt-readiness-probe', () => ({ @@ -143,7 +143,7 @@ describe('LocalPtyProvider', () => { readWindowsPtyJobProcessIdsMock, killWithDescendantSweepMock, isWslAvailableAsyncMock, - wslUncDirectoryExistsMock, + wslUncDirectoryExistsAsyncMock, createShellPromptReadinessProbeMock }) @@ -358,7 +358,7 @@ describe('LocalPtyProvider', () => { expect.stringContaining(`cd '${cwd}'`) ]) expect(spawnCall[2].cwd).not.toBe(cwd) - expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() } ) @@ -375,7 +375,50 @@ describe('LocalPtyProvider', () => { terminalWindowsWslDistro: 'Debian' }) - expect(wslUncDirectoryExistsMock).toHaveBeenCalledWith('\\\\wsl.localhost\\Debian\\a') + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith('\\\\wsl.localhost\\Debian\\a') + }) + + it('rejects a WSL cwd the distro reports missing without spawning a shell', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) + const spawnCallsBefore = spawnMock.mock.calls.length + + await expect( + provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Debian\\home\\jin\\gone' + }) + ).rejects.toThrow(/does not exist/) + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith( + '\\\\wsl.localhost\\Debian\\home\\jin\\gone' + ) + expect(spawnMock.mock.calls).toHaveLength(spawnCallsBefore) + }) + + it('rejects a canceled spawn while its WSL cwd probe is still pending', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const probeStarted = Promise.withResolvers() + wslUncDirectoryExistsAsyncMock.mockImplementation(() => { + probeStarted.resolve() + return new Promise(() => {}) + }) + const spawnCallsBefore = spawnMock.mock.calls.length + + const spawn = provider.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Stalled\\home\\jin\\repo', + sessionId: 'stalled-wsl-cwd-session' + }) + const canceledSpawn = expect(spawn).rejects.toThrow( + 'PTY spawn canceled: stalled-wsl-cwd-session' + ) + await probeStarted.promise + await provider.shutdown('stalled-wsl-cwd-session', { immediate: true }) + + await canceledSpawn + expect(spawnMock.mock.calls).toHaveLength(spawnCallsBefore) }) it('resolves and persists the default distro for Windows cwd WSL terminals', async () => { diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index 72b52ffe19a..619e8aff61b 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -9,6 +9,7 @@ import { finalizeLocalPtySpawnEnvironment } from './local-pty-finalize-environme import { normalizeLocalCallerSessionId } from './local-pty-launch-helpers' import { createLocalPtyLaunchPlan, DeferredLocalPtyLaunchPlan } from './local-pty-launch-plan' import type { LocalPtyProviderOptions } from './local-pty-provider-types' +import { validateWorkingDirectory, WorkingDirectoryValidationAbortedError } from './local-pty-utils' import { allocatePtyId, ptyShutdownOperations } from './local-pty-provider-state' import { activateLocalPtySession } from './local-pty-session-activation' import { @@ -68,6 +69,18 @@ export async function spawnLocalPty( plan = plan.finish(available) } throwIfCanceled() + if (args.prevalidatedCwd !== plan.validationCwd) { + try { + await validateWorkingDirectory(plan.validationCwd, cancellation) + } catch (error) { + // Why: an abandoned probe keeps running for other waiters; report the spawn's own cancel. + if (error instanceof WorkingDirectoryValidationAbortedError) { + throwIfCanceled() + } + throw error + } + throwIfCanceled() + } const envResult = buildLocalPtySpawnEnvironment({ id, spawn: args, diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts index f7dfbb05983..e0de4d591ab 100644 --- a/src/main/providers/local-pty-utils.test.ts +++ b/src/main/providers/local-pty-utils.test.ts @@ -1,15 +1,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as fs from 'node:fs' +import type * as FsPromises from 'node:fs/promises' import type { Stats } from 'node:fs' -const { existsSyncMock, statSyncMock, accessSyncMock, wslUncDirectoryExistsMock, wrapSpawnMock } = - vi.hoisted(() => ({ - existsSyncMock: vi.fn(), - statSyncMock: vi.fn(), - accessSyncMock: vi.fn(), - wslUncDirectoryExistsMock: vi.fn(), - wrapSpawnMock: vi.fn() - })) +const { + existsSyncMock, + statSyncMock, + statMock, + accessSyncMock, + wslUncDirectoryExistsAsyncMock, + wrapSpawnMock +} = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + statSyncMock: vi.fn(), + statMock: vi.fn(), + accessSyncMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), + wrapSpawnMock: vi.fn() +})) vi.mock('fs', async (importOriginal) => { const actual = await importOriginal() @@ -21,12 +29,17 @@ vi.mock('fs', async (importOriginal) => { } }) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: statMock +})) + function dirStats(isDirectory: boolean): Stats { return { isDirectory: () => isDirectory } as Stats } vi.mock('../wsl', () => ({ - wslUncDirectoryExists: wslUncDirectoryExistsMock + wslUncDirectoryExistsAsync: wslUncDirectoryExistsAsyncMock })) vi.mock('./macos-tcc-login-shell', () => ({ @@ -39,6 +52,7 @@ import { spawnShellWithFallback, validateWorkingDirectory } from './local-pty-utils' +import { _resetWorkingDirectoryValidationStateForTest } from './working-directory-validation' import { getFishXdgDataDirsLaunchEnv } from '../fish-xdg-data-dirs-handoff' const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' @@ -48,61 +62,81 @@ describe('validateWorkingDirectory', () => { beforeEach(() => { existsSyncMock.mockReset() statSyncMock.mockReset() - wslUncDirectoryExistsMock.mockReset() + statMock.mockReset() + wslUncDirectoryExistsAsyncMock.mockReset() + _resetWorkingDirectoryValidationStateForTest() }) afterEach(() => { vi.restoreAllMocks() }) - it('accepts a WSL UNC worktree that exists inside the distro even when fs.statSync would fail', () => { + it('accepts a WSL UNC worktree that exists inside the distro even when fs.statSync would fail', async () => { // Why: the Win32 9P stat is the exact path that falsely reported ENOENT and // broke opening WSL worktrees. The distro answer must win. - wslUncDirectoryExistsMock.mockReturnValue(true) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(true) existsSyncMock.mockReturnValue(false) - expect(() => validateWorkingDirectory(WSL_UNC_DIR)).not.toThrow() - expect(wslUncDirectoryExistsMock).toHaveBeenCalledWith(WSL_UNC_DIR) + await expect(validateWorkingDirectory(WSL_UNC_DIR)).resolves.toBeUndefined() + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(WSL_UNC_DIR) // The fs fallback must not run when the distro confirmed existence. expect(existsSyncMock).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() }) - it('rejects a WSL UNC worktree that does not exist inside the distro', () => { - wslUncDirectoryExistsMock.mockReturnValue(false) + it('returns to the event loop while the distro probe is still running', async () => { + const probe = Promise.withResolvers() + wslUncDirectoryExistsAsyncMock.mockReturnValue(probe.promise) + let settled = false - expect(() => validateWorkingDirectory(WSL_UNC_DIR)).toThrow(/does not exist/) + const validation = validateWorkingDirectory(WSL_UNC_DIR).finally(() => { + settled = true + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(settled).toBe(false) + + probe.resolve(true) + await validation + expect(settled).toBe(true) + }) + + it('rejects a WSL UNC worktree that does not exist inside the distro', async () => { + wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) + + await expect(validateWorkingDirectory(WSL_UNC_DIR)).rejects.toThrow(/does not exist/) + expect(existsSyncMock).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() + }) + + it('falls back to the fs check when the distro answer is inconclusive', async () => { + wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) + statMock.mockResolvedValue(dirStats(true)) + + await expect(validateWorkingDirectory(WSL_UNC_DIR)).resolves.toBeUndefined() + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith(WSL_UNC_DIR) + expect(statMock).toHaveBeenCalledWith(WSL_UNC_DIR) expect(existsSyncMock).not.toHaveBeenCalled() }) - it('falls back to the fs check when the distro answer is inconclusive', () => { - wslUncDirectoryExistsMock.mockReturnValue(null) + it('validates native Windows paths via fs without consulting the distro', async () => { existsSyncMock.mockReturnValue(true) statSyncMock.mockReturnValue(dirStats(true)) - expect(() => validateWorkingDirectory(WSL_UNC_DIR)).not.toThrow() - expect(wslUncDirectoryExistsMock).toHaveBeenCalledWith(WSL_UNC_DIR) - expect(existsSyncMock).toHaveBeenCalledWith(WSL_UNC_DIR) - }) - - it('validates native Windows paths via fs without consulting the distro', () => { - existsSyncMock.mockReturnValue(true) - statSyncMock.mockReturnValue(dirStats(true)) - - expect(() => validateWorkingDirectory(NATIVE_DIR)).not.toThrow() - expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() + await expect(validateWorkingDirectory(NATIVE_DIR)).resolves.toBeUndefined() + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() expect(existsSyncMock).toHaveBeenCalledWith(NATIVE_DIR) }) - it('rejects a missing native Windows path', () => { + it('rejects a missing native Windows path', async () => { existsSyncMock.mockReturnValue(false) const previousVersion = process.env.ORCA_APP_VERSION process.env.ORCA_APP_VERSION = '1.4.178-test' try { - expect(() => validateWorkingDirectory(NATIVE_DIR)).toThrow( + await expect(validateWorkingDirectory(NATIVE_DIR)).rejects.toThrow( /does not exist.*orca: 1\.4\.178-test/ ) - expect(wslUncDirectoryExistsMock).not.toHaveBeenCalled() + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() } finally { if (previousVersion === undefined) { delete process.env.ORCA_APP_VERSION @@ -112,11 +146,11 @@ describe('validateWorkingDirectory', () => { } }) - it('rejects a native Windows path that exists but is not a directory', () => { + it('rejects a native Windows path that exists but is not a directory', async () => { existsSyncMock.mockReturnValue(true) statSyncMock.mockReturnValue(dirStats(false)) - expect(() => validateWorkingDirectory(NATIVE_DIR)).toThrow(/is not a directory/) + await expect(validateWorkingDirectory(NATIVE_DIR)).rejects.toThrow(/is not a directory/) }) }) diff --git a/src/main/providers/working-directory-validation-retention.test.ts b/src/main/providers/working-directory-validation-retention.test.ts index ab711bdadf3..8cf6a5e24e2 100644 --- a/src/main/providers/working-directory-validation-retention.test.ts +++ b/src/main/providers/working-directory-validation-retention.test.ts @@ -6,9 +6,6 @@ const { stat } = vi.hoisted(() => ({ })) vi.mock('node:fs/promises', () => ({ stat })) vi.mock('../wsl', () => ({ - wslUncDirectoryExists: () => { - throw new Error('Unexpected WSL probe') - }, wslUncDirectoryExistsAsync: () => { throw new Error('Unexpected WSL probe') } diff --git a/src/main/providers/working-directory-validation.ts b/src/main/providers/working-directory-validation.ts index 7f9ec89a9e8..ec688aa6918 100644 --- a/src/main/providers/working-directory-validation.ts +++ b/src/main/providers/working-directory-validation.ts @@ -6,7 +6,7 @@ import { existsSync, statSync } from 'node:fs' import { stat } from 'node:fs/promises' import { release } from 'node:os' import { isWslUncPath, uncRouteKey } from '../../shared/wsl-paths' -import { wslUncDirectoryExists, wslUncDirectoryExistsAsync } from '../wsl' +import { wslUncDirectoryExistsAsync } from '../wsl' import { PrioritySemaphore } from '../../shared/priority-semaphore' const pendingWorkingDirectoryValidations = new Map>() @@ -89,20 +89,13 @@ function throwMissingWorkingDirectory(cwd: string): never { /** * Validate that a working directory exists and is a directory. - * Throws a descriptive Error if not. + * Rejects with a descriptive Error if not. */ -export function validateWorkingDirectory(cwd: string): void { - // Why: Win32 fs.statSync against the WSL 9P share (\\wsl.localhost\...) can - // falsely report ENOENT for directories that exist on the Linux side. Ask the - // distro itself; only fall back to the fs check when wsl.exe is inconclusive. +export async function validateWorkingDirectory(cwd: string, signal?: AbortSignal): Promise { + // Why: the distro probe spawns wsl.exe (~70 ms warm, 300+ ms for a stopped + // distro), so it must never run synchronously on the main process. if (isWslUncPath(cwd)) { - const existsInDistro = wslUncDirectoryExists(cwd) - if (existsInDistro === false) { - throwMissingWorkingDirectory(cwd) - } - if (existsInDistro === true) { - return - } + return validateWorkingDirectoryAsync(cwd, { signal }) } if (!existsSync(cwd)) { @@ -155,6 +148,9 @@ function validateWorkingDirectoryUncached(cwd: string): Promise { } async function probeWorkingDirectory(cwd: string): Promise { + // Why: Win32 stat against the WSL 9P share (\\wsl.localhost\...) can falsely + // report ENOENT for directories that exist on the Linux side. Ask the distro + // itself; only fall back to the fs check when wsl.exe is inconclusive. if (isWslUncPath(cwd)) { const existsInDistro = await wslUncDirectoryExistsAsync(cwd) if (existsInDistro === false) { diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index 9834d020cf0..2123075c2a1 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -41,7 +41,6 @@ import { parseWslPath, toLinuxPath, toWindowsWslPath, - wslUncDirectoryExists, wslUncDirectoryExistsAsync } from './wsl' @@ -608,60 +607,6 @@ describe('wsl path helpers', () => { }) }) -describe('wslUncDirectoryExists', () => { - afterEach(() => { - runProcessSyncMock.mockReset() - }) - - it('returns true when the distro reports the directory exists', () => { - runProcessSyncMock.mockReturnValue(exited('__ORCA_DIRECTORY_EXISTS__')) - const result = withPlatform('win32', () => - wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') - ) - expect(result).toBe(true) - expect(runProcessSyncMock).toHaveBeenCalledWith( - expect.objectContaining({ - program: 'wsl.exe', - timeoutMs: 5000, - args: [ - '-d', - 'Ubuntu', - '--exec', - 'sh', - '-c', - expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), - 'sh', - '/home/jin/repo' - ] - }) - ) - }) - - it('returns false when the guest reports the directory missing', () => { - runProcessSyncMock.mockReturnValue(exited('__ORCA_DIRECTORY_MISSING__')) - const result = withPlatform('win32', () => - wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') - ) - expect(result).toBe(false) - }) - - it('returns null when wsl.exe or the distro is unavailable', () => { - runProcessSyncMock.mockReturnValue(exited('', 4294967295)) - const result = withPlatform('win32', () => - wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') - ) - expect(result).toBeNull() - }) - - it('returns null for non-WSL paths and off Windows', () => { - expect(withPlatform('win32', () => wslUncDirectoryExists('C:\\Users\\jin\\repo'))).toBeNull() - expect( - withPlatform('linux', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin')) - ).toBeNull() - expect(runProcessSyncMock).not.toHaveBeenCalled() - }) -}) - describe('wslUncDirectoryExistsAsync', () => { afterEach(() => { runProcessMock.mockReset() diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 45a288fdd78..9bd1a9dca14 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -55,7 +55,7 @@ export function isWslPath(path: string): boolean { * Check whether a WSL UNC working directory exists by testing it inside the * distro itself, returning null when the answer can't be determined. * - * Why: Win32 fs.statSync against the WSL 9P filesystem (\\wsl.localhost\...) + * Why: Win32 fs.stat against the WSL 9P filesystem (\\wsl.localhost\...) * is unreliable for repos that live on the WSL side — it can report ENOENT for * directories that exist, which made opening a WSL worktree fail with * "Working directory ... does not exist". The guest marker probe asks the @@ -63,24 +63,6 @@ export function isWslPath(path: string): boolean { * than false) when wsl.exe is unavailable or errors so callers can fall back to * the fs check instead of falsely rejecting a valid directory. */ -export function wslUncDirectoryExists(uncPath: string): boolean | null { - if (process.platform !== 'win32') { - return null - } - const info = parseWslUncPath(uncPath) - if (!info) { - return null - } - try { - const stdout = wslExeStdout( - runProcessSync(wslExeProcessSpec(getWslDirectoryProbeArgs(info), WSL_PROBE_TIMEOUT_MS)) - ) - return parseWslDirectoryProbeOutput(stdout) - } catch { - return null - } -} - export function wslUncDirectoryExistsAsync(uncPath: string): Promise { if (process.platform !== 'win32') { return Promise.resolve(null) diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index 76217c297bc..0a9b3fb13af 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -177,7 +177,7 @@ describe('wsl login shell command helpers', () => { ['awk field reference', ['sh', '-c', `echo 'a b' | awk '{print $2}'`], 'b\n'], ['literal escaped dollar', ['sh', '-c', `printf '[%s]' "\\$HOME"`], '[$HOME]'], ['single-quoted dollar', ['sh', '-c', `printf '[%s]' '$PATH'`], '[$PATH]'], - // The shape wslUncDirectoryExists uses: `--` blanked $1, so every existing + // The shape wslUncDirectoryExistsAsync uses: `--` blanked $1, so every existing // directory probed as missing. ['positional argument', ['sh', '-c', 'printf "[%s]" "$1"', 'sh', 'ARG'], '[ARG]'], ['shell local', ['sh', '-c', 'x=hi; printf "[%s]" "$x"'], '[hi]']