fix(plugins): stop fallback server process trees

This commit is contained in:
OpenAI Codex 2026-10-13 23:33:15 +08:00
parent 45bae55787
commit 9d22eea7d2
5 changed files with 2360 additions and 3 deletions

2215
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -359,6 +359,7 @@
"lint-staged": "16.4.0",
"oxfmt": "0.72.0",
"oxlint": "1.87.0",
"release-please": "17.3.0",
"rolldown": "1.2.12",
"turbo": "2.11.7",
"typescript": "7.0.2",

View File

@ -73,6 +73,10 @@ async function launch(packageSpecifier) {
}
const child = spawn(command, args, {
cwd: workingDirectory,
// A dedicated POSIX process group gives this launcher ownership of the
// package runner and any server it starts. Windows keeps direct-child
// signaling until a verified Job Object owner is available here.
detached: process.platform !== "win32",
stdio: "inherit",
shell: false,
});
@ -80,8 +84,17 @@ async function launch(packageSpecifier) {
const signals = new Map();
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
const forward = () => {
if (child.exitCode === null && child.signalCode === null)
child.kill(signal);
if (child.exitCode !== null || child.signalCode !== null) return;
try {
if (process.platform !== "win32" && child.pid !== undefined)
process.kill(-child.pid, signal);
else child.kill(signal);
} catch (cause) {
if (!isMissingProcess(cause))
process.stderr.write(
`REA plugin could not forward ${signal}: ${cause.message}\n`,
);
}
};
signals.set(signal, forward);
process.on(signal, forward);
@ -129,3 +142,12 @@ function isMissing(cause) {
cause.code === "ENOENT"
);
}
function isMissingProcess(cause) {
return (
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ESRCH"
);
}

View File

@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { lstat, mkdir, writeFile } from "node:fs/promises";
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it, onTestFinished } from "vitest";
@ -94,6 +94,56 @@ it.skipIf(process.platform === "win32")(
},
);
it.skipIf(process.platform === "win32")(
"stops a package runner and its long-lived server with stdin still open",
async () => {
const root = await createTestTempDirectory("rea-plugin-process-tree-");
const runnerMarker = join(root, "runner-stopped");
const serverMarker = join(root, "server-stopped");
const bin = await fakeRunner(`
const { appendFileSync } = require("node:fs");
const { spawn } = require("node:child_process");
const server = spawn(process.execPath, ["-e", ${JSON.stringify(`
const { appendFileSync } = require("node:fs");
process.on("SIGTERM", () => {
appendFileSync(${JSON.stringify(serverMarker)}, "stopped\\n");
process.exit(0);
});
process.stdin.resume();
process.stdout.write("server-ready\\n");
`)}], { stdio: "inherit" });
let stopping = false;
process.on("SIGTERM", () => {
stopping = true;
appendFileSync(${JSON.stringify(runnerMarker)}, "stopped\\n");
});
process.stdin.resume();
server.once("close", () => { if (stopping) process.exit(0); });
`);
const { child, closed } = start(bin);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("package server did not become ready")),
5_000,
);
child.stdout.on("data", (bytes: Buffer) => {
if (bytes.toString("utf8").includes("server-ready\n")) {
clearTimeout(timeout);
resolve();
}
});
child.once("error", reject);
});
// Keep the protocol pipe open: shutdown must reach the package runner and
// its server process independently of EOF on stdin.
child.kill("SIGTERM");
expect(await closed).toMatchObject({ code: 0, signal: null });
await expect(readFile(runnerMarker, "utf8")).resolves.toBe("stopped\n");
await expect(readFile(serverMarker, "utf8")).resolves.toBe("stopped\n");
},
);
it.skipIf(process.platform === "win32")(
"isolates local package resolution and removes the owned runner directory",
async () => {

View File

@ -1,5 +1,7 @@
import { readFile } from "node:fs/promises";
import { GenericJson } from "release-please/build/src/updaters/generic-json.js";
import { Version } from "release-please/build/src/version.js";
import { describe, expect, it } from "vitest";
import { SUPPORTED_NODE_VERSION_RANGE } from "../../src/domain/runtimeVersion.js";
@ -210,6 +212,73 @@ describe("plugin manifests", () => {
},
);
it("advances every plugin pin through Release Please JSON extra-files", async () => {
const packageJson = (await readJson("package.json")) as {
version: string;
};
const [major, minor] = packageJson.version.split(".").map(Number);
const nextVersion = `${major}.${minor + 1}.0`;
const version = Version.parse(nextVersion);
const config = (await readJson("release-please-config.json")) as {
packages: {
".": {
"extra-files": Array<{
type: string;
path: string;
jsonpath?: string;
}>;
};
};
};
const updated = new Map<string, string>();
for (const extraFile of config.packages["."]["extra-files"]) {
if (extraFile.type !== "json" || extraFile.jsonpath === undefined)
continue;
const content =
updated.get(extraFile.path) ?? (await readFile(extraFile.path, "utf8"));
updated.set(
extraFile.path,
new GenericJson(extraFile.jsonpath, version).updateContent(content),
);
}
const updatedJson = new Map<string, unknown>(
[...updated].map(
([path, content]) => [path, JSON.parse(content) as unknown] as const,
),
);
expect(updatedJson.get(".claude-plugin/plugin.json")).toMatchObject({
version: nextVersion,
mcpServers: {
rea: { args: ["-y", `rea-agents@${nextVersion}`, "mcp"] },
},
});
expect(updatedJson.get(".codex-plugin/plugin.json")).toMatchObject({
version: nextVersion,
});
expect(updatedJson.get(pluginServerConfig)).toMatchObject({
mcpServers: {
rea: { args: ["-y", `rea-agents@${nextVersion}`, "mcp"] },
},
});
for (const path of [
".claude-plugin/marketplace.json",
".agents/plugins/marketplace.json",
])
expect(updatedJson.get(path)).toMatchObject({
plugins: [
{
source: {
source: "npm",
package: "rea-agents",
version: nextVersion,
},
},
],
});
});
it("pins the plugin server launch from the shared MCP declaration", async () => {
const packageJson = (await readJson("package.json")) as {
name: string;