Files
rea/tests/support/cli/compiledCliSession.ts
71b696582b 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 <morluto@users.noreply.github.com>

* style(test): format the warm CLI session

oxfmt --check rejected the session and its worker.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* 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 <morluto@users.noreply.github.com>

* 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 <morluto@users.noreply.github.com>

* 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 <morluto@users.noreply.github.com>

* 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 <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-21 12:57:04 +08:00

445 lines
13 KiB
TypeScript

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<typeof responseMessage>;
interface PendingResponse {
readonly resolve: (response: CliResponse) => void;
readonly reject: (cause: unknown) => void;
}
interface OwnedStop {
readonly child: ChildProcessWithoutNullStreams;
attempt: Promise<boolean> | undefined;
}
interface SessionState {
child: ChildProcessWithoutNullStreams | undefined;
starting: Promise<void> | undefined;
buffer: string;
nextId: number;
readonly pending: Map<number, PendingResponse>;
readonly stopping: OwnedStop[];
queue: Promise<void>;
stderr: string;
closed: boolean;
closing: Promise<void> | 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<unknown>;
close(): Promise<void>;
}
/** 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<unknown>,
): Promise<unknown> => {
const run = state.queue.then(task);
state.queue = run.then(
() => undefined,
() => undefined,
);
return run;
};
const dispatch = async (
state: SessionState,
args: readonly string[],
): Promise<unknown> => {
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<CliResponse> =>
new Promise((resolve, reject) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | 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<void> => {
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<void> =>
new Promise((resolve, reject) => {
const child = state.spawnWorker();
state.child = child;
let settled = false;
let timer: ReturnType<typeof setTimeout> | 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<boolean> => {
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<void> => {
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<void> => {
state.closed = true;
if (state.closing !== undefined) return state.closing;
let attempt: Promise<void>;
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<void> => {
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");