mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
fix(terminal): explain a dead terminal host instead of leaking a socket error
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.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
+9
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user