diff --git a/src/cli/runtime/status.test.ts b/src/cli/runtime/status.test.ts index 4c62be8d977..68d6f392a40 100644 --- a/src/cli/runtime/status.test.ts +++ b/src/cli/runtime/status.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { createServer, type Socket } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap' import type { RuntimeStatus } from '../../shared/runtime-types' import { RuntimeClient } from './client' @@ -86,6 +86,57 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { }) }) +// Why: `kill(pid, 0)` answers EPERM when the pid exists under another uid — an Orca the +// CLI was pointed at with ORCA_USER_DATA_PATH, or one started with sudo. Reading that +// refusal as absence reports a live app as a dead one +// (docs/reference/ssh-execution-boundary.md). +describe.skipIf(process.platform === 'win32')('CLI status pid fallback', () => { + async function statusWithUnreachableRuntime( + killError: NodeJS.ErrnoException + ): Promise>> { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-status-probe-')) + writeFileSync( + getRuntimeMetadataPath(userDataPath), + JSON.stringify({ + runtimeId: 'runtime-unreachable', + pid: 424242, + // Nothing is listening here, so `status.get` fails and the pid probe decides. + transport: { kind: 'unix', endpoint: join(userDataPath, 'absent.sock') }, + authToken: 'token', + startedAt: Date.now() + }) + ) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw killError + }) + try { + return await new RuntimeClient(userDataPath).getCliStatus() + } finally { + killSpy.mockRestore() + } + } + + it('keeps an unsignalable app running rather than calling the bootstrap stale', async () => { + const status = await statusWithUnreachableRuntime( + Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) + ) + + expect(status.result.app).toMatchObject({ running: true, pid: 424242 }) + expect(status.result.runtime.state).toBe('starting') + expect(status.result.graph.state).toBe('starting') + }) + + it('still reports a stale bootstrap when the host proves the pid is gone', async () => { + const status = await statusWithUnreachableRuntime( + Object.assign(new Error('kill ESRCH'), { code: 'ESRCH' }) + ) + + expect(status.result.app).toMatchObject({ running: false, pid: null }) + expect(status.result.runtime.state).toBe('stale_bootstrap') + expect(status.result.graph.state).toBe('not_running') + }) +}) + describe('projectRemoteAppStatus', () => { function remoteStatus(overrides: Partial = {}): RuntimeStatus { return { diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index 8736f4cc177..05f13ffae4b 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -106,7 +106,9 @@ function isProcessRunning(pid: number | null | undefined): boolean { try { process.kill(pid, 0) return true - } catch { - return false + } catch (error) { + // Why: only ESRCH proves the pid is gone. EPERM means it exists under another uid, and + // reporting that as `stale_bootstrap` calls a live Orca dead. + return (error as NodeJS.ErrnoException)?.code !== 'ESRCH' } } diff --git a/src/relay/pty-handler-revive.test.ts b/src/relay/pty-handler-revive.test.ts index e184cd56817..6dc2a8d4b30 100644 --- a/src/relay/pty-handler-revive.test.ts +++ b/src/relay/pty-handler-revive.test.ts @@ -518,6 +518,37 @@ describe('PtyHandler', () => { expect(JSON.parse(live).map((entry: { id: string }) => entry.id)).toEqual(['pty-21']) }) + // Why: the pid gate is the one place revive turns an observation into "this pane is + // finished". `kill(pid, 0)` answers EPERM when the process exists under another uid, and + // the same ESRCH-only rule `reapPtyProvenExited` applies has to hold here + // (docs/reference/ssh-execution-boundary.md). + it('keeps a pane whose pid refuses the probe and drops only a proven-gone one', async () => { + const state = JSON.stringify([ + { id: 'pty-30', pid: 424242, cols: 80, rows: 24, cwd: LIVE_CWD }, + { id: 'pty-31', pid: 434343, cols: 80, rows: 24, cwd: LIVE_CWD } + ]) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === 424242) { + throw Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) + } + if (pid === 434343) { + throw Object.assign(new Error('kill ESRCH'), { code: 'ESRCH' }) + } + return true + }) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + expect(mockPtySpawn).toHaveBeenCalledTimes(1) + const live = (await dispatcher.callRequest('pty.serialize', { + ids: ['pty-30', 'pty-31'] + })) as string + expect(JSON.parse(live).map((entry: { id: string }) => entry.id)).toEqual(['pty-30']) + }) + describe('a Windows relay reviving a WSL pane', () => { const worktreeId = 'r::/remote/wsl-worktree' const historyFile = join( diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index cdf436bca2a..e69e3c1da56 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -2907,10 +2907,10 @@ export class PtyHandler { if (this.ptys.has(entry.id) || this.pendingReviveIds.has(entry.id)) { continue } - // Only re-attach if the original process is still alive - try { - process.kill(entry.pid, 0) - } catch { + // Only re-attach if the host proves the original process is still there. `isProcessAlive` + // is ESRCH-only for the same reason `reapPtyProvenExited` is: a refusal this host cannot + // resolve is unverifiable, not absence (docs/reference/ssh-execution-boundary.md). + if (!Number.isInteger(entry.pid) || entry.pid <= 0 || !isProcessAlive(entry.pid)) { continue } const ownedPath = entry.worktreeId