mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
* fix(codex): the provider supervisor outlives its provider group when stopped A signalled supervisor forwards the signal to the provider group, escalates to SIGKILL after the grace, and exits only once the group is gone, so recovery's proof that the recorded pid is dead also proves the provider is. It refuses to spawn when its parent is already not the owner named in its spec, and watches that owner rather than whichever parent it first saw. The grace is a spec field. Recovery's SIGTERM stage now outlasts the supervisor's own stop, since a SIGKILL that lands first cannot be handled and leaves the group running. * fix(codex): a closed owner pipe no longer ends the supervisor before its provider group When Orca dies, the supervisor's stdout pipe has no reader. Provider output in the window before the parent-death watch fired raised an unhandled EPIPE that exited the supervisor with the provider group still running. * fix(codex): bound the supervisor grace so recovery's SIGTERM stage always covers it Recovery sized its SIGTERM stage from the default grace, so a launch with a longer grace would be SIGKILLed mid-stop and orphan its group with no test noticing. The spec now refuses any grace above one exported maximum, and recovery derives its SIGTERM stage from that maximum. * fix(codex): every supervisor stop asks the provider with SIGTERM first Owner death, stdin end after the grace, and a signal to the supervisor now all take one path: SIGTERM the provider group, SIGKILL it after the grace, and exit only once it is gone. The signal handlers are registered before the provider is spawned, so a stop that lands in the spawn window still reaps it. The longest stop grows to two graces plus the reap wait, and both recovery's SIGTERM stage and the connection's graceful close now wait that long before forcing, since forcing the supervisor sooner can orphan its group. * fix(codex): give the provider 1 s after stdin end and 3 s after SIGTERM to flush before SIGKILL The supervisor's stop was stdin end, 1.25 s, SIGTERM, 1.25 s, SIGKILL. Codex now gets 3 s after SIGTERM to flush its state. The two graces are separate constants, the longest stop they derive becomes 5.5 s, and a test keeps it inside quit's 8 s child-eviction bound. * test(codex): count eviction's pre-stop drain in the quit budget test Eviction drains the sink for up to 1 s before it stops the child, inside the same 8 s bound.
777 lines
28 KiB
TypeScript
777 lines
28 KiB
TypeScript
import { EventEmitter } from 'node:events'
|
|
import { realpathSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { PassThrough } from 'node:stream'
|
|
import { providerDiagnosticOf } from '../../shared/agent-session-failure'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { spawnProcess } from '../../shared/child-process/run-process'
|
|
import {
|
|
isCodexAppServerRequestError,
|
|
openCodexAppServerConnection,
|
|
type CodexAppServerConnection,
|
|
type CodexAppServerConnectionHandlers
|
|
} from './codex-app-server-connection'
|
|
import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from './codex-app-server-posix-supervisor'
|
|
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
|
|
|
|
// close() waits out the supervisor's own stop before forcing the tree.
|
|
const GRACEFUL_EXIT_MS = process.platform === 'win32' ? 1_500 : PROVIDER_SUPERVISOR_MAX_STOP_MS
|
|
|
|
const originalCodexHome = process.env.CODEX_HOME
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
if (originalCodexHome === undefined) {
|
|
delete process.env.CODEX_HOME
|
|
} else {
|
|
process.env.CODEX_HOME = originalCodexHome
|
|
}
|
|
})
|
|
|
|
/**
|
|
* A real `node -e` child speaking the same JSONL framing Codex does. Slower than
|
|
* a stub, but it is the only thing that proves the spawn, the environment, and
|
|
* both traffic directions actually work end to end.
|
|
*/
|
|
const FAKE_APP_SERVER = String.raw`
|
|
const readline = require('node:readline')
|
|
const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n')
|
|
readline.createInterface({ input: process.stdin }).on('line', (line) => {
|
|
const message = JSON.parse(line)
|
|
if (message.method === 'initialize') return send({ id: message.id, result: {} })
|
|
if (message.method === 'test/env') {
|
|
return send({ id: message.id, result: { codexHome: process.env.CODEX_HOME ?? null } })
|
|
}
|
|
if (message.method === 'test/cwd') {
|
|
return send({ id: message.id, result: { cwd: process.cwd() } })
|
|
}
|
|
if (message.method === 'test/notify') {
|
|
send({ method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-7' } } })
|
|
return send({ id: message.id, result: {} })
|
|
}
|
|
if (message.method === 'test/ask') {
|
|
return send({ id: 99, method: 'item/fileChange/requestApproval', params: { itemId: 'i1' } })
|
|
}
|
|
if (message.method === 'test/refuse') {
|
|
return send({ id: message.id, error: { code: -32602, message: 'bad params' } })
|
|
}
|
|
if (message.method === 'test/missing') {
|
|
return send({ id: message.id, error: { code: -32601, message: 'method not found' } })
|
|
}
|
|
if (message.id === 99) {
|
|
return send({ method: 'test/answered', params: message })
|
|
}
|
|
})
|
|
`
|
|
|
|
async function openFakeServer(
|
|
handlers: CodexAppServerConnectionHandlers = {},
|
|
env?: Record<string, string>,
|
|
envToDelete?: string[],
|
|
cwd?: string
|
|
): Promise<CodexAppServerConnection> {
|
|
return openCodexAppServerConnection(
|
|
{ command: process.execPath, args: ['-e', FAKE_APP_SERVER], env, envToDelete, cwd },
|
|
handlers
|
|
)
|
|
}
|
|
|
|
type StubChild = EventEmitter & {
|
|
stdout: PassThrough
|
|
stderr: PassThrough
|
|
stdin: PassThrough
|
|
pid: number
|
|
kill: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
/** Full control over framing and death, which a real child cannot give. */
|
|
function stubChild(options: { exitOnStdinEnd?: boolean } = {}): {
|
|
child: StubChild
|
|
spawnImpl: typeof spawnProcess
|
|
written: Record<string, unknown>[]
|
|
} {
|
|
const child = new EventEmitter() as StubChild
|
|
child.stdout = new PassThrough()
|
|
child.stderr = new PassThrough()
|
|
child.stdin = new PassThrough()
|
|
// Keep the synthetic pid outside any real process table so teardown never
|
|
// mistakes an unrelated process for this stub.
|
|
child.pid = 9_999_999
|
|
child.kill = vi.fn()
|
|
const written: Record<string, unknown>[] = []
|
|
child.stdin.on('data', (chunk: Buffer) => {
|
|
for (const line of chunk.toString('utf8').split('\n')) {
|
|
if (line.trim()) {
|
|
written.push(JSON.parse(line) as Record<string, unknown>)
|
|
}
|
|
}
|
|
})
|
|
if (options.exitOnStdinEnd !== false) {
|
|
child.stdin.on('finish', () => child.emit('exit', 0, null))
|
|
}
|
|
return { child, spawnImpl: (() => child) as unknown as typeof spawnProcess, written }
|
|
}
|
|
|
|
/** Answers the handshake so `openCodexAppServerConnection` can resolve. */
|
|
function answerInitialize(child: StubChild): void {
|
|
child.stdin.once('data', () => {
|
|
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`)
|
|
})
|
|
}
|
|
|
|
/** Stream writes land a tick later, so the stderr tail is only complete here. */
|
|
async function flushStreams(): Promise<void> {
|
|
await new Promise((resolve) => setImmediate(resolve))
|
|
}
|
|
|
|
function rejection(promise: Promise<unknown>): Promise<Error> {
|
|
return promise.then(
|
|
() => {
|
|
throw new Error('expected the call to reject')
|
|
},
|
|
(error: Error) => error
|
|
)
|
|
}
|
|
|
|
function commandCompletionFixture(
|
|
targetBytes: number,
|
|
itemId = 'item-large'
|
|
): { line: string; output: string } {
|
|
const frame = {
|
|
method: 'item/completed',
|
|
params: {
|
|
turnId: 'turn-large',
|
|
item: { id: itemId, type: 'commandExecution', aggregated_output: '' }
|
|
}
|
|
}
|
|
const emptyBytes = Buffer.byteLength(JSON.stringify(frame), 'utf8')
|
|
const remaining = targetBytes - emptyBytes
|
|
if (remaining < 0) {
|
|
throw new Error(`target ${targetBytes} is smaller than fixture envelope ${emptyBytes}`)
|
|
}
|
|
const output = `${'\n'.repeat(Math.floor(remaining / 2))}${remaining % 2 ? 'x' : ''}`
|
|
frame.params.item.aggregated_output = output
|
|
const line = JSON.stringify(frame)
|
|
expect(Buffer.byteLength(line, 'utf8')).toBe(targetBytes)
|
|
return { line: `${line}\n`, output }
|
|
}
|
|
|
|
function commandCompletionLine(targetBytes: number): string {
|
|
return commandCompletionFixture(targetBytes).line
|
|
}
|
|
|
|
function responseLine(targetBytes: number, id: number): string {
|
|
const frame = { id, result: { data: '' } }
|
|
const emptyBytes = Buffer.byteLength(JSON.stringify(frame), 'utf8')
|
|
frame.result.data = 'x'.repeat(targetBytes - emptyBytes)
|
|
const line = JSON.stringify(frame)
|
|
expect(Buffer.byteLength(line, 'utf8')).toBe(targetBytes)
|
|
return `${line}\n`
|
|
}
|
|
|
|
describe('openCodexAppServerConnection', () => {
|
|
it('advertises the experimental API required for rollout-path resume', async () => {
|
|
const { child, spawnImpl, written } = stubChild()
|
|
answerInitialize(child)
|
|
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{},
|
|
spawnImpl
|
|
)
|
|
|
|
expect(written[0]).toMatchObject({
|
|
method: 'initialize',
|
|
params: { capabilities: { experimentalApi: true } }
|
|
})
|
|
await connection.close()
|
|
})
|
|
|
|
it('reports the spawned pid before it sends the handshake', async () => {
|
|
const { child, spawnImpl, written } = stubChild()
|
|
answerInitialize(child)
|
|
const writtenAtSpawn: number[] = []
|
|
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{
|
|
onSpawned: async (pid) => {
|
|
writtenAtSpawn.push(written.length)
|
|
expect(pid).toBe(child.pid)
|
|
}
|
|
},
|
|
spawnImpl
|
|
)
|
|
|
|
// The owner is durable before initialize, so a crash mid-handshake leaves it stoppable.
|
|
expect(writtenAtSpawn).toEqual([0])
|
|
expect(written[0]).toMatchObject({ method: 'initialize' })
|
|
await connection.close()
|
|
})
|
|
|
|
it('reaps the child and never handshakes when its spawn cannot be recorded', async () => {
|
|
const { spawnImpl, written } = stubChild()
|
|
|
|
await expect(
|
|
openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{
|
|
onSpawned: async () => {
|
|
throw new Error('agent_session_checkpoint_stale')
|
|
}
|
|
},
|
|
spawnImpl
|
|
)
|
|
).rejects.toThrow('agent_session_checkpoint_stale')
|
|
expect(written).toEqual([])
|
|
})
|
|
|
|
it('completes the handshake and keeps the child alive across calls', async () => {
|
|
const notifications: { method: string; params: unknown }[] = []
|
|
const connection = await openFakeServer({
|
|
onNotification: (method, params) => notifications.push({ method, params })
|
|
})
|
|
|
|
await connection.request('test/notify')
|
|
await connection.request('test/notify')
|
|
|
|
expect(connection.pid).toBeGreaterThan(0)
|
|
expect(connection.closed).toBe(false)
|
|
expect(notifications).toHaveLength(2)
|
|
expect(notifications[0]).toEqual({
|
|
method: 'turn/started',
|
|
params: { threadId: 'thread-1', turn: { id: 'turn-7' } }
|
|
})
|
|
await connection.close()
|
|
expect(connection.closed).toBe(true)
|
|
})
|
|
|
|
it('applies the environment overlay after stripping inherited keys', async () => {
|
|
process.env.CODEX_HOME = '/tmp/inherited-home'
|
|
const pinned = await openFakeServer({}, { CODEX_HOME: '/tmp/pinned-home' })
|
|
expect(await pinned.request('test/env')).toEqual({ codexHome: '/tmp/pinned-home' })
|
|
await pinned.close()
|
|
|
|
const stripped = await openFakeServer({}, undefined, ['CODEX_HOME'])
|
|
expect(await stripped.request('test/env')).toEqual({ codexHome: null })
|
|
await stripped.close()
|
|
})
|
|
|
|
it('starts the provider in the resolved workspace directory', async () => {
|
|
const workspace = realpathSync(tmpdir())
|
|
const connection = await openFakeServer({}, undefined, undefined, workspace)
|
|
|
|
await expect(connection.request('test/cwd')).resolves.toEqual({ cwd: workspace })
|
|
await connection.close()
|
|
})
|
|
|
|
it('routes a server request to the handler and writes the reply back', async () => {
|
|
const requests: { id: number | string; method: string }[] = []
|
|
let resolveAnswered: (params: unknown) => void = () => {}
|
|
const answered = new Promise<unknown>((resolve) => {
|
|
resolveAnswered = resolve
|
|
})
|
|
const connection = await openFakeServer({
|
|
onServerRequest: (request) => {
|
|
requests.push({ id: request.id, method: request.method })
|
|
connection.respond(request.id, { decision: 'accept' })
|
|
},
|
|
onNotification: (method, params) => {
|
|
if (method === 'test/answered') {
|
|
resolveAnswered(params)
|
|
}
|
|
}
|
|
})
|
|
|
|
connection.notify('test/ask')
|
|
|
|
expect(await answered).toEqual({ id: 99, result: { decision: 'accept' } })
|
|
expect(requests).toEqual([{ id: 99, method: 'item/fileChange/requestApproval' }])
|
|
await connection.close()
|
|
})
|
|
|
|
it('classifies a refusal apart from a missing method', async () => {
|
|
const connection = await openFakeServer()
|
|
|
|
const refusal = await connection.request('test/refuse').catch((error: unknown) => error)
|
|
const missing = await connection.request('test/missing').catch((error: unknown) => error)
|
|
|
|
expect(isCodexAppServerRequestError(refusal)).toBe(true)
|
|
expect((refusal as Error).message).toContain('bad params')
|
|
// Codex's own words, apart from Orca's prefix, for a person to read.
|
|
expect(providerDiagnosticOf(refusal)).toEqual({ text: 'bad params', audience: 'person' })
|
|
expect(providerDiagnosticOf(missing)).toBeUndefined()
|
|
expect(isCodexAppServerUnsupportedError(missing)).toBe(true)
|
|
expect(isCodexAppServerRequestError(missing)).toBe(false)
|
|
await connection.close()
|
|
})
|
|
|
|
it('reassembles a message split mid-character across chunks', async () => {
|
|
const { child, spawnImpl } = stubChild()
|
|
answerInitialize(child)
|
|
const notifications: unknown[] = []
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{ onNotification: (_method, params) => notifications.push(params) },
|
|
spawnImpl
|
|
)
|
|
|
|
const payload = Buffer.from(
|
|
`${JSON.stringify({ method: 'item/agentMessage/delta', params: { delta: '日本語' } })}\n`,
|
|
'utf8'
|
|
)
|
|
const split = payload.indexOf(Buffer.from('日', 'utf8')) + 1
|
|
child.stdout.write(payload.subarray(0, split))
|
|
child.stdout.write(payload.subarray(split))
|
|
await vi.waitFor(() => expect(notifications).toHaveLength(1))
|
|
|
|
expect(notifications[0]).toEqual({ delta: '日本語' })
|
|
await connection.close()
|
|
})
|
|
|
|
it('surfaces valid but unclassified frames instead of dropping them', async () => {
|
|
const { child, spawnImpl } = stubChild()
|
|
answerInitialize(child)
|
|
const frames: { kind: string; payload: unknown }[] = []
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{ onUnhandledFrame: (kind, payload) => frames.push({ kind, payload }) },
|
|
spawnImpl
|
|
)
|
|
|
|
child.stdout.write(`${JSON.stringify({ id: 'late-string-id', result: { value: 1 } })}\n`)
|
|
child.stdout.write(`${JSON.stringify({ id: 999, result: { value: 2 } })}\n`)
|
|
await vi.waitFor(() => expect(frames).toHaveLength(2))
|
|
|
|
expect(frames.map((frame) => frame.kind)).toEqual(['frame:unclassified', 'response:unmatched'])
|
|
await connection.close()
|
|
})
|
|
|
|
it('fails in-flight requests and reports an unexpected exit once', async () => {
|
|
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
|
answerInitialize(child)
|
|
const exits: string[] = []
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{ onExit: (error) => exits.push(error.message) },
|
|
spawnImpl
|
|
)
|
|
|
|
const inFlight = rejection(connection.request('turn/start'))
|
|
child.stderr.write('codex crashed\n')
|
|
await flushStreams()
|
|
child.emit('exit', 1, null)
|
|
child.emit('close', 1, null)
|
|
|
|
expect((await inFlight).message).toContain('codex crashed')
|
|
expect(exits).toHaveLength(1)
|
|
await connection.close()
|
|
})
|
|
|
|
it('classifies a CLI without the app-server subcommand as unsupported', async () => {
|
|
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
|
const opening = openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{},
|
|
spawnImpl
|
|
).catch((error: unknown) => error)
|
|
|
|
child.stderr.write("error: unrecognized subcommand 'app-server'\n")
|
|
await flushStreams()
|
|
child.emit('exit', 2, null)
|
|
child.emit('close', 2, null)
|
|
|
|
expect(isCodexAppServerUnsupportedError(await opening)).toBe(true)
|
|
})
|
|
|
|
it('exposes an unproven handshake child for later cleanup', async () => {
|
|
vi.useFakeTimers()
|
|
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
|
child.stdin.once('data', () => {
|
|
child.stdout.write(
|
|
`${JSON.stringify({ id: 1, error: { code: -32602, message: 'initialize failed' } })}\n`
|
|
)
|
|
})
|
|
const opening = rejection(
|
|
openCodexAppServerConnection({ command: 'codex', args: ['app-server'] }, {}, spawnImpl)
|
|
)
|
|
|
|
await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 3_500)
|
|
const error = (await opening) as Error & { connection?: CodexAppServerConnection }
|
|
|
|
expect(error.name).toBe('CodexAppServerHandshakeExitUnprovenError')
|
|
expect(error.connection).toBeDefined()
|
|
child.emit('close', 1, null)
|
|
await expect(error.connection?.close()).resolves.toBe(true)
|
|
})
|
|
|
|
it('times out one request without ending the connection', async () => {
|
|
vi.useFakeTimers()
|
|
const { child, spawnImpl } = stubChild()
|
|
answerInitialize(child)
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{},
|
|
spawnImpl
|
|
)
|
|
|
|
const slow = rejection(connection.request('turn/start', undefined, { timeoutMs: 50 }))
|
|
await vi.advanceTimersByTimeAsync(60)
|
|
|
|
expect((await slow).name).toBe('CodexAppServerTimeoutError')
|
|
expect(connection.closed).toBe(false)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
})
|
|
|
|
it('kills a child that ignores stdin EOF', async () => {
|
|
vi.useFakeTimers()
|
|
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
|
answerInitialize(child)
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{},
|
|
spawnImpl
|
|
)
|
|
child.kill.mockImplementation(() => {
|
|
child.emit('exit', null, 'SIGKILL')
|
|
return true
|
|
})
|
|
|
|
const closing = connection.close()
|
|
await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 500)
|
|
await closing
|
|
|
|
await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL'))
|
|
})
|
|
|
|
it('reports unproven close when forced termination did not produce an exit event', async () => {
|
|
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
|
answerInitialize(child)
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{},
|
|
spawnImpl
|
|
)
|
|
|
|
await expect(connection.close()).resolves.toBe(false)
|
|
}, 10_000)
|
|
|
|
it('shares one eventual exit proof across concurrent close callers', async () => {
|
|
vi.useFakeTimers()
|
|
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
|
answerInitialize(child)
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{},
|
|
spawnImpl
|
|
)
|
|
child.kill.mockImplementation(() => {
|
|
setTimeout(() => child.emit('exit', null, 'SIGKILL'), 10)
|
|
return true
|
|
})
|
|
|
|
const first = connection.close()
|
|
const second = connection.close()
|
|
await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 2_600)
|
|
|
|
await expect(Promise.all([first, second])).resolves.toEqual([true, true])
|
|
expect(child.kill.mock.calls.map(([signal]) => signal)).toEqual(['SIGSTOP', 'SIGKILL'])
|
|
})
|
|
|
|
it('allows a later close to observe exit after an unproven attempt', async () => {
|
|
vi.useFakeTimers()
|
|
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
|
answerInitialize(child)
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{},
|
|
spawnImpl
|
|
)
|
|
|
|
const first = connection.close()
|
|
await vi.advanceTimersByTimeAsync(GRACEFUL_EXIT_MS + 3_500)
|
|
await expect(first).resolves.toBe(false)
|
|
child.emit('exit', 0, null)
|
|
|
|
await expect(connection.close()).resolves.toBe(true)
|
|
})
|
|
|
|
it.each([1_090_188, 2_900_090])(
|
|
'accepts a realistic %i-byte escaped command completion and keeps processing',
|
|
async (frameBytes) => {
|
|
const { child, spawnImpl } = stubChild()
|
|
answerInitialize(child)
|
|
const completed: unknown[] = []
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{
|
|
onNotification: (method, params) => {
|
|
if (method === 'item/completed') {
|
|
completed.push(params)
|
|
}
|
|
}
|
|
},
|
|
spawnImpl
|
|
)
|
|
|
|
const line = Buffer.from(commandCompletionLine(frameBytes), 'utf8')
|
|
const split = Math.floor(line.length / 3)
|
|
child.stdout.write(line.subarray(0, split))
|
|
child.stdout.write(line.subarray(split, split * 2))
|
|
child.stdout.write(line.subarray(split * 2))
|
|
child.stdout.write('{"method":"turn/completed","params":{"turn":{"id":"turn-large"}}}\n')
|
|
await vi.waitFor(() => expect(completed).toHaveLength(1))
|
|
|
|
expect(
|
|
(completed[0] as { item: { aggregated_output: string } }).item.aggregated_output.length
|
|
).toBeGreaterThan(500_000)
|
|
expect(connection.closed).toBe(false)
|
|
await connection.close()
|
|
}
|
|
)
|
|
|
|
it('accepts two realistic large command completions without losing either payload', async () => {
|
|
const { child, spawnImpl } = stubChild()
|
|
answerInitialize(child)
|
|
const completed: { item: { id: string; aggregated_output: string } }[] = []
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{
|
|
onNotification: (method, params) => {
|
|
if (method === 'item/completed') {
|
|
completed.push(params as { item: { id: string; aggregated_output: string } })
|
|
}
|
|
}
|
|
},
|
|
spawnImpl
|
|
)
|
|
const fixtures = [
|
|
commandCompletionFixture(1_090_188, 'item-large-a'),
|
|
commandCompletionFixture(2_900_090, 'item-large-b')
|
|
]
|
|
|
|
child.stdout.write(fixtures[0]!.line)
|
|
child.stdout.write(fixtures[1]!.line)
|
|
await vi.waitFor(() => expect(completed).toHaveLength(2))
|
|
|
|
expect(completed.map((entry) => entry.item.id)).toEqual(['item-large-a', 'item-large-b'])
|
|
expect(
|
|
completed.map((entry) => Buffer.byteLength(entry.item.aggregated_output, 'utf8'))
|
|
).toEqual(fixtures.map((fixture) => Buffer.byteLength(fixture.output, 'utf8')))
|
|
expect(connection.closed).toBe(false)
|
|
await connection.close()
|
|
})
|
|
|
|
it('accepts a response beyond the daemon wire limit and keeps the provider alive', async () => {
|
|
const { child, spawnImpl } = stubChild()
|
|
answerInitialize(child)
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{},
|
|
spawnImpl
|
|
)
|
|
|
|
const large = connection.request('thread/resume')
|
|
child.stdout.write(responseLine(16 * 1024 * 1024 + 1, 2))
|
|
await expect(large).resolves.toMatchObject({ data: expect.any(String) })
|
|
expect(child.kill).not.toHaveBeenCalled()
|
|
expect(connection.closed).toBe(false)
|
|
|
|
const followup = connection.request('turn/start')
|
|
child.stdout.write('{"id":3,"result":{"turn":{"id":"turn-next"}}}\n')
|
|
await expect(followup).resolves.toEqual({ turn: { id: 'turn-next' } })
|
|
await connection.close()
|
|
})
|
|
|
|
it('keeps malformed and non-object JSON non-fatal and processes the next record', async () => {
|
|
const { child, spawnImpl } = stubChild()
|
|
answerInitialize(child)
|
|
const frames: { kind: string; payload: unknown }[] = []
|
|
const notifications: string[] = []
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{
|
|
onUnhandledFrame: (kind, payload) => frames.push({ kind, payload }),
|
|
onNotification: (method) => notifications.push(method)
|
|
},
|
|
spawnImpl
|
|
)
|
|
|
|
child.stdout.write('not json\n[]\n{"method":"turn/completed","params":{}}\n')
|
|
await vi.waitFor(() => expect(notifications).toEqual(['turn/completed']))
|
|
|
|
expect(frames).toEqual([
|
|
{ kind: 'frame:invalid-json', payload: 'not json' },
|
|
{ kind: 'frame:invalid-json', payload: '[]' }
|
|
])
|
|
expect(connection.closed).toBe(false)
|
|
await connection.close()
|
|
})
|
|
|
|
it('pauses between coalesced records and resumes the retained remainder', async () => {
|
|
const { child, spawnImpl } = stubChild()
|
|
answerInitialize(child)
|
|
const notifications: string[] = []
|
|
let connection: CodexAppServerConnection
|
|
connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{
|
|
onNotification: (method) => {
|
|
notifications.push(method)
|
|
if (notifications.length === 1) {
|
|
connection.pauseReading?.()
|
|
}
|
|
}
|
|
},
|
|
spawnImpl
|
|
)
|
|
|
|
child.stdout.write(
|
|
'{"method":"item/started","params":{}}\n{"method":"item/completed","params":{}}\n'
|
|
)
|
|
await vi.waitFor(() => expect(notifications).toEqual(['item/started']))
|
|
connection.resumeReading?.()
|
|
await vi.waitFor(() => expect(notifications).toEqual(['item/started', 'item/completed']))
|
|
|
|
await connection.close()
|
|
})
|
|
|
|
it.each([
|
|
{
|
|
kind: 'notification',
|
|
frame: { method: 'turn/started', params: { turn: { id: 'turn-1' } } }
|
|
},
|
|
{
|
|
kind: 'server request',
|
|
frame: { id: 41, method: 'item/fileChange/requestApproval', params: { itemId: 'item-1' } }
|
|
}
|
|
])('surfaces a synchronous $kind handler failure as a terminal exit', async ({ frame }) => {
|
|
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
|
answerInitialize(child)
|
|
const exits: string[] = []
|
|
const fail = (): never => {
|
|
throw new Error('structured sink failed')
|
|
}
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{
|
|
onNotification: fail,
|
|
onServerRequest: fail,
|
|
onExit: (error) => exits.push(error.message)
|
|
},
|
|
spawnImpl
|
|
)
|
|
child.kill.mockImplementation(() => {
|
|
child.emit('exit', null, 'SIGKILL')
|
|
return true
|
|
})
|
|
|
|
const inFlight = rejection(connection.request('turn/start'))
|
|
child.stdout.write(`${JSON.stringify(frame)}\n`)
|
|
|
|
expect((await inFlight).message).toContain('structured sink failed')
|
|
expect(exits).toEqual([expect.stringContaining('structured sink failed')])
|
|
expect(connection.closed).toBe(true)
|
|
await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL'))
|
|
await connection.close()
|
|
})
|
|
|
|
it('reports one exit for a death that arrives through two listeners', async () => {
|
|
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
|
answerInitialize(child)
|
|
const exits: string[] = []
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{ onExit: (error) => exits.push(error.message) },
|
|
spawnImpl
|
|
)
|
|
|
|
child.emit('error', new Error('provider transport failed'))
|
|
child.stderr.write('provider died\n')
|
|
await flushStreams()
|
|
child.emit('exit', null, 'SIGKILL')
|
|
child.emit('close', null, 'SIGKILL')
|
|
|
|
expect(exits).toHaveLength(1)
|
|
// The first cause survives; the generic exit that follows does not overwrite it.
|
|
expect(exits[0]).toContain('provider transport failed')
|
|
await connection.close()
|
|
})
|
|
|
|
it('does not report recovery for a handler failure until child exit is observed', async () => {
|
|
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
|
answerInitialize(child)
|
|
const exits: string[] = []
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{
|
|
onNotification: () => {
|
|
throw new Error('structured sink failed')
|
|
},
|
|
onExit: (error) => exits.push(error.message)
|
|
},
|
|
spawnImpl
|
|
)
|
|
|
|
const inFlight = rejection(connection.request('turn/start'))
|
|
child.stdout.write('{"method":"turn/started","params":{}}\n')
|
|
await flushStreams()
|
|
|
|
expect(exits).toHaveLength(0)
|
|
expect((await inFlight).message).toContain('structured sink failed')
|
|
|
|
child.emit('exit', null, 'SIGKILL')
|
|
expect(exits).toHaveLength(1)
|
|
await connection.close()
|
|
})
|
|
|
|
it('treats a broken stdin pipe as the end of the transport', async () => {
|
|
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
|
answerInitialize(child)
|
|
const exits: string[] = []
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{ onExit: (error) => exits.push(error.message) },
|
|
spawnImpl
|
|
)
|
|
child.kill.mockImplementation(() => {
|
|
child.emit('exit', null, 'SIGKILL')
|
|
return true
|
|
})
|
|
|
|
const inFlight = rejection(connection.request('turn/start'))
|
|
child.stdin.emit('error', new Error('write EPIPE'))
|
|
|
|
expect((await inFlight).message).toContain('EPIPE')
|
|
expect(exits).toHaveLength(1)
|
|
// A child nobody can write to is not a live session: the owner must see the
|
|
// connection as gone rather than keep issuing calls that can only time out.
|
|
expect(connection.closed).toBe(true)
|
|
await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL'))
|
|
expect((await rejection(connection.request('turn/start'))).message).toContain('EPIPE')
|
|
await connection.close()
|
|
})
|
|
|
|
it('keeps a graceful close quiet when stdin breaks during the reap', async () => {
|
|
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
|
|
answerInitialize(child)
|
|
const exits: string[] = []
|
|
const connection = await openCodexAppServerConnection(
|
|
{ command: 'codex', args: ['app-server'] },
|
|
{ onExit: (error) => exits.push(error.message) },
|
|
spawnImpl
|
|
)
|
|
child.stdin.on('finish', () => child.stdin.emit('error', new Error('write EPIPE')))
|
|
child.kill.mockImplementation(() => {
|
|
child.emit('exit', null, 'SIGKILL')
|
|
return true
|
|
})
|
|
|
|
const inFlight = rejection(connection.request('turn/start'))
|
|
await connection.close()
|
|
|
|
expect((await inFlight).message).toContain('EPIPE')
|
|
expect(exits).toHaveLength(0)
|
|
})
|
|
})
|