Files
orca/src/main/daemon/daemon-client-rpc-request.ts
T
Neil fd1dba9db9 fix(daemon): validate spawn cwd asynchronously so one dead share cannot freeze every terminal (#14848)
* fix(daemon): validate spawn cwd asynchronously so one dead share cannot freeze every terminal

createOrAttach validated the working directory synchronously on the daemon's
only thread. Measured on Windows 11 + Ubuntu-24.04:

  existsSync on an unreachable UNC share   21,022 ms
  wsl.exe probe, cold distro                1,266 ms
  wsl.exe probe, warm distro                   59 ms
  existsSync/statSync on healthy \\wsl.localhost  4 ms / 1 ms

A single unreachable share therefore blocks the whole RPC loop past the
client's 30s request ceiling, so every other terminal stalls behind it and
reports `DaemonProtocolError: Request createOrAttach timed out after 30000ms`.
The main process already validates asynchronously and passes prevalidatedCwd
(ipc/pty.ts); the daemon never got the same treatment.

Add validateWorkingDirectoryAsync (one stat, not exists-then-stat, so an
unreachable share is not paid for twice) and await it from the daemon spawn
preflights. spawnSubprocess now returns SubprocessHandle | Promise<...>, which
existing sync stubs still satisfy.

Deliberately not bounding the stat with a timeout: the 30s ceiling comes from
blocking the shared loop, not from the duration. A timeout cannot tell "slow
share" from "gone share", so it would fail spawns that succeed today at 3-8s
on a cold VPN mount, and trade an accurate "working directory does not exist"
for a guess.

The new await opened a race: it sits between the "already exists?" check and
the sessions.set that publishes the session, so two concurrent creates for one
session id both spawned. Gate creation per session id; distinct ids still spawn
in parallel.

STA-4470

* fix(daemon): fence async spawn lifecycle
2026-08-16 01:16:11 -07:00

108 lines
3.0 KiB
TypeScript

import type { Socket } from 'node:net'
import { encodeNdjson } from './ndjson'
import { DaemonProtocolError } from './types'
import type { DaemonPendingRequests } from './daemon-client-pending-requests'
type DaemonRpcRequestOptions = {
socket: Socket
pendingRequests: DaemonPendingRequests
id: string
type: string
payload: unknown
timeoutMs: number
signal?: AbortSignal
onCreateCancellationFailure: () => void
settleCreateCancellation: (sessionId: string, requestId: string) => Promise<{ canceled: boolean }>
}
export function requestDaemonRpc<T>(opts: DaemonRpcRequestOptions): Promise<T> {
const { payload, type } = opts
const createSessionId =
type === 'createOrAttach' && payload !== null && typeof payload === 'object'
? Reflect.get(payload, 'sessionId')
: null
const requestPayload =
type === 'createOrAttach' && payload !== null && typeof payload === 'object'
? { ...payload, cancelAfterMs: Math.max(1, opts.timeoutMs - 100) }
: payload
const encoded = encodeNdjson({
id: opts.id,
type,
...(requestPayload !== undefined ? { payload: requestPayload } : {})
})
return new Promise<T>((resolve, reject) => {
let sent = false
let cancellationStarted = false
let settled = false
const removeAbortListener = (): void => opts.signal?.removeEventListener('abort', onAbort)
const rejectAndDrop = (error: Error): void => {
if (settled) {
return
}
settled = true
opts.pendingRequests.drop(opts.id)
removeAbortListener()
clearTimeout(timer)
reject(error)
}
const cancelCreate = (error: Error): void => {
if (cancellationStarted) {
return
}
cancellationStarted = true
clearTimeout(timer)
if (!sent || typeof createSessionId !== 'string') {
rejectAndDrop(error)
return
}
void opts
.settleCreateCancellation(createSessionId, opts.id)
.then((result) => {
if (result.canceled) {
rejectAndDrop(error)
}
})
.catch(() => {
if (!settled) {
opts.onCreateCancellationFailure()
}
})
}
const timer = setTimeout(() => {
const error = new DaemonProtocolError(`Request ${type} timed out after ${opts.timeoutMs}ms`)
if (typeof createSessionId === 'string') {
cancelCreate(error)
} else {
rejectAndDrop(error)
}
}, opts.timeoutMs)
const onAbort = (): void => {
removeAbortListener()
cancelCreate(new Error('client_disconnected'))
}
opts.pendingRequests.add(opts.id, {
resolve: (value) => {
settled = true
removeAbortListener()
resolve(value as T)
},
reject: (error) => {
settled = true
removeAbortListener()
reject(error)
},
timer
})
opts.signal?.addEventListener('abort', onAbort, { once: true })
if (opts.signal?.aborted) {
onAbort()
return
}
sent = true
opts.socket.write(encoded)
})
}