mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user