openclaw/test/scripts/pr-git-maintenance.test.ts
Peter Steinberger 10b6bf71e2
fix(test): release-gated fixtures still poll release files; add release and exit receipts to the fixture receipt helper (#163063)
Tests whose fixtures had to be told to proceed, or whose fixture exit could not be observed, still polled generated release and readiness files against 3-15 s wall-clock deadlines and failed on loaded hosts: the Vitest progress and report-owner alpha/beta fixtures, node-host workspace retention, release preflight scheduling, the upgrade-survivor systemd PTY startup gate, check-deadcode reporting (scanner barrier, compiler start/drain), and the PR git-maintenance hook.

The shared fixture receipt helper (test/helpers/fixture-receipts.ts) was one-way, so these sites were left on file-polling loops in earlier batches. It now owns both directions instead of a second broker: release(source, token) sends a retained release over the existing socket to connections associated with the source, and fixtures call awaitRelease(source, token), which keeps the fixture alive only while a release is pending; waitForExit(source) resolves when every connection that announced the source has closed (Node sockets are close-on-exec, so close means process exit for a fixture that never ends its socket); openFixtureReleaseFifo gives shell fixtures and synchronously blocked callers a POSIX FIFO release with no polling. A new test/helpers/fixture-receipts.test.ts covers exit receipts (normal exit, SIGKILL, late callers), release before and after the fixture waits, FIFO release of a shell child, close rejecting pending waits, and malformed input. The CI checkout backoff site was retired upstream by #162925. No product source, test timeout, product timeout, or assertion meaning changed.

Part of the polling audit from #162274. Proof on Blacksmith Testbox: original/fixed delay probes and forced-abort probes for each new waiting pattern, 80/80 standalone runs on the rebased head (20 per file including the helper test), 9/9 owning-shard replays, OpenGrep, deadcode, type-aware lint, base-aware timeout-race ratchet; Codex autoreview focused on the helper contract and ClawSweeper clean.
2026-10-01 16:05:20 -07:00

260 lines
10 KiB
TypeScript

import { execFileSync, spawn, spawnSync } from "node:child_process";
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
realpathSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
fixtureReceiptClientSource,
openFixtureReceiptChannel,
type FixtureReceiptChannel,
} from "../helpers/fixture-receipts.js";
import { withinTest } from "../helpers/promise.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const runner = join(process.cwd(), "scripts/pr-lib/process-group-runner.mjs");
const lockScript = join(process.cwd(), "scripts/pr-lib/operation-lock.sh");
const lockRef = "refs/openclaw/pr-operation-locks/42";
const describePosix = process.platform === "win32" ? describe.skip : describe;
let receipts: FixtureReceiptChannel;
function shellQuote(value: string) {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function createMaintenanceFixture(command: string, exitCode: number) {
const root = realpathSync(tempDirs.make("openclaw-pr-git-maintenance-"));
const home = join(root, "home");
const repo = join(root, "repo");
mkdirSync(home);
mkdirSync(repo);
const env = {
PATH: process.env.PATH,
HOME: home,
XDG_CONFIG_HOME: join(home, ".config"),
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_NOSYSTEM: "1",
GIT_AUTHOR_NAME: "OpenClaw Test",
GIT_AUTHOR_EMAIL: "test@example.invalid",
GIT_COMMITTER_NAME: "OpenClaw Test",
GIT_COMMITTER_EMAIL: "test@example.invalid",
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "test.fromCount",
GIT_CONFIG_VALUE_0: "preserved count value",
GIT_CONFIG_PARAMETERS:
"'maintenance.auto=true' 'gc.auto=1' 'maintenance.autoDetach=true' 'gc.autoDetach=true' 'test.fromParameters=value with spaces=kept'",
};
const git = (args: string[], input?: string) =>
execFileSync("git", args, { cwd: repo, env, input, encoding: "utf8" }).trim();
git(["init", "-q", "-b", "main"]);
writeFileSync(join(repo, "base.txt"), "synthetic maintenance fixture\n");
git(["add", "base.txt"]);
git(["-c", "commit.gpgSign=false", "commit", "-qm", "base"]);
git(["remote", "add", "origin", repo]);
git(["fetch", "-q", "origin", "main"]);
// Two tiny packs make auto-GC eligible; old unreachable objects exercise
// the documented recentObjectsHook after maintenance's daemonization point.
for (let index = 0; index < 2; index++) {
const oid = git(["hash-object", "-w", "--stdin"], `unreachable fixture ${index}\n`);
const packed = git(["pack-objects", join(repo, ".git/objects/pack/pack")], `${oid}\n`);
for (const extension of ["pack", "idx"]) {
utimesSync(join(repo, `.git/objects/pack/pack-${packed}.${extension}`), 1, 1);
}
utimesSync(join(repo, ".git/objects", oid.slice(0, 2), oid.slice(2)), 1, 1);
}
git(["config", "maintenance.strategy", "gc"]);
git(["config", "gc.auto", "1"]);
git(["config", "gc.autoPackLimit", "1"]);
const ready = join(root, "maintenance-ready.json");
const completed = join(root, "maintenance-completed");
const hook = join(root, "recent-objects.mjs");
writeFileSync(
hook,
[
fixtureReceiptClientSource(receipts.endpoint),
'import { execFileSync } from "node:child_process";',
'import { existsSync, fstatSync, renameSync, writeFileSync } from "node:fs";',
`const ready = ${JSON.stringify(ready)};`,
"if (!existsSync(ready)) {",
' const pgid = Number(execFileSync("ps", ["-o", "pgid=", "-p", String(process.pid)], { encoding: "utf8" }).trim());',
" const notifierOpen = fstatSync(3).isSocket() || fstatSync(3).isFIFO();",
' writeFileSync(ready + ".pending", JSON.stringify({ pgid, notifierOpen }));',
' renameSync(ready + ".pending", ready);',
' sendReceipt(ready, "ready");',
' await awaitRelease(ready, "release");',
` writeFileSync(${JSON.stringify(completed)}, "complete\\n");`,
"}",
].join("\n"),
);
git(["config", "gc.recentObjectsHook", `${shellQuote(process.execPath)} ${shellQuote(hook)}`]);
const script = join(root, "operation.sh");
writeFileSync(
script,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`source ${shellQuote(lockScript)}`,
`repo_root() { printf '%s\\n' ${shellQuote(repo)}; }`,
"acquire_pr_operation_lock 42",
"begin_pr_operation_validation_phase",
"mark_pr_operation_side_effects_started",
'test "$(git config --get test.fromCount)" = "preserved count value"',
'test "$(git config --get test.fromParameters)" = "value with spaces=kept"',
command,
`exit ${exitCode}`,
].join("\n"),
);
chmodSync(script, 0o755);
return { repo, env, git, script, ready, completed };
}
describePosix("scripts/pr Git maintenance ownership", () => {
beforeAll(async () => {
receipts = await openFixtureReceiptChannel();
});
afterAll(async () => {
await receipts.close();
});
it.for(["git fetch origin main", "git gc --auto"])(
"suppresses automatic maintenance during %s",
async (command, { signal }) => {
const fixture = createMaintenanceFixture(command, 0);
const config = readFileSync(join(fixture.repo, ".git/config"), "utf8");
receipts.release(fixture.ready, "release");
const child = spawn(process.execPath, [runner, fixture.repo, fixture.script], {
cwd: fixture.repo,
env: fixture.env,
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => (stderr += chunk));
const closed = new Promise<number | null>((resolve, reject) => {
child.once("error", reject);
child.once("close", resolve);
});
try {
expect(await withinTest(closed, signal), stderr).toBe(0);
} finally {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
await closed;
}
expect(existsSync(fixture.ready)).toBe(false);
expect(existsSync(fixture.completed)).toBe(false);
expect(
spawnSync("git", ["show-ref", "--verify", "--quiet", lockRef], {
cwd: fixture.repo,
env: fixture.env,
}).status,
).toBe(1);
expect(readFileSync(join(fixture.repo, ".git/config"), "utf8")).toBe(config);
expect(fixture.git(["config", "--bool", "maintenance.auto"])).toBe("true");
expect(fixture.git(["config", "--int", "gc.auto"])).toBe("1");
},
);
it.for([
{ command: "git gc", exitCode: 0 },
{ command: "git gc", exitCode: 7 },
])(
"joins $command before operation completion (exit $exitCode)",
async ({ command, exitCode }, { signal }) => {
const fixture = createMaintenanceFixture(command, exitCode);
const child = spawn(process.execPath, [runner, fixture.repo, fixture.script], {
cwd: fixture.repo,
env: fixture.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stdout.resume();
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
const closed = new Promise<number | null>((resolve, reject) => {
child.once("error", reject);
child.once("close", resolve);
});
let ownerOid = "";
try {
await withinTest(
Promise.race([
receipts.waitFor(fixture.ready, "ready"),
closed.then(() => {
// The hook publishes this record before its receipt; separate pipes may reorder.
expect(existsSync(fixture.ready), stderr).toBe(true);
}),
]),
signal,
);
ownerOid = fixture.git(["rev-parse", lockRef]);
const owner = fixture.git(["cat-file", "blob", ownerOid]);
const pgid = Number(/^pgid=(\d+)$/m.exec(owner)?.[1]);
const hookState = JSON.parse(readFileSync(fixture.ready, "utf8")) as {
pgid: number;
notifierOpen: boolean;
};
expect(hookState.notifierOpen).toBe(true);
expect(hookState.pgid).toBe(pgid);
expect(child.exitCode).toBeNull();
expect(existsSync(fixture.completed)).toBe(false);
// A second operation cannot acquire the exact PR while maintenance is held.
const probe = spawn(
"bash",
[
"-c",
`source ${shellQuote(lockScript)}; repo_root() { printf '%s\\n' ${shellQuote(fixture.repo)}; }; try_acquire_pr_operation_lock 42`,
],
{ cwd: fixture.repo, env: fixture.env, detached: true, stdio: "ignore" },
);
const probeClosed = new Promise<number | null>((resolve, reject) => {
probe.once("error", reject);
probe.once("close", resolve);
});
try {
expect(await withinTest(probeClosed, signal)).toBe(1);
} finally {
if (probe.exitCode === null && probe.signalCode === null) {
probe.kill("SIGKILL");
}
await probeClosed;
}
expect(fixture.git(["rev-parse", lockRef])).toBe(ownerOid);
} finally {
// The gate belongs to this fixture. Let Git finish and join it; no PID-based cleanup.
receipts.release(fixture.ready, "release");
await closed;
}
expect(child.exitCode, stderr).toBe(exitCode);
expect(existsSync(fixture.completed)).toBe(true);
expect(stderr).not.toContain("drain deadline");
expect(stderr).not.toContain("process group remained active");
if (exitCode === 0) {
expect(
spawnSync("git", ["show-ref", "--verify", "--quiet", lockRef], {
cwd: fixture.repo,
env: fixture.env,
}).status,
).toBe(1);
} else {
expect(fixture.git(["rev-parse", lockRef])).toBe(ownerOid);
expect(stderr).toContain(`reason: child exited with code ${exitCode}`);
}
expect(fixture.git(["config", "--bool", "maintenance.autoDetach"])).toBe("true");
expect(fixture.git(["config", "--bool", "gc.autoDetach"])).toBe("true");
},
);
});