mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(terminal): preserve host-gone errors across versions
This commit is contained in:
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user