
























11// Plugin Gateway Gauntlet tests cover plugin gateway gauntlet script behavior.
2-import { spawnSync } from "node:child_process";
2+import { spawn, spawnSync } from "node:child_process";
33import fs from "node:fs/promises";
44import os from "node:os";
55import path from "node:path";
66import { setTimeout as delay } from "node:timers/promises";
7+import { pathToFileURL } from "node:url";
78import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
89import {
910buildObservationGuardFailures,
@@ -92,6 +93,20 @@ describe("plugin gateway gauntlet helpers", () => {
9293throw new Error("condition was not met before timeout");
9394}
949596+async function waitForClose(child: ReturnType<typeof spawn>, timeoutMs = 5_000) {
97+return await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
98+(resolve, reject) => {
99+const timer = setTimeout(() => {
100+reject(new Error("child did not close before timeout"));
101+}, timeoutMs);
102+child.once("close", (code, signal) => {
103+clearTimeout(timer);
104+resolve({ code, signal });
105+});
106+},
107+);
108+}
109+95110it("stops parsing options after the argument terminator", () => {
96111expect(parseArgs(["--plugin", "telegram", "--", "--plugin", "discord"])).toMatchObject({
97112pluginIds: ["telegram"],
@@ -720,6 +735,196 @@ setInterval(() => {}, 1000);
720735expect(process.listenerCount("SIGTERM")).toBe(before);
721736});
722737738+it.runIf(process.platform !== "win32")(
739+"cleans parent-terminated measured process groups when the leader exits first",
740+async () => {
741+const logDir = path.join(repoRoot, "logs");
742+const harnessPath = path.join(repoRoot, "parent-termination-harness.mjs");
743+const scriptPath = path.join(repoRoot, "parent-termination-leader.mjs");
744+const grandchildPidPath = path.join(repoRoot, "grandchild.pid");
745+const grandchildReadyPath = path.join(repoRoot, "grandchild.ready");
746+let grandchildPid = 0;
747+748+await fs.writeFile(
749+scriptPath,
750+`
751+import { spawn } from "node:child_process";
752+import fs from "node:fs";
753+754+const grandchildScript = [
755+ "const fs = require('node:fs');",
756+ "process.on('SIGTERM', () => {});",
757+ "process.on('SIGHUP', () => {});",
758+ "fs.writeFileSync(process.argv[2], 'ready');",
759+ "setInterval(() => {}, 1000);",
760+].join("\\n");
761+const grandchild = spawn(process.execPath, ["-e", grandchildScript, "child", process.argv[3]], {
762+ stdio: "ignore",
763+});
764+fs.writeFileSync(process.argv[2], String(grandchild.pid));
765+process.on("SIGTERM", () => process.exit(0));
766+setInterval(() => {}, 1000);
767+`,
768+"utf8",
769+);
770+await fs.writeFile(
771+harnessPath,
772+`
773+import { runMeasuredCommandLive } from ${JSON.stringify(
774+ pathToFileURL(path.resolve("scripts/check-plugin-gateway-gauntlet.mjs")).href,
775+ )};
776+777+await runMeasuredCommandLive({
778+ cwd: ${JSON.stringify(repoRoot)},
779+ env: process.env,
780+ logDir: ${JSON.stringify(logDir)},
781+ command: process.execPath,
782+ args: [${JSON.stringify(scriptPath)}, ${JSON.stringify(grandchildPidPath)}, ${JSON.stringify(
783+ grandchildReadyPath,
784+ )}],
785+ label: "parent-termination-leader-exits",
786+ phase: "probe",
787+ timeoutKillGraceMs: 25,
788+ timeoutMs: 60_000,
789+ timeMode: "none",
790+});
791+`,
792+"utf8",
793+);
794+795+const harness = spawn(process.execPath, [harnessPath], {
796+cwd: repoRoot,
797+stdio: ["ignore", "pipe", "pipe"],
798+});
799+try {
800+await waitFor(async () => {
801+try {
802+await fs.access(grandchildReadyPath);
803+return true;
804+} catch {
805+return false;
806+}
807+});
808+grandchildPid = Number.parseInt(await fs.readFile(grandchildPidPath, "utf8"), 10);
809+expect(isProcessAlive(grandchildPid)).toBe(true);
810+811+harness.kill("SIGTERM");
812+await expect(waitForClose(harness)).resolves.toEqual({ code: null, signal: "SIGTERM" });
813+await waitFor(() => !isProcessAlive(grandchildPid));
814+} finally {
815+if (grandchildPid && isProcessAlive(grandchildPid)) {
816+process.kill(grandchildPid, "SIGKILL");
817+}
818+if (harness.pid && isProcessAlive(harness.pid)) {
819+harness.kill("SIGKILL");
820+}
821+}
822+},
823+);
824+825+it.runIf(process.platform !== "win32")(
826+"rethrows parent termination received during timeout cleanup",
827+async () => {
828+const logDir = path.join(repoRoot, "logs");
829+const harnessPath = path.join(repoRoot, "timeout-parent-termination-harness.mjs");
830+const scriptPath = path.join(repoRoot, "timeout-parent-termination-leader.mjs");
831+const grandchildPidPath = path.join(repoRoot, "timeout-grandchild.pid");
832+const grandchildReadyPath = path.join(repoRoot, "timeout-grandchild.ready");
833+const leaderExitedPath = path.join(repoRoot, "timeout-leader.exited");
834+let grandchildPid = 0;
835+836+await fs.writeFile(
837+scriptPath,
838+`
839+import { spawn } from "node:child_process";
840+import fs from "node:fs";
841+842+const grandchildScript = [
843+ "const fs = require('node:fs');",
844+ "process.on('SIGTERM', () => {});",
845+ "process.on('SIGHUP', () => {});",
846+ "fs.writeFileSync(process.argv[2], 'ready');",
847+ "setInterval(() => {}, 1000);",
848+].join("\\n");
849+const grandchild = spawn(process.execPath, ["-e", grandchildScript, "child", process.argv[3]], {
850+ stdio: "ignore",
851+});
852+fs.writeFileSync(process.argv[2], String(grandchild.pid));
853+process.on("SIGTERM", () => {
854+ fs.writeFileSync(process.argv[4], "exited");
855+ process.exit(0);
856+});
857+setInterval(() => {}, 1000);
858+`,
859+"utf8",
860+);
861+await fs.writeFile(
862+harnessPath,
863+`
864+import fs from "node:fs";
865+import { setTimeout as delay } from "node:timers/promises";
866+import { runMeasuredCommandLive } from ${JSON.stringify(
867+ pathToFileURL(path.resolve("scripts/check-plugin-gateway-gauntlet.mjs")).href,
868+ )};
869+870+const promise = runMeasuredCommandLive({
871+ cwd: ${JSON.stringify(repoRoot)},
872+ env: process.env,
873+ logDir: ${JSON.stringify(logDir)},
874+ command: process.execPath,
875+ args: [${JSON.stringify(scriptPath)}, ${JSON.stringify(grandchildPidPath)}, ${JSON.stringify(
876+ grandchildReadyPath,
877+ )}, ${JSON.stringify(leaderExitedPath)}],
878+ label: "timeout-parent-termination",
879+ phase: "probe",
880+ timeoutKillGraceMs: 1_000,
881+ timeoutMs: 100,
882+ timeMode: "none",
883+});
884+for (let attempt = 0; attempt < 100 && !fs.existsSync(${JSON.stringify(
885+ leaderExitedPath,
886+ )}); attempt += 1) {
887+ await delay(25);
888+}
889+if (!fs.existsSync(${JSON.stringify(leaderExitedPath)})) {
890+ process.exit(2);
891+}
892+await delay(100);
893+process.kill(process.pid, "SIGTERM");
894+await promise;
895+process.exit(7);
896+`,
897+"utf8",
898+);
899+900+const harness = spawn(process.execPath, [harnessPath], {
901+cwd: repoRoot,
902+stdio: ["ignore", "pipe", "pipe"],
903+});
904+try {
905+await waitFor(async () => {
906+try {
907+await fs.access(grandchildReadyPath);
908+return true;
909+} catch {
910+return false;
911+}
912+});
913+grandchildPid = Number.parseInt(await fs.readFile(grandchildPidPath, "utf8"), 10);
914+915+await expect(waitForClose(harness)).resolves.toEqual({ code: null, signal: "SIGTERM" });
916+await waitFor(() => !isProcessAlive(grandchildPid));
917+} finally {
918+if (grandchildPid && isProcessAlive(grandchildPid)) {
919+process.kill(grandchildPid, "SIGKILL");
920+}
921+if (harness.pid && isProcessAlive(harness.pid)) {
922+harness.kill("SIGKILL");
923+}
924+}
925+},
926+);
927+723928it("bounds captured output from live measured commands", async () => {
724929const logDir = path.join(repoRoot, "logs");
725930const row = await runMeasuredCommandLive({
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。