fix(test): Docker, e2e-instance, and CI tooling tests fail on loaded hosts when fixture children outlast polling deadlines (#162729)

Script-tooling tests for the SQLite reliability bench, CI workflow guards and git-owner auth fixture, Docker build helper, iOS node e2e, the OpenClaw e2e instance, Parallels smoke model, plugin SDK API diff, unchanged-plugin Docker update, Android Gradle runner, and Vitest worker-artifact verification polled files, ports, and PIDs against 500 ms to 15 s deadlines, and raced child readiness/exit against timers of up to 30 s, so a fixture child that outlasted the bound on a loaded host failed the test.

Waits now await child IPC, inherited readiness pipes, durable fixture receipts, native exit/close promises, and existing read-admission deferreds; Python fixtures join their retained subprocess handles under the caller's lifetime guard. Suspended test bodies are bound to the Vitest signal and fixture roots are retained until native children settle; the tracked Docker build awaits its inherited pipe EOF through close before its TERM record is checked. Where no reaper handle is owned, one deadline-free, signal-bound foreign-PID check remains per file. ci-git-owner-settlement's native Windows Job cases keep main's bytes until a Windows host can prove them. No product source, test timeout, product timeout, or assertion meaning changed.

Part of the polling audit from #162274. Proof on Blacksmith Testbox: delay probes fail the original bytes and pass these bytes, forced-abort probes reach cleanup, 20/20 standalone runs per touched file, 3/3 replays of each owning shard (core-tooling-12 on the rebased head), type-aware lint, base-aware timeout-race ratchet; Codex autoreview clean; ClawSweeper found no findings.
This commit is contained in:
Peter Steinberger 2026-10-01 06:50:40 -07:00 • committed by GitHub
parent 777421df55
commit 5c4e9aba92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1060 additions and 661 deletions

View file

@ -140,13 +140,11 @@ test/plugins/team-reports-http.gateway.test.ts 1
test/scripts/ci-run-node-test-shard.test.ts 3
test/scripts/compiler-input-snapshot.test.ts 1
test/scripts/plugin-gateway-gauntlet.test.ts 3
test/scripts/plugin-sdk-api-diff.test.ts 2
test/scripts/run-tsgo.test.ts 2
test/scripts/run-with-env.test.ts 1
test/scripts/test-projects-build-admission.test.ts 7
test/scripts/upstream-repository-advisories.test.ts 2
test/scripts/vitest-fork-os-diagnostics.test.ts 1
test/scripts/vitest-worker-artifacts.verification.test.ts 1
test/slack-download-read-authority.integration.test.ts 2
test/subagent-requester-owner.e2e.test.ts 2
ui/src/e2e/chat-flow.catalog-bootstrap.e2e.test.ts 4

View file

@ -26,6 +26,7 @@ import {
openOpenClawStateDatabase,
} from "../../src/state/openclaw-state-db.js";
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
import { withinTest } from "../helpers/promise.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
import { toolingTsEntrypoints } from "./tooling-ts-runtime.test-support.js";
@ -63,16 +64,8 @@ function runProof(args: string[], env: NodeJS.ProcessEnv = {}) {
async function waitForChildReady(child: ChildProcess): Promise<void> {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("writer child did not become ready"));
}, 10_000);
const onMessage = (message: unknown) => {
if (
message &&
typeof message === "object" &&
(message as { kind?: unknown }).kind === "ready"
) {
if (message && typeof message === "object" && "kind" in message && message.kind === "ready") {
cleanup();
resolve();
}
@ -86,7 +79,6 @@ async function waitForChildReady(child: ChildProcess): Promise<void> {
reject(new Error("writer child exited before ready"));
};
const cleanup = () => {
clearTimeout(timeout);
child.off("message", onMessage);
child.off("error", onError);
child.off("exit", onExit);
@ -102,10 +94,6 @@ async function waitForChildExit(child: ChildProcess): Promise<{
signal: NodeJS.Signals | null;
}> {
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("writer child did not exit after IPC disconnect"));
}, 10_000);
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
cleanup();
resolve({ code, signal });
@ -115,7 +103,6 @@ async function waitForChildExit(child: ChildProcess): Promise<{
reject(error);
};
const cleanup = () => {
clearTimeout(timeout);
child.off("exit", onExit);
child.off("error", onError);
};
@ -493,7 +480,7 @@ if (isMainThread && !process.execArgv.includes("--no-concurrent-sparkplug")) {
);
});
it("stops the writer when its parent IPC channel disconnects", async () => {
it("stops the writer when its parent IPC channel disconnects", async ({ signal }) => {
const databasePath = path.join(
tempDirs.make("openclaw-sqlite-reliability-test-"),
"writer.sqlite",
@ -510,15 +497,18 @@ if (isMainThread && !process.execArgv.includes("--no-concurrent-sparkplug")) {
stdio: ["ignore", "ignore", "pipe", "ipc"],
},
);
const exitPromise = waitForChildExit(child);
// Readiness and exit are both owned by this child; the test signal owns the deadline.
void exitPromise.catch(() => {});
try {
await waitForChildReady(child);
const exitPromise = waitForChildExit(child);
await withinTest(waitForChildReady(child), signal);
child.disconnect();
await expect(exitPromise).resolves.toEqual({ code: 0, signal: null });
await expect(withinTest(exitPromise, signal)).resolves.toEqual({ code: 0, signal: null });
} finally {
if (child.exitCode === null && child.signalCode === null) {
child.kill();
child.kill("SIGKILL");
}
await exitPromise;
}
});
});

View file

