From 02cbbd117760ed4b28bc99f64f5db508089bf16b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:09:04 -0700 Subject: [PATCH] fix(terminal): explain a dead terminal host instead of leaking a socket error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the daemon that owned a session has exited, resuming that terminal surfaced the raw connect error for its endpoint — on Windows a named pipe path, since a pipe disappears with its server process — in a red toast ending in "file an issue". The session is unrecoverable and the text is unactionable. Translate it at the daemon boundary into terminal_host_gone, following the existing terminal_pane_owner_unverified path, and humanize it in the toast. Suppress the issue link for a condition Orca can fully explain. Covers the remote-host case: the conversion sits in attachStablePaneOwner, the first catch a paired client's resume reaches before the error is serialized onto the wire. --- src/main/daemon/daemon-errors.test.ts | 41 +++++++++++++++++++ src/main/daemon/daemon-errors.ts | 22 ++++++++++ src/main/ipc/pty.ts | 10 ++++- .../terminal-pane/TerminalErrorToast.test.ts | 28 +++++++++++++ .../terminal-pane/TerminalErrorToast.tsx | 41 ++++++++++++++----- src/renderer/src/i18n/locales/en.json | 3 +- 6 files changed, 132 insertions(+), 13 deletions(-) diff --git a/src/main/daemon/daemon-errors.test.ts b/src/main/daemon/daemon-errors.test.ts index d12ce645ee7..717921ef881 100644 --- a/src/main/daemon/daemon-errors.test.ts +++ b/src/main/daemon/daemon-errors.test.ts @@ -2,9 +2,14 @@ import { describe, expect, it } from 'vitest' import { DaemonProtocolError, decodeDaemonResponseError, + isDaemonEndpointGoneError, SessionNotFoundError } from './daemon-errors' +function socketError(code: string, syscall: string): Error & { code: string; syscall: string } { + return Object.assign(new Error(`${syscall} ${code}`), { code, syscall }) +} + describe('decodeDaemonResponseError', () => { it('types the exact legacy session-absence response', () => { expect(decodeDaemonResponseError('Session not found: pty-1')).toBeInstanceOf( @@ -18,3 +23,39 @@ 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( + new Error('connect ENOENT \\\\?\\pipe\\orca-terminal-host-v30-14cb7f94b511'), + { code: 'ENOENT', syscall: 'connect' } + ) + expect(isDaemonEndpointGoneError(err)).toBe(true) + }) + + it('recognizes a refused socket', () => { + expect(isDaemonEndpointGoneError(socketError('ECONNREFUSED', 'connect'))).toBe(true) + }) + + 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) + }) + + it('ignores connect failures that are not proof of absence', () => { + for (const code of ['ETIMEDOUT', 'ECONNRESET', 'EPIPE', 'EACCES']) { + expect(isDaemonEndpointGoneError(socketError(code, 'connect'))).toBe(false) + } + }) + + it('ignores non-socket errors and non-objects', () => { + expect(isDaemonEndpointGoneError(new Error('Connection lost'))).toBe(false) + expect(isDaemonEndpointGoneError('connect ENOENT')).toBe(false) + expect(isDaemonEndpointGoneError(null)).toBe(false) + expect(isDaemonEndpointGoneError(undefined)).toBe(false) + }) +}) diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts index bddb3690a4a..c001a5f5e66 100644 --- a/src/main/daemon/daemon-errors.ts +++ b/src/main/daemon/daemon-errors.ts @@ -28,6 +28,28 @@ export class TerminalSessionOwnerUnverifiedError extends Error { } } +export class TerminalHostGoneError extends Error { + constructor(sessionId: string) { + super(`Terminal host that owned session ${sessionId} is 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). + */ +export function isDaemonEndpointGoneError(err: unknown): boolean { + const candidate = err as { code?: unknown; syscall?: unknown } | null + return ( + typeof candidate === 'object' && + candidate !== null && + candidate.syscall === 'connect' && + (candidate.code === 'ENOENT' || candidate.code === 'ECONNREFUSED') + ) +} + export function decodeDaemonResponseError(message: string): Error { const prefix = 'Session not found: ' return message.startsWith(prefix) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 46498fdf0a3..beac1b10aea 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -93,7 +93,10 @@ import { import { resolveWslSessionContext } from '../daemon/wsl-session-context' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' -import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' +import { + isDaemonEndpointGoneError, + TerminalSessionOwnerUnverifiedError +} from '../daemon/daemon-errors' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' @@ -815,6 +818,11 @@ 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. + if (isDaemonEndpointGoneError(error)) { + throw new Error('terminal_host_gone') + } 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 bd9aedd375c..ff0cb62de14 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { humanizeTerminalError, + isExplainedTerminalError, isSshReconnectOwnedTerminalError, shouldOfferDaemonRestart, stripSshReconnectOwnedErrorLines @@ -53,6 +54,33 @@ describe('humanizeTerminalError', () => { it('leaves other errors untouched', () => { expect(humanizeTerminalError('Paste failed.')).toBe('Paste failed.') }) + + it('replaces the terminal-host-gone code with copy that explains the loss', () => { + const humanized = humanizeTerminalError('terminal_host_gone') + expect(humanized).not.toContain('terminal_host_gone') + expect(humanized).toContain('Open a new terminal to continue') + }) + + it('humanizes an IPC-wrapped terminal-host-gone error', () => { + const wrapped = "Error invoking remote method 'pty:spawn': Error: terminal_host_gone" + expect(humanizeTerminalError(wrapped)).not.toContain('terminal_host_gone') + }) +}) + +describe('isExplainedTerminalError', () => { + it('suppresses the issue link for a provably dead terminal host', () => { + expect(isExplainedTerminalError('terminal_host_gone')).toBe(true) + expect( + isExplainedTerminalError( + "Error invoking remote method 'pty:spawn': Error: terminal_host_gone" + ) + ).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) + }) }) describe('stripSshReconnectOwnedErrorLines', () => { diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index 99a00e54f9a..ba5d57e02c7 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -14,6 +14,8 @@ 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' function isSshError(error: string): boolean { return error.startsWith(SSH_PREFIX) || error.includes(SSH_RELAY_LOST_MARKER) @@ -44,18 +46,33 @@ export function shouldOfferDaemonRestart(error: string): boolean { ) } -/** Swaps the raw pane-owner-unverified code for copy a user can act on. */ +/** 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) +} + +/** Swaps raw daemon-boundary codes for copy a user can act on. */ export function humanizeTerminalError(error: string): string { - if (!error.includes(PANE_OWNER_UNVERIFIED_MARKER)) { - return error - } - return error.replace( - PANE_OWNER_UNVERIFIED_MARKER, - translate( - 'auto.components.terminal.pane.TerminalErrorToast.7ee11bc0db', - "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry." + let humanized = error + if (humanized.includes(PANE_OWNER_UNVERIFIED_MARKER)) { + humanized = humanized.replace( + PANE_OWNER_UNVERIFIED_MARKER, + translate( + 'auto.components.terminal.pane.TerminalErrorToast.7ee11bc0db', + "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry." + ) ) - ) + } + 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.' + ) + ) + } + return humanized } export function TerminalErrorToast({ @@ -69,6 +86,8 @@ 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. + const showIssueLink = !ssh && !showDaemonRestart && !isExplainedTerminalError(error) const displayError = humanizeTerminalError(error) return ( @@ -101,7 +120,7 @@ export function TerminalErrorToast({ 'Restart the terminal daemon from here to clear stale daemon state.' )} - ) : !ssh ? ( + ) : showIssueLink ? ( <> {'\n'} {translate( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 568e139ebed..d0bf4df17df 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2806,7 +2806,8 @@ "a7e2fd2699": "file an issue", "5c8ce20be6": "If this persists, please", "cc6d997c65": "Restart the terminal daemon from here to clear stale daemon state.", - "7ee11bc0db": "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry." + "7ee11bc0db": "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry.", + "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." }, "TerminalPane": { "ac112e9036": "Remove title",