Files
orca/src/main/codex/codex-app-server-connection.ts
T
Brennan Benson 52c4e0646b Opening or resuming a Codex chat waits for Codex instead of failing after 30 seconds (#30395)
* fix(codex): opening or resuming a Codex chat waits for Codex instead of failing after 30 s

The handshake (15 s) and thread/start / thread/resume (30 s) had their own deadlines because
the Codex start ignored the host's startup signal. It now honours it, as Claude's start does, so
those requests carry no deadline: the host's startup limits, a close, a Stop or quit end a start
that hangs, and the abort reaps Codex. Calls after the start keep the 30 s default for now: they
run in the session's queue, which a Stop or a close waits behind.

* test(codex): keep the handshake wait test in its own file under the line limit

* fix(codex): the one-shot app-server runner takes the same no-deadline option as the chat connection

* test: accept nullable Codex request timeout in probe fake

* fix(codex): bound direct opens at their caller

* Remove obsolete Codex startup timeout scenario
2026-10-20 11:49:29 -07:00

281 lines
10 KiB
TypeScript

import type { PipedProcessSpawner } from '@orca/process-host/process-spec'
import { spawnProcess } from '@orca/process-host'
import { spawnManagedProviderProcess } from '../provider-process/managed-provider-process'
import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch'
import { buildCodexAppServerExitError } from './codex-app-server-exit-error'
import { withMissingProviderExecutable } from '../provider-process/provider-executable-missing'
import { initializeCodexAppServerConnection } from './codex-app-server-handshake'
import { CodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof'
import {
CodexAppServerTimeoutError,
CodexAppServerUnsupportedError
} from './codex-app-server-session'
import { createCodexAppServerRecordDispatcher } from './codex-app-server-record-dispatch'
import { createProviderRecordReader } from '../provider-process/provider-record-reader'
import { withProviderRequestTimeout } from '../provider-process/provider-request-timeout'
import type {
CodexAppServerConnection,
CodexAppServerConnectionHandlers
} from './codex-app-server-connection-types'
export type {
CodexAppServerConnection,
CodexAppServerConnectionHandlers,
CodexAppServerServerRequest
} from './codex-app-server-connection-types'
export {
CodexAppServerRequestError,
isCodexAppServerRequestError
} from './codex-app-server-request-error'
export { CodexAppServerFrameSizeError } from './codex-app-server-frame-size-error'
export { ROOT_ONLY_GRACEFUL_EXIT_MS as GRACEFUL_EXIT_MS } from '../provider-process/provider-process-close'
// Structured chat needs a persistent bidirectional child and per-request deadlines;
// the request-scoped app-server runner cannot carry approvals or streamed turns.
export type CodexAppServerLaunch = ProviderProcessLaunch
// Why: calls made after the start run in the session's queue, which a Stop or a close waits behind,
// so their deadline is what frees it from a Codex that never answers. Temporary: it goes once a
// Stop or close can end the child without queueing behind its request.
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
/** Builds the transport so an owner can record its process before initialization. */
async function connectCodexAppServer(
launch: CodexAppServerLaunch,
handlers: CodexAppServerConnectionHandlers = {},
spawnImpl: PipedProcessSpawner,
initialize: boolean
): Promise<CodexAppServerConnection> {
const managed = spawnManagedProviderProcess(launch, {
spawnImpl,
site: 'codex-app-server-teardown'
})
const { child } = managed
let nextRequestId = 1
let closing = false
let exitReported = false
/** First terminal cause, or null while the transport is still usable. Set once:
* a child that dies reaches us through several listeners, and the specific
* first cause is the one worth reporting. */
let terminalError: Error | null = null
function buildExitError(cause?: Error): Error {
const error = buildCodexAppServerExitError(managed.stderrTail(), cause)
return managed.executableMissing ? withMissingProviderExecutable(error) : error
}
const dispatcher = createCodexAppServerRecordDispatcher({
handlers,
writeResponse,
onProtocolFailure: (error) => {
handleUnexpectedEnd(error)
void managed.close()
}
})
/** A death nobody asked for kills every in-flight call AND tells the owner,
* which is the only signal the session has that its lease is now worthless.
* Once only: a fatal handler failure kills the child before `close` arrives,
* and a spawn failure arrives as both `error` and `close`. */
function handleUnexpectedEnd(cause?: Error): void {
if (!terminalError) {
terminalError = buildExitError(cause)
dispatcher.failPending(terminalError)
}
// Transport/protocol failures make the connection unusable immediately so
// callers do not hang, but recovery must not treat that as a child exit
// until the execution host has observed `exit`/`close`.
if (managed.rootVerdict === 'exited' && !exitReported) {
exitReported = true
handlers.onExit?.(terminalError, { expected: closing })
}
}
child.on('error', (error) => {
handleUnexpectedEnd(error)
})
managed.onExit(() => handleUnexpectedEnd())
child.stdin.on('error', (error) => {
// A broken pipe is terminal, not one failed write: every later request can
// only error or time out, so the session must learn its lease is worthless
// instead of staying live in front of a child nobody can reach. During a
// close the reap is already under way and `exited` must stay honest, or
// `close` would skip the kill it still owes.
if (closing) {
dispatcher.failPending(error)
return
}
handleUnexpectedEnd(error)
void managed.close()
})
const recordReader = createProviderRecordReader({
stdout: child.stdout,
onRecord: (parsed, line) => {
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
handlers.onUnhandledFrame?.('frame:invalid-json', line)
return
}
dispatcher.dispatch(parsed as Record<string, unknown>)
},
onRejected: (rejected) => {
if (rejected.kind === 'invalid-json') {
handlers.onUnhandledFrame?.('frame:invalid-json', rejected.line)
} else {
dispatcher.rejectOversized(rejected)
}
},
onFatal: (error) => {
handleUnexpectedEnd(error)
void managed.close()
}
})
function sendLine(payload: Record<string, unknown>): void {
child.stdin.write(`${JSON.stringify(payload)}\n`)
}
function notify(method: string, params?: Record<string, unknown>): void {
if (managed.rootVerdict === 'exited' || terminalError) {
return
}
try {
sendLine(params === undefined ? { method } : { method, params })
} catch {
// Fire-and-forget; the next request surfaces a dead child.
}
}
function request(
method: string,
params?: Record<string, unknown>,
options: { timeoutMs?: number | null; onReply?: () => void } = {}
): Promise<unknown> {
if (closing) {
return Promise.reject(new Error(`codex app-server connection is closed (${method})`))
}
if (terminalError) {
return Promise.reject(terminalError)
}
if (managed.rootVerdict === 'exited') {
return Promise.reject(buildExitError())
}
const id = nextRequestId++
const timeoutMs =
options.timeoutMs === undefined ? DEFAULT_REQUEST_TIMEOUT_MS : options.timeoutMs
return new Promise<unknown>((resolve, reject) => {
// Null requests are bounded by their caller; timed requests belong to this call.
const timer =
timeoutMs === null
? undefined
: setTimeout(() => {
dispatcher.timeOutPending(id)
reject(
withProviderRequestTimeout(
new CodexAppServerTimeoutError(
`codex app-server ${method} exceeded ${timeoutMs}ms`
),
{ method, timeoutMs }
)
)
}, timeoutMs)
dispatcher.addPending(id, { method, resolve, reject, timer, onReply: options.onReply })
try {
sendLine(params === undefined ? { method, id } : { method, id, params })
} catch (error) {
dispatcher.deletePending(id)
clearTimeout(timer)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
function writeResponse(payload: Record<string, unknown>): void {
if (
managed.rootVerdict === 'exited' ||
terminalError ||
child.stdin.destroyed ||
!child.stdin.writable
) {
return
}
try {
sendLine(payload)
} catch {
// The turn that asked is already gone with the child.
}
}
function close(): Promise<boolean> {
closing ||= managed.rootVerdict !== 'exited'
return managed.close().then((result) => {
dispatcher.failPending(new Error('codex app-server connection closed'))
return result.root === 'exited'
})
}
const connection: CodexAppServerConnection = {
get pid() {
return child.pid
},
get closed() {
return closing || managed.rootVerdict === 'exited' || terminalError !== null
},
get processTreeUnproven() {
const tree = managed.lastCloseResult?.tree
return (
managed.lastCloseResult?.root === 'exited' && (tree === 'unverifiable' || tree === 'live')
)
},
request,
notify,
respond: (id, result) => writeResponse({ id, result }),
respondWithError: (id, code, message) => writeResponse({ id, error: { code, message } }),
pauseReading: recordReader.pause,
resumeReading: recordReader.resume,
close
}
let handshaking = false
try {
// A spawn that failed has no pid; the handshake below reports why.
if (child.pid !== undefined) {
await handlers.onSpawned?.(child.pid)
}
if (initialize) {
handshaking = true
// Direct opens cannot expose the connection for cancellation until initialization finishes.
await initializeCodexAppServerConnection(connection, 15_000)
}
} catch (error) {
if ((await close()) !== true) {
throw new CodexAppServerHandshakeExitUnprovenError(connection, error)
}
throw !handshaking ||
error instanceof CodexAppServerUnsupportedError ||
error instanceof CodexAppServerTimeoutError
? error
: buildExitError(error instanceof Error ? error : new Error(String(error)))
}
return connection
}
/** Publishes the transport after its process identity is recorded, before initialization. */
export function spawnCodexAppServerConnection(
launch: CodexAppServerLaunch,
handlers: CodexAppServerConnectionHandlers = {},
spawnImpl: PipedProcessSpawner = spawnProcess
): Promise<CodexAppServerConnection> {
return connectCodexAppServer(launch, handlers, spawnImpl, false)
}
export function openCodexAppServerConnection(
launch: CodexAppServerLaunch,
handlers: CodexAppServerConnectionHandlers = {},
spawnImpl: PipedProcessSpawner = spawnProcess
): Promise<CodexAppServerConnection> {
return connectCodexAppServer(launch, handlers, spawnImpl, true)
}