From 3ac54502b8032aa7fbceb06e0abba7fa11705ece Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:44:07 -0700 Subject: [PATCH] fix(terminal): preserve host-gone errors across versions --- src/main/daemon/daemon-errors.test.ts | 19 ++++++++---- src/main/daemon/daemon-errors.ts | 10 ++---- src/main/ipc/pty.ts | 6 ++-- .../terminal-pane/TerminalErrorToast.test.ts | 22 +++++++++++++ .../terminal-pane/TerminalErrorToast.tsx | 31 ++++++++++++------- 5 files changed, 61 insertions(+), 27 deletions(-) diff --git a/src/main/daemon/daemon-errors.test.ts b/src/main/daemon/daemon-errors.test.ts index 717921ef881..71374415772 100644 --- a/src/main/daemon/daemon-errors.test.ts +++ b/src/main/daemon/daemon-errors.test.ts @@ -3,8 +3,10 @@ import { DaemonProtocolError, decodeDaemonResponseError, isDaemonEndpointGoneError, - SessionNotFoundError + SessionNotFoundError, + TerminalHostGoneError } from './daemon-errors' +import { mapRuntimeError } from '../runtime/rpc/errors' function socketError(code: string, syscall: string): Error & { code: string; syscall: string } { return Object.assign(new Error(`${syscall} ${code}`), { code, syscall }) @@ -24,10 +26,6 @@ describe('decodeDaemonResponseError', () => { }) }) -/** - * On Windows a named pipe vanishes with its server process, so `connect ENOENT - * \\?\pipe\orca-terminal-host-vNN-…` is proof the owning daemon died — it used to reach the user raw. - */ describe('isDaemonEndpointGoneError', () => { it('recognizes a missing Windows named pipe', () => { const err = Object.assign( @@ -42,7 +40,6 @@ describe('isDaemonEndpointGoneError', () => { }) it('ignores a missing token file, which does not prove the endpoint is gone', () => { - // Why: ENOENT on open is the token path, and an initially missing token can still hide a live daemon. expect(isDaemonEndpointGoneError(socketError('ENOENT', 'open'))).toBe(false) }) @@ -58,4 +55,14 @@ describe('isDaemonEndpointGoneError', () => { expect(isDaemonEndpointGoneError(null)).toBe(false) expect(isDaemonEndpointGoneError(undefined)).toBe(false) }) + + it('keeps the host-gone marker across runtime RPC error mapping', () => { + const response = mapRuntimeError( + 'req-1', + { runtimeId: 'runtime-1' }, + new TerminalHostGoneError() + ) + + expect(response.error).toEqual({ code: 'runtime_error', message: 'terminal_host_gone' }) + }) }) diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts index c001a5f5e66..d5b7dc2418e 100644 --- a/src/main/daemon/daemon-errors.ts +++ b/src/main/daemon/daemon-errors.ts @@ -29,17 +29,13 @@ export class TerminalSessionOwnerUnverifiedError extends Error { } export class TerminalHostGoneError extends Error { - constructor(sessionId: string) { - super(`Terminal host that owned session ${sessionId} is gone`) + constructor() { + super('terminal_host_gone') this.name = 'TerminalHostGoneError' } } -/** - * A connect-time ENOENT/ECONNREFUSED proves the endpoint is absent rather than busy — on Windows a - * named pipe disappears with its server process — so the daemon that owned the session is gone. Reaching - * a caller at all means respawn-and-retry already failed or was unavailable (legacy adapters never respawn). - */ +// Connect ENOENT/ECONNREFUSED proves the endpoint is absent; open ENOENT can be a missing token file. export function isDaemonEndpointGoneError(err: unknown): boolean { const candidate = err as { code?: unknown; syscall?: unknown } | null return ( diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index beac1b10aea..1e7a65ba3a5 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -95,6 +95,7 @@ import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' import { isDaemonEndpointGoneError, + TerminalHostGoneError, TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -818,10 +819,9 @@ async function attachStablePaneOwner( if (error instanceof TerminalSessionOwnerUnverifiedError) { throw new Error('terminal_pane_owner_unverified') } - // Why: a raw `connect ENOENT \\?\pipe\orca-terminal-host-vNN-…` is unactionable in a toast, and the - // session it names is unrecoverable — translate it before it can reach the renderer. + // Why: translate before paired-runtime RPC strips the socket error's code and syscall. if (isDaemonEndpointGoneError(error)) { - throw new Error('terminal_host_gone') + throw new TerminalHostGoneError() } if (!isPtyAlreadyGoneError(error)) { throw error diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts index ff0cb62de14..7366f5b6594 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts @@ -12,6 +12,8 @@ const SSH_FAILURE = // Relay loss reaches reportError already IPC-wrapped, so the marker is mid-string. const RELAY_LOST = "Error invoking remote method 'pty:attach': Error: SSH connection lost, reconnecting..." +const LEGACY_HOST_GONE = + "Error invoking remote method 'pty:spawn': Error: connect ENOENT \\\\?\\pipe\\orca-terminal-host-v30-14cb7f94b511" describe('isSshReconnectOwnedTerminalError', () => { it('matches raw ssh:connect failures and inactive-host messages', () => { @@ -65,6 +67,13 @@ describe('humanizeTerminalError', () => { const wrapped = "Error invoking remote method 'pty:spawn': Error: terminal_host_gone" expect(humanizeTerminalError(wrapped)).not.toContain('terminal_host_gone') }) + + it('humanizes a legacy host raw named-pipe error', () => { + const humanized = humanizeTerminalError(LEGACY_HOST_GONE) + expect(humanized).not.toContain('connect ENOENT') + expect(humanized).not.toContain('orca-terminal-host-v30') + expect(humanized).toContain('Open a new terminal to continue') + }) }) describe('isExplainedTerminalError', () => { @@ -75,11 +84,24 @@ describe('isExplainedTerminalError', () => { "Error invoking remote method 'pty:spawn': Error: terminal_host_gone" ) ).toBe(true) + expect(isExplainedTerminalError(LEGACY_HOST_GONE)).toBe(true) + expect( + isExplainedTerminalError('connect ECONNREFUSED /tmp/orca-terminal-host-v30-14cb7f94b511.sock') + ).toBe(true) }) it('keeps the issue link for errors Orca cannot explain', () => { expect(isExplainedTerminalError('Paste failed.')).toBe(false) expect(isExplainedTerminalError('node-pty: open_slave failed: EMFILE')).toBe(false) + expect(isExplainedTerminalError('terminal_gone')).toBe(false) + expect(isExplainedTerminalError('terminal_host_gone_extra')).toBe(false) + expect(isExplainedTerminalError('open ENOENT \\\\?\\pipe\\orca-terminal-host-v30-dead')).toBe( + false + ) + expect( + isExplainedTerminalError('connect ETIMEDOUT \\\\?\\pipe\\orca-terminal-host-v30-dead') + ).toBe(false) + expect(isExplainedTerminalError('connect ENOENT \\\\?\\pipe\\unrelated')).toBe(false) }) }) diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index ba5d57e02c7..ef98350c8d5 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -14,8 +14,10 @@ const STALE_DAEMON_CWD_MARKERS = [ ] // Thrown by ipc/pty.ts when a persisted pane owner can't be proven alive or dead (STA-3536). const PANE_OWNER_UNVERIFIED_MARKER = 'terminal_pane_owner_unverified' -// Thrown by ipc/pty.ts when the daemon that owned this session is provably gone (its endpoint is absent). const TERMINAL_HOST_GONE_MARKER = 'terminal_host_gone' +const TERMINAL_HOST_GONE_PATTERN = /(?:^|[^a-z0-9_])terminal_host_gone(?:$|[^a-z0-9_])/ +const LEGACY_TERMINAL_HOST_ENDPOINT_MARKER = 'orca-terminal-host-v' +const LEGACY_TERMINAL_HOST_GONE_PATTERN = /connect (?:ENOENT|ECONNREFUSED) [^\n]*/ function isSshError(error: string): boolean { return error.startsWith(SSH_PREFIX) || error.includes(SSH_RELAY_LOST_MARKER) @@ -46,9 +48,12 @@ export function shouldOfferDaemonRestart(error: string): boolean { ) } -/** A condition the copy already fully explains — an issue link would just add noise. */ export function isExplainedTerminalError(error: string): boolean { - return error.includes(TERMINAL_HOST_GONE_MARKER) + return ( + TERMINAL_HOST_GONE_PATTERN.test(error) || + (error.includes(LEGACY_TERMINAL_HOST_ENDPOINT_MARKER) && + LEGACY_TERMINAL_HOST_GONE_PATTERN.test(error)) + ) } /** Swaps raw daemon-boundary codes for copy a user can act on. */ @@ -63,14 +68,18 @@ export function humanizeTerminalError(error: string): string { ) ) } - if (humanized.includes(TERMINAL_HOST_GONE_MARKER)) { - humanized = humanized.replace( - TERMINAL_HOST_GONE_MARKER, - translate( - 'auto.components.terminal.pane.TerminalErrorToast.e16012e31e', - 'The terminal daemon that owned this session exited, so the session and its scrollback could not be recovered. Open a new terminal to continue.' - ) + if (isExplainedTerminalError(humanized)) { + const explanation = translate( + 'auto.components.terminal.pane.TerminalErrorToast.e16012e31e', + 'The terminal daemon that owned this session exited, so the session and its scrollback could not be recovered. Open a new terminal to continue.' ) + humanized = humanized.replaceAll(TERMINAL_HOST_GONE_MARKER, explanation) + if (humanized.includes(LEGACY_TERMINAL_HOST_ENDPOINT_MARKER)) { + humanized = humanized + .split('\n') + .map((line) => line.replace(LEGACY_TERMINAL_HOST_GONE_PATTERN, explanation)) + .join('\n') + } } return humanized } @@ -86,7 +95,7 @@ export function TerminalErrorToast({ }): React.JSX.Element { const ssh = isSshError(error) const showDaemonRestart = !ssh && onRestartDaemon && shouldOfferDaemonRestart(error) - // Why: restarting the daemon cannot recover a session whose owner already exited, so the copy stands alone. + // Restart cannot recover a session after its owning daemon exits. const showIssueLink = !ssh && !showDaemonRestart && !isExplainedTerminalError(error) const displayError = humanizeTerminalError(error)