@ -40,6 +40,7 @@ import { collectRuntimeImportClosure } from "../../scripts/lib/runtime-import-cl
import { resolveRunVitestSpawnEnv } from "../../scripts/lib/vitest-process-env.mts";
import { resolvePnpmRunner } from "../../scripts/pnpm-runner.mts";
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
import { awaitGateBeforeSettlement, withinTest } from "../helpers/promise.js";
import { createTempDirTracker, useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
import { createPrebuiltUiE2eVitestConfig } from "../vitest/vitest.ui-e2e-prebuilt.config.ts";
import { uiE2eRealGatewayTestFiles } from "../vitest/vitest.ui-paths.mjs";
@ -2919,11 +2920,9 @@ process.exit(JSON.parse(process.env.RECIPE_EXITS)[count] ?? 99);
const fixtureDirs = createTempDirTracker();
// oxlint-disable-next-line prefer-const -- Failure cleanup can run before the registry is started.
let stopRegistry: (() => Promise<void>) | undefined;
let readyTimeout: NodeJS.Timeout | undefined;
// Timeout does not join the test body. Keep close and deletion in one hook,
// outside afterEach, so a failed join cannot release the registry's files.
onTestFinished(async () => {
clearTimeout(readyTimeout);
await stopRegistry?.();
fixtureDirs.cleanup();
});
@ -3073,8 +3072,7 @@ server.listen(0, "127.0.0.1", () => {
await registryClosed;
};
try {
const port = await new Promise<number>((resolve, reject) => {
readyTimeout = setTimeout(() => reject(new Error("fixture registry not ready")), 2_000);
const ready = new Promise<number>((resolve, reject) => {
registryServer.once("message", (message) => {
if (typeof message !== "number") {
reject(new Error("fixture registry sent an invalid port"));
@ -3083,9 +3081,11 @@ server.listen(0, "127.0.0.1", () => {
resolve(message);
});
registryServer.once("error", reject);
void registryClosed.then(() => reject(new Error("fixture registry closed before ready")));
});
clearTimeout(readyTimeout);
const port = await withinTest(
awaitGateBeforeSettlement(ready, registryClosed, "fixture registry closed before ready"),
signal,
);
signal.throwIfAborted();
const registryUrl = `http://127.0.0.1:${port}`;
writeFileSync(
@ -3234,7 +3234,6 @@ server.listen(0, "127.0.0.1", () => {
failures.unshift(error);
}
} finally {
clearTimeout(readyTimeout);
try {
await stopRegistry();
} catch (error) {

View file

@ -22,10 +22,16 @@ import {
import { basename, dirname, join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { mainLanes } from "../../scripts/lib/docker-e2e-scenarios.mts";
import { buildSystemdUnit } from "../../src/daemon/systemd-unit.js";
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
import {
fixtureReceiptClientSource,
openFixtureReceiptChannel,
type FixtureReceiptChannel,
} from "../helpers/fixture-receipts.js";
import { awaitGateBeforeSettlement, withinTest } from "../helpers/promise.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
import {
copySurvivorCaptureClosure,
@ -36,8 +42,69 @@ import {
UPGRADE_SURVIVOR_PATHS_HELPER,
} from "./upgrade-survivor-paths.test-support.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const pendingChildCompletions = new Set<Promise<unknown>>();
const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
afterEach(async () => {
// Vitest starts afterEach without joining a timed-out body's async finally.
// Keep stop-policy scripts and child state until their native owners settle.
await Promise.allSettled(pendingChildCompletions);
cleanup();
}),
);
const testNodeExecPath = resolveTestNodeExecPath();
let receipts: FixtureReceiptChannel;
beforeAll(async () => {
receipts = await openFixtureReceiptChannel();
});
afterAll(async () => {
await receipts?.close();
});
function ownChildCompletion<T>(completion: Promise<T>): Promise<T> {
const owned = completion.finally(() => {
pendingChildCompletions.delete(owned);
});
pendingChildCompletions.add(owned);
return owned;
}
function writeFixtureReceiptReporter(workDir: string): string {
const reporter = join(workDir, "fixture-receipt.mjs");
writeFileSync(
reporter,
`${fixtureReceiptClientSource(receipts.endpoint)}
import fs from "node:fs";
const [source, line] = process.argv.slice(2);
fs.appendFileSync(source, line + "\\n");
sendReceipt(source, line);
fixtureReceiptSocket.ref();
fixtureReceiptSocket.end();
`,
);
return reporter;
}
function fixtureEventBeforeSettlement(
source: string,
text: string,
operation: PromiseLike<unknown>,
message: string,
): Promise<void> {
const recorded = () => existsSync(source) && readFileSync(source, "utf8").includes(text);
const settled = Promise.resolve(operation).then(
() => {
if (!recorded()) {
throw new Error(message);
}
},
(error: unknown) => {
if (!recorded()) {
throw error;
}
},
);
return Promise.race([receipts.waitFor(source, text), settled]);
}
const PACKAGE_BUILDER_NODE_SCRIPT = `node() {
local script="$1"
@ -203,6 +270,8 @@ function containerCleanupFixture(scenario: string) {
const log = join(temp, "runner log");
const eventsPath = join(root, "events.jsonl");
const pidPath = join(root, "docker.pid");
const readyPath = join(root, "docker.ready");
const reporter = writeFixtureReceiptReporter(root);
const stdinPath = join(root, "stdin");
mkdirSync(temp);
writeFileSync(join(root, "retained-evidence"), "keep evidence");
@ -263,6 +332,7 @@ console.log("fixture container output");
if (scenario === "signal") {
fs.writeFileSync(process.env.FIXTURE_PID, String(process.pid));
process.on("SIGTERM", () => process.exit(143));
require("node:child_process").spawn(process.execPath, [${JSON.stringify(reporter)}, ${JSON.stringify(readyPath)}, "ready"], { stdio: "ignore" });
setInterval(() => {}, 1000);
} else {
console.log("Tests 4 passed");
@ -292,7 +362,7 @@ if (scenario === "signal") {
.trim()
.split("\n")
.map((line) => JSON.parse(line));
return { root, temp, log, env, events, pidPath };
return { root, temp, log, env, events, pidPath, readyPath };
}
function expectContainerCleanup(
@ -437,6 +507,37 @@ function spawnDockerSnippet(
return spawnSync(...prepareDockerSnippet(script, options, args));
}
async function runDockerSnippet(script: string, signal: AbortSignal): Promise<void> {
const child = spawn("/bin/bash", ["--noprofile", "--norc", "-c", script], {
detached: true,
env: { ...process.env, BASH_ENV: "", ENV: "" },
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString()));
child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString()));
let closed = false;
const completion = ownChildCompletion(
new Promise<number | null>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => {
closed = true;
resolve(code);
});
}),
);
try {
expect(await withinTest(completion, signal), output).toBe(0);
} finally {
if (!closed && child.pid) {
try {
process.kill(-child.pid, "SIGKILL");
} catch {}
await completion;
}
}
}
function writeExecutables(directory: string, files: Record<string, string>): void {
mkdirSync(directory, { recursive: true });
for (const [name, contents] of Object.entries(files)) {
@ -504,20 +605,28 @@ function installUpgradeSurvivorSystemctlShim(
return join(prefix, "bin", "systemctl");
}
async function waitForProcessExit(child: ChildProcess, timeoutMs = 5_000): Promise<number | null> {
function waitForProcessExit(child: ChildProcess): Promise<number | null> {
if (child.exitCode !== null || child.signalCode !== null) {
return child.exitCode;
return Promise.resolve(child.exitCode);
}
return ownChildCompletion(
new Promise<number | null>((resolve, reject) => {
child.once("error", reject);
child.once("exit", resolve);
}),
);
}
// The watchdog's escalation callback exits before reaping its foreign child.
// Keep this exact-PID observation tied to the test lifetime, without another deadline.
async function waitForForeignProcessExit(pid: number, signal: AbortSignal): Promise<void> {
try {
while (isProcessRunning(pid)) {
await delay(10, undefined, { signal });
}
} catch (cause) {
throw new Error(`process stayed alive: ${pid}`, { cause });
}
return await new Promise<number | null>((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("process did not exit before its test deadline"));
}, timeoutMs);
child.once("exit", (code) => {
clearTimeout(timeout);
resolve(code);
});
});
}
function isProcessRunning(pid: number): boolean {
@ -559,6 +668,7 @@ process.on("SIGTERM", () => {});
const pendingPid = process.env.DESCENDANT_PID_FILE + ".pending";
fs.writeFileSync(pendingPid, String(process.pid));
fs.renameSync(pendingPid, process.env.DESCENDANT_PID_FILE);
process.send?.({ kind: "ready", pid: process.pid });
setInterval(() => {}, 1_000);
`,
);
@ -566,6 +676,7 @@ setInterval(() => {}, 1_000);
}
async function forEachUpgradeSurvivorSystemctlShim(
signal: AbortSignal,
callback: (fixture: {
pid: number;
pidPath: string;
@ -581,12 +692,17 @@ async function forEachUpgradeSurvivorSystemctlShim(
const childPidPath = join(workDir, "child.pid");
const child = spawn(process.execPath, [writeTermIgnoringDescendant(workDir)], {
env: { ...process.env, DESCENDANT_PID_FILE: childPidPath },
stdio: "ignore",
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
const exited = waitForProcessExit(child);
const ready = new Promise<void>((resolve) => {
child.once("message", () => resolve());
});
try {
for (let attempt = 0; attempt < 100 && !existsSync(childPidPath); attempt += 1) {
await delay(10);
}
await withinTest(
awaitGateBeforeSettlement(ready, exited, `file was not written: ${childPidPath}`),
signal,
);
const pid = Number.parseInt(readFileSync(childPidPath, "utf8"), 10);
writeFileSync(pidPath, `${pid}\n`);
const daemonLog = join(workDir, "gateway.log");
@ -1237,7 +1353,7 @@ grep -q '^build --progress=plain --build-arg GITHUB_ACTIONS -t demo-image .$' "$
execDockerSnippet(script);
});
it("stops the tracked build command without retrying when interrupted", async () => {
it("stops the tracked build command without retrying when interrupted", async ({ signal }) => {
const workDir = tempDirs.make("openclaw-docker-build-signal-");
writeExecutables(join(workDir, "bin"), {
docker: `#!/bin/bash
@ -1253,6 +1369,7 @@ printf 'rpc error: code = Unavailable\\n'
trap 'printf "term\\n" >"$TMPDIR/docker.term"; exit 0' TERM
mkfifo "$TMPDIR/docker.block"
printf 'ready\\n' >"$TMPDIR/docker.ready"
printf 'ready\\n' >&3
while true; do
read -r -t 1 _ <> "$TMPDIR/docker.block" || true
done
@ -1272,31 +1389,7 @@ docker_build_run e2e-build -t demo-image .
`,
});
const waitForFile = async (filePath: string) => {
for (let attempt = 0; attempt < 500; attempt += 1) {
if (existsSync(filePath)) {
return;
}
await delay(10);
}
throw new Error(`file was not written: ${filePath}`);
};
const waitForExit = async (child: ReturnType<typeof spawn>) =>
await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
child.once("exit", (code, signal) => resolve({ code, signal }));
});
const waitForDead = async (pid: number) => {
for (let attempt = 0; attempt < 500; attempt += 1) {
try {
process.kill(pid, 0);
} catch {
return;
}
await delay(10);
}
throw new Error(`process stayed alive: ${pid}`);
};
const runInterruptedBuild = async (signal: NodeJS.Signals, expectedCode: number) => {
const runInterruptedBuild = async (childSignal: NodeJS.Signals, expectedCode: number) => {
rmSync(join(workDir, "docker.pid"), { force: true });
rmSync(join(workDir, "docker.term"), { force: true });
rmSync(join(workDir, "docker.ready"), { force: true });
@ -1304,25 +1397,44 @@ docker_build_run e2e-build -t demo-image .
rmSync(join(workDir, "docker-count"), { force: true });
const runner = spawn(join(workDir, "runner.sh"), {
env: { ...process.env, TMPDIR: workDir },
stdio: "ignore",
stdio: ["ignore", "ignore", "ignore", "pipe"],
});
const exited = waitForProcessExit(runner);
const closed = ownChildCompletion(
new Promise<number | null>((resolve, reject) => {
runner.once("error", reject);
runner.once("close", resolve);
}),
);
const ready = new Promise<void>((resolve, reject) => {
runner.stdio[3]!.once("data", () => resolve());
runner.stdio[3]!.once("error", reject);
});
try {
const pidPath = join(workDir, "docker.pid");
await waitForFile(pidPath);
await waitForFile(join(workDir, "docker.ready"));
// A builtin FD write leaves no foreground reporter that tree shutdown could
// kill first, making errexit bypass the fixture's own TERM trap.
await withinTest(
awaitGateBeforeSettlement(ready, exited, `file was not written: ${pidPath}`),
signal,
);
expect(existsSync(join(workDir, "docker.ready"))).toBe(true);
const buildPid = Number.parseInt(readFileSync(pidPath, "utf8"), 10);
runner.kill(signal);
const exit = await waitForExit(runner);
expect(exit).toEqual({ code: expectedCode, signal: null });
await waitForFile(join(workDir, "docker.term"));
runner.kill(childSignal);
expect(await withinTest(closed, signal)).toBe(expectedCode);
expect(runner.signalCode).toBeNull();
// The timeout wrapper can exit first; the Docker fixture holds FD 3 until
// its TERM trap finishes, so runner close also joins that fixture's output.
expect(existsSync(join(workDir, "docker.term"))).toBe(true);
expect(readFileSync(join(workDir, "docker-count"), "utf8").trim()).toBe("1");
await waitForDead(buildPid);
await waitForForeignProcessExit(buildPid, signal);
expect(isProcessRunning(buildPid)).toBe(false);
} finally {
if (runner.exitCode === null && runner.signalCode === null) {
runner.kill("SIGKILL");
runner.kill("SIGTERM");
}
await closed;
}
};
@ -1454,7 +1566,7 @@ stdout="$(<"$TMPDIR/stdout")"
execDockerSnippet(script);
});
it.each([
it.for([
{
title: "keeps reused Docker image probes behind the timeout-aware helper",
tempPrefix: "openclaw-docker-image-reuse-timeout-",
@ -1827,32 +1939,24 @@ export OPENCLAW_DOCKER_E2E_HEARTBEAT_TERM_GRACE_SECONDS=1
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
command_pid_file="$TMPDIR/command.pid"
mkfifo "$TMPDIR/ready.pipe"
(
run_logged_print_heartbeat plugins-run 30 bash -c 'trap "exit 0" TERM; printf "%s" "$$" > "$1"; while true; do /bin/sleep 0.05; done' bash "$command_pid_file"
) &
run_logged_print_heartbeat plugins-run 30 bash -c 'trap "exit 0" TERM; printf "%s" "$$" > "$1"; printf "ready\\n" >&3; while true; do /bin/sleep 0.05; done' bash "$command_pid_file"
) 3>"$TMPDIR/ready.pipe" &
wrapper_pid="$!"
for _ in $(seq 1 100); do
[ -s "$command_pid_file" ] && break
/bin/sleep 0.01
done
if [ ! -s "$command_pid_file" ]; then
if ! IFS= read -r ready <"$TMPDIR/ready.pipe"; then
kill -TERM "$wrapper_pid" 2>/dev/null || true
echo "heartbeat command pid was not recorded" >&2
exit 1
fi
command_pid="$(cat "$command_pid_file")"
kill -TERM "$wrapper_pid"
for _ in $(seq 1 50); do
if ! kill -0 "$command_pid" 2>/dev/null; then
wait "$wrapper_pid" 2>/dev/null || true
exit 0
fi
/bin/sleep 0.01
done
kill -TERM "$command_pid" 2>/dev/null || true
kill -TERM "$wrapper_pid" 2>/dev/null || true
echo "heartbeat command still alive after wrapper termination: $command_pid" >&2
exit 1
wait "$wrapper_pid" 2>/dev/null || true
# cleanup_heartbeat_command joins the exact command before the wrapper returns.
if kill -0 "$command_pid" 2>/dev/null; then
echo "heartbeat command still alive after wrapper termination: $command_pid" >&2
exit 1
fi
`,
},
{
@ -1892,26 +1996,21 @@ docker() {
printf "started\\n" >"$TMPDIR/docker-started"
printf "docker running\\n"
trap 'exit 143' TERM
printf "ready\\n" >&3
while true; do /bin/sleep 0.05; done
}
export -f docker
mkfifo "$TMPDIR/ready.pipe"
(
docker_e2e_run_logged_print_with_harness plugins-run image-name bash -lc true
) &
) 3>"$TMPDIR/ready.pipe" &
wrapper_pid="$!"
for _ in $(seq 1 50); do
[ -s "$TMPDIR/docker-started" ] && break
/bin/sleep 0.01
kill -0 "$wrapper_pid" 2>/dev/null || true
done
IFS= read -r ready <"$TMPDIR/ready.pipe"
test -s "$TMPDIR/docker-started"
kill -TERM "$wrapper_pid" 2>/dev/null || true
wait "$wrapper_pid" 2>/dev/null || true
for _ in $(seq 1 50); do
grep -qx "container-term" "$TMPDIR/docker-rm-seen" 2>/dev/null && break
/bin/sleep 0.01
done
# The joined harness removes its container before the heartbeat wrapper returns.
grep -qx "container-term" "$TMPDIR/docker-rm-seen"
test -z "$(find "$TMPDIR" -maxdepth 1 -name 'openclaw-docker-e2e-container.*' -print)"
`,
@ -1960,11 +2059,11 @@ heartbeat_elapsed="\${BASH_REMATCH[1]}"
[[ -s "$stats_log" ]]
`,
},
])("$title", ({ tempPrefix, scriptSource }) => {
])("$title", async ({ tempPrefix, scriptSource }, { signal }) => {
const workDir = tempDirs.make(tempPrefix);
const script = scriptSource(workDir);
execDockerSnippet(script);
await runDockerSnippet(script, signal);
});
it("derives the browser CDP image from the shared functional image", () => {
@ -2255,14 +2354,19 @@ OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS=1 docker_e2e_docker_cmd run demo
["TERM", "143"],
["HUP", "129"],
] as const) {
it(`escalates Docker watchdog children that ignore parent SIG${shellSignal}`, () => {
it(`escalates Docker watchdog children that ignore parent SIG${shellSignal}`, async ({
signal,
}) => {
const workDir = tempDirs.make("openclaw-docker-node-signal-");
const reporter = writeFixtureReceiptReporter(workDir);
const readyPath = join(workDir, "ready");
writeExecutables(join(workDir, "bin"), {
node: `#!/bin/bash\nexec ${shellQuote(process.execPath)} "$@"\n`,
docker: `#!/bin/bash
trap "" TERM HUP
printf "%s\\n" "$$" >"$TMPDIR/docker-pid"
printf "%s\\n" "$PPID" >"$TMPDIR/watchdog-pid"
${shellQuote(process.execPath)} ${shellQuote(reporter)} ${shellQuote(readyPath)} ready
while true; do /bin/sleep 1; done
`,
});
@ -2276,30 +2380,44 @@ export OPENCLAW_DOCKER_TIMEOUT_KILL_GRACE_MS=100
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
docker_e2e_docker_cmd run demo &
watchdog_pid="$!"
for ((i = 0; i < 100; i += 1)); do
[ -s "$TMPDIR/docker-pid" ] && [ -s "$TMPDIR/watchdog-pid" ] && break
/bin/sleep 0.02
done
[ -s "$TMPDIR/docker-pid" ]
[ -s "$TMPDIR/watchdog-pid" ]
kill -${shellSignal} "$(/bin/cat "$TMPDIR/watchdog-pid")"
set +e
wait "$watchdog_pid"
status="$?"
set -e
[ "$status" = "${expectedStatus}" ]
docker_pid="$(/bin/cat "$TMPDIR/docker-pid")"
for ((i = 0; i < 100; i += 1)); do
kill -0 "$docker_pid" 2>/dev/null || exit 0
/bin/sleep 0.02
done
echo "docker child still alive after watchdog termination" >&2
exit 1
docker_e2e_docker_cmd run demo
`;
execDockerSnippet(script);
const runner = spawn("/bin/bash", ["--noprofile", "--norc", "-c", script], {
env: { ...process.env, BASH_ENV: "", ENV: "" },
stdio: "ignore",
});
const exited = waitForProcessExit(runner);
try {
await withinTest(
fixtureEventBeforeSettlement(
readyPath,
"ready",
exited,
"docker child PID was not recorded",
),
signal,
);
const watchdogPid = Number(readFileSync(join(workDir, "watchdog-pid"), "utf8"));
const dockerPid = Number(readFileSync(join(workDir, "docker-pid"), "utf8"));
process.kill(watchdogPid, `SIG${shellSignal}`);
expect(await withinTest(exited, signal)).toBe(Number(expectedStatus));
await waitForForeignProcessExit(dockerPid, signal);
} finally {
for (const name of ["watchdog-pid", "docker-pid"]) {
const path = join(workDir, name);
if (existsSync(path)) {
try {
process.kill(Number(readFileSync(path, "utf8")), "SIGKILL");
} catch {}
}
}
if (runner.exitCode === null && runner.signalCode === null) {
runner.kill("SIGKILL");
}
await exited;
}
});
}
@ -2653,14 +2771,15 @@ printf '%s\\n' "$seconds"
}
});
it("records an interrupted upgrade survivor phase as failed", async () => {
it("records an interrupted upgrade survivor phase as failed", async ({ signal }) => {
const workDir = tempDirs.make("openclaw-upgrade-survivor-signal-");
const binDir = join(workDir, "bin");
const markerPath = join(workDir, "npm-started");
const reporter = writeFixtureReceiptReporter(workDir);
const summaryPath = join(workDir, "artifacts", "summary.json");
writeExecutables(binDir, {
npm: `#!/bin/sh
touch "$FAKE_NPM_MARKER"
${shellQuote(process.execPath)} ${shellQuote(reporter)} "$FAKE_NPM_MARKER" ready
exec sleep 300
`,
timeout: `#!/bin/sh
@ -2691,20 +2810,28 @@ exec "$@"
if (!childPid) {
throw new Error("upgrade survivor process did not start");
}
const exitPromise = new Promise<{
code: number | null;
signal: NodeJS.Signals | null;
}>((resolve) => {
child.once("exit", (code, signal) => resolve({ code, signal }));
});
const exitPromise = ownChildCompletion(
new Promise<{
code: number | null;
signal: NodeJS.Signals | null;
}>((resolve) => {
child.once("exit", (code, childSignal) => resolve({ code, signal: childSignal }));
}),
);
try {
for (let attempt = 0; attempt < 500 && !existsSync(markerPath); attempt += 1) {
await delay(10);
}
await withinTest(
fixtureEventBeforeSettlement(
markerPath,
"ready",
exitPromise,
"npm-started marker was not written",
),
signal,
);
expect(existsSync(markerPath)).toBe(true);
process.kill(-childPid, "SIGTERM");
const exit = await exitPromise;
const exit = await withinTest(exitPromise, signal);
expect(exit).toEqual({ code: 143, signal: null });
const diagnostics = JSON.parse(
@ -2734,6 +2861,7 @@ exec "$@"
if (child.exitCode === null && child.signalCode === null) {
process.kill(-childPid, "SIGKILL");
}
await exitPromise;
}
});
@ -2882,9 +3010,10 @@ fi
]);
});
it.skipIf(process.platform !== "linux").each(["published", "current"])(
it.skipIf(process.platform !== "linux").for(["published", "current"])(
"starts the %s auth probe under the manager that owns its restart and stop",
async (lane) => {
{ timeout: 60_000 },
async (lane, { signal }) => {
const workDir = tempDirs.make("survivor-managed-probe-");
const paths = readUpgradeSurvivorPaths(workDir);
const artifacts = paths.artifactRoot;
@ -2897,16 +3026,22 @@ fi
const childPath = join(workDir, "listener.mjs");
const startsPath = join(workDir, "starts.jsonl");
const portPath = join(workDir, "port");
const readyPipe = join(workDir, "ready.pipe");
execFileSync("mkfifo", [readyPipe]);
writeFileSync(
childPath,
`import fs from "node:fs";
`${fixtureReceiptClientSource(receipts.endpoint)}
import fs from "node:fs";
import http from "node:http";
const identity = { pid: process.pid, managed: process.env.OPENCLAW_SYSTEMD_UNIT === "openclaw-gateway.service" };
const server = http.createServer((_req, res) => res.end(JSON.stringify(identity)));
const firstStart = !fs.existsSync(process.env.PORT_FILE);
const port = fs.existsSync(process.env.PORT_FILE) ? Number(fs.readFileSync(process.env.PORT_FILE, "utf8")) : 0;
server.listen(port, "127.0.0.1", () => {
fs.writeFileSync(process.env.PORT_FILE, String(server.address().port));
fs.appendFileSync(process.env.STARTS_FILE, JSON.stringify(identity) + "\\n");
sendReceipt(process.env.STARTS_FILE, "ready");
if (firstStart) fs.writeFileSync(process.env.READY_PIPE, "ready\\n");
console.log("[gateway] ready on 127.0.0.1:" + server.address().port);
});
`,
@ -2966,6 +3101,7 @@ process.on("SIGTERM", () => {
LISTENER_SCRIPT: childPath,
STARTS_FILE: startsPath,
PORT_FILE: portPath,
READY_PIPE: readyPipe,
};
const source = readFileSync(UPGRADE_SURVIVOR_RUN_SCRIPT, "utf8");
const setup =
@ -2990,7 +3126,7 @@ ${tcpProbeAdapter}
# This fixture chooses an ephemeral port; retain the actual readiness implementation.
eval "$(declare -f openclaw_e2e_wait_gateway_ready | sed '1s/openclaw_e2e_wait_gateway_ready/fixture_wait_gateway_ready/')"
openclaw_e2e_wait_gateway_ready() {
for _ in {1..200}; do [ -s "$PORT_FILE" ] && break; sleep 0.01; done
IFS= read -r ready <"$READY_PIPE"
fixture_wait_gateway_ready "$1" "$2" 20 "$(cat "$PORT_FILE")" "\${5:-strict}"
}
${lane === "published" ? "prepare_update_restart_probe" : 'prepare_update_restart_probe_current_install 18789 "$OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_DAEMON_LOG"'}
@ -3033,9 +3169,7 @@ openclaw_e2e_probe_tcp 127.0.0.1 18789 400`,
expect(isProcessRunning(records()[0]!.pid)).toBe(false);
await expect(fetch(url, { signal: AbortSignal.timeout(1_000) })).rejects.toThrow();
expect(systemctl("start", "openclaw-gateway.service").status).toBe(0);
for (let attempt = 0; attempt < 200 && records().length < 2; attempt++) {
await delay(10);
}
await withinTest(receipts.waitFor(startsPath, "ready", 2), signal);
expect(records()).toHaveLength(2);
}
const initial = (await (
@ -3044,9 +3178,7 @@ openclaw_e2e_probe_tcp 127.0.0.1 18789 400`,
expect(initial.managed).toBe(true);
expect(systemctl("restart", "openclaw-gateway.service").status).toBe(0);
const expectedStarts = lane === "published" ? 3 : 2;
for (let attempt = 0; attempt < 200 && records().length < expectedStarts; attempt++) {
await delay(10);
}
await withinTest(receipts.waitFor(startsPath, "ready", expectedStarts), signal);
expect(records()).toHaveLength(expectedStarts);
const replacement = (await (
await fetch(url, { signal: AbortSignal.timeout(1_000) })
@ -3067,7 +3199,6 @@ openclaw_e2e_probe_tcp 127.0.0.1 18789 400`,
}
}
},
60_000,
);
it("returns the gateway readiness failure when startup is called conditionally", () => {
@ -3325,8 +3456,8 @@ printf '%s\n' "$status" >"$TMPDIR/status"
it.skipIf(process.platform === "win32")(
"stops promptly when the systemctl target is a zombie with spaces and parentheses in comm",
async () => {
await forEachUpgradeSurvivorSystemctlShim(({ pid, run, readLog, scriptPath }) => {
async ({ signal }) => {
await forEachUpgradeSurvivorSystemctlShim(signal, ({ pid, run, readLog, scriptPath }) => {
const procTail = Array.from({ length: 49 }, (_, field) => field + 1).join(" ");
expect(run(`${pid} (gateway (old) worker) Z ${procTail}`, true), scriptPath).toBe(0);
expect(readLog()).toEqual([
@ -3341,20 +3472,23 @@ printf '%s\n' "$status" >"$TMPDIR/status"
it.skipIf(process.platform === "win32")(
"waits for a killable systemctl target when proc stat is unreadable or malformed",
async () => {
await forEachUpgradeSurvivorSystemctlShim(({ pid, pidPath, run, readLog, scriptPath }) => {
for (const procStat of [undefined, `${pid} (gateway) Z`]) {
// Reaching the wait sentinel proves the shell did not mistake missing stat data for exit.
expect(run(procStat), `${scriptPath}: ${procStat ?? "unreadable"}`).toBe(97);
expect(readLog()).toEqual([
"--user stop openclaw-gateway.service",
"proc-stat-read",
"wait",
]);
expect(isProcessRunning(pid)).toBe(true);
expect(readFileSync(pidPath, "utf8")).toBe(`${pid}\n`);
}
});
async ({ signal }) => {
await forEachUpgradeSurvivorSystemctlShim(
signal,
({ pid, pidPath, run, readLog, scriptPath }) => {
for (const procStat of [undefined, `${pid} (gateway) Z`]) {
// Reaching the wait sentinel proves the shell did not mistake missing stat data for exit.
expect(run(procStat), `${scriptPath}: ${procStat ?? "unreadable"}`).toBe(97);
expect(readLog()).toEqual([
"--user stop openclaw-gateway.service",
"proc-stat-read",
"wait",
]);
expect(isProcessRunning(pid)).toBe(true);
expect(readFileSync(pidPath, "utf8")).toBe(`${pid}\n`);
}
},
);
},
);
@ -3994,9 +4128,9 @@ writePluginInstallIndexForE2E(${JSON.stringify(index)}, { stateDir: ${JSON.strin
}
});
it.each([true])(
it.for([true])(
"retains a failed service child and only sanitized diagnostics (candidate redactor: %s)",
async (candidateRedactorPresent) => {
async (candidateRedactorPresent, { signal }) => {
const workDir = tempDirs.make("openclaw-survivor-diagnostics-");
const artifacts = join(workDir, "artifacts");
const state = join(workDir, "home", ".openclaw");
@ -4043,7 +4177,15 @@ writePluginInstallIndexForE2E(${JSON.stringify(index)}, { stateDir: ${JSON.strin
},
stdio: "ignore",
});
expect(await waitForProcessExit(supervisor)).toBe(0);
const exited = waitForProcessExit(supervisor);
try {
expect(await withinTest(exited, signal)).toBe(0);
} finally {
if (supervisor.exitCode === null && supervisor.signalCode === null) {
supervisor.kill("SIGTERM");
}
await exited;
}
const observation = JSON.parse(readFileSync(`${logPath}.exit.json`, "utf8"));
expect(observation.last).toMatchObject({ code: 78, signal: null });
const managerEnv = {
@ -4128,9 +4270,9 @@ writePluginInstallIndexForE2E(${JSON.stringify(index)}, { stateDir: ${JSON.strin
},
);
it.each([UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH])(
it.for([UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH])(
"retains supervisor bootstrap stderr without inventing a child exit in %s",
async (scriptPath) => {
async (scriptPath, { signal }) => {
const workDir = tempDirs.make("openclaw-survivor-bootstrap-");
const unitDir = join(workDir, ".config", "systemd", "user");
mkdirSync(unitDir, { recursive: true });
@ -4139,10 +4281,12 @@ writePluginInstallIndexForE2E(${JSON.stringify(index)}, { stateDir: ${JSON.strin
`[Service]\nExecStart="${process.execPath}" unused\n`,
);
const binDir = join(workDir, "bin");
const reporter = writeFixtureReceiptReporter(workDir);
const readyPath = join(workDir, "bootstrap.ready");
writeExecutables(binDir, {
node: `#!/bin/sh
case "$1" in
*.supervisor.mjs) echo supervisor-bootstrap-failure >&2; exit 17 ;;
*.supervisor.mjs) echo supervisor-bootstrap-failure >&2; ${shellQuote(process.execPath)} ${shellQuote(reporter)} ${shellQuote(readyPath)} ready; exit 17 ;;
esac
exec ${shellQuote(process.execPath)} "$@"
`,
@ -4162,15 +4306,7 @@ exec ${shellQuote(process.execPath)} "$@"
});
expect(started.status, started.stderr).toBe(0);
const bootstrapPath = `${logPath}.bootstrap.log`;
for (let attempt = 0; attempt < 100; attempt += 1) {
if (
existsSync(bootstrapPath) &&
readFileSync(bootstrapPath, "utf8").includes("supervisor-bootstrap-failure")
) {
break;
}
await delay(10);
}
await withinTest(receipts.waitFor(readyPath, "ready"), signal);
expect(readFileSync(bootstrapPath, "utf8")).toContain("supervisor-bootstrap-failure");
expect(existsSync(`${logPath}.exit.json`)).toBe(false);
const shown = spawnSync("bash", [shimPath, ...SURVIVOR_SERVICE_SHOW_ARGS], {
@ -4514,7 +4650,7 @@ exit 0
},
);
it("stops supervised gateway restarts after the systemd burst limit", async () => {
it("stops supervised gateway restarts after the systemd burst limit", async ({ signal }) => {
const workDir = tempDirs.make("openclaw-update-restart-supervisor-");
const scripts = [readFileSync(UPGRADE_SURVIVOR_UPDATE_RESTART_AUTH_PATH, "utf8")];
@ -4540,9 +4676,15 @@ exit 0
},
stdio: "ignore",
});
const exitCode = await waitForProcessExit(supervisor);
expect(exitCode).toBe(0);
const exited = waitForProcessExit(supervisor);
try {
expect(await withinTest(exited, signal)).toBe(0);
} finally {
if (supervisor.exitCode === null && supervisor.signalCode === null) {
supervisor.kill("SIGTERM");
}
await exited;
}
expect(readFileSync(countPath, "utf8")).toBe("xxxxx");
expect(readFileSync(logPath, "utf8")).toContain(
"[systemctl-shim] gateway restart limit reached",
@ -4552,13 +4694,14 @@ exit 0
it.skipIf(process.platform === "win32")(
"terminates supervised gateway descendants at the systemd stop timeout",
async () => {
async ({ signal }) => {
const workDir = tempDirs.make("openclaw-update-restart-process-group-");
const descendantPath = writeTermIgnoringDescendant(workDir);
const gatewayPath = join(workDir, "gateway.mjs");
writeFileSync(
gatewayPath,
`import fs from "node:fs";
`${fixtureReceiptClientSource(receipts.endpoint)}
import fs from "node:fs";
import { spawn } from "node:child_process";
process.on("SIGTERM", () => {
setTimeout(() => {
@ -4566,12 +4709,13 @@ process.on("SIGTERM", () => {
process.exit(0);
}, 50);
});
spawn(process.execPath, [process.env.DESCENDANT_SCRIPT], { stdio: "ignore" });
const ready = setInterval(() => {
if (!fs.existsSync(process.env.DESCENDANT_PID_FILE)) return;
clearInterval(ready);
const descendant = spawn(process.execPath, [process.env.DESCENDANT_SCRIPT], {
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
descendant.once("message", () => {
fs.writeFileSync(process.env.STATE_FILE, "ready");
}, 5);
sendReceipt(process.env.STATE_FILE, "ready");
});
setInterval(() => {}, 1_000);
`,
);
@ -4598,24 +4742,25 @@ setInterval(() => {}, 1_000);
},
stdio: "ignore",
});
const exited = waitForProcessExit(supervisor);
try {
for (let attempt = 0; attempt < 100 && !existsSync(statePath); attempt += 1) {
await delay(10);
}
expect(
existsSync(statePath),
`${supervisorPath}: readiness missing (exit=${supervisor.exitCode}, signal=${supervisor.signalCode})`,
).toBe(true);
await withinTest(
fixtureEventBeforeSettlement(
statePath,
"ready",
exited,
`${supervisorPath}: readiness missing (exit=${supervisor.exitCode}, signal=${supervisor.signalCode})`,
),
signal,
);
const descendantPid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10);
expect(descendantPid).toBeGreaterThan(1);
expect(isProcessRunning(descendantPid)).toBe(true);
supervisor.kill("SIGTERM");
expect(await waitForProcessExit(supervisor)).toBe(0);
expect(await withinTest(exited, signal)).toBe(0);
expect(readFileSync(statePath, "utf8")).toBe("ready-graceful");
for (let attempt = 0; attempt < 100 && isProcessRunning(descendantPid); attempt += 1) {
await delay(10);
}
// The supervisor exits only after drainProcessGroup observes the group absent.
expect(isProcessRunning(descendantPid)).toBe(false);
} finally {
await stopUpgradeSurvivorSupervisor(supervisor, descendantPidPath);
@ -4626,7 +4771,7 @@ setInterval(() => {}, 1_000);
it.skipIf(process.platform === "win32")(
"drains the previous gateway process group before restarting",
async () => {
async ({ signal }) => {
const workDir = tempDirs.make("openclaw-update-restart-process-group-restart-");
const descendantPath = writeTermIgnoringDescendant(workDir);
const gatewayPath = join(workDir, "restart-gateway.mjs");
@ -4637,12 +4782,10 @@ import { spawn } from "node:child_process";
fs.appendFileSync(process.env.STARTS_FILE, "x");
const starts = fs.readFileSync(process.env.STARTS_FILE, "utf8").length;
if (starts === 1) {
spawn(process.execPath, [process.env.DESCENDANT_SCRIPT], { stdio: "ignore" });
const ready = setInterval(() => {
if (!fs.existsSync(process.env.DESCENDANT_PID_FILE)) return;
clearInterval(ready);
process.exit(1);
}, 5);
const descendant = spawn(process.execPath, [process.env.DESCENDANT_SCRIPT], {
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
descendant.once("message", () => process.exit(1));
setInterval(() => {}, 1_000);
} else {
const pid = Number.parseInt(fs.readFileSync(process.env.DESCENDANT_PID_FILE, "utf8"), 10);
@ -4686,8 +4829,9 @@ if (starts === 1) {
},
stdio: "ignore",
});
const exited = waitForProcessExit(supervisor);
try {
expect(await waitForProcessExit(supervisor)).toBe(0);
expect(await withinTest(exited, signal)).toBe(0);
const descendantPid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10);
expect(descendantPid).toBeGreaterThan(1);
expect(readFileSync(startsPath, "utf8")).toBe("xx");
@ -5196,36 +5340,48 @@ process.exit(73);
expectContainerCleanup(fixture, "openclaw-plugin-binding-command-escape-e2e-");
});
it.each([
it.for([
["SIGINT", 130],
["SIGTERM", 143],
["SIGHUP", 129],
] as const)("cleans the actual cron runner and its harness on %s", async (signal, status) => {
const fixture = containerCleanupFixture("signal");
const runner = spawn("/bin/bash", ["scripts/e2e/cron-cli-docker.sh"], {
env: fixture.env,
stdio: "ignore",
});
try {
await expect.poll(() => existsSync(fixture.pidPath), { timeout: 5_000 }).toBe(true);
runner.kill(signal);
expect(await waitForProcessExit(runner)).toBe(status);
expectContainerCleanup(fixture, "openclaw-cron-cli-e2e-");
expect(isProcessRunning(Number(readFileSync(fixture.pidPath, "utf8")))).toBe(false);
} finally {
if (runner.exitCode === null && runner.signalCode === null) {
runner.kill("SIGTERM");
await waitForProcessExit(runner);
}
if (existsSync(fixture.pidPath)) {
const pid = Number(readFileSync(fixture.pidPath, "utf8"));
if (isProcessRunning(pid)) {
process.kill(pid, "SIGKILL");
await expect.poll(() => isProcessRunning(pid), { timeout: 5_000 }).toBe(false);
] as const)(
"cleans the actual cron runner and its harness on %s",
async ([childSignal, status], { signal }) => {
const fixture = containerCleanupFixture("signal");
const runner = spawn("/bin/bash", ["scripts/e2e/cron-cli-docker.sh"], {
env: fixture.env,
stdio: "ignore",
});
const exited = waitForProcessExit(runner);
try {
await withinTest(
fixtureEventBeforeSettlement(
fixture.readyPath,
"ready",
exited,
"Docker PID file was not written",
),
signal,
);
runner.kill(childSignal);
expect(await withinTest(exited, signal)).toBe(status);
expectContainerCleanup(fixture, "openclaw-cron-cli-e2e-");
expect(isProcessRunning(Number(readFileSync(fixture.pidPath, "utf8")))).toBe(false);
} finally {
if (runner.exitCode === null && runner.signalCode === null) {
runner.kill("SIGTERM");
await exited;
}
if (existsSync(fixture.pidPath)) {
const pid = Number(readFileSync(fixture.pidPath, "utf8"));
if (isProcessRunning(pid)) {
process.kill(pid, "SIGKILL");
await waitForForeignProcessExit(pid, signal);
}
}
}
}
});
},
);
it.each(
[

View file

@ -299,18 +299,15 @@ elif phase == "redirect":
with subprocess.Popen([sys.executable, "-I", "-S", owner, "--policy", str(selected_policy),
remote, token, phase], cwd=workspace, env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) as child:
# The managed fixture caller owns the lifetime deadline. Keep the
# server/root alive until this owner and its output pipes settle.
try:
stdout, stderr = child.communicate(timeout=25)
stdout, stderr = child.communicate()
except BaseException:
# The Git owner must drain its separately owned Git groups
# before this fixture closes the server or removes its root.
child.terminate()
try:
child.communicate(timeout=12)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
raise RuntimeError("checkout owner did not finish cancellation cleanup")
child.communicate()
raise
return subprocess.CompletedProcess(child.args, child.returncode, stdout, stderr)

View file

@ -4,6 +4,7 @@ import { createServer, type Server } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { WebSocket, WebSocketServer, type RawData } from "ws";
import { createBoundedChildOutput } from "../helpers/bounded-child-output.js";
import { withinTest } from "../helpers/promise.js";
type ScriptResult = {
status: number | null;
@ -169,118 +170,95 @@ async function listenGateway(params: {
return `ws://127.0.0.1:${address.port}`;
}
function runScript(url: string, extraArgs: readonly string[] = []): Promise<ScriptResult> {
return new Promise((resolve) => {
const child = spawn(
process.execPath,
[
"--import",
"tsx",
"scripts/dev/ios-node-e2e.ts",
"--url",
url,
"--token",
"token",
"--json",
...extraArgs,
],
{ stdio: "pipe" },
);
const stdout = createBoundedChildOutput();
const stderr = createBoundedChildOutput();
let settled = false;
const timeout = setTimeout(() => {
if (settled) {
return;
}
settled = true;
child.kill("SIGKILL");
resolve({
status: null,
signal: "SIGKILL",
stdout: stdout.text(),
stderr: stderr.text(),
timedOut: true,
});
}, 5000);
timeout.unref?.();
child.stdout.on("data", (chunk) => {
stdout.append(chunk);
});
child.stderr.on("data", (chunk) => {
stderr.append(chunk);
});
child.on("close", (status, signal) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve({ status, signal, stdout: stdout.text(), stderr: stderr.text(), timedOut: false });
});
});
function runScript(
url: string,
signal: AbortSignal,
extraArgs: readonly string[] = [],
): Promise<ScriptResult> {
return runScriptRaw(["--url", url, "--token", "token", "--json", ...extraArgs], signal);
}
function runScriptRaw(args: readonly string[]): Promise<ScriptResult> {
return new Promise((resolve) => {
const child = spawn(
process.execPath,
["--import", "tsx", "scripts/dev/ios-node-e2e.ts", ...args],
{
stdio: "pipe",
},
);
const stdout = createBoundedChildOutput();
const stderr = createBoundedChildOutput();
child.stdout.on("data", (chunk) => {
stdout.append(chunk);
});
child.stderr.on("data", (chunk) => {
stderr.append(chunk);
});
child.on("close", (status, signal) => {
resolve({ status, signal, stdout: stdout.text(), stderr: stderr.text(), timedOut: false });
async function runScriptRaw(args: readonly string[], signal: AbortSignal): Promise<ScriptResult> {
const child = spawn(
process.execPath,
["--import", "tsx", "scripts/dev/ios-node-e2e.ts", ...args],
{ stdio: "pipe" },
);
const stdout = createBoundedChildOutput();
const stderr = createBoundedChildOutput();
child.stdout.on("data", (chunk) => {
stdout.append(chunk);
});
child.stderr.on("data", (chunk) => {
stderr.append(chunk);
});
let spawnError: Error | undefined;
child.once("error", (error) => {
spawnError = error;
});
// Retain close from launch so abort cleanup joins the process and both output pipes.
const closed = new Promise<ScriptResult>((resolve) => {
child.once("close", (status, childSignal) => {
resolve({
status,
signal: childSignal,
stdout: stdout.text(),
stderr: stderr.text(),
timedOut: false,
});
});
});
try {
const result = await withinTest(closed, signal);
if (spawnError) {
throw spawnError;
}
return result;
} finally {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
await closed;
}
}
describe("ios-node-e2e", () => {
it("prints CLI help without connecting", async () => {
const result = await runScriptRaw(["--help"]);
it("prints CLI help without connecting", async ({ signal }) => {
const result = await runScriptRaw(["--help"], signal);
expect(result).toMatchObject({ signal: null, status: 0, timedOut: false });
expect(result.stdout).toContain("Usage: bun scripts/dev/ios-node-e2e.ts");
expect(result.stderr).toBe("");
});
it("rejects unknown CLI args before connecting", async () => {
const result = await runScript("ws://127.0.0.1:9", ["--wat"]);
it("rejects unknown CLI args before connecting", async ({ signal }) => {
const result = await runScript("ws://127.0.0.1:9", signal, ["--wat"]);
expect(result).toMatchObject({ signal: null, status: 1, timedOut: false });
expect(result.stderr.trim()).toBe("Unknown argument: --wat");
expect(result.stdout).toBe("");
});
it("rejects short flags as CLI option values before help handling", async () => {
const result = await runScriptRaw(["--url", "-h", "--token", "token"]);
it("rejects short flags as CLI option values before help handling", async ({ signal }) => {
const result = await runScriptRaw(["--url", "-h", "--token", "token"], signal);
expect(result).toMatchObject({ signal: null, status: 1, timedOut: false });
expect(result.stderr.trim()).toBe("--url requires a value");
expect(result.stdout).toBe("");
});
it("rejects malformed wait seconds before connecting", async () => {
const result = await runScript("ws://127.0.0.1:9", ["--wait-seconds", "1e3"]);
it("rejects malformed wait seconds before connecting", async ({ signal }) => {
const result = await runScript("ws://127.0.0.1:9", signal, ["--wait-seconds", "1e3"]);
expect(result).toMatchObject({ signal: null, status: 1, timedOut: false });
expect(result.stderr).toContain("--wait-seconds must be a positive integer; got: 1e3");
expect(result.stdout).toBe("");
});
it("fails empty node invoke payloads instead of counting them as proof", async () => {
it("fails empty node invoke payloads instead of counting them as proof", async ({ signal }) => {
const invokeParams: Array<{ command?: string; idempotencyKey?: string }> = [];
const url = await listenGateway({ mode: "empty", invokeParams });
const result = await runScript(url);
const result = await runScript(url, signal);
const report = JSON.parse(result.stdout) as {
results: Array<{ error?: string; id: string; ok: boolean; payload?: unknown }>;
};
@ -299,10 +277,10 @@ describe("ios-node-e2e", () => {
expect(invokeParams.length).toBeGreaterThan(0);
});
it("fails malformed primitive device info payloads", async () => {
it("fails malformed primitive device info payloads", async ({ signal }) => {
const invokeParams: Array<{ command?: string; idempotencyKey?: string }> = [];
const url = await listenGateway({ mode: "primitive-device-info", invokeParams });
const result = await runScript(url);
const result = await runScript(url, signal);
const report = JSON.parse(result.stdout) as {
results: Array<{ error?: string; id: string; ok: boolean; payload?: unknown }>;
};
@ -315,10 +293,10 @@ describe("ios-node-e2e", () => {
});
});
it("fails malformed nested payloadJSON payloads", async () => {
it("fails malformed nested payloadJSON payloads", async ({ signal }) => {
const invokeParams: Array<{ command?: string; idempotencyKey?: string }> = [];
const url = await listenGateway({ mode: "invalid-payload-json", invokeParams });
const result = await runScript(url);
const result = await runScript(url, signal);
const report = JSON.parse(result.stdout) as {
results: Array<{ error?: string; id: string; ok: boolean; payload?: unknown }>;
};
@ -331,10 +309,10 @@ describe("ios-node-e2e", () => {
});
});
it("accepts non-empty node invoke payloads and sends idempotency keys", async () => {
it("accepts non-empty node invoke payloads and sends idempotency keys", async ({ signal }) => {
const invokeParams: Array<{ command?: string; idempotencyKey?: string }> = [];
const url = await listenGateway({ mode: "valid", invokeParams });
const result = await runScript(url);
const result = await runScript(url, signal);
const report = JSON.parse(result.stdout) as {
results: Array<{ id: string; ok: boolean }>;
};

View file

@ -1,9 +1,33 @@
import { execFileSync, spawnSync } from "node:child_process";
import { execFileSync, spawn, spawnSync, type ChildProcess } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
fixtureReceiptClientSource,
openFixtureReceiptChannel,
type FixtureReceiptChannel,
} from "../helpers/fixture-receipts.js";
import { awaitGateBeforeSettlement, withinTest } from "../helpers/promise.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const pendingChildCompletions = new Set<Promise<unknown>>();
const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
afterEach(async () => {
// Timeout cleanup still needs fixture PID files until its native child closes.
await Promise.allSettled(pendingChildCompletions);
cleanup();
}),
);
let receipts: FixtureReceiptChannel;
beforeAll(async () => {
receipts = await openFixtureReceiptChannel();
});
afterAll(async () => {
await receipts.close();
});
const helperPath = path.resolve("scripts/lib/openclaw-e2e-instance.sh");
const hostPath = [
@ -18,6 +42,98 @@ function shellQuote(value: string): string {
return `'${value.replace(/'/gu, `'\\''`)}'`;
}
function observeChild(child: ChildProcess) {
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8").on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr?.setEncoding("utf8").on("data", (chunk: string) => {
stderr += chunk;
});
const closed = new Promise<{ status: number | null; stdout: string; stderr: string }>(
(resolve, reject) => {
child.once("error", reject);
child.once("close", (status) => resolve({ status, stdout, stderr }));
},
).finally(() => {
pendingChildCompletions.delete(closed);
});
pendingChildCompletions.add(closed);
return { child, closed };
}
function startNodeFixture(script: string, args: string[]) {
const fixture = observeChild(
spawn(process.execPath, [script, ...args], {
stdio: ["ignore", "pipe", "pipe", "ipc"],
}),
);
const ready = new Promise<void>((resolve) => {
fixture.child.once("message", () => resolve());
});
return {
...fixture,
ready: awaitGateBeforeSettlement(
ready,
fixture.closed,
"fixture exited before publishing readiness",
),
};
}
function startBash(script: string, env: Record<string, string | undefined> = {}) {
return observeChild(
spawn("/bin/bash", ["-c", script], {
env: shellTestEnv(env),
detached: true,
stdio: ["ignore", "pipe", "pipe"],
}),
);
}
function killFixturePid(pid: number) {
if (pid > 1) {
try {
process.kill(pid, "SIGKILL");
} catch {}
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
// Shell stop/watchdog escalation can return before a foreign PID has been reaped.
async function waitForProcessGone(
pid: number,
signal: AbortSignal,
message: string,
): Promise<void> {
while (isProcessAlive(pid)) {
await delay(5, undefined, { signal }).catch((error: unknown) => {
throw new Error(`${message}: ${pid}`, { cause: error });
});
}
}
async function fixtureReadyBeforeSettlement(marker: string, operation: PromiseLike<unknown>) {
await Promise.race([
receipts.waitFor(marker, "ready"),
Promise.resolve(operation).then(() => {
// Marker publication precedes the fixture receipt; a separate pipe may deliver it late.
if (!fs.existsSync(marker)) {
throw new Error(`fixture exited before publishing ${marker}`);
}
}),
]);
}
function runHelper(payload: string) {
return runSourcedHelper(
[
@ -361,51 +477,56 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
});
});
it.each([
it.for([
["accepts the March listening marker", "listening on", true, "legacy-ready-log-ok", 0],
["rejects a closed March listener", "listening on", false, "legacy-ready-log-ok", 1],
["accepts a live legacy ready marker", "ready", true, "legacy-ready-log-ok", 0],
["rejects a closed legacy ready listener", "ready", false, "legacy-ready-log-ok", 1],
["rejects the March marker in strict mode", "listening on", true, "strict", 1],
] as const)("%s", (_label, marker, listening, mode, expectedStatus) => {
withTempDir("openclaw-e2e-readyz-legacy-", (tempDir) => {
const logPath = path.join(tempDir, "gateway.log");
const portPath = path.join(tempDir, "port.txt");
const resultPath = path.join(tempDir, "readiness-status.txt");
const serverPath = path.join(tempDir, "gateway.cjs");
fs.writeFileSync(
serverPath,
[
"const fs = require('node:fs');",
"const http = require('node:http');",
"const [logPath, portPath, marker, listening, mode] = process.argv.slice(2);",
"const server = http.createServer((_request, response) => {",
// A working endpoint must not let strict mode accept the historical marker.
" response.writeHead(mode === 'strict' ? 200 : 503);",
" response.end('{}');",
"});",
"server.listen(0, '127.0.0.1', () => {",
" const port = server.address().port;",
" const publish = () => {",
" fs.writeFileSync(logPath, `[gateway] ${marker} ws://127.0.0.1:${port} (PID ${process.pid})\\n`);",
" fs.writeFileSync(portPath, String(port));",
" };",
" if (listening === 'true') publish(); else server.close(publish);",
"});",
"setInterval(() => {}, 1000);",
"process.on('SIGTERM', () => process.exit(0));",
"",
].join("\n"),
);
] as const)("%s", async ([_label, marker, listening, mode, expectedStatus], { signal }) => {
const tempDir = tempDirs.make("openclaw-e2e-readyz-legacy-");
const logPath = path.join(tempDir, "gateway.log");
const portPath = path.join(tempDir, "port.txt");
const resultPath = path.join(tempDir, "readiness-status.txt");
const serverPath = path.join(tempDir, "gateway.cjs");
fs.writeFileSync(
serverPath,
[
"const fs = require('node:fs');",
"const http = require('node:http');",
"const [logPath, portPath, marker, listening, mode] = process.argv.slice(2);",
"const server = http.createServer((_request, response) => {",
// A working endpoint must not let strict mode accept the historical marker.
" response.writeHead(mode === 'strict' ? 200 : 503);",
" response.end('{}');",
"});",
"server.listen(0, '127.0.0.1', () => {",
" const port = server.address().port;",
" const publish = () => {",
" fs.writeFileSync(logPath, `[gateway] ${marker} ws://127.0.0.1:${port} (PID ${process.pid})\\n`);",
" fs.writeFileSync(portPath, String(port));",
" process.send('ready');",
" };",
" if (listening === 'true') publish(); else server.close(publish);",
"});",
"setInterval(() => {}, 1000);",
"process.on('SIGTERM', () => process.exit(0));",
"",
].join("\n"),
);
const fixture = startNodeFixture(serverPath, [
logPath,
portPath,
marker,
String(listening),
mode,
]);
try {
await withinTest(fixture.ready, signal);
const result = runBashWithHelper(
[
`${shellQuote(process.execPath)} ${shellQuote(serverPath)} ${shellQuote(logPath)} ${shellQuote(portPath)} ${shellQuote(marker)} ${listening} ${shellQuote(mode)} &`,
'gateway_pid="$!"',
'trap \'kill "$gateway_pid" >/dev/null 2>&1 || true; wait "$gateway_pid" >/dev/null 2>&1 || true\' EXIT',
`for _ in $(seq 1 100); do [ -s ${shellQuote(portPath)} ] && break; sleep 0.02; done`,
`[ -s ${shellQuote(portPath)} ]`,
`port="$(cat ${shellQuote(portPath)})"`,
`if openclaw_e2e_wait_gateway_ready "$gateway_pid" ${shellQuote(logPath)} 2 "$port" ${shellQuote(mode)}; then`,
`if openclaw_e2e_wait_gateway_ready "${fixture.child.pid}" ${shellQuote(logPath)} 2 "$port" ${shellQuote(mode)}; then`,
` printf '0' >${shellQuote(resultPath)}`,
"else",
` printf '%s' "$?" >${shellQuote(resultPath)}`,
@ -414,10 +535,12 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
{},
5_000,
);
expectShellSuccess(result);
expect(fs.readFileSync(resultPath, "utf8")).toBe(String(expectedStatus));
});
} finally {
fixture.child.kill("SIGKILL");
await fixture.closed;
}
});
it("wraps package installs with the configured timeout", () => {
@ -667,57 +790,59 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
["TERM", "143"],
["HUP", "129"],
] as const) {
it(`escalates Node watchdog children that ignore parent SIG${shellSignal}`, () => {
withTempDir("openclaw-e2e-instance-node-watchdog-signal-", (tempDir) => {
writeNodeShim(tempDir);
const childPath = path.join(tempDir, "ignore-term.cjs");
const pidPath = path.join(tempDir, "child.pid");
const watchdogPidPath = path.join(tempDir, "watchdog.pid");
fs.writeFileSync(
childPath,
[
"const fs = require('node:fs');",
"process.on('SIGTERM', () => {});",
"process.on('SIGHUP', () => {});",
"setInterval(() => {}, 1000);",
// PID publication lets the shell signal us; install handlers first.
"fs.writeFileSync(process.argv[2], String(process.pid));",
"fs.writeFileSync(process.argv[3], String(process.ppid));",
"",
].join("\n"),
);
const script = `
it(`escalates Node watchdog children that ignore parent SIG${shellSignal}`, async ({
signal,
}) => {
const tempDir = tempDirs.make("openclaw-e2e-instance-node-watchdog-signal-");
writeNodeShim(tempDir);
const childPath = path.join(tempDir, "ignore-term.mjs");
const pidPath = path.join(tempDir, "child.pid");
const watchdogPidPath = path.join(tempDir, "watchdog.pid");
fs.writeFileSync(
childPath,
`${fixtureReceiptClientSource(receipts.endpoint)}
import fs from "node:fs";
process.on("SIGTERM", () => {});
process.on("SIGHUP", () => {});
setInterval(() => {}, 1000);
fs.writeFileSync(process.argv[2], String(process.pid));
fs.writeFileSync(process.argv[3], String(process.ppid));
sendReceipt(process.argv[2], "ready");
`,
);
const runner = startBash(
`
set -euo pipefail
source ${shellQuote(helperPath)}
export OPENCLAW_E2E_TIMEOUT_KILL_GRACE_MS=100
openclaw_e2e_maybe_timeout 30s node ${shellQuote(childPath)} ${shellQuote(pidPath)} ${shellQuote(watchdogPidPath)} &
wrapper_pid="$!"
for ((i = 0; i < 100; i += 1)); do
[ -s ${shellQuote(pidPath)} ] && [ -s ${shellQuote(watchdogPidPath)} ] && break
/bin/sleep 0.02
done
[ -s ${shellQuote(pidPath)} ]
[ -s ${shellQuote(watchdogPidPath)} ]
kill -${shellSignal} "$(/bin/cat ${shellQuote(watchdogPidPath)})"
set +e
wait "$wrapper_pid"
status="$?"
set -e
[ "$status" = "${expectedStatus}" ]
child_pid="$(/bin/cat ${shellQuote(pidPath)})"
for ((i = 0; i < 100; i += 1)); do
kill -0 "$child_pid" 2>/dev/null || exit 0
/bin/sleep 0.02
done
echo "child still alive after watchdog termination" >&2
exit 1
`;
const result = runBash(script, { PATH: tempDir }, 5_000);
expectShellSuccess(result);
});
openclaw_e2e_maybe_timeout 30s node ${shellQuote(childPath)} ${shellQuote(pidPath)} ${shellQuote(watchdogPidPath)}
`,
{ PATH: tempDir },
);
try {
await withinTest(fixtureReadyBeforeSettlement(pidPath, runner.closed), signal);
const childPid = Number(fs.readFileSync(pidPath, "utf8"));
process.kill(Number(fs.readFileSync(watchdogPidPath, "utf8")), `SIG${shellSignal}`);
const result = await withinTest(runner.closed, signal);
expect(result.status, result.stderr).toBe(Number(expectedStatus));
await waitForProcessGone(childPid, signal, "child still alive after watchdog termination");
} finally {
for (const marker of [pidPath, watchdogPidPath]) {
if (fs.existsSync(marker)) {
killFixturePid(Number(fs.readFileSync(marker, "utf8")));
}
}
if (
runner.child.exitCode === null &&
runner.child.signalCode === null &&
runner.child.pid
) {
try {
process.kill(-runner.child.pid, "SIGKILL");
} catch {}
}
await runner.closed;
}
});
}
@ -761,117 +886,143 @@ fi
});
});
it("terminates descendants in the tracked process group", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-e2e-process-group-"));
it("terminates descendants in the tracked process group", async ({ signal }) => {
const tempDir = tempDirs.make("openclaw-e2e-process-group-");
const parentPidPath = path.join(tempDir, "parent.pid");
const childPidPath = path.join(tempDir, "child.pid");
const childTermPath = path.join(tempDir, "child.term");
const parentPath = path.join(tempDir, "parent.mjs");
const childPath = path.join(tempDir, "child.mjs");
const logPath = path.join(tempDir, "tracked.log");
fs.writeFileSync(
childPath,
`${fixtureReceiptClientSource(receipts.endpoint)}
import fs from "node:fs";
process.on("SIGTERM", () => {
fs.writeFileSync(process.argv[3], "terminated");
sendReceipt(process.argv[3], "terminated");
process.exit(0);
});
setInterval(() => {}, 1000);
fs.writeFileSync(process.argv[2], String(process.pid));
process.send("ready");
`,
);
fs.writeFileSync(
parentPath,
`${fixtureReceiptClientSource(receipts.endpoint)}
import fs from "node:fs";
import { spawn } from "node:child_process";
process.on("SIGTERM", () => process.exit(0));
const child = spawn(process.execPath, [process.argv[2], process.argv[4], process.argv[5]], {
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
child.once("message", () => {
fs.writeFileSync(process.argv[3], String(process.pid));
sendReceipt(process.argv[3], "ready");
});
setInterval(() => {}, 1000);
`,
);
const starter = startBash(
`source ${shellQuote(helperPath)}
openclaw_e2e_start_tracked_process ${shellQuote(logPath)} ${shellQuote(process.execPath)} ${shellQuote(parentPath)} ${shellQuote(childPath)} ${shellQuote(parentPidPath)} ${shellQuote(childPidPath)} ${shellQuote(childTermPath)}`,
{ PATH: hostPath },
);
let stopper: ReturnType<typeof startBash> | undefined;
let trackedPid = 0;
try {
const parentPath = path.join(tempDir, "parent.cjs");
const childPath = path.join(tempDir, "child.cjs");
const logPath = path.join(tempDir, "tracked.log");
fs.writeFileSync(
childPath,
[
"const fs = require('node:fs');",
"process.on('SIGTERM', () => {",
" fs.writeFileSync(process.argv[3], 'terminated');",
" process.exit(0);",
"});",
"setInterval(() => {}, 1000);",
// Both PID files announce readiness for immediate group termination.
"fs.writeFileSync(process.argv[2], String(process.pid));",
"",
].join("\n"),
const started = await withinTest(starter.closed, signal);
expect(started.status, started.stderr).toBe(0);
trackedPid = Number(started.stdout.trim());
// The start helper promises spawn only; its detached child reports actual readiness.
await withinTest(receipts.waitFor(parentPidPath, "ready"), signal);
const childPid = Number(fs.readFileSync(childPidPath, "utf8"));
stopper = startBash(
`source ${shellQuote(helperPath)}
openclaw_e2e_stop_process ${trackedPid}`,
{ PATH: hostPath },
);
fs.writeFileSync(
parentPath,
[
"const fs = require('node:fs');",
"const { spawn } = require('node:child_process');",
"const child = spawn(process.execPath, [process.argv[2], process.argv[4], process.argv[5]], {",
" stdio: 'ignore',",
"});",
"child.unref();",
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
"fs.writeFileSync(process.argv[3], String(process.pid));",
"",
].join("\n"),
await withinTest(
Promise.race([
receipts.waitFor(childTermPath, "terminated"),
stopper.closed.then(() => {
expect(fs.existsSync(childTermPath), "tracked child did not receive SIGTERM").toBe(
true,
);
}),
]),
signal,
);
const stopped = await withinTest(stopper.closed, signal);
expect(stopped.status, stopped.stderr).toBe(0);
expect(
fs.statSync(childTermPath).size,
"tracked child did not receive SIGTERM",
).toBeGreaterThan(0);
await waitForProcessGone(
childPid,
signal,
"tracked child still alive after group termination",
);
const script = `
set -euo pipefail
source ${shellQuote(helperPath)}
tracked_pid="$(openclaw_e2e_start_tracked_process ${shellQuote(logPath)} ${shellQuote(process.execPath)} ${shellQuote(parentPath)} ${shellQuote(childPath)} ${shellQuote(parentPidPath)} ${shellQuote(childPidPath)} ${shellQuote(childTermPath)})"
for ((i = 0; i < 100; i += 1)); do
[ -s ${shellQuote(parentPidPath)} ] && [ -s ${shellQuote(childPidPath)} ] && break
/bin/sleep 0.02
done
[ -s ${shellQuote(parentPidPath)} ]
[ -s ${shellQuote(childPidPath)} ]
child_pid="$(/bin/cat ${shellQuote(childPidPath)})"
openclaw_e2e_stop_process "$tracked_pid"
for ((i = 0; i < 100; i += 1)); do
[ -s ${shellQuote(childTermPath)} ] && break
/bin/sleep 0.02
done
[ -s ${shellQuote(childTermPath)} ] || {
echo "tracked child did not receive SIGTERM" >&2
exit 1
}
for ((i = 0; i < 100; i += 1)); do
kill -0 "$child_pid" 2>/dev/null || exit 0
/bin/sleep 0.02
done
echo "tracked child still alive after group termination" >&2
exit 1
`;
const result = runBash(script, { PATH: hostPath }, 5_000);
expectShellSuccess(result);
} finally {
for (const pidPath of [childPidPath, parentPidPath]) {
if (!fs.existsSync(pidPath)) {
continue;
}
const pid = Number(fs.readFileSync(pidPath, "utf8"));
if (Number.isInteger(pid) && pid > 1) {
try {
process.kill(pid, "SIGKILL");
} catch {}
if (fs.existsSync(pidPath)) {
killFixturePid(Number(fs.readFileSync(pidPath, "utf8")));
}
}
fs.rmSync(tempDir, { force: true, recursive: true });
if (trackedPid > 1) {
try {
process.kill(-trackedPid, "SIGKILL");
} catch {}
}
killFixturePid(trackedPid);
for (const command of [starter, stopper]) {
if (!command) {
continue;
}
if (
command.child.exitCode === null &&
command.child.signalCode === null &&
command.child.pid
) {
try {
process.kill(-command.child.pid, "SIGKILL");
} catch {}
}
await command.closed;
}
}
});
it("bounds HTTP readiness probes when a server accepts connections but never responds", () => {
withTempDir("openclaw-e2e-http-probe-", (tempDir) => {
const portPath = path.join(tempDir, "port.txt");
const serverPath = path.join(tempDir, "stalling-server.cjs");
fs.writeFileSync(
serverPath,
[
"const fs = require('node:fs');",
"const net = require('node:net');",
"const server = net.createServer((socket) => socket.on('data', () => {}));",
"server.listen(0, '127.0.0.1', () => {",
" fs.writeFileSync(process.argv[2], String(server.address().port));",
"});",
"process.on('SIGTERM', () => server.close(() => process.exit(0)));",
"",
].join("\n"),
);
it("bounds HTTP readiness probes when a server accepts connections but never responds", async ({
signal,
}) => {
const tempDir = tempDirs.make("openclaw-e2e-http-probe-");
const portPath = path.join(tempDir, "port.txt");
const serverPath = path.join(tempDir, "stalling-server.cjs");
fs.writeFileSync(
serverPath,
[
"const fs = require('node:fs');",
"const net = require('node:net');",
"const server = net.createServer((socket) => socket.on('data', () => {}));",
"server.listen(0, '127.0.0.1', () => {",
" fs.writeFileSync(process.argv[2], String(server.address().port));",
" process.send('ready');",
"});",
"process.on('SIGTERM', () => server.close(() => process.exit(0)));",
"",
].join("\n"),
);
const startedAt = Date.now();
const startedAt = Date.now();
const fixture = startNodeFixture(serverPath, [portPath]);
try {
await withinTest(fixture.ready, signal);
const result = runBash(
[
"set -euo pipefail",
`${shellQuote(process.execPath)} ${shellQuote(serverPath)} ${shellQuote(portPath)} & server_pid=$!`,
'trap \'kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true\' EXIT',
`for _ in $(seq 1 50); do [ -s ${shellQuote(portPath)} ] && break; sleep 0.02; done`,
`port="$(cat ${shellQuote(portPath)})"`,
`source ${shellQuote(helperPath)}`,
'openclaw_e2e_probe_http_status "http://127.0.0.1:${port}/health" 200 100',
@ -881,11 +1032,13 @@ exit 1
"; ",
);
const elapsedMs = Date.now() - startedAt;
expect(result.error).toBeUndefined();
expect(result.status).not.toBe(0);
expect(elapsedMs).toBeLessThan(2_500);
});
} finally {
fixture.child.kill("SIGKILL");
await fixture.closed;
}
});
it("cancels HTTP readiness probe response bodies", () => {

View file

@ -19,7 +19,7 @@ import {
MAX_TIMER_TIMEOUT_MS,
MAX_TIMER_TIMEOUT_SECONDS,
} from "@openclaw/normalization-core/number-coercion";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
extractLastOpenClawVersionFromLog,
isLikelyMacosDesktopHome,
@ -77,6 +77,12 @@ import {
import { withEnv } from "../../src/test-utils/env.js";
import { resolveTestNodeExecPath, spawnNodeEvalSync } from "../../src/test-utils/node-process.js";
import { acquireTestPortBlock } from "../../src/test-utils/port-claims.js";
import {
fixtureReceiptClientSource,
openFixtureReceiptChannel,
type FixtureReceiptChannel,
} from "../helpers/fixture-receipts.js";
import { withinTest } from "../helpers/promise.js";
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
const WRAPPERS = {
@ -117,9 +123,20 @@ const TS_SOURCE = Object.fromEntries(
const OS_TS_PATHS = [TS_PATHS.linux, TS_PATHS.macos, TS_PATHS.windows];
const tempDirs: string[] = [];
const pendingChildCompletions = new Set<Promise<unknown>>();
const testNodeExecPath = resolveTestNodeExecPath();
afterEach(() => {
let receipts: FixtureReceiptChannel;
beforeAll(async () => {
receipts = await openFixtureReceiptChannel();
});
afterAll(async () => {
await receipts.close();
});
afterEach(async () => {
// A timed-out body's finally still needs the PID files while joining its native runner.
await Promise.allSettled(pendingChildCompletions);
cleanupTempDirs(tempDirs);
});
@ -280,30 +297,30 @@ function isProcessAlive(pid: number): boolean {
}
}
async function waitFor(predicate: () => boolean, timeoutMs = 3_000): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return;
}
await delay(5);
// The host wrapper escalates then settles; it exposes no foreign-PID reap signal.
async function waitForProcessGone(pid: number, signal: AbortSignal): Promise<void> {
while (isProcessAlive(pid)) {
await delay(5, undefined, { signal }).catch((error: unknown) => {
throw new Error(`condition was not met before timeout: process ${pid} is still alive`, {
cause: error,
});
});
}
throw new Error("condition was not met before timeout");
}
async function waitForProcessClose(
function waitForProcessClose(
child: ReturnType<typeof spawn>,
timeoutMs = 3_000,
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error("child process did not close before timeout"));
}, timeoutMs);
child.once("close", (code, signal) => {
clearTimeout(timer);
resolve({ code, signal });
});
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => resolve({ code, signal }));
},
).finally(() => {
pendingChildCompletions.delete(closed);
});
pendingChildCompletions.add(closed);
return closed;
}
function runNode(source: string, options: NonNullable<Parameters<typeof run>[2]> = {}) {
@ -459,31 +476,49 @@ function createSignaledHostCommandFixture() {
const tempDir = makeTempDir(tempDirs, "openclaw-parallels-host-command-signal-");
const runnerPath = join(tempDir, "runner.mjs");
const readyPath = join(tempDir, "ready");
const parentPidPath = join(tempDir, "parent.pid");
const grandchildPidPath = join(tempDir, "grandchild.pid");
const hostCommandUrl = resolveRuntimeWorkerUrl(scriptProcessEntrypoints.parallelsHostCommand);
const parentScript = `${fixtureReceiptClientSource(receipts.endpoint)}
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
process.on("SIGTERM", () => process.exit(0));
writeFileSync(${JSON.stringify(parentPidPath)}, String(process.pid));
const child = spawn(process.execPath, ["-e", ${JSON.stringify(`${SIGNAL_GRANDCHILD_SCRIPT} process.send("ready");`)}], {
env: process.env,
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
child.once("message", () => {
writeFileSync(process.env.OPENCLAW_TEST_READY_FILE, "ready");
sendReceipt(process.env.OPENCLAW_TEST_READY_FILE, "ready");
});
setInterval(() => {}, 1000);`;
writeFileSync(
runnerPath,
`import { run } from ${JSON.stringify(hostCommandUrl.href)};
run(process.execPath, ['-e', ${JSON.stringify(SIGNAL_PARENT_SCRIPT)}], {
run(process.execPath, ['--input-type=module', '-e', ${JSON.stringify(parentScript)}], {
check: false,
env: { ...process.env, OPENCLAW_TEST_GRANDCHILD_PID: ${JSON.stringify(grandchildPidPath)}, OPENCLAW_TEST_READY_FILE: ${JSON.stringify(readyPath)} },
quiet: true,
timeoutMs: 30_000,
});`,
);
return {
grandchildPidPath,
readyPath,
runner: spawn(
testNodeExecPath,
[...resolveRuntimeWorkerArgv(hostCommandUrl, testNodeExecPath).slice(0, -1), runnerPath],
{
cwd: process.cwd(),
detached: true,
stdio: "ignore",
},
),
};
const runner = spawn(
testNodeExecPath,
[...resolveRuntimeWorkerArgv(hostCommandUrl, testNodeExecPath).slice(0, -1), runnerPath],
{ cwd: process.cwd(), detached: true, stdio: "ignore" },
);
const closed = waitForProcessClose(runner);
const ready = Promise.race([
receipts.waitFor(readyPath, "ready"),
closed.then(() => {
// PID publication precedes the parent's ready record; receipt delivery is unordered.
if (!existsSync(readyPath) || !existsSync(grandchildPidPath)) {
throw new Error("condition was not met before timeout");
}
}),
]);
return { grandchildPidPath, parentPidPath, readyPath, runner, ready, closed };
}
function forceKillSignaledFixture(
@ -2224,7 +2259,7 @@ if (commandArgs[0] === "list") {
it.runIf(process.platform !== "win32")(
"kills timed-out host command process groups",
async () => {
async ({ signal }) => {
const tempDir = makeTempDir(tempDirs, "openclaw-parallels-host-command-");
const grandchildPidPath = join(tempDir, "grandchild.pid");
const deadlineFile = join(tempDir, "deadline");
@ -2249,7 +2284,7 @@ if (commandArgs[0] === "list") {
expect(readFileSync(deadlineFile, "utf8")).toBe("elapsed");
grandchildPid = Number.parseInt(readFileSync(grandchildPidPath, "utf8"), 10);
expect(Number.isInteger(grandchildPid)).toBe(true);
await waitFor(() => !isProcessAlive(grandchildPid));
await waitForProcessGone(grandchildPid, signal);
} finally {
if (grandchildPid && isProcessAlive(grandchildPid)) {
process.kill(grandchildPid, "SIGKILL");
@ -2260,7 +2295,7 @@ if (commandArgs[0] === "list") {
it.runIf(process.platform !== "win32")(
"settles timed host commands when an escaped descendant retains child pipes",
async () => {
() => {
const tempDir = makeTempDir(tempDirs, "openclaw-parallels-host-command-pipes-");
const grandchildPidPath = join(tempDir, "grandchild.pid");
const deadlineFile = join(tempDir, "deadline");
@ -2301,7 +2336,7 @@ if (commandArgs[0] === "list") {
const durationMs = Date.now() - startedAt;
// The renamed path publishes a complete PID even if timeout settles first.
await waitFor(() => existsSync(grandchildPidPath));
expect(existsSync(grandchildPidPath)).toBe(true);
grandchildPid = Number(readFileSync(grandchildPidPath, "utf8"));
expect(Number.isInteger(grandchildPid)).toBe(true);
@ -2319,7 +2354,7 @@ if (commandArgs[0] === "list") {
it.runIf(process.platform !== "win32")(
"reaps externally signaled timed host command descendants",
async () => {
async ({ signal }) => {
const fixture = createSignaledHostCommandFixture();
let runnerPid = 0;
let grandchildPid = 0;
@ -2327,21 +2362,28 @@ if (commandArgs[0] === "list") {
try {
runnerPid = fixture.runner.pid ?? 0;
expect(runnerPid).toBeGreaterThan(0);
await waitFor(
() => existsSync(fixture.readyPath) && existsSync(fixture.grandchildPidPath),
2_000,
);
await withinTest(fixture.ready, signal);
grandchildPid = Number.parseInt(readFileSync(fixture.grandchildPidPath, "utf8"), 10);
process.kill(-runnerPid, "SIGTERM");
await expect(waitForProcessClose(fixture.runner, 3_000)).resolves.toEqual({
await expect(withinTest(fixture.closed, signal)).resolves.toEqual({
code: null,
signal: "SIGTERM",
});
await waitFor(() => !isProcessAlive(grandchildPid), 3_000);
await waitForProcessGone(grandchildPid, signal);
} finally {
if (!grandchildPid && existsSync(fixture.grandchildPidPath)) {
grandchildPid = Number.parseInt(readFileSync(fixture.grandchildPidPath, "utf8"), 10);
}
forceKillSignaledFixture(runnerPid, grandchildPid, true);
if (existsSync(fixture.parentPidPath)) {
const parentPid = Number.parseInt(readFileSync(fixture.parentPidPath, "utf8"), 10);
if (isProcessAlive(parentPid)) {
process.kill(-parentPid, "SIGKILL");
}
}
await fixture.closed;
}
},
);

View file

@ -9,7 +9,7 @@ import {
writeFileSync,
} from "node:fs";
import { basename, delimiter, dirname, join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
expandPluginSdkApiDiffSet,
selectPluginSdkApiReleaseEvidence,
@ -20,11 +20,31 @@ import {
resolveRuntimeWorkerArgv,
resolveRuntimeWorkerUrl,
} from "../../src/infra/runtime-worker-url.js";
import { withTestTimeout } from "../helpers/promise.js";
import {
fixtureReceiptClientSource,
openFixtureReceiptChannel,
type FixtureReceiptChannel,
} from "../helpers/fixture-receipts.js";
import { withinTest } from "../helpers/promise.js";
import { withRuntimePreload } from "../helpers/runtime-preload.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const pendingChildCompletions = new Set<Promise<unknown>>();
const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
afterEach(async () => {
// Timed-out bodies can still be joining installers and deleting worktrees in finally.
await Promise.allSettled(pendingChildCompletions);
cleanup();
}),
);
function ownChildCompletion<T>(completion: Promise<T>): Promise<T> {
const owned = completion.finally(() => {
pendingChildCompletions.delete(owned);
});
pendingChildCompletions.add(owned);
return owned;
}
const emptyDiff = {
entrypointsAdded: [],
entrypointsRemoved: [],
@ -78,24 +98,48 @@ function runCli(repo: string, runnerTemp: string, binDir: string, args: string[]
);
}
async function waitFor(
check: () => boolean,
timeoutMs: number,
let receipts: FixtureReceiptChannel;
beforeAll(async () => {
receipts = await openFixtureReceiptChannel();
});
afterAll(async () => {
await receipts.close();
});
function writeInstallReceipt(binDir: string): string {
const receiptPath = join(binDir, "install-receipt.mjs");
writeFileSync(
receiptPath,
`${fixtureReceiptClientSource(receipts.endpoint)}
sendReceipt(process.argv[2], "ready");
// This reporter is a short-lived shell child: retain the socket until its write drains.
fixtureReceiptSocket.ref();
fixtureReceiptSocket.end();
`,
);
return `'${process.execPath.replace(/'/gu, `'\\''`)}' '${receiptPath.replace(/'/gu, `'\\''`)}'`;
}
async function installBeforeSettlement(
marker: string,
close: PromiseLike<unknown>,
label = "Plugin SDK API diff child",
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!check()) {
if (Date.now() >= deadline) {
throw new Error(`timed out waiting for ${label}`);
}
await new Promise((resolveWait) => {
setTimeout(resolveWait, 25);
});
}
// The fixture writes its marker before reporting; socket delivery may follow CLI close.
await Promise.race([
receipts.waitFor(marker, "ready"),
Promise.resolve(close).then(() => {
if (!existsSync(marker)) {
throw new Error(`timed out waiting for ${label}`);
}
}),
]);
}
describe("Plugin SDK API diff CLI", () => {
it("finishes every revision install before starting declaration rendering", async () => {
it("finishes every revision install before starting declaration rendering", async ({
signal,
}) => {
const repo = tempDirs.make("plugin-sdk-install-order-repo-");
const runnerTemp = tempDirs.make("plugin-sdk-install-order-temp-");
const binDir = tempDirs.make("plugin-sdk-install-order-bin-");
@ -142,6 +186,7 @@ if mkdir "$PNPM_MARKER" 2>/dev/null; then
exit 0
fi
: > "$PNPM_BLOCKED"
${writeInstallReceipt(binDir)} "$PNPM_BLOCKED"
while [ ! -e "$PNPM_RELEASE" ]; do sleep 0.05; done
`,
);
@ -207,18 +252,24 @@ if (process.argv.includes("--render-root")) {
stdio: ["ignore", "ignore", "pipe"],
},
);
const close = ownChildCompletion(
new Promise<number | null>((resolveClose) => {
child.once("close", resolveClose);
}),
);
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
const close = new Promise<number | null>((resolveClose) => {
child.once("close", resolveClose);
});
let exitCode: number | null = null;
try {
await waitFor(() => existsSync(blockedMarker), 15_000, "second revision install");
exitCode = await withTestTimeout(close, 30_000, "Plugin SDK API diff did not finish");
await withinTest(
installBeforeSettlement(blockedMarker, close, "second revision install"),
signal,
);
exitCode = await withinTest(close, signal);
} finally {
writeFileSync(releaseMarker, "release\n");
if (child.exitCode === null) {
@ -329,7 +380,7 @@ if (process.argv.includes("--render-root")) {
35_000,
);
it("interrupts a running child and removes its registered worktree", async () => {
it("interrupts a running child and removes its registered worktree", async ({ signal }) => {
// Keep revision checkout bounded so startup reaches the child this test cancels.
const repo = tempDirs.make("plugin-sdk-api-diff-repo-");
const runnerTemp = tempDirs.make("plugin-sdk-api-diff-temp-");
@ -349,7 +400,7 @@ if (process.argv.includes("--render-root")) {
const fakePnpm = join(binDir, "pnpm");
writeFileSync(
fakePnpm,
"#!/bin/sh\n: > \"$PNPM_MARKER\"\ntrap 'exit 143' INT TERM\nwhile :; do sleep 1; done\n",
`#!/bin/sh\ntrap 'exit 143' INT TERM\n: > "$PNPM_MARKER"\n${writeInstallReceipt(binDir)} "$PNPM_MARKER"\nwhile :; do sleep 1; done\n`,
);
chmodSync(fakePnpm, 0o755);
@ -379,19 +430,22 @@ if (process.argv.includes("--render-root")) {
);
let closed = false;
const close = ownChildCompletion(
new Promise<number | null>((resolveClose) => {
child.once("close", (code) => {
closed = true;
resolveClose(code);
});
}),
);
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
const close = new Promise<number | null>((resolveClose) => {
child.once("close", (code) => {
closed = true;
resolveClose(code);
});
});
try {
await waitFor(() => existsSync(pnpmMarker) || closed, 10_000);
await withinTest(installBeforeSettlement(pnpmMarker, close), signal);
expect(closed, stderr).toBe(false);
const revisionRoot = git(repo, ["worktree", "list", "--porcelain", "-z"])
.split("\0")
@ -403,7 +457,7 @@ if (process.argv.includes("--render-root")) {
expect(existsSync(temporaryRoot)).toBe(true);
const interruptedAt = Date.now();
child.kill("SIGTERM");
const exitCode = await withTestTimeout(close, 5_000, "Plugin SDK API diff ignored SIGTERM");
const exitCode = await withinTest(close, signal);
expect(exitCode).toBe(143);
expect(Date.now() - interruptedAt).toBeLessThan(5_000);
@ -414,7 +468,8 @@ if (process.argv.includes("--render-root")) {
expect(readFileSync(runnerSentinel, "utf8")).toBe("preserve\n");
} finally {
if (!closed) {
child.kill("SIGKILL");
// Let the CLI join its installer and remove revision worktrees on test cancellation.
child.kill("SIGTERM");
await close;
}
}

View file

@ -10,6 +10,7 @@ import { loadInstalledPluginIndex } from "../../src/plugins/installed-plugin-ind
import { createInstalledPluginOwnershipResolver } from "../../src/plugins/installed-plugin-package-ownership.js";
import { closeOpenClawStateDatabaseByPath } from "../../src/state/openclaw-state-db-cache.js";
import { openOpenClawStateDatabase } from "../../src/state/openclaw-state-db.js";
import { awaitGateBeforeSettlement, withinTest } from "../helpers/promise.js";
const PLUGIN_UPDATE_SCENARIO_SCRIPT = "scripts/e2e/lib/plugin-update/unchanged-scenario.sh";
const CORRUPT_UPDATE_SCENARIO_SCRIPT = "scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh";
@ -146,17 +147,22 @@ fi
}
}
async function waitForPortFile(portFile: string): Promise<number> {
for (let attempt = 0; attempt < 50; attempt += 1) {
async function waitForPortFile(portFile: string, signal: AbortSignal): Promise<number> {
// The production registry publishes only this file; no IPC or stdout readiness is exposed.
while (!signal.aborted) {
if (existsSync(portFile)) {
const port = Number.parseInt(readFileSync(portFile, "utf8"), 10);
if (Number.isInteger(port) && port > 0) {
return port;
}
}
await delay(50);
await delay(10, undefined, { signal }).catch((error: unknown) => {
if (!signal.aborted) {
throw error;
}
});
}
throw new Error("registry did not write a port file");
throw new Error("registry did not write a port file", { cause: signal.reason });
}
describe("plugin update unchanged Docker E2E", () => {
@ -249,14 +255,26 @@ describe("plugin update unchanged Docker E2E", () => {
expect(script).not.toContain("cat /tmp/openclaw-e2e-registry.log");
});
it("serves plugin metadata from an ephemeral registry port", async () => {
it("serves plugin metadata from an ephemeral registry port", async ({ signal }) => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-update-registry-"));
const portFile = path.join(root, "registry.port");
const child = spawn("node", [PLUGIN_UPDATE_REGISTRY_SCRIPT, portFile], {
stdio: "ignore",
});
const waiting = new AbortController();
child.once("error", (error) => waiting.abort(error));
const closed = new Promise<void>((resolve) => {
child.once("close", () => resolve());
});
try {
const port = await waitForPortFile(portFile);
const port = await withinTest(
awaitGateBeforeSettlement(
waitForPortFile(portFile, AbortSignal.any([signal, waiting.signal])),
closed,
"registry did not write a port file",
),
signal,
);
const response = await fetch(`http://127.0.0.1:${port}/@example%2flossless-claw`);
expect(response.status).toBe(200);
@ -267,7 +285,9 @@ describe("plugin update unchanged Docker E2E", () => {
`http://127.0.0.1:${port}/@example/lossless-claw/-/lossless-claw-0.9.0.tgz`,
);
} finally {
waiting.abort();
child.kill("SIGTERM");
await closed;
rmSync(root, { recursive: true, force: true });
}
});

View file

@ -12,6 +12,7 @@ import {
shouldSkipLinuxArmAndroidGradle,
splitAndroidGradleArgs,
} from "../../scripts/run-android-gradle.mts";
import { withinTest } from "../helpers/promise.js";
const posixIt = process.platform === "win32" ? it.skip : it;
@ -99,9 +100,11 @@ describe("run-android-gradle", () => {
});
});
posixIt("terminates the active command tree when the wrapper is terminated", async () => {
const moduleUrl = pathToFileURL(path.resolve("scripts/run-android-gradle.mts")).href;
const childSource = `
posixIt(
"terminates the active command tree when the wrapper is terminated",
async ({ signal }) => {
const moduleUrl = pathToFileURL(path.resolve("scripts/run-android-gradle.mts")).href;
const childSource = `
const { spawn } = require("node:child_process");
const descendant = spawn(process.execPath, [
"-e",
@ -112,7 +115,7 @@ process.stdout.write(
);
setInterval(() => {}, 1_000);
`;
const runnerSource = `
const runnerSource = `
import { run } from ${JSON.stringify(moduleUrl)};
process.exitCode = await run(
process.execPath,
@ -120,45 +123,48 @@ process.exitCode = await run(
process.cwd(),
);
`;
const runner = spawn(
process.execPath,
["--import", "tsx", "--input-type=module", "-e", runnerSource],
{ stdio: ["ignore", "pipe", "ignore"] },
);
const runnerPid = expectPid(runner.pid);
const processTreeReady = readProcessTree(runner);
let childPid = 0;
let descendantPid = 0;
const runner = spawn(
process.execPath,
["--import", "tsx", "--input-type=module", "-e", runnerSource],
{ stdio: ["ignore", "pipe", "ignore"] },
);
const runnerPid = expectPid(runner.pid);
const processTreeReady = readProcessTree(runner);
const closed = waitForClose(runner);
let childPid = 0;
let descendantPid = 0;
try {
const processTree = await processTreeReady;
childPid = processTree.childPid;
descendantPid = processTree.descendantPid;
expect(Number.isInteger(childPid)).toBe(true);
expect(Number.isInteger(descendantPid)).toBe(true);
expect(isProcessAlive(childPid)).toBe(true);
expect(isProcessAlive(descendantPid)).toBe(true);
try {
const processTree = await withinTest(processTreeReady, signal);
childPid = processTree.childPid;
descendantPid = processTree.descendantPid;
expect(Number.isInteger(childPid)).toBe(true);
expect(Number.isInteger(descendantPid)).toBe(true);
expect(isProcessAlive(childPid)).toBe(true);
expect(isProcessAlive(descendantPid)).toBe(true);
process.kill(runnerPid, "SIGTERM");
const result = await waitForClose(runner);
await waitFor(() => !isProcessAlive(childPid), 1_500);
await waitFor(() => !isProcessAlive(descendantPid), 1_500);
process.kill(runnerPid, "SIGTERM");
const result = await withinTest(closed, signal);
// The owner joins the tree, but Darwin may still expose a reaping descendant's PID.
await waitForProcessGone(descendantPid, signal);
expect(isProcessAlive(childPid)).toBe(false);
expect(isProcessAlive(descendantPid)).toBe(false);
expect(result).toEqual({ code: 143, signal: null });
} finally {
if (isProcessAlive(runnerPid)) {
process.kill(runnerPid, "SIGKILL");
expect(isProcessAlive(childPid)).toBe(false);
expect(isProcessAlive(descendantPid)).toBe(false);
expect(result).toEqual({ code: 143, signal: null });
} finally {
if (isProcessAlive(runnerPid)) {
process.kill(runnerPid, "SIGKILL");
}
if (childPid && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
if (descendantPid && isProcessAlive(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
}
await closed;
}
if (childPid && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
if (descendantPid && isProcessAlive(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
}
}
});
},
);
it("reports spawn errors and returns a failure status", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
@ -209,7 +215,9 @@ async function readProcessTree(child: ReturnType<typeof spawn>): Promise<{
try {
resolve(JSON.parse(output.slice(0, newline)));
} catch (error) {
reject(error);
reject(
error instanceof Error ? error : new Error("invalid process tree", { cause: error }),
);
}
};
stdout.on("data", onData);
@ -217,13 +225,13 @@ async function readProcessTree(child: ReturnType<typeof spawn>): Promise<{
});
}
async function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise<void> {
const startedAt = Date.now();
while (!condition()) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error("timed out waiting for condition");
}
await delay(5);
async function waitForProcessGone(pid: number, signal: AbortSignal): Promise<void> {
while (isProcessAlive(pid)) {
await delay(5, undefined, { signal }).catch((error: unknown) => {
throw new Error(`timed out waiting for condition: process ${pid} is still alive`, {
cause: error,
});
});
}
}

View file

@ -8,7 +8,7 @@ import {
type VitestWorkerManifest,
} from "../../scripts/lib/vitest-worker-artifacts.mts";
import { createVitestWorkerRun } from "../../scripts/lib/vitest-worker-run.mts";
import { createDeferred, withTestTimeout } from "../helpers/promise.js";
import { awaitGateBeforeSettlement, createDeferred, withinTest } from "../helpers/promise.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
@ -49,9 +49,9 @@ it("keeps the runner event loop responsive while verifying a completed generatio
}
});
it.each(["inputs", "outputs"] as const)(
it.for(["inputs", "outputs"] as const)(
"drains active %s reads before failed verification releases the generation",
async (group) => {
async (group, { signal }) => {
const owner = createVitestWorkerRun();
const directory = owner.descriptor.directory;
const files = group === "inputs" ? directory : path.join(directory, "dist");
@ -100,10 +100,13 @@ it.each(["inputs", "outputs"] as const)(
completed = true;
});
try {
await withTestTimeout(
Promise.all([started.promise, failedRead.promise]),
5_000,
"verification did not admit both reads",
await withinTest(
awaitGateBeforeSettlement(
Promise.all([started.promise, failedRead.promise]),
disposal,
"verification did not admit both reads",
),
signal,
);
await nextTurn();
expect(completed).toBe(false);