From 95c77502d22851abba049682e39985a87e0151b8 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 11 Sep 2026 04:47:36 -0700 Subject: [PATCH 1/2] fix(cli): stop reporting an unsignalable Orca pid as a stale bootstrap `orca status` falls back to a `kill(pid, 0)` probe when `status.get` cannot be reached, and a bare catch read every refusal as absence. EPERM means the pid exists under another uid -- an Orca reached via ORCA_USER_DATA_PATH, or one started with sudo -- so a live app was reported `running: false`, `pid: null`, `runtime.state: stale_bootstrap`, `graph.state: not_running`. Only ESRCH proves the pid is gone, which is the rule every other liveness probe in the repo already applies (`isProcessAlive` in relay/pty-shell-utils.ts, pack-refs-lock-ownership.ts, runtime-metadata-ownership-watch.ts, and agent-session-process-identity-probe.ts). See docs/reference/ssh-execution-boundary.md. --- src/cli/runtime/status.test.ts | 53 +++++++++++++++++++++++++++++++++- src/cli/runtime/status.ts | 6 ++-- 2 files changed, 56 insertions(+), 3 deletions(-) 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' } } From 959616039443e160d389887231dc9fab0ea26ec4 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 11 Sep 2026 04:49:30 -0700 Subject: [PATCH 2/2] fix(relay): keep a revived pane whose pid only refuses the liveness probe `revive` gated each serialized pane on a hand-rolled `process.kill(pid, 0)` in a bare try/catch, so any refusal retired the pane. EPERM means the process exists under another uid; only ESRCH is evidence of absence. The file already imports `isProcessAlive`, whose ESRCH-only contract `reapPtyProvenExited` documents 450 lines earlier -- this call site just did not use it. Reuse it rather than keeping a second implementation of the same concept. Malformed pids still skip, as before. See docs/reference/ssh-execution-boundary.md. --- src/relay/pty-handler-revive.test.ts | 31 ++++++++++++++++++++++++++++ src/relay/pty-handler.ts | 8 +++---- 2 files changed, 35 insertions(+), 4 deletions(-) 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