diff --git a/src/main/daemon/daemon-health-socket-cleanup.test.ts b/src/main/daemon/daemon-health-socket-cleanup.test.ts index a6404ed0220..8d2f8e9ad96 100644 --- a/src/main/daemon/daemon-health-socket-cleanup.test.ts +++ b/src/main/daemon/daemon-health-socket-cleanup.test.ts @@ -42,7 +42,12 @@ describe('daemon health socket listener cleanup', () => { const result = healthCheckDaemon(socketPath, tokenPath) socket.emit('connect') - socket.emit('data', Buffer.from('{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n')) + socket.emit( + 'data', + Buffer.from( + '{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n{"id":"health-2","ok":true}\n' + ) + ) await expect(result).resolves.toBe(true) expect(socket.listenerCount('connect')).toBe(0) diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 92220f8d548..6bf71a37f98 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -74,15 +74,36 @@ describe('daemon health', () => { }) it('passes when a daemon answers ping', async () => { + const ptySpawnHealthCheck = vi.fn(async () => {}) const server = new DaemonServer({ socketPath, tokenPath, + ptySpawnHealthCheck, spawnSubprocess: () => createMockSubprocess() }) await server.start() try { await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true) + expect(ptySpawnHealthCheck).toHaveBeenCalledOnce() + } finally { + await server.shutdown() + } + }) + + it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => { + const server = new DaemonServer({ + socketPath, + tokenPath, + ptySpawnHealthCheck: vi.fn(async () => { + throw new Error('stale node-pty helper') + }), + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + + try { + await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false) } finally { await server.shutdown() } diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 049743aa97e..e74e3a848db 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -19,6 +19,8 @@ const KILL_WAIT_MS = 3_000 const KILL_POLL_MS = 100 const START_TIME_TOLERANCE_MS = 1_500 +export type DaemonHealthCheckResult = 'healthy' | 'unhealthy' | 'pty-spawn-unhealthy' + type ParsedDaemonPid = { pid: number startedAtMs: number | null @@ -63,10 +65,13 @@ function canConnectSocket(socketPath: string): Promise { }) } -export function healthCheckDaemon(socketPath: string, tokenPath: string): Promise { +export function checkDaemonHealth( + socketPath: string, + tokenPath: string +): Promise { return new Promise((resolve) => { if (process.platform !== 'win32' && !existsSync(socketPath)) { - resolve(false) + resolve('unhealthy') return } @@ -74,13 +79,13 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis try { token = readFileSync(tokenPath, 'utf8').trim() } catch { - resolve(false) + resolve('unhealthy') return } let settled = false let sock: Socket | null = null - const settle = (result: boolean): void => { + const settle = (result: DaemonHealthCheckResult): void => { if (settled) { return } @@ -95,7 +100,7 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis sock?.off('connect', onConnect) sock?.off('data', onData) } - const onError = (): void => settle(false) + const onError = (): void => settle('unhealthy') const onConnect = (): void => { const hello: HelloMessage = { type: 'hello', @@ -126,13 +131,13 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis try { message = JSON.parse(line) as Record } catch { - settle(false) + settle('unhealthy') return } if (message.type === 'hello') { if (!(message as HelloResponse).ok) { - settle(false) + settle('unhealthy') return } sock?.write(encodeNdjson({ id: 'health-1', type: 'ping' })) @@ -140,12 +145,24 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis } if (message.id === 'health-1') { - settle(Boolean(message.ok)) + if (!message.ok) { + settle('unhealthy') + return + } + // Why: protocol ping only proves the socket loop is alive. New + // terminals also depend on node-pty's native helper state inside + // the daemon process, which can go stale after dev rebuilds. + sock?.write(encodeNdjson({ id: 'health-2', type: 'ptySpawnHealth' })) + continue + } + + if (message.id === 'health-2') { + settle(message.ok ? 'healthy' : 'pty-spawn-unhealthy') return } } } - const timer = setTimeout(() => settle(false), HEALTH_CHECK_TIMEOUT_MS) + const timer = setTimeout(() => settle('unhealthy'), HEALTH_CHECK_TIMEOUT_MS) sock = connect({ path: socketPath }) sock.on('error', onError) @@ -156,6 +173,10 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis }) } +export async function healthCheckDaemon(socketPath: string, tokenPath: string): Promise { + return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy' +} + function isSystemResolverHealth(value: unknown): value is SystemResolverHealth { return value === 'healthy' || value === 'unhealthy' || value === 'unknown' } diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 8e3be87a60b..e320609ef09 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -20,6 +20,7 @@ const { writeFileSyncMock, netConnectMock, forkMock, + checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, getDaemonLaunchIdentityMock, @@ -66,6 +67,7 @@ const { } }) + const checkDaemonHealthMock = vi.fn(async () => 'healthy') const healthCheckDaemonMock = vi.fn(async () => true) const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy') const getDaemonLaunchIdentityMock = vi.fn(() => 'match') @@ -101,6 +103,7 @@ const { writeFileSyncMock, netConnectMock, forkMock, + checkDaemonHealthMock, healthCheckDaemonMock, getMacDaemonSystemResolverHealthMock, getDaemonLaunchIdentityMock, @@ -172,6 +175,7 @@ vi.mock('child_process', () => ({ fork: forkMock })) vi.mock('net', () => ({ connect: netConnectMock })) vi.mock('./daemon-health', () => ({ + checkDaemonHealth: checkDaemonHealthMock, getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock, healthCheckDaemon: healthCheckDaemonMock, @@ -268,6 +272,8 @@ async function importFresh() { setLocalPtyProviderMock.mockClear() unbindLocalProviderListenersMock.mockClear() rebindLocalProviderListenersMock.mockClear() + checkDaemonHealthMock.mockClear() + checkDaemonHealthMock.mockResolvedValue('healthy') healthCheckDaemonMock.mockClear() getMacDaemonSystemResolverHealthMock.mockReset() getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy') @@ -1025,7 +1031,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { probeSocketExistsMock.mockImplementation( (p?: string) => p === '/fake/app/out/main/daemon-entry.js' ) - healthCheckDaemonMock.mockResolvedValueOnce(false) + checkDaemonHealthMock.mockResolvedValueOnce('unhealthy') const mod = await importFresh() getAppPathMock.mockReturnValue('/fake/app/out/main') await mod.initDaemonPtyProvider() @@ -1068,7 +1074,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) it('removes detached daemon startup listeners after readiness', async () => { - healthCheckDaemonMock.mockResolvedValueOnce(false) + checkDaemonHealthMock.mockResolvedValueOnce('unhealthy') const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1123,7 +1129,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) it('removes detached daemon startup listeners after startup error', async () => { - healthCheckDaemonMock.mockResolvedValueOnce(false) + checkDaemonHealthMock.mockResolvedValueOnce('unhealthy') const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1167,6 +1173,88 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(child.unref).not.toHaveBeenCalled() }) + it('preserves a spawn-unhealthy daemon when it owns live sessions', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const requestMock = vi.fn(async (method: string) => { + if (method === 'listSessions') { + return { + sessions: [{ sessionId: 'wt-1@@live', isAlive: true }] + } + } + return {} + }) + const disconnectMock = vi.fn() + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: requestMock, + disconnect: disconnectMock + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy') + + await launcher('/fake/socket', '/fake/token') + + expect(requestMock).toHaveBeenCalledWith('listSessions', undefined) + expect(disconnectMock).toHaveBeenCalledOnce() + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + + it('replaces a spawn-unhealthy daemon when no live sessions would be lost', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy') + forkMock.mockImplementationOnce(() => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + return { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + off(event: string, cb: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + } + }) + + await launcher('/fake/socket', '/fake/token') + + expect(killStaleDaemonMock).toHaveBeenCalledWith( + '/fake/userData/daemon', + '/fake/socket', + '/fake/token' + ) + expect(forkMock).toHaveBeenCalledWith( + '/fake/app/out/main/daemon-entry.js', + ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.objectContaining({ detached: true }) + ) + }) + it('preserves a packaged healthy daemon when its app bundle is current', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index cd9e335bc45..28807352612 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -28,10 +28,10 @@ import { type ListSessionsResult } from './types' import { + checkDaemonHealth, getMacDaemonSystemResolverHealth, getDaemonLaunchIdentity, getProcessStartedAtMs, - healthCheckDaemon, isDaemonStaleForCurrentBundle, killStaleDaemon } from './daemon-health' @@ -164,49 +164,65 @@ async function shouldPreserveDaemonWithLiveSessions( function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { return async (socketPath, tokenPath) => { const entryPath = getDaemonEntryPath() - const healthy = await healthCheckDaemon(socketPath, tokenPath) - if (healthy) { - const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath) - if (resolverHealth === 'unhealthy') { - const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) - if (liveSessionCount !== 0) { - console.warn( - liveSessionCount === null - ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified' - : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + const health = await checkDaemonHealth(socketPath, tokenPath) + if (health !== 'unhealthy') { + if (health === 'pty-spawn-unhealthy') { + if ( + await shouldPreserveDaemonWithLiveSessions( + socketPath, + tokenPath, + 'that cannot spawn new PTYs' ) + ) { return createPreservedDaemonHandle(runtimeDir) } - console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') + console.warn('[daemon] Replacing daemon that cannot spawn new PTYs') await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) } else { - // Why: a protocol-healthy daemon can outlive the app bundle that - // launched it. In dev this happens after deleting/rebuilding a - // worktree; in packaged apps it happens when the stable - // /Applications/Orca.app path is replaced during update. - const identity = getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath) - const stalePackagedBundle = - app.isPackaged && - isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion()) - if (identity === 'mismatch' || stalePackagedBundle) { - // Why: replacing a healthy daemon kills its child PTYs; defer code - // freshness until no live terminal sessions would be lost. - const replacementLabel = stalePackagedBundle - ? 'launched before the current app bundle was installed' - : 'launched from a different app path' - if (await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)) { + const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath) + if (resolverHealth === 'unhealthy') { + const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) + if (liveSessionCount !== 0) { + console.warn( + liveSessionCount === null + ? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified' + : `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) return createPreservedDaemonHandle(runtimeDir) } - console.warn( - stalePackagedBundle - ? '[daemon] Replacing daemon launched before the current app bundle was installed' - : '[daemon] Replacing daemon launched from a different app path' - ) + console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) } else { - // Why: daemon is already running from a previous app session and - // responded to a protocol-level ping. Safe to reuse. - return createPreservedDaemonHandle(runtimeDir) + // Why: a protocol-healthy daemon can outlive the app bundle that + // launched it. In dev this happens after deleting/rebuilding a + // worktree; in packaged apps it happens when the stable + // /Applications/Orca.app path is replaced during update. + const identity = getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath) + const stalePackagedBundle = + app.isPackaged && + isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion()) + if (identity === 'mismatch' || stalePackagedBundle) { + // Why: replacing a healthy daemon kills its child PTYs; defer code + // freshness until no live terminal sessions would be lost. + const replacementLabel = stalePackagedBundle + ? 'launched before the current app bundle was installed' + : 'launched from a different app path' + if ( + await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel) + ) { + return createPreservedDaemonHandle(runtimeDir) + } + console.warn( + stalePackagedBundle + ? '[daemon] Replacing daemon launched before the current app bundle was installed' + : '[daemon] Replacing daemon launched from a different app path' + ) + await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION) + } else { + // Why: daemon is already running from a previous app session and + // responded to a protocol-level ping. Safe to reuse. + return createPreservedDaemonHandle(runtimeDir) + } } } } diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index feb9fe9259e..7d10ce1f10a 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -198,6 +198,21 @@ describe('DaemonServer', () => { expect(result).toEqual({ pong: true }) }) + it('handles ptySpawnHealth through the daemon process', async () => { + const ptySpawnHealthCheck = vi.fn(async () => {}) + server = new DaemonServer({ + socketPath, + tokenPath, + ptySpawnHealthCheck, + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + const c = await connectClient() + + await expect(c.request('ptySpawnHealth', undefined)).resolves.toEqual({ healthy: true }) + expect(ptySpawnHealthCheck).toHaveBeenCalledOnce() + }) + it('handles systemResolverHealth', async () => { await startServer() const c = await connectClient() diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 558a41b53f9..7d9940470aa 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -11,6 +11,7 @@ import { TerminalHost } from './terminal-host' import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import type { SubprocessHandle } from './session' +import { checkPtySpawnHealth } from './pty-subprocess' import { PROTOCOL_VERSION, NOTIFY_PREFIX, @@ -22,6 +23,7 @@ import { export type DaemonServerOptions = { socketPath: string tokenPath: string + ptySpawnHealthCheck?: () => Promise spawnSubprocess: (opts: { sessionId: string cols: number @@ -45,6 +47,7 @@ export class DaemonServer { private host: TerminalHost private socketPath: string private tokenPath: string + private ptySpawnHealthCheck: () => Promise private clients = new Map() private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId)) @@ -62,6 +65,7 @@ export class DaemonServer { this.tokenPath = opts.tokenPath this.token = randomUUID() this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) + this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth } async start(): Promise { @@ -375,6 +379,10 @@ export class DaemonServer { case 'systemResolverHealth': return { health: await readCurrentProcessMacSystemResolverHealth() } + case 'ptySpawnHealth': + await this.ptySpawnHealthCheck() + return { healthy: true } + case 'shutdown': if (request.payload.killSessions) { this.host.dispose() diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index a9a5fef3253..ad62e6272d1 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1,13 +1,19 @@ /* oxlint-disable max-lines -- Why: exercises full PTY subprocess surface (spawn setup, signal routing, data events, platform-specific shell configs, and Windows PowerShell implementations) with co-located test scenarios to prevent fixture drift. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, realpathSync, rmSync } from 'fs' +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import type * as LocalPtyUtils from '../providers/local-pty-utils' -const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock } = vi.hoisted(() => ({ +const { + spawnMock, + isPwshAvailableMock, + validateWorkingDirectoryMock, + getNodePtySpawnHelperCandidatesMock +} = vi.hoisted(() => ({ spawnMock: vi.fn(), isPwshAvailableMock: vi.fn(), + getNodePtySpawnHelperCandidatesMock: vi.fn(), validateWorkingDirectoryMock: vi.fn((cwd: string) => { if (cwd.includes('definitely-missing')) { throw new Error( @@ -29,11 +35,12 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + getNodePtySpawnHelperCandidates: getNodePtySpawnHelperCandidatesMock, validateWorkingDirectory: validateWorkingDirectoryMock } }) -import { createPtySubprocess } from './pty-subprocess' +import { checkPtySpawnHealth, createPtySubprocess } from './pty-subprocess' const ORCA_SHELL_WRAPPER_ENV = [ 'ORCA_ATTRIBUTION_SHIM_DIR', @@ -79,6 +86,10 @@ describe('createPtySubprocess', () => { isPwshAvailableMock.mockReturnValue(false) previousUserDataPath = process.env.ORCA_USER_DATA_PATH userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-subprocess-test-')) + const spawnHelperPath = join(userDataPath, 'spawn-helper') + writeFileSync(spawnHelperPath, '') + getNodePtySpawnHelperCandidatesMock.mockReset() + getNodePtySpawnHelperCandidatesMock.mockReturnValue([spawnHelperPath]) process.env.ORCA_USER_DATA_PATH = userDataPath for (const key of ORCA_SHELL_WRAPPER_ENV) { savedWrapperEnv[key] = process.env[key] @@ -169,6 +180,54 @@ describe('createPtySubprocess', () => { ) }) + it('checks macOS PTY spawn health with a short-lived shell', async () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'darwin' }) + + try { + const result = checkPtySpawnHealth() + proc._simulateExit(0) + await expect(result).resolves.toBeUndefined() + expect(proc.kill).not.toHaveBeenCalled() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + '/bin/sh', + ['-c', 'exit 0'], + expect.objectContaining({ + cols: 2, + rows: 1, + cwd: userDataPath, + name: 'xterm-256color' + }) + ) + }) + + it('surfaces stale node-pty helper failures during macOS PTY spawn health', async () => { + spawnMock.mockImplementation(() => { + throw new Error( + "node-pty: posix_spawn failed: ENOENT (errno 2, No such file or directory) - helper='/tmp/deleted/spawn-helper'" + ) + }) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'darwin' }) + + try { + await expect(checkPtySpawnHealth()).rejects.toThrow('Daemon failed to spawn shell "/bin/sh"') + await expect(checkPtySpawnHealth()).rejects.toThrow('posix_spawn failed: ENOENT') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('returns a SubprocessHandle with correct pid', () => { const proc = mockPtyProcess(42) spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 8f6fb96a313..53287122abc 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -29,6 +29,7 @@ import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../gi import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const +const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000 export type PtySubprocessOptions = { sessionId: string @@ -231,6 +232,75 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): return formatted } +export async function checkPtySpawnHealth(): Promise { + if (process.platform !== 'darwin') { + return + } + + ensureNodePtySpawnHelperExecutable() + preflightMacNodePtySpawnEnvironment() + + const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) + ? process.env.ORCA_USER_DATA_PATH + : getDefaultCwd() + + let proc: pty.IPty + try { + proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { + name: 'xterm-256color', + cols: 2, + rows: 1, + cwd, + env: { + ...process.env, + TERM: 'xterm-256color' + } + }) + } catch (err) { + throw formatPtySpawnError(err, '/bin/sh', cwd) + } + + await new Promise((resolve, reject) => { + let settled = false + let exitDisposable: { dispose(): void } | undefined + const finish = (error?: Error, opts?: { kill?: boolean }): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + exitDisposable?.dispose() + if (opts?.kill) { + try { + proc.kill() + } catch { + // Best-effort cleanup for a short-lived health probe. + } + } + if (error) { + reject(error) + return + } + resolve() + } + const timer = setTimeout(() => { + finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), { + kill: true + }) + }, PTY_SPAWN_HEALTH_TIMEOUT_MS) + + // Why: ping only proves the daemon protocol is alive. A real short-lived + // PTY spawn catches stale node-pty helper paths captured by this process. + exitDisposable = proc.onExit(({ exitCode }) => { + if (exitCode === 0) { + finish() + return + } + finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) + }) + }) +} + function normalizeForegroundProcessName(processName: string | null | undefined): string | null { const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' if (!trimmed || trimmed === 'xterm-256color') { diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index f0eeb9d0af8..abac7f20dee 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -3,8 +3,8 @@ // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. // Why: bump when adding daemon wire behavior so same-version old daemons do // not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 11 -export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const +export const PROTOCOL_VERSION = 12 +export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] as const // ─── Session State Machine ────────────────────────────────────────── export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' @@ -182,6 +182,11 @@ export type SystemResolverHealthRequest = { type: 'systemResolverHealth' } +export type PtySpawnHealthRequest = { + id: string + type: 'ptySpawnHealth' +} + export type GetSnapshotRequest = { id: string type: 'getSnapshot' @@ -205,6 +210,7 @@ export type DaemonRequest = | ShutdownRequest | PingRequest | SystemResolverHealthRequest + | PtySpawnHealthRequest | GetSnapshotRequest // ─── RPC Responses (Daemon → Client, on control socket) ────────────