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); +}