mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
* fix(daemon): stop a failed spawn cancel from tearing down the shared connection (STA-4663)
`onCreateCancellationFailure` fired on ANY rejection of the `cancelCreateOrAttach`
RPC, including its own 5s timeout and application-level `ok:false` replies. That
called `handleDisconnect`, which rejects every in-flight request and destroys both
sockets — killing every sibling session on the daemon.
Only an undeliverable cancel now escalates, signalled by the new
`DaemonConnectionLostError`. A refused or timed-out cancel falls back to the
existing bounded `unmatchedCancelGraceMs` wait and then rejects just its own request.
Also wraps the control-socket write so a synchronous throw drops the pending entry
and its timer instead of leaking them.
STA-4663's premise — that legacy daemons reject `cancelCreateOrAttach` as an unknown
request type — is incorrect; the handler has existed since protocol v11 (181741d769).
* fix(daemon): keep the wedged-daemon respawn signal when a spawn cancel times out
STA-4663 stopped a failed cancel from tearing down the shared connection, but
with it went the only path that recovered a daemon wedged with its socket still
open: create times out at 30s, its cancel times out at 5s, and nothing else in
the client ever notices.
Classify our own deadline as DaemonRequestTimeoutError. When the request and its
cancel both hit it, reject that request alone with the message isDaemonGoneError
matches, so withDaemonRetry respawns instead of retrying forever. Siblings are
untouched, and aborts are excluded — the caller asked to stop, not to retry.
95 lines
3.3 KiB
TypeScript
95 lines
3.3 KiB
TypeScript
// Error classes shared across the daemon protocol boundary (client, server,
|
|
// host). Split from types.ts, which is capped for wire-shape declarations.
|
|
const ATTACH_CANCELED_PREFIX = 'Attach canceled for session '
|
|
|
|
export class TerminalAttachCanceledError extends Error {
|
|
constructor(sessionId: string) {
|
|
super(`${ATTACH_CANCELED_PREFIX}${sessionId}`)
|
|
this.name = 'TerminalAttachCanceledError'
|
|
}
|
|
}
|
|
|
|
/** Recognizes the daemon's attach-cancellation across the wire, where only the message survives. */
|
|
export function isTerminalAttachCanceledMessage(message: string): boolean {
|
|
return message.startsWith(ATTACH_CANCELED_PREFIX)
|
|
}
|
|
|
|
export class DaemonProtocolError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'DaemonProtocolError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The control connection itself failed: nothing was delivered and no reply is
|
|
* coming. Distinct from a DaemonProtocolError the daemon actually answered with
|
|
* (or one we raised on our own timeout) — only this class proves the socket, and
|
|
* therefore every session sharing it, is already lost. Extends
|
|
* DaemonProtocolError and keeps the same messages so existing instanceof and
|
|
* message checks (isDaemonGoneError) still match.
|
|
*/
|
|
export class DaemonConnectionLostError extends DaemonProtocolError {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'DaemonConnectionLostError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Nothing came back before our own deadline. The daemon may still be alive and merely
|
|
* slow, so this is not a connection loss — but a request AND the cancel sent to clean
|
|
* it up both hitting this means the daemon is wedged with its socket still open.
|
|
*/
|
|
export class DaemonRequestTimeoutError extends DaemonProtocolError {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'DaemonRequestTimeoutError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The one message `isDaemonGoneError` matches that a still-open connection can produce,
|
|
* so it is the only way a wedged-but-connected daemon reaches the respawn path.
|
|
*/
|
|
export const DAEMON_UNAVAILABLE_RECONNECT_MESSAGE = 'Daemon temporarily unavailable; reconnect'
|
|
|
|
export class SessionNotFoundError extends Error {
|
|
constructor(sessionId: string) {
|
|
super(`Session not found: ${sessionId}`)
|
|
this.name = 'SessionNotFoundError'
|
|
}
|
|
}
|
|
|
|
export class TerminalSessionOwnerUnverifiedError extends Error {
|
|
constructor(sessionId: string) {
|
|
super(`Terminal session owner could not be verified: ${sessionId}`)
|
|
this.name = 'TerminalSessionOwnerUnverifiedError'
|
|
}
|
|
}
|
|
|
|
export class TerminalHostGoneError extends Error {
|
|
constructor() {
|
|
super('terminal_host_gone')
|
|
this.name = 'TerminalHostGoneError'
|
|
}
|
|
}
|
|
|
|
// 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 (
|
|
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)
|
|
? new SessionNotFoundError(message.slice(prefix.length))
|
|
: new DaemonProtocolError(message)
|
|
}
|