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
This commit is contained in:
Neil
2026-10-17 22:54:54 -07:00
committed by GitHub
parent ffb818bf61
commit 4baacca5c0
4 changed files with 95 additions and 16 deletions
@@ -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
@@ -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<HydrationSpawner>()
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,
+31 -15
View File
@@ -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<HydrationResult> {
let stdout = ''
let timer: ReturnType<typeof setTimeout> | 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<HydrationResult> {
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<Hydratio
() => 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<Hydratio
}
return result
})
return cached
cached = hydration
// Why: a cached rejection would fail every later caller without re-probing.
hydration.catch(() => {
if (cached === hydration) {
cached = null
}
})
return hydration
}
export function configureWindowsShellPathHydration(
@@ -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<HydrationSpawner>(async () => ({
segments: ['C:\\tools'],