fix(plugins): stop fallback server process trees
This commit is contained in:
parent
45bae55787
commit
9d22eea7d2
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue