From 71b696582b9399d446c7ce16d8c91d4a4196e0ac Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:57:04 +0800 Subject: [PATCH] perf(test): reuse one compiled CLI for export-binding parity (#5679) * perf(test): reuse one compiled CLI for export-binding parity The slow export-binding cases each spawned the compiled CLI three times. One warm process now serves those JSON commands from a single command instance, so startup and retained registrations are paid once per file. Co-authored-by: morluto * style(test): format the warm CLI session oxfmt --check rejected the session and its worker. Co-authored-by: morluto * fix(test): report warm CLI shutdown, deadlines, and exit status A compiled CLI session that needed a signal after stdin closed reported success. A command that never answered left later checks waiting, and a failure recorded only on process.exitCode was returned as status 0. Co-authored-by: morluto * fix(test): close the warm CLI worker when a status check fails The failure-then-success case only closed the compiled worker after both assertions passed. Register the close with the test so a failed check still stops the child. Co-authored-by: morluto * fix(test): retain a timed-out compiled CLI worker until it exits A request deadline cleared the child before SIGKILL was observed. The next command could start a replacement, and close could miss the original process. Retired workers stay owned until exit, and a failed stop remains for a later close to retry. Co-authored-by: morluto * fix(test): reject a compiled CLI launch that close interrupts Closing during the ready handshake detached the child before the launch promise settled. The queued command then waited on the startup timer. Close now rejects that startup before the child is detached. Co-authored-by: morluto --------- Co-authored-by: Cursor Agent Co-authored-by: morluto --- docs/testing.md | 7 + .../exportedFunctionBindingsCli.test.ts | 40 +- tests/boundary/cli/compiledCliSession.test.ts | 266 +++++++++++ tests/support/cli/compiledCliSession.ts | 444 ++++++++++++++++++ tests/support/cli/compiledCliWorker.mjs | 139 ++++++ 5 files changed, 867 insertions(+), 29 deletions(-) create mode 100644 tests/boundary/cli/compiledCliSession.test.ts create mode 100644 tests/support/cli/compiledCliSession.ts create mode 100644 tests/support/cli/compiledCliWorker.mjs diff --git a/docs/testing.md b/docs/testing.md index ab8e501a5f..746706905c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -165,6 +165,13 @@ the next command starts. `cli.run` still starts a fresh process, including stdin and explicit deadlines. File teardown stops the shared CLI process, including startup still in flight, and does not open another after close. +`exportedFunctionBindingsCli.test.ts` keeps its own compiled CLI parity +process for the file and still compares that process's JSON command results +with the MCP session. A command that does not respond is rejected. That +process stays owned until it exits, and another process does not start while +it remains. A process that stays running after SIGKILL remains owned so a +later close can retry. Closing the session reports a failure when the process +does not exit after stdin closes, once the child has been stopped. The Apple artifact verifier step shares a job-temporary bytecode cache among its fresh CLI/MCP children via `REA_ARTIFACT_NODE_COMPILE_CACHE`. The artifact diff --git a/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts b/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts index 97c470a4f1..9a6d0cd66c 100644 --- a/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts +++ b/tests/acceptance/applications/exportedFunctionBindingsCli.test.ts @@ -7,19 +7,17 @@ import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it } from "vitest"; import { z } from "zod"; import { closeVerifierResources } from "../../../scripts/lib/verifier-cleanup.mjs"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { javaScriptExportShapeComparisonResultSchema } from "../../../src/domain/javascript/javascriptExportShapeComparisonSchemas.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { cliCompileCacheEnvironment } from "../../support/cli/cliFixture.js"; import { - createTestWorkspace, - removeTestWorkspace, - type TestWorkspace, -} from "../../support/workspace/workspaceFixture.js"; + startCompiledCliSession, + type CompiledCliSession, +} from "../../support/cli/compiledCliSession.js"; const execute = promisify(execFile); const requireFixture = createRequire(import.meta.url); @@ -29,14 +27,11 @@ const comparisonEnvelope = z.object({ const applicationEnvelope = z.object({ normalized_result: javascriptApplicationAnalysisResultSchema, }); -let cliCacheWorkspace: TestWorkspace | undefined; +let cliSession: CompiledCliSession | undefined; -beforeAll(async () => { - cliCacheWorkspace = await createTestWorkspace("rea-export-bindings-cache-"); -}); - -// Source semantics use the real stdio workflow throughout the matrix. The -// missing-export and count-change cases also check compiled CLI adapter parity. +// Source semantics use one real stdio MCP session. Cases that also check the +// compiled CLI adapter share one warm process: it loads the production command +// runtime once and serves the same JSON commands as scripts/rea.mjs. let mcpConnection: | { readonly client: Client; @@ -61,8 +56,7 @@ afterAll(async () => { await mcpConnection?.transport.close(); }, async () => { - if (cliCacheWorkspace !== undefined) - await removeTestWorkspace(cliCacheWorkspace.root); + await cliSession?.close(); }, ]); }); @@ -1213,20 +1207,8 @@ const expectCountChange = ( }; const runCli = async (args: readonly string[]): Promise => { - if (cliCacheWorkspace === undefined) - throw new Error("CLI compile-cache workspace was not initialized"); - const { stdout } = await execute( - process.execPath, - [resolve("scripts/rea.mjs"), ...args], - { - maxBuffer: 16 * 1024 * 1024, - env: cliCompileCacheEnvironment( - { ...process.env, REA_LOG_LEVEL: "silent" }, - cliCacheWorkspace.path("node-compile"), - ), - }, - ); - return JSON.parse(stdout); + cliSession ??= startCompiledCliSession(); + return cliSession.run(args); }; const analyzeVersions = async ( diff --git a/tests/boundary/cli/compiledCliSession.test.ts b/tests/boundary/cli/compiledCliSession.test.ts new file mode 100644 index 0000000000..0499a5c44c --- /dev/null +++ b/tests/boundary/cli/compiledCliSession.test.ts @@ -0,0 +1,266 @@ +import { EventEmitter, PassThrough } from "node:stream"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, onTestFinished, test } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { startCompiledCliSession } from "../../support/cli/compiledCliSession.js"; + +interface FakeCliProcess extends EventEmitter { + stdin: PassThrough; + stdout: PassThrough; + stderr: PassThrough; + pid: number; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + kill(signal?: NodeJS.Signals): boolean; +} + +const fakeProcess = (pid: number): FakeCliProcess => { + const child = new EventEmitter() as FakeCliProcess; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.pid = pid; + child.exitCode = null; + child.signalCode = null; + child.kill = (signal: NodeJS.Signals = "SIGTERM") => { + if (child.exitCode !== null || child.signalCode !== null) return true; + child.signalCode = signal; + child.emit("exit", null, signal); + child.emit("close", null, signal); + return true; + }; + return child; +}; + +const asCliProcess = (child: FakeCliProcess): ChildProcessWithoutNullStreams => + child as unknown as ChildProcessWithoutNullStreams; + +const ready = (child: FakeCliProcess): void => { + child.stdout.write(`${JSON.stringify({ ready: true })}\n`); +}; + +const exitWhenStdinCloses = (child: FakeCliProcess): void => { + child.stdin.on("finish", () => { + if (child.exitCode !== null || child.signalCode !== null) return; + child.exitCode = 0; + child.emit("exit", 0, null); + child.emit("close", 0, null); + }); +}; + +const replyWhenAsked = ( + child: FakeCliProcess, + stdout: string, + exitCode = 0, +): void => { + let buffer = ""; + child.stdin.setEncoding("utf8"); + child.stdin.on("data", (chunk: string) => { + buffer += chunk; + let newline = buffer.indexOf("\n"); + while (newline !== -1) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.length > 0) { + const request = JSON.parse(line) as { id: number }; + child.stdout.write( + `${JSON.stringify({ id: request.id, exitCode, stdout })}\n`, + ); + } + newline = buffer.indexOf("\n"); + } + }); +}; + +test("rejects a compiled CLI command when close wins the ready handshake", async () => { + let child: FakeCliProcess | undefined; + let releaseReady: (() => void) | undefined; + const session = startCompiledCliSession({ + stopTimeoutMs: 50, + spawnWorker: () => { + const started = fakeProcess(60); + child = started; + exitWhenStdinCloses(started); + releaseReady = () => { + ready(started); + }; + return asCliProcess(started); + }, + }); + + const pending = session.run(["early"]); + for (let attempt = 0; child === undefined && attempt < 30; attempt += 1) + await new Promise((resolve) => { + setImmediate(resolve); + }); + if (child === undefined) throw new Error("compiled CLI worker did not start"); + const closing = session.close(); + releaseReady?.(); + await expect(pending).rejects.toThrow("Compiled CLI session is closed"); + await closing; +}); + +test("rejects a compiled CLI command that never replies and serves the next one", async () => { + const children: FakeCliProcess[] = []; + const session = startCompiledCliSession({ + requestTimeoutMs: 30, + spawnWorker: () => { + const child = fakeProcess(70 + children.length); + children.push(child); + exitWhenStdinCloses(child); + ready(child); + if (children.length > 1) + replyWhenAsked(child, JSON.stringify({ ok: true })); + return asCliProcess(child); + }, + }); + + await expect(session.run(["stuck"])).rejects.toThrow(/did not respond/); + expect(children[0]?.signalCode).toBe("SIGKILL"); + await expect(session.run(["next"])).resolves.toEqual({ ok: true }); + expect(children).toHaveLength(2); + await session.close(); +}); + +test("keeps a timed-out compiled CLI worker until it exits", async () => { + const children: FakeCliProcess[] = []; + let releaseExit: (() => void) | undefined; + const session = startCompiledCliSession({ + requestTimeoutMs: 20, + stopTimeoutMs: 40, + spawnWorker: () => { + const child = fakeProcess(90 + children.length); + children.push(child); + if (children.length === 1) { + child.kill = () => { + releaseExit = () => { + if (child.exitCode !== null || child.signalCode !== null) return; + child.signalCode = "SIGKILL"; + child.emit("exit", null, "SIGKILL"); + child.emit("close", null, "SIGKILL"); + }; + return true; + }; + } else { + exitWhenStdinCloses(child); + replyWhenAsked(child, JSON.stringify({ ok: true })); + } + ready(child); + return asCliProcess(child); + }, + }); + + const stalled = session.run(["stuck"]); + const next = session.run(["next"]); + await expect(stalled).rejects.toThrow(/did not respond/); + expect(children).toHaveLength(1); + expect(children[0]?.signalCode).toBeNull(); + releaseExit?.(); + await expect(next).resolves.toEqual({ ok: true }); + expect(children).toHaveLength(2); + await session.close(); +}); + +test("retries a timed-out compiled CLI worker that survives SIGKILL", async () => { + let child: FakeCliProcess | undefined; + const signals: NodeJS.Signals[] = []; + const session = startCompiledCliSession({ + requestTimeoutMs: 20, + stopTimeoutMs: 20, + spawnWorker: () => { + child = fakeProcess(91); + child.kill = (signal: NodeJS.Signals = "SIGTERM") => { + signals.push(signal); + return true; + }; + ready(child); + return asCliProcess(child); + }, + }); + + await expect(session.run(["stuck"])).rejects.toThrow(/did not respond/); + const replacement = session.run(["next"]); + await expect(replacement).rejects.toThrow(/stayed running after SIGKILL/); + await expect(session.close()).rejects.toThrow(/stayed running after SIGKILL/); + expect(signals).toEqual(["SIGKILL", "SIGKILL"]); + const stalledChild = child; + if (stalledChild === undefined) throw new Error("missing stalled worker"); + stalledChild.kill = (signal: NodeJS.Signals = "SIGKILL") => { + signals.push(signal); + stalledChild.signalCode = signal; + stalledChild.emit("exit", null, signal); + stalledChild.emit("close", null, signal); + return true; + }; + await session.close(); + expect(stalledChild.signalCode).toBe("SIGKILL"); +}); + +test("reports a compiled CLI session that needed a signal after stdin closed", async () => { + let child: FakeCliProcess | undefined; + const signals: NodeJS.Signals[] = []; + const session = startCompiledCliSession({ + stopTimeoutMs: 20, + spawnWorker: () => { + child = fakeProcess(81); + child.kill = (signal: NodeJS.Signals = "SIGTERM") => { + signals.push(signal); + if (signal !== "SIGKILL") return true; + if (child?.exitCode !== null || child?.signalCode !== null) return true; + child.signalCode = signal; + child.emit("exit", null, signal); + child.emit("close", null, signal); + return true; + }; + ready(child); + replyWhenAsked(child, JSON.stringify({ ok: true })); + return asCliProcess(child); + }, + }); + + await expect(session.run(["once"])).resolves.toEqual({ ok: true }); + await expect(session.close()).rejects.toThrow( + "Compiled CLI session did not exit after stdin closed", + ); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); + expect(child?.signalCode).toBe("SIGKILL"); +}); + +test("closes a compiled CLI session that exits after stdin closes", async () => { + const session = startCompiledCliSession({ + spawnWorker: () => { + const child = fakeProcess(82); + exitWhenStdinCloses(child); + ready(child); + replyWhenAsked(child, JSON.stringify({ ok: true })); + return asCliProcess(child); + }, + }); + + await expect(session.run(["once"])).resolves.toEqual({ ok: true }); + await session.close(); +}); + +test("keeps a compiled CLI failure status and serves the next command", async () => { + const session = startCompiledCliSession(); + onTestFinished(() => session.close()); + const root = await createTestTempDirectory("rea-warm-cli-status-"); + await writeFile( + join(root, "app.mjs"), + "export function current() { return 1; }\n", + ); + + await expect( + session.run(["compare-javascript-export-shapes", "{", "--json"]), + ).rejects.toThrow(/status 1:.*invalid_request/); + await expect( + session.run(["analyze-javascript-application", root, "--json"]), + ).resolves.toMatchObject({ + normalized_result: expect.any(Object), + }); + await session.close(); +}); diff --git a/tests/support/cli/compiledCliSession.ts b/tests/support/cli/compiledCliSession.ts new file mode 100644 index 0000000000..086efa6806 --- /dev/null +++ b/tests/support/cli/compiledCliSession.ts @@ -0,0 +1,444 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { z } from "zod"; + +import { waitForExit } from "../process/processFixture.js"; + +const START_TIMEOUT_MS = 20_000; +const REQUEST_TIMEOUT_MS = 20_000; +const STOP_TIMEOUT_MS = 1_000; +const STDERR_TAIL_CHARS = 4_000; +const workerPath = fileURLToPath( + new URL("./compiledCliWorker.mjs", import.meta.url), +); +const readyMessage = z.object({ ready: z.literal(true) }).strict(); +const responseMessage = z + .object({ + id: z.number(), + exitCode: z.number(), + stdout: z.string(), + error: z.string().optional(), + }) + .strict(); + +type CliResponse = z.infer; + +interface PendingResponse { + readonly resolve: (response: CliResponse) => void; + readonly reject: (cause: unknown) => void; +} + +interface OwnedStop { + readonly child: ChildProcessWithoutNullStreams; + attempt: Promise | undefined; +} + +interface SessionState { + child: ChildProcessWithoutNullStreams | undefined; + starting: Promise | undefined; + buffer: string; + nextId: number; + readonly pending: Map; + readonly stopping: OwnedStop[]; + queue: Promise; + stderr: string; + closed: boolean; + closing: Promise | undefined; + settleStart: ((cause?: Error) => void) | undefined; + ready: boolean; + readonly requestTimeoutMs: number; + readonly stopTimeoutMs: number; + readonly spawnWorker: () => ChildProcessWithoutNullStreams; +} + +/** One warm compiled CLI process serving production JSON commands. */ +export interface CompiledCliSession { + run(args: readonly string[]): Promise; + close(): Promise; +} + +/** Limits and process factory for session tests. Production callers omit them. */ +export interface CompiledCliSessionOptions { + readonly spawnWorker?: () => ChildProcessWithoutNullStreams; + readonly requestTimeoutMs?: number; + readonly stopTimeoutMs?: number; +} + +/** Start a session that loads the compiled CLI once and reuses that process. */ +export const startCompiledCliSession = ( + options: CompiledCliSessionOptions = {}, +): CompiledCliSession => { + const state = createSessionState(options); + return { + run: (args) => enqueue(state, () => dispatch(state, args)), + close: () => closeSession(state), + }; +}; + +const positiveMilliseconds = ( + value: number | undefined, + fallback: number, +): number => + value !== undefined && Number.isSafeInteger(value) && value > 0 + ? value + : fallback; + +const defaultSpawnWorker = (): ChildProcessWithoutNullStreams => + spawn(process.execPath, [workerPath], { + cwd: process.cwd(), + env: { ...process.env, REA_LOG_LEVEL: "silent" }, + stdio: ["pipe", "pipe", "pipe"], + }); + +const createSessionState = ( + options: CompiledCliSessionOptions, +): SessionState => ({ + child: undefined, + starting: undefined, + buffer: "", + nextId: 1, + pending: new Map(), + stopping: [], + queue: Promise.resolve(), + stderr: "", + closed: false, + closing: undefined, + settleStart: undefined, + ready: false, + requestTimeoutMs: positiveMilliseconds( + options.requestTimeoutMs, + REQUEST_TIMEOUT_MS, + ), + stopTimeoutMs: positiveMilliseconds(options.stopTimeoutMs, STOP_TIMEOUT_MS), + spawnWorker: options.spawnWorker ?? defaultSpawnWorker, +}); + +const enqueue = ( + state: SessionState, + task: () => Promise, +): Promise => { + const run = state.queue.then(task); + state.queue = run.then( + () => undefined, + () => undefined, + ); + return run; +}; + +const dispatch = async ( + state: SessionState, + args: readonly string[], +): Promise => { + await ensureStarted(state); + const child = state.child; + if (child === undefined) + throw new Error("Compiled CLI session is not running"); + const id = state.nextId; + state.nextId += 1; + const response = await request(state, child, id, args); + if (response.exitCode !== 0) throw new Error(failureText(state, response)); + return JSON.parse(response.stdout) as unknown; +}; + +const request = ( + state: SessionState, + child: ChildProcessWithoutNullStreams, + id: number, + args: readonly string[], +): Promise => + new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType | undefined; + const settle = (finish: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + state.pending.delete(id); + finish(); + }; + timer = setTimeout(() => { + settle(() => { + reject( + new Error( + `Compiled CLI command did not respond within ${String(state.requestTimeoutMs)} ms`, + ), + ); + retireChild(state, child); + }); + }, state.requestTimeoutMs); + timer.unref(); + state.pending.set(id, { + resolve: (response) => { + settle(() => { + resolve(response); + }); + }, + reject: (cause) => { + settle(() => { + reject(cause); + }); + }, + }); + child.stdin.write(`${JSON.stringify({ id, args })}\n`, (cause) => { + if (cause === undefined || cause === null) return; + settle(() => { + reject(cause); + }); + }); + }); + +const failureText = (state: SessionState, response: CliResponse): string => { + const detail = response.error ?? response.stdout.slice(0, 500); + const stderr = state.stderr.trim(); + return stderr.length === 0 + ? `Compiled CLI command failed with status ${String(response.exitCode)}: ${detail}` + : `Compiled CLI command failed with status ${String(response.exitCode)}: ${detail}\n${stderr}`; +}; + +const ensureStarted = async (state: SessionState): Promise => { + if (state.closed) throw sessionClosed(); + await releaseRetired(state); + if (state.closed) throw sessionClosed(); + if (state.ready && state.child !== undefined) return; + state.starting ??= launch(state); + await state.starting; + if (state.closed) throw sessionClosed(); +}; + +const launch = (state: SessionState): Promise => + new Promise((resolve, reject) => { + const child = state.spawnWorker(); + state.child = child; + let settled = false; + let timer: ReturnType | undefined; + const finish = (cause?: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (state.settleStart === finish) state.settleStart = undefined; + if (cause === undefined) resolve(); + else reject(cause); + }; + timer = setTimeout(() => { + signalChild(child, "SIGKILL"); + finish( + new Error( + `Compiled CLI session did not start within ${String(START_TIMEOUT_MS)} ms\n${state.stderr}`, + ), + ); + }, START_TIMEOUT_MS); + timer.unref(); + state.settleStart = finish; + const fail = (cause: unknown): void => { + finish(cause instanceof Error ? cause : new Error(String(cause))); + }; + const currentChild = (): boolean => state.child === child; + child.once("error", fail); + child.once("exit", (code, signal) => { + if (!currentChild()) return; + state.child = undefined; + state.ready = false; + state.starting = undefined; + state.buffer = ""; + const cause = new Error( + `Compiled CLI session exited (${String(code)}, ${String(signal)})\n${state.stderr}`, + ); + fail(cause); + rejectPending(state, cause); + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + if (!currentChild()) return; + state.stderr = `${state.stderr}${chunk}`.slice(-STDERR_TAIL_CHARS); + }); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + if (!currentChild()) return; + state.buffer += chunk; + drainLines(state, () => { + state.ready = true; + finish(); + }); + }); + }); + +const hasExited = (child: ChildProcessWithoutNullStreams): boolean => + child.exitCode !== null || child.signalCode !== null; + +const signalChild = ( + child: ChildProcessWithoutNullStreams, + signal: NodeJS.Signals, +): void => { + try { + child.kill(signal); + } catch (error) { + const code = + error instanceof Error && "code" in error ? error.code : undefined; + if (code !== "ESRCH") throw error; + } +}; + +const detachChild = ( + state: SessionState, + child: ChildProcessWithoutNullStreams, +): void => { + if (state.child !== child) return; + state.child = undefined; + state.ready = false; + state.starting = undefined; + state.buffer = ""; +}; + +const stopOwned = (state: SessionState, entry: OwnedStop): Promise => { + entry.attempt ??= (async () => { + if (hasExited(entry.child)) return true; + signalChild(entry.child, "SIGKILL"); + return waitForExit(entry.child, state.stopTimeoutMs); + })().finally(() => { + entry.attempt = undefined; + }); + return entry.attempt; +}; + +const trackRetired = ( + state: SessionState, + child: ChildProcessWithoutNullStreams, +): OwnedStop => { + const existing = state.stopping.find((entry) => entry.child === child); + if (existing !== undefined) return existing; + const entry: OwnedStop = { child, attempt: undefined }; + state.stopping.push(entry); + if (!hasExited(child)) void stopOwned(state, entry).catch(() => undefined); + return entry; +}; + +const releaseRetired = async (state: SessionState): Promise => { + let failed = false; + for (const entry of [...state.stopping]) { + if (hasExited(entry.child)) continue; + const stopped = await stopOwned(state, entry).catch(() => false); + if (!stopped || !hasExited(entry.child)) failed = true; + } + const retained = state.stopping.filter((entry) => !hasExited(entry.child)); + state.stopping.length = 0; + state.stopping.push(...retained); + if (failed) + throw new Error("Compiled CLI session stayed running after SIGKILL"); +}; + +const retireChild = ( + state: SessionState, + child: ChildProcessWithoutNullStreams, +): void => { + detachChild(state, child); + if (hasExited(child)) return; + trackRetired(state, child); +}; + +const drainLines = (state: SessionState, onReady: () => void): void => { + let newline = state.buffer.indexOf("\n"); + while (newline !== -1) { + const line = state.buffer.slice(0, newline); + state.buffer = state.buffer.slice(newline + 1); + if (line.length > 0) acceptLine(state, line, onReady); + newline = state.buffer.indexOf("\n"); + } +}; + +const acceptLine = ( + state: SessionState, + line: string, + onReady: () => void, +): void => { + let message: unknown; + try { + message = JSON.parse(line) as unknown; + } catch (cause) { + rejectPending( + state, + cause instanceof Error ? cause : new Error(String(cause)), + ); + return; + } + if (readyMessage.safeParse(message).success) { + onReady(); + return; + } + const parsed = responseMessage.safeParse(message); + if (!parsed.success) { + rejectPending( + state, + new Error("Compiled CLI session returned an unexpected message"), + ); + return; + } + const pending = state.pending.get(parsed.data.id); + state.pending.delete(parsed.data.id); + if (pending === undefined) { + rejectPending( + state, + new Error( + `Compiled CLI session returned unexpected id ${String(parsed.data.id)}`, + ), + ); + return; + } + pending.resolve(parsed.data); +}; + +const rejectPending = (state: SessionState, cause: Error): void => { + for (const pending of state.pending.values()) pending.reject(cause); + state.pending.clear(); +}; + +const closeSession = (state: SessionState): Promise => { + state.closed = true; + if (state.closing !== undefined) return state.closing; + let attempt: Promise; + attempt = shutdown(state).catch((error: unknown) => { + if (state.closing === attempt) state.closing = undefined; + throw error; + }); + state.closing = attempt; + return attempt; +}; + +const shutdown = async (state: SessionState): Promise => { + const child = state.child; + let forced = false; + const stillRunning: OwnedStop[] = []; + if (child !== undefined) { + rejectPending(state, sessionClosed()); + state.settleStart?.(sessionClosed()); + detachChild(state, child); + const stopped = await stopGracefully(state, child); + if (stopped === "forced") forced = true; + if (stopped === "stuck" && !hasExited(child)) + stillRunning.push({ child, attempt: undefined }); + } + try { + await releaseRetired(state); + } finally { + state.stopping.push(...stillRunning); + } + if (state.stopping.length > 0) + throw new Error("Compiled CLI session stayed running after SIGKILL"); + if (forced) + throw new Error("Compiled CLI session did not exit after stdin closed"); +}; + +const stopGracefully = async ( + state: SessionState, + child: ChildProcessWithoutNullStreams, +): Promise<"clean" | "forced" | "stuck"> => { + child.stdin.end(); + if (await waitForExit(child, state.stopTimeoutMs)) return "clean"; + signalChild(child, "SIGTERM"); + if (await waitForExit(child, state.stopTimeoutMs)) return "forced"; + signalChild(child, "SIGKILL"); + if (await waitForExit(child, state.stopTimeoutMs)) return "forced"; + return "stuck"; +}; + +const sessionClosed = (): Error => new Error("Compiled CLI session is closed"); diff --git a/tests/support/cli/compiledCliWorker.mjs b/tests/support/cli/compiledCliWorker.mjs new file mode 100644 index 0000000000..e8012c7510 --- /dev/null +++ b/tests/support/cli/compiledCliWorker.mjs @@ -0,0 +1,139 @@ +import { Writable } from "node:stream"; +import { createInterface } from "node:readline"; + +const { cliConfigurationError, createCli } = + await import("../../../dist/cli-runtime/cli.js"); +const { createCliResultOutput } = + await import("../../../dist/cli-runtime/cliResultOutput.js"); +const { + normalizeCliArguments, + renderCliOutputArgumentError, + renderEmptyFilteredCliOutput, + validateCliOutputArguments, +} = await import("../../../dist/cli-runtime/cliOutput.js"); + +const MAX_STDOUT_CHARS = 16 * 1024 * 1024; + +const writeMessage = (message) => { + process.stdout.write(`${JSON.stringify(message)}\n`); +}; + +// One CLI instance serves every command. A fresh instance per command retains +// its command registrations after the call returns; production exits instead. +const outputParts = []; +let outputCharacters = 0; +const destination = new Writable({ + write(chunk, _encoding, callback) { + const text = Buffer.isBuffer(chunk) + ? chunk.toString("utf8") + : String(chunk); + outputCharacters += text.length; + if (outputCharacters > MAX_STDOUT_CHARS) { + callback(new Error("Compiled CLI stdout exceeded 16 MiB")); + return; + } + outputParts.push(text); + callback(); + }, +}); +Object.defineProperty(destination, "isTTY", { value: false }); +const resultOutput = createCliResultOutput(["--json"], destination); +const cli = createCli(process.env, resultOutput); + +const resetOutput = () => { + outputParts.length = 0; + outputCharacters = 0; +}; + +const runCommand = async (args) => { + const previousExitCode = process.exitCode; + process.exitCode = undefined; + resetOutput(); + try { + const cliArgs = normalizeCliArguments(args); + const outputArguments = validateCliOutputArguments(cliArgs); + if (!outputArguments.ok) + return { + exitCode: 1, + stdout: renderCliOutputArgumentError(outputArguments), + }; + const configurationError = cliConfigurationError(process.env); + if (configurationError !== undefined) + return { exitCode: 1, stdout: "", error: configurationError }; + + let wroteOutput = false; + let explicitExit; + try { + await cli.serve(cliArgs, { + stdout: (output) => { + if (resultOutput?.handled) return; + if (output.length === 0) return; + wroteOutput = true; + destination.write(output); + }, + exit: (code) => { + explicitExit = code; + }, + }); + } catch (cause) { + return { + exitCode: 1, + stdout: outputParts.join(""), + error: + cause instanceof Error + ? (cause.stack ?? cause.message) + : String(cause), + }; + } + if (!wroteOutput && !resultOutput?.handled) { + const empty = renderEmptyFilteredCliOutput(cliArgs); + if (empty !== undefined) destination.write(empty); + } + // logCliCommand reports some failures only through process.exitCode. + return { + exitCode: + explicitExit ?? + (typeof process.exitCode === "number" ? process.exitCode : 0), + stdout: outputParts.join(""), + }; + } finally { + process.exitCode = previousExitCode; + } +}; + +const acceptRequest = async (line) => { + let request; + try { + request = JSON.parse(line); + } catch (cause) { + writeMessage({ + id: -1, + exitCode: 1, + stdout: "", + error: cause instanceof Error ? cause.message : String(cause), + }); + return; + } + const args = request?.args; + if ( + typeof request?.id !== "number" || + !Array.isArray(args) || + args.some((argument) => typeof argument !== "string") + ) { + writeMessage({ + id: typeof request?.id === "number" ? request.id : -1, + exitCode: 1, + stdout: "", + error: "Compiled CLI session expected an id and string arguments", + }); + return; + } + writeMessage({ id: request.id, ...(await runCommand(args)) }); +}; + +const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); +writeMessage({ ready: true }); +for await (const line of lines) { + if (line.trim().length === 0) continue; + await acceptRequest(line); +}