Files
rea/tests/support/cli/cliCommandSessionWorker.mjs
T
647717c050 perf(javascript): reuse the source-analysis worker within a process (#5819)
* perf(javascript): reuse the source-analysis worker within a process

Each analyze-javascript-application call started a new Node worker.
That startup, not fixture IO or the shared CLI session, dominated the
slow loader-review and factory-completion files.

Park one healthy worker on the artifact resource scope and on the CLI
process. Stop it when the scope, session, or one-shot process ends.
Do not reuse after failure, cancellation, or a different heap or timeout.

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

* fix(cli): close the JavaScript worker when dispatch throws

Minimal dispatcher runtimes now export a no-op closeCliJavaScriptResources,
and CLI dispatch plus output rendering always await that cleanup. A dispatch
failure and a cleanup failure are both reported.

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

* fix(artifacts): report close-hook failures with retained cleanup

When retained cleanup and the scope close hook both fail, the close error
keeps both causes and both resource labels. The pending owners and the hook
stay registered so the next close retries each path.

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

* fix(javascript): stop a replaced analysis worker before the next one starts

A heap or timeout change now closes the parked worker before constructing
its replacement. The scope retains that process only when the close fails.

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

* style: format worker-reuse close and launch helpers

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

* fix(cli): report a failed JavaScript worker shutdown before exit

Share one in-flight close and drop the CLI scope only after it succeeds,
so a later caller retries a worker that is still owned. The one-shot
exit callback writes that failure and exits non-zero, while a closed
stdout pipe still exits 141.

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

* fix(javascript): keep a worker parked during an overlapping limit change

Claim the scope slot only when it is still empty after the replaced
worker closes. A concurrent analysis that parked another healthy worker
keeps that process, and the extra worker is closed through its own port.

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

* fix(artifacts): run later close hooks when an earlier hook fails

Run every registered close hook on each close. One failure is returned
unchanged, and several failures are aggregated so a later hook is not
skipped.

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

* fix(javascript): fail a limit change when the previous worker stays alive

A failed close used to be retained and then ignored, so MCP could return
a successful analysis while the old process and its replacement were
both owned. The request now fails before the replacement is constructed,
and the still-live worker stays owned for a later close.

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 19:01:20 +08:00

267 lines
8.0 KiB
JavaScript

import { Writable } from "node:stream";
import { access } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
createCliSessionFrameReader,
encodeCliSessionFrame,
} from "./cliCommandSessionFrames.mjs";
// One-shot command sequence matches scripts/rea.mjs. Output is captured per
// command so this process can serve the next invocation without restarting.
const packageRoot = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../..",
);
const { default: packageJson } = await import(
new URL("../../../package.json", import.meta.url),
{ with: { type: "json" } }
);
const prepareCliProcess = () => {
process.env.REA_PACKAGE_VERSION = packageJson.version;
// Windows home lookup throws for an empty USERPROFILE. Dependencies resolve
// home during import, before REA's own resolver can apply its account fallback.
if (process.platform === "win32" && process.env.USERPROFILE === "") {
delete process.env.USERPROFILE;
}
if (
process.platform === "android" &&
process.env.PLAYWRIGHT_BROWSERS_PATH === undefined
) {
const cacheRoot =
process.env.XDG_CACHE_HOME ?? join(process.env.HOME ?? ".", ".cache");
process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright");
}
};
const runtimeFiles = [
"dist/cli-runtime/cli.js",
"dist/cli-runtime/cliOutput.js",
"dist/cli-runtime/cliResultOutput.js",
];
prepareCliProcess();
if (!(await compiledRuntimeExists(runtimeFiles))) {
process.stderr.write(
`REA's compiled runtime is missing. Run \`npm ci && npm run build:cached\` in ${packageRoot} to install dependencies and build REA, then restart it. If this is an installed package, reinstall rea-agents.\n`,
);
process.exit(1);
}
const { cliConfigurationError, closeCliJavaScriptResources, 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 writeFrame = (value) =>
new Promise((resolveWrite, rejectWrite) => {
process.stdout.write(encodeCliSessionFrame(value), (error) => {
if (error) rejectWrite(error);
else resolveWrite();
});
});
await writeFrame({ ready: true });
let chain = Promise.resolve();
const reader = createCliSessionFrameReader((value) => {
chain = chain.then(
() => handle(value),
() => handle(value),
);
});
process.stdin.on("data", (chunk) => {
try {
reader(chunk);
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exit(1);
}
});
process.stdin.on("end", () => {
chain.finally(async () => {
await closeCliJavaScriptResources();
process.exit(0);
});
});
async function handle(value) {
const request = value;
const savedEnv = { ...process.env };
const savedCwd = process.cwd();
try {
if (
request === null ||
typeof request !== "object" ||
!Array.isArray(request.arguments) ||
typeof request.cwd !== "string" ||
request.env === null ||
typeof request.env !== "object"
) {
throw new Error(
"CLI session command is missing its arguments, cwd, or environment",
);
}
for (const key of Object.keys(process.env)) delete process.env[key];
Object.assign(process.env, request.env);
prepareCliProcess();
process.chdir(request.cwd);
const result = await runOneShot(request.arguments);
await writeFrame({
id: request.id,
pid: process.pid,
rss: process.memoryUsage().rss,
...result,
});
} catch (error) {
const id =
request !== null && typeof request === "object" ? request.id : undefined;
await writeFrame({
id,
pid: process.pid,
exitCode: 1,
stdout: "",
stderr:
error instanceof Error ? (error.stack ?? error.message) : String(error),
fatal: true,
});
process.exit(1);
} finally {
for (const key of Object.keys(process.env)) delete process.env[key];
Object.assign(process.env, savedEnv);
process.chdir(savedCwd);
}
}
async function runOneShot(args) {
const stdoutChunks = [];
const stderrChunks = [];
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
process.stdout.write = captureWrite(stdoutChunks);
process.stderr.write = captureWrite(stderrChunks);
const destination = new Writable({
write(chunk, encoding, callback) {
process.stdout.write(chunk, encoding, callback);
},
});
destination.isTTY = process.stdout.isTTY === true;
const previousExitCode = process.exitCode;
process.exitCode = undefined;
let explicitExit;
try {
const cliArgs = normalizeCliArguments(args);
const outputArguments = validateCliOutputArguments(cliArgs);
const configurationError = cliConfigurationError(process.env);
if (configurationError !== undefined && !isDoctorCommand(cliArgs)) {
process.stderr.write(`${configurationError}\n`);
process.exitCode = 1;
} else if (!outputArguments.ok) {
process.stdout.write(renderCliOutputArgumentError(outputArguments));
process.exitCode = 1;
} else {
let wroteOutput = false;
const resultOutput = createCliResultOutput(cliArgs, destination);
try {
await createCli(process.env, resultOutput).serve(cliArgs, {
stdout: (output) => {
if (resultOutput?.handled) {
if (resultOutput.failed) process.stderr.write(output);
return;
}
if (output.length > 0) wroteOutput = true;
process.stdout.write(output);
},
exit: (code) => {
explicitExit = code;
},
});
} catch (cause) {
if (wroteOutput) throw cause;
const recovered = await resultOutput?.recoverFormattingFailure(cause);
if (!recovered) throw cause;
process.exitCode = 1;
}
if (!wroteOutput && !resultOutput?.handled) {
const empty = renderEmptyFilteredCliOutput(cliArgs);
if (empty) process.stdout.write(empty);
}
}
} finally {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
const exitCode =
explicitExit ??
(typeof process.exitCode === "number" ? process.exitCode : 0);
process.exitCode = previousExitCode;
return {
exitCode,
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
stderr: Buffer.concat(stderrChunks).toString("utf8"),
};
}
function captureWrite(chunks) {
return function patched(chunk, encoding, callback) {
const encodingName = typeof encoding === "string" ? encoding : "utf8";
chunks.push(
Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), encodingName),
);
const done = typeof encoding === "function" ? encoding : callback;
if (typeof done === "function") done();
return true;
};
}
function isDoctorCommand(arguments_) {
const valuedFlags = new Set([
"--format",
"--filter-output",
"--token-limit",
"--token-offset",
]);
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
if (argument === "--") return arguments_[index + 1] === "doctor";
if (valuedFlags.has(argument)) {
index += 1;
continue;
}
if (argument.startsWith("-")) continue;
return argument === "doctor";
}
return false;
}
async function compiledRuntimeExists(paths) {
for (const path of paths) {
try {
await access(resolve(packageRoot, path));
} catch (cause) {
if (isMissing(cause)) return false;
throw cause;
}
}
return true;
}
function isMissing(cause) {
return (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT"
);
}