mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 14:12:28 +08:00
* fix: keep PR landing working across GitHub API quotas * test: align PR lifecycle fixtures with GitHub adapters * fix(pr): preserve squash defaults and repair landing fixtures * test: capture stalled child trees before cancellation * test: reduce large upload fixture memory and diagnose timeouts
316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
import type { ChildProcess } from "node:child_process";
|
|
import { EventEmitter, once } from "node:events";
|
|
import { createServer, type Socket } from "node:net";
|
|
import { createInterface } from "node:readline";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createCommandFixture } from "../helpers/command-fixture.js";
|
|
import { createFixtureDiagnostics } from "../helpers/fixture-diagnostics.js";
|
|
import { isProcessAlive } from "../helpers/process-wait.js";
|
|
import { createDeferred } from "../helpers/promise.js";
|
|
|
|
const observer = vi.hoisted((): { onChild?: (child: ChildProcess) => void } => ({}));
|
|
|
|
// Observe the real leader's exit without replacing spawning or process cleanup.
|
|
vi.mock("../../scripts/lib/managed-child-process.mts", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import("../../scripts/lib/managed-child-process.mts")>();
|
|
return {
|
|
...actual,
|
|
runManagedCommand: (options: Parameters<typeof actual.runManagedCommand>[0]) =>
|
|
actual.runManagedCommand({
|
|
...options,
|
|
onReady(child) {
|
|
options.onReady?.(child);
|
|
observer.onChild?.(child);
|
|
},
|
|
}),
|
|
};
|
|
});
|
|
|
|
describe.skipIf(process.platform === "win32")("POSIX command fixture output drainage", () => {
|
|
const modes =
|
|
process.platform === "linux"
|
|
? (["drain", "cancel", "cancel-live"] as const)
|
|
: (["drain", "cancel"] as const);
|
|
it.for(modes)(
|
|
"settles descendant output after leader exit or live cancellation through %s",
|
|
async (mode, context) => {
|
|
const stop = new AbortController();
|
|
const signal = context.signal;
|
|
const diagnostics = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
// Registered first so this observes the fixture's existing teardown hook, not only run().
|
|
context.onTestFinished(() => {
|
|
try {
|
|
expect(diagnostics).toHaveBeenCalledTimes(mode === "drain" ? 0 : 1);
|
|
} finally {
|
|
diagnostics.mockRestore();
|
|
}
|
|
});
|
|
const command = createCommandFixture({
|
|
signal: AbortSignal.any([signal, stop.signal]),
|
|
onTestFinished: context.onTestFinished,
|
|
});
|
|
command.enableDiagnostics("command-fixture-drainage").stage(mode);
|
|
try {
|
|
await command.lifetime.run(async () => {
|
|
signal.throwIfAborted();
|
|
const server = createServer();
|
|
let completion: ReturnType<typeof command.run> | undefined;
|
|
let socket: Socket | undefined;
|
|
let reader: ReturnType<typeof createInterface> | undefined;
|
|
const connected = createDeferred<Socket>();
|
|
const cancelled = createDeferred<never>();
|
|
// Observe early rejection while retaining the original awaited promises.
|
|
void connected.promise.catch(() => {});
|
|
void cancelled.promise.catch(() => {});
|
|
const aborted = () => cancelled.reject(signal.reason);
|
|
signal.addEventListener("abort", aborted, { once: true });
|
|
server.once("connection", (connection) => {
|
|
socket = connection;
|
|
connected.resolve(connection);
|
|
});
|
|
server.once("error", connected.reject);
|
|
try {
|
|
server.listen({ port: 0, host: "127.0.0.1", signal });
|
|
await once(server, "listening", { signal });
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Missing fixture listener address");
|
|
}
|
|
const exited = createDeferred();
|
|
const ready = createDeferred();
|
|
let leader: ChildProcess | undefined;
|
|
observer.onChild = (child) => {
|
|
leader = child;
|
|
child.once("exit", () => exited.resolve());
|
|
child.stdout!.once("data", () => ready.resolve());
|
|
};
|
|
const descendant = `
|
|
process.title = "private fixture process title";
|
|
const socket = require("node:net").connect(${address.port}, "127.0.0.1", () => process.send("ready"));
|
|
require("node:readline").createInterface({ input: socket }).on("line", (line) => {
|
|
if (line === "ping") socket.write("pong\\n");
|
|
if (line === "release") {
|
|
process.stderr.write("drained\\n");
|
|
socket.end();
|
|
}
|
|
});
|
|
`;
|
|
completion = command.run(process.execPath, [
|
|
"--eval",
|
|
`
|
|
const child = require("node:child_process").spawn(process.execPath, ["--eval", ${JSON.stringify(descendant)}], {
|
|
stdio: ["ignore", "ignore", "inherit", "ipc"],
|
|
});
|
|
child.once("message", () => {
|
|
console.log(child.pid);
|
|
${mode === "cancel-live" ? "" : "child.disconnect(); child.unref();"}
|
|
});
|
|
`,
|
|
]);
|
|
const connection = await Promise.race([connected.promise, cancelled.promise]);
|
|
const lines = createInterface({ input: connection });
|
|
reader = lines;
|
|
await Promise.race([
|
|
mode === "cancel-live" ? ready.promise : exited.promise,
|
|
cancelled.promise,
|
|
]);
|
|
// The descendant remains usable while it owns the final output pipe.
|
|
const pong = new Promise<string>((resolve, reject) => {
|
|
if (connection.destroyed) {
|
|
reject(new Error("Descendant exited before its output drained"));
|
|
return;
|
|
}
|
|
lines.once("line", resolve);
|
|
lines.once("error", reject);
|
|
connection.once("close", () =>
|
|
reject(new Error("Descendant exited before acknowledging drainage")),
|
|
);
|
|
connection.once("error", reject);
|
|
});
|
|
connection.write("ping\n");
|
|
expect(await Promise.race([pong, cancelled.promise])).toBe("pong");
|
|
if (mode === "drain") {
|
|
connection.end("release\n");
|
|
const result = await completion;
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toBe("drained\n");
|
|
} else {
|
|
stop.abort();
|
|
const result = await completion;
|
|
expect(result.error).toMatchObject({ code: "ABORT_ERR" });
|
|
const descendantPid = Number(result.stdout.trim());
|
|
expect(descendantPid).toBeGreaterThan(0);
|
|
expect(isProcessAlive(descendantPid)).toBe(false);
|
|
if (mode === "cancel-live") {
|
|
expect(diagnostics).toHaveBeenCalledTimes(1);
|
|
const reportText = String(diagnostics.mock.calls[0]?.[0]);
|
|
const report = JSON.parse(reportText.slice("[fixture-lifecycle] ".length));
|
|
expect(reportText).not.toContain("private fixture");
|
|
expect(report.current).toMatchObject({ exitCode: null, signalCode: null });
|
|
expect(report.current.processTree.processes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
pid: leader?.pid,
|
|
threads: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
tid: leader?.pid,
|
|
state: expect.stringMatching(/^[A-Z]$/u),
|
|
waitChannel: expect.stringMatching(/^[A-Za-z0-9_]{1,96}$/u),
|
|
}),
|
|
]),
|
|
}),
|
|
expect.objectContaining({ pid: descendantPid, parentPid: leader?.pid }),
|
|
]),
|
|
);
|
|
expect(isProcessAlive(leader?.pid ?? 0)).toBe(false);
|
|
}
|
|
}
|
|
} finally {
|
|
observer.onChild = undefined;
|
|
signal.removeEventListener("abort", aborted);
|
|
reader?.close();
|
|
socket?.destroy();
|
|
await completion;
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error?: NodeJS.ErrnoException) => {
|
|
if (error && error.code !== "ERR_SERVER_NOT_RUNNING") {
|
|
reject(error);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
} finally {
|
|
await command.lifetime.cleanup();
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
class ObservedChild extends EventEmitter {
|
|
pid = 42;
|
|
exitCode: number | null = null;
|
|
signalCode: NodeJS.Signals | null = null;
|
|
stdout = { closed: false };
|
|
stderr = { closed: false };
|
|
spawnargs = ["private command payload"];
|
|
}
|
|
|
|
describe("failure-only fixture diagnostics", () => {
|
|
afterEach(() => vi.restoreAllMocks());
|
|
it("keeps successful, expected nonzero, and expected signal settlements silent", () => {
|
|
const output = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const diagnostics = createFixtureDiagnostics("fixture");
|
|
for (const signal of [null, "SIGKILL"] as const) {
|
|
const command = diagnostics.command("probe");
|
|
const child = new ObservedChild();
|
|
command.ready(child);
|
|
child.emit("spawn");
|
|
child.exitCode = signal ? null : 1;
|
|
child.signalCode = signal;
|
|
child.emit("exit");
|
|
child.stdout.closed = child.stderr.closed = true;
|
|
child.emit("close");
|
|
command.settled();
|
|
command.inputComplete();
|
|
}
|
|
expect(output).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports bounded safe metadata once without consuming output or inventing spawn", () => {
|
|
const output = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const now = vi.spyOn(performance, "now").mockReturnValue(10);
|
|
const diagnostics = createFixtureDiagnostics("fixture");
|
|
diagnostics.stage("sparse-import");
|
|
const command = diagnostics.command("probe", true);
|
|
const child = new ObservedChild();
|
|
command.ready(child);
|
|
expect(child.listenerCount("data")).toBe(0);
|
|
now.mockReturnValue(25);
|
|
command.output("stdout", 3);
|
|
command.output("stderr", 7);
|
|
diagnostics.report("abort");
|
|
command.settled(Object.assign(new Error("private error payload"), { code: "ABORT_ERR" }));
|
|
command.inputComplete();
|
|
diagnostics.report("failure");
|
|
expect(output).toHaveBeenCalledTimes(1);
|
|
const text = String(output.mock.calls[0]?.[0]);
|
|
const report = JSON.parse(text.slice("[fixture-lifecycle] ".length));
|
|
expect(report.records.map((record: { event: string }) => record.event)).toEqual([
|
|
"stage",
|
|
"command-start",
|
|
"on-ready",
|
|
]);
|
|
expect(report.current).toMatchObject({
|
|
id: 1,
|
|
role: "probe",
|
|
stage: "sparse-import",
|
|
pid: 42,
|
|
elapsedMs: 15,
|
|
stdoutClosed: false,
|
|
stderrClosed: false,
|
|
stdoutBytes: 3,
|
|
stderrBytes: 7,
|
|
input: "pending",
|
|
});
|
|
expect(text).not.toContain("private");
|
|
expect(text).not.toContain("spawnargs");
|
|
});
|
|
|
|
it("records native lifecycle order and the existing input completion boundary", () => {
|
|
const output = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const diagnostics = createFixtureDiagnostics("fixture");
|
|
const command = diagnostics.command("probe", true);
|
|
const child = new ObservedChild();
|
|
command.ready(child);
|
|
child.emit("spawn");
|
|
child.exitCode = 0;
|
|
child.emit("exit");
|
|
child.stdout.closed = child.stderr.closed = true;
|
|
child.emit("close");
|
|
command.settled(Object.assign(new Error("failed"), { code: "ETIMEDOUT" }));
|
|
command.inputComplete();
|
|
diagnostics.report("failure");
|
|
const report = JSON.parse(
|
|
String(output.mock.calls[0]?.[0]).slice("[fixture-lifecycle] ".length),
|
|
);
|
|
expect(report.records.map((record: { event: string }) => record.event)).toEqual([
|
|
"command-start",
|
|
"on-ready",
|
|
"spawn",
|
|
"exit",
|
|
"close",
|
|
"managed-settled",
|
|
"input-complete",
|
|
]);
|
|
expect(report.current).toMatchObject({
|
|
exitCode: 0,
|
|
errorCode: "ETIMEDOUT",
|
|
input: "settled",
|
|
});
|
|
});
|
|
|
|
it("bounds retained events and labels while keeping the latest stage and child", () => {
|
|
const output = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const diagnostics = createFixtureDiagnostics("n".repeat(300));
|
|
for (let index = 0; index < 100; index++) {
|
|
diagnostics.stage("s".repeat(300));
|
|
}
|
|
diagnostics.command("r".repeat(300)).settled({ code: "unsafe/path" });
|
|
diagnostics.report("failure");
|
|
const report = JSON.parse(
|
|
String(output.mock.calls[0]?.[0]).slice("[fixture-lifecycle] ".length),
|
|
);
|
|
expect(report.records).toHaveLength(48);
|
|
expect(report.dropped).toBe(54);
|
|
expect(report.name).toHaveLength(96);
|
|
expect(report.stage).toHaveLength(96);
|
|
expect(report.current.role).toHaveLength(96);
|
|
expect(report.current.errorCode).toBeUndefined();
|
|
});
|
|
});
|