From 4baacca5c0be8f22ed19ced5aa3e30aca96e705a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:54:54 -0700 Subject: [PATCH] fix(startup): shell PATH probe falls back and retries when the shell cannot launch (#30204) * fix(startup): shell PATH probe falls back and retries when the shell cannot launch * chore: drop hydrate-shell-path from the process-host direct-import baseline --- .../process-host-direct-import-baseline.txt | 1 - src/main/startup/hydrate-shell-path.test.ts | 29 ++++++++++++ src/main/startup/hydrate-shell-path.ts | 46 +++++++++++++------ .../hydrate-shell-path.windows.test.ts | 35 ++++++++++++++ 4 files changed, 95 insertions(+), 16 deletions(-) diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index 9844b3d07e8..d6ae6c998f2 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -108,7 +108,6 @@ src/main/ssh/system-ssh-file-transfer.ts src/main/ssh/system-ssh-forward-process.ts src/main/ssh/system-ssh-operation-lifecycle.ts src/main/startup/ensure-virtual-display.ts -src/main/startup/hydrate-shell-path.ts src/main/windows-process-tree-kill.ts src/main/zsh-startup-hook-pty-harness.ts src/shared/ephemeral-vm-recipe-process.ts diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index f09b965486f..54f741e8c75 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -115,6 +115,35 @@ describe('hydrateShellPath', () => { expect(successfulSpawner).toHaveBeenCalledWith('/bin/bash') }) + it('re-probes after a rejected hydration instead of serving the cached rejection', async () => { + const spawner = vi.fn() + spawner + .mockRejectedValueOnce(new Error('profile failed')) + .mockResolvedValueOnce({ segments: ['/retried'], ok: true, failureReason: 'none' }) + + await expect(hydrateShellPath({ shellOverride: '/bin/zsh', spawner })).rejects.toThrow( + 'profile failed' + ) + await expect(hydrateShellPath({ shellOverride: '/bin/zsh', spawner })).resolves.toEqual({ + segments: ['/retried'], + ok: true, + failureReason: 'none' + }) + expect(spawner).toHaveBeenCalledTimes(2) + }) + + it('reports spawn_error instead of rejecting when spawn throws synchronously', async () => { + spawnMock.mockImplementation(() => { + throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES' }) + }) + + await expect(hydrateShellPath({ shellOverride: '/bin/zsh', force: true })).resolves.toEqual({ + segments: [], + ok: false, + failureReason: 'spawn_error' + }) + }) + it('returns failureReason:no_shell when no shell is available (Windows path)', async () => { const result = await hydrateShellPath({ shellOverride: null, diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 4934425322b..9e8bbd866df 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -1,5 +1,6 @@ -import { spawn } from 'node:child_process' +import type { SpawnedProcess } from '@orca/process-host/process-spec' import { delimiter, win32 as pathWin32 } from 'node:path' +import { spawnProcess } from '@orca/process-host' import type { ShellHydrationFailureReason } from '../../shared/shell-path-hydration-types' import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' import { SHELL_PATH_DELIMITER as DELIMITER, shellPathProbe } from './shell-path-probe' @@ -188,23 +189,31 @@ function spawnShellAndReadPath(shell: string): Promise { let stdout = '' let timer: ReturnType | null = null - const child = spawn(shell, probe.args, { - // Why: inherit current env so the shell sees the same baseline, then let - // it layer its own rc files on top. Do NOT forward stdio — some shells - // (oh-my-zsh setups, powerlevel10k) print a lot to stderr on startup, - // and we don't want that in Orca's console. - env: shellProbeEnv(), - stdio: ['ignore', 'pipe', 'ignore'], - detached: false, - windowsHide: true - }) + let child: SpawnedProcess + try { + child = spawnProcess({ + program: shell, + args: probe.args, + // Why: inherit current env so the shell sees the same baseline, then let + // it layer its own rc files on top. Do NOT forward stdio — some shells + // (oh-my-zsh setups, powerlevel10k) print a lot to stderr on startup, + // and we don't want that in Orca's console. + env: shellProbeEnv(), + stdio: ['ignore', 'pipe', 'ignore'], + detached: false + }) + } catch { + // Why: a synchronous throw (EPERM from a Store pwsh.exe) must reach the fallback shell. + resolve({ segments: [], ok: false, failureReason: 'spawn_error' }) + return + } const cleanup = (): void => { if (timer) { clearTimeout(timer) timer = null } - child.stdout.off('data', onStdoutData) + child.stdout?.off('data', onStdoutData) child.off('error', onError) child.off('close', onClose) } @@ -246,7 +255,7 @@ function spawnShellAndReadPath(shell: string): Promise { finish({ segments, ok: true, failureReason: 'none' }) } - child.stdout.on('data', onStdoutData) + child.stdout?.on('data', onStdoutData) child.on('error', onError) child.on('close', onClose) }) @@ -295,7 +304,7 @@ export function hydrateShellPath(options: HydrateOptions = {}): Promise undefined, () => undefined ) - cached = probe.then((result) => { + const hydration = probe.then((result) => { if ( platform === 'win32' && options.shellOverride === undefined && @@ -305,7 +314,14 @@ export function hydrateShellPath(options: HydrateOptions = {}): Promise { + if (cached === hydration) { + cached = null + } + }) + return hydration } export function configureWindowsShellPathHydration( diff --git a/src/main/startup/hydrate-shell-path.windows.test.ts b/src/main/startup/hydrate-shell-path.windows.test.ts index ee1957c8a42..681890115d4 100644 --- a/src/main/startup/hydrate-shell-path.windows.test.ts +++ b/src/main/startup/hydrate-shell-path.windows.test.ts @@ -108,6 +108,41 @@ describe('Windows shell PATH hydration', () => { expect(spawner.mock.calls).toEqual([['pwsh.exe'], ['powershell.exe']]) }) + it('falls back to Windows PowerShell when pwsh throws synchronously at spawn', async () => { + const proc = createMockShellProcess() + spawnMock + .mockImplementationOnce(() => { + throw Object.assign(new Error('spawn EPERM'), { code: 'EPERM' }) + }) + .mockReturnValueOnce(proc) + configureWindowsShellPathHydration('pwsh.exe', null, 'powershell.exe') + + const resultPromise = hydrateShellPath() + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + proc.stdout.emit('data', Buffer.from('__ORCA_SHELL_PATH__C:\\inbox-ps__ORCA_SHELL_PATH__')) + proc.emit('close', 0, null) + + await expect(resultPromise).resolves.toEqual({ + segments: ['C:\\inbox-ps'], + ok: true, + failureReason: 'none' + }) + expect(spawnMock.mock.calls.map((call) => call[0])).toEqual(['pwsh.exe', 'powershell.exe']) + }) + + it('reports spawn_error when every shell throws synchronously at spawn', async () => { + spawnMock.mockImplementation(() => { + throw Object.assign(new Error('spawn EPERM'), { code: 'EPERM' }) + }) + configureWindowsShellPathHydration('pwsh.exe', null, 'powershell.exe') + + await expect(hydrateShellPath()).resolves.toEqual({ + segments: [], + ok: false, + failureReason: 'spawn_error' + }) + }) + it('uses the resolved Git Bash executable for the configured sentinel', async () => { const spawner = vi.fn(async () => ({ segments: ['C:\\tools'],