mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* fix(daemon): validate spawn cwd asynchronously so one dead share cannot freeze every terminal createOrAttach validated the working directory synchronously on the daemon's only thread. Measured on Windows 11 + Ubuntu-24.04: existsSync on an unreachable UNC share 21,022 ms wsl.exe probe, cold distro 1,266 ms wsl.exe probe, warm distro 59 ms existsSync/statSync on healthy \\wsl.localhost 4 ms / 1 ms A single unreachable share therefore blocks the whole RPC loop past the client's 30s request ceiling, so every other terminal stalls behind it and reports `DaemonProtocolError: Request createOrAttach timed out after 30000ms`. The main process already validates asynchronously and passes prevalidatedCwd (ipc/pty.ts); the daemon never got the same treatment. Add validateWorkingDirectoryAsync (one stat, not exists-then-stat, so an unreachable share is not paid for twice) and await it from the daemon spawn preflights. spawnSubprocess now returns SubprocessHandle | Promise<...>, which existing sync stubs still satisfy. Deliberately not bounding the stat with a timeout: the 30s ceiling comes from blocking the shared loop, not from the duration. A timeout cannot tell "slow share" from "gone share", so it would fail spawns that succeed today at 3-8s on a cold VPN mount, and trade an accurate "working directory does not exist" for a guess. The new await opened a race: it sits between the "already exists?" check and the sessions.set that publishes the session, so two concurrent creates for one session id both spawned. Gate creation per session id; distinct ids still spawn in parallel. STA-4470 * fix(daemon): fence async spawn lifecycle
131 lines
4.5 KiB
TypeScript
131 lines
4.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { spawnMock, resolveForegroundMock } = vi.hoisted(() => ({
|
|
spawnMock: vi.fn(),
|
|
resolveForegroundMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('node-pty', () => ({ spawn: spawnMock }))
|
|
vi.mock('../../src/main/pwsh', () => ({ isPwshAvailable: vi.fn(() => false) }))
|
|
vi.mock('../../src/main/providers/windows-powershell-executable', () => ({
|
|
resolveWindowsPowerShellExecutablePath: () =>
|
|
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
|
resolveWindowsPowerShellSpawnChain: () => [
|
|
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
|
],
|
|
getWindowsCmdPath: () => 'C:\\Windows\\System32\\cmd.exe'
|
|
}))
|
|
vi.mock('../../src/main/providers/agent-foreground-process', () => ({
|
|
resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => {
|
|
const value = await resolveForegroundMock(...args)
|
|
return value && typeof value === 'object' && 'available' in value
|
|
? value
|
|
: { available: true, processName: value }
|
|
}
|
|
}))
|
|
|
|
import { createPtySubprocess } from '../../src/main/daemon/pty-subprocess'
|
|
import { createPaneForegroundAgentTracker } from '../../src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker'
|
|
|
|
function mockWindowsPty() {
|
|
const exitListeners: ((event: { exitCode: number }) => void)[] = []
|
|
return {
|
|
pid: 12345,
|
|
process: 'powershell.exe',
|
|
write: vi.fn(),
|
|
resize: vi.fn(),
|
|
kill: vi.fn(),
|
|
onData: vi.fn(() => ({ dispose: vi.fn() })),
|
|
onExit: vi.fn((callback: (event: { exitCode: number }) => void) => {
|
|
exitListeners.push(callback)
|
|
return { dispose: vi.fn() }
|
|
})
|
|
}
|
|
}
|
|
|
|
describe('daemon foreground confirmation composes with pane tracking', () => {
|
|
let platform: PropertyDescriptor | undefined
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
spawnMock.mockReset()
|
|
resolveForegroundMock.mockReset()
|
|
platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
Object.defineProperty(process, 'platform', { value: 'win32' })
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
if (platform) {
|
|
Object.defineProperty(process, 'platform', platform)
|
|
}
|
|
})
|
|
|
|
async function createComposedTracker(publish: ReturnType<typeof vi.fn>) {
|
|
const handle = await createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 })
|
|
const tracker = createPaneForegroundAgentTracker({
|
|
getPtyId: () => 'pty-1',
|
|
isTrackablePtyId: () => true,
|
|
readForegroundProcess: () => handle.confirmForegroundProcess!(),
|
|
publish,
|
|
hasKnownAgentIdentity: () => true
|
|
})
|
|
return { handle, tracker }
|
|
}
|
|
|
|
it('keeps a restored Droid through a scan resolving after the old cache window', async () => {
|
|
spawnMock.mockReturnValue(mockWindowsPty())
|
|
let resolveFresh!: (value: string) => void
|
|
resolveForegroundMock.mockReturnValue(
|
|
new Promise<string>((resolve) => {
|
|
resolveFresh = resolve
|
|
})
|
|
)
|
|
const publish = vi.fn()
|
|
const { tracker } = await createComposedTracker(publish)
|
|
|
|
tracker.onVisiblePtyBound(true)
|
|
await vi.advanceTimersByTimeAsync(350)
|
|
await vi.advanceTimersByTimeAsync(2_000)
|
|
expect(publish).not.toHaveBeenCalled()
|
|
resolveFresh('droid')
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
|
|
expect(publish).toHaveBeenCalledExactlyOnceWith({
|
|
agent: 'droid',
|
|
routingTrusted: true,
|
|
shellForeground: false
|
|
})
|
|
expect(resolveForegroundMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('clears launch identity after one fresh scan confirms a real shell exit', async () => {
|
|
spawnMock.mockReturnValue(mockWindowsPty())
|
|
resolveForegroundMock.mockResolvedValue('powershell.exe')
|
|
const publish = vi.fn()
|
|
const { tracker } = await createComposedTracker(publish)
|
|
|
|
tracker.onCommandFinished()
|
|
await vi.advanceTimersByTimeAsync(350)
|
|
|
|
expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: true })
|
|
expect(resolveForegroundMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('fails closed without claiming shell when every fresh scan is unavailable', async () => {
|
|
spawnMock.mockReturnValue(mockWindowsPty())
|
|
resolveForegroundMock.mockResolvedValue({
|
|
available: false,
|
|
processName: 'powershell.exe'
|
|
})
|
|
const publish = vi.fn()
|
|
const { tracker } = await createComposedTracker(publish)
|
|
|
|
tracker.onCommandFinished()
|
|
await vi.advanceTimersByTimeAsync(350 + 1_200 + 6_000)
|
|
|
|
expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: false })
|
|
expect(resolveForegroundMock).toHaveBeenCalledTimes(3)
|
|
})
|
|
})
|