orca/src/relay/subprocess-test-utils.ts

176 lines
4.8 KiB
TypeScript

import { spawn, type ChildProcess, type SpawnOptions } from 'child_process'
import {
RELAY_SENTINEL,
FrameDecoder,
encodeJsonRpcFrame,
parseJsonRpcMessage,
MessageType,
type JsonRpcRequest,
type JsonRpcResponse,
type JsonRpcNotification
} from './protocol'
export type RelayProcess = {
proc: ChildProcess
responses: (JsonRpcResponse | JsonRpcNotification)[]
sentinelReceived: Promise<void>
send: (method: string, params?: Record<string, unknown>) => number
sendNotification: (method: string, params?: Record<string, unknown>) => void
waitForResponse: (id: number, timeoutMs?: number) => Promise<JsonRpcResponse>
waitForNotification: (method: string, timeoutMs?: number) => Promise<JsonRpcNotification>
kill: (signal?: NodeJS.Signals) => void
waitForExit: (timeoutMs?: number) => Promise<number | null>
}
export function spawnRelay(
entryPath: string,
args: string[] = [],
options: Pick<SpawnOptions, 'env'> = {}
): RelayProcess {
const proc = spawn('node', [entryPath, ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
...options
})
const responses: (JsonRpcResponse | JsonRpcNotification)[] = []
let nextSeq = 1
let sentinelResolved = false
let stdoutBuffer = Buffer.alloc(0)
let sentinelResolve: () => void
let decoderActive = false
const sentinelReceived = new Promise<void>((resolve) => {
sentinelResolve = resolve
})
const decoder = new FrameDecoder((frame) => {
if (frame.type !== MessageType.Regular) {
return
}
try {
const msg = parseJsonRpcMessage(frame.payload)
responses.push(msg as JsonRpcResponse | JsonRpcNotification)
} catch {
/* skip malformed */
}
})
proc.stdout!.on('data', (chunk: Buffer) => {
if (!sentinelResolved) {
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const sentinelBuf = Buffer.from(RELAY_SENTINEL, 'utf-8')
const idx = stdoutBuffer.indexOf(sentinelBuf)
if (idx !== -1) {
sentinelResolved = true
decoderActive = true
sentinelResolve()
const remainder = stdoutBuffer.subarray(idx + sentinelBuf.length)
if (remainder.length > 0) {
decoder.feed(remainder)
}
}
} else if (decoderActive) {
decoder.feed(chunk)
}
})
proc.stderr!.on('data', () => {
/* drain */
})
const send = (method: string, params?: Record<string, unknown>): number => {
const id = nextSeq++
const req: JsonRpcRequest = {
jsonrpc: '2.0',
id,
method,
...(params !== undefined ? { params } : {})
}
proc.stdin!.write(encodeJsonRpcFrame(req, id, 0))
return id
}
const sendNotification = (method: string, params?: Record<string, unknown>): void => {
const seq = nextSeq++
const notif: JsonRpcNotification = {
jsonrpc: '2.0',
method,
...(params !== undefined ? { params } : {})
}
proc.stdin!.write(encodeJsonRpcFrame(notif, seq, 0))
}
const waitForResponse = (id: number, timeoutMs = 5000): Promise<JsonRpcResponse> => {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
const check = () => {
const found = responses.find((r) => 'id' in r && r.id === id) as JsonRpcResponse | undefined
if (found) {
resolve(found)
return
}
if (Date.now() > deadline) {
reject(new Error(`Timed out waiting for response id=${id}`))
return
}
setTimeout(check, 10)
}
check()
})
}
const waitForNotification = (method: string, timeoutMs = 5000): Promise<JsonRpcNotification> => {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
const seen = responses.length
const check = () => {
for (let i = seen; i < responses.length; i++) {
const r = responses[i]
if ('method' in r && r.method === method) {
resolve(r as JsonRpcNotification)
return
}
}
if (Date.now() > deadline) {
reject(new Error(`Timed out waiting for notification "${method}"`))
return
}
setTimeout(check, 10)
}
check()
})
}
const kill = (signal: NodeJS.Signals = 'SIGTERM') => {
proc.kill(signal)
}
const waitForExit = (timeoutMs = 5000): Promise<number | null> => {
return new Promise((resolve, reject) => {
if (proc.exitCode !== null) {
resolve(proc.exitCode)
return
}
const timer = setTimeout(() => {
reject(new Error('Timed out waiting for process exit'))
}, timeoutMs)
proc.once('exit', (code) => {
clearTimeout(timer)
resolve(code)
})
})
}
return {
proc,
responses,
sentinelReceived,
send,
sendNotification,
waitForResponse,
waitForNotification,
kill,
waitForExit
}
}