mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
Couple test-worker arguments to the executable selected by the shared subprocess helper and migrate its runtime-dependent callers. Repair command parsing, runtime pinning, worker observation, storage settlement, and cleanup-clock fixtures while preserving their contracts. Fourteen scoped suites pass on Node 24 and Bun; the Bun readSync gap remains an unchanged stop. Refreshed proof covers 638 passed/1 existing skip on Node and 636 passed/3 existing skips on Bun. P2 review and exact-head ClawSweeper found no actionable defects. Production LOC delta is zero. The first CI head exposed an inherited UI-validator fixture omission. After incorporating its existing main fix, exact-head CI passed, including the published-driver update job. No same-head retry or admin exception was used.
205 lines
7.8 KiB
TypeScript
205 lines
7.8 KiB
TypeScript
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
import { createVitestResourceOwner } from "../../scripts/lib/vitest-resource-ownership.mts";
|
|
import { createFixtureLifetime } from "./fixture-lifetime.js";
|
|
import { isProcessAlive, waitForDead, waitForPidFile } from "./process-wait.js";
|
|
import { withinTest } from "./promise.js";
|
|
import { runNodeScript } from "./run-node-script.js";
|
|
import { useAutoCleanupTempDirTracker } from "./temp-dir.js";
|
|
|
|
const PID_CLEANUP_HANG_GUARD_MS = 2_000;
|
|
const PROCESS_CLEANUP_HANG_GUARD_MS = 15_000;
|
|
let cleanupFixture: (() => Promise<void>) | undefined;
|
|
const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
|
|
afterEach(async () => {
|
|
// Timeout hooks can run before the suspended body finishes its finally block.
|
|
await cleanupFixture?.();
|
|
cleanupFixture = undefined;
|
|
cleanup();
|
|
}),
|
|
);
|
|
|
|
// The escaped leaf's parent exits, leaving no ChildProcess handle to join.
|
|
async function waitForProcessExit(pid: number, signal: AbortSignal): Promise<void> {
|
|
try {
|
|
for (;;) {
|
|
signal.throwIfAborted();
|
|
if (!isProcessAlive(pid)) {
|
|
return;
|
|
}
|
|
await delay(5, undefined, { signal });
|
|
}
|
|
} catch (error) {
|
|
if (signal.aborted) {
|
|
throw new Error(`process still alive: ${pid}`, { cause: error });
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
it("captures inherited output written after the script exits", async () => {
|
|
const script = join(tempDirs.make("openclaw-node-script-output-"), "parent.mjs");
|
|
writeFileSync(
|
|
script,
|
|
`import { spawn } from "node:child_process";
|
|
const child = spawn(process.execPath, ["-e", \`
|
|
process.on("disconnect", () => {
|
|
process.stdout.write("drained stdout\\\\n");
|
|
process.stderr.write("drained stderr\\\\n");
|
|
});
|
|
process.send("ready");
|
|
\`], { stdio: ["ignore", "inherit", "inherit", "ipc"] });
|
|
child.once("message", () => process.exit(17));
|
|
`,
|
|
);
|
|
|
|
const result = await runNodeScript(script, process.env, 5_000);
|
|
expect(result).toEqual({
|
|
error: undefined,
|
|
status: 17,
|
|
stdout: "drained stdout\n",
|
|
stderr: "drained stderr\n",
|
|
});
|
|
});
|
|
|
|
it.for(["node", "current"] as const)(
|
|
"builds source worker arguments for the selected %s runtime",
|
|
async (runtime) => {
|
|
const script = join(tempDirs.make("openclaw-node-script-runtime-"), "worker.ts");
|
|
writeFileSync(
|
|
script,
|
|
`enum Answer { value = 42 }
|
|
console.log(JSON.stringify({ answer: Answer.value, bun: Boolean(process.versions.bun), args: process.argv.slice(2) }));
|
|
`,
|
|
);
|
|
const result = await runNodeScript(
|
|
(workerArgv) => [...workerArgv(pathToFileURL(script)), "worker-argument"],
|
|
process.env,
|
|
5_000,
|
|
{ executable: runtime === "current" ? process.execPath : undefined },
|
|
);
|
|
expect(result.error, result.stderr).toBeUndefined();
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
answer: 42,
|
|
bun: runtime === "current" && Boolean(process.versions.bun),
|
|
args: ["worker-argument"],
|
|
});
|
|
},
|
|
);
|
|
|
|
it.for(["at limit", "stdout overflow", "stderr overflow"])(
|
|
"preserves independent 2 MiB output failure boundaries: %s",
|
|
async (mode) => {
|
|
const directory = tempDirs.make("openclaw-node-script-buffer-");
|
|
const script = join(directory, "output.mjs");
|
|
const pidFile = join(directory, "pid");
|
|
const maxBuffer = 2 * 1024 * 1024;
|
|
writeFileSync(
|
|
script,
|
|
`import fs from 'node:fs';
|
|
fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));
|
|
const mode=${JSON.stringify(mode)}, bytes=${maxBuffer};
|
|
if(mode==='at limit') {
|
|
process.stdout.write(Buffer.alloc(bytes,97));
|
|
process.stderr.write(Buffer.alloc(bytes,98));
|
|
} else {
|
|
process[mode.split(' ')[0]].write(Buffer.alloc(bytes+1,97));
|
|
setTimeout(()=>process.exit(73),10000);
|
|
}
|
|
`,
|
|
);
|
|
const result = await runNodeScript(script, process.env, 5_000, { maxBuffer });
|
|
const pid = Number(readFileSync(pidFile, "utf8"));
|
|
expect(() => process.kill(pid, 0)).toThrow();
|
|
if (mode === "at limit") {
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toBe("a".repeat(maxBuffer));
|
|
expect(result.stderr).toBe("b".repeat(maxBuffer));
|
|
} else {
|
|
expect(result.error).toHaveProperty("code", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER");
|
|
expect(result.status).toBeNull();
|
|
expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(maxBuffer);
|
|
expect(Buffer.byteLength(result.stderr)).toBeLessThanOrEqual(maxBuffer);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"retains inputs when captured command output leaves cleanup uncertain",
|
|
async ({ signal }) => {
|
|
const ownerRoot = tempDirs.make("node-script-owner-");
|
|
const owner = createVitestResourceOwner(ownerRoot);
|
|
for (const key of ["TMPDIR", "TMP", "TEMP"]) {
|
|
vi.stubEnv(key, ownerRoot);
|
|
}
|
|
const fixture = createFixtureLifetime();
|
|
const directory = fixture.createTempDir("openclaw-node-script-uncertain-");
|
|
const script = join(directory, "parent.mjs");
|
|
const pidFile = join(directory, "leaf.pid");
|
|
const release = join(directory, "release");
|
|
const input = join(directory, "input");
|
|
const read = join(directory, "read");
|
|
writeFileSync(input, "still owned");
|
|
const leaf = `
|
|
const fs=require('node:fs');
|
|
const deadline=setTimeout(()=>process.exit(73),15000);
|
|
const poll=setInterval(()=>{
|
|
if(!fs.existsSync(${JSON.stringify(release)})) return;
|
|
fs.writeFileSync(${JSON.stringify(read)},fs.readFileSync(${JSON.stringify(input)}));
|
|
clearInterval(poll);clearTimeout(deadline);
|
|
},5);
|
|
fs.writeFileSync(${JSON.stringify(pidFile)},String(process.pid));
|
|
process.send('ready');process.disconnect();
|
|
`;
|
|
writeFileSync(
|
|
script,
|
|
`import {spawn} from 'node:child_process';
|
|
const child=spawn(process.execPath,['--eval',${JSON.stringify(leaf)}],{detached:true,stdio:['ignore','inherit','inherit','ipc']});
|
|
child.once('message',()=>process.exit(0));
|
|
`,
|
|
);
|
|
const command = fixture.track(
|
|
runNodeScript(script, process.env, undefined, { signal, requireProcessTreeExit: true }),
|
|
);
|
|
let cleanupPromise: Promise<void> | undefined;
|
|
const cleanup = () =>
|
|
(cleanupPromise ??= (async () => {
|
|
await command;
|
|
// Rescue only this bounded escaped leaf, and certify its exit before
|
|
// manually disposing roots the lifetime owner deliberately retained.
|
|
if (existsSync(pidFile)) {
|
|
writeFileSync(release, "release");
|
|
// Cleanup hang guards after the owner released the leaf, not readiness races.
|
|
const pid = await waitForPidFile(pidFile, AbortSignal.timeout(PID_CLEANUP_HANG_GUARD_MS));
|
|
await waitForDead(pid, AbortSignal.timeout(PROCESS_CLEANUP_HANG_GUARD_MS));
|
|
}
|
|
await fixture.cleanup();
|
|
rmSync(directory, { recursive: true, force: true });
|
|
vi.unstubAllEnvs();
|
|
})());
|
|
cleanupFixture = cleanup;
|
|
try {
|
|
const result = await command;
|
|
expect(result.error).toMatchObject({
|
|
code: "EPROCESSGROUP_CLEANUP_FAILED",
|
|
processTreeState: "indeterminate",
|
|
});
|
|
await expect(fixture.cleanup()).rejects.toThrow("Fixture cleanup unverified");
|
|
expect(() => owner.assertReleased()).toThrow("Unreleased Vitest resource claim");
|
|
expect(existsSync(directory)).toBe(true);
|
|
writeFileSync(release, "release");
|
|
// The leaf writes its PID before ready; only that IPC lets the parent exit.
|
|
const pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10);
|
|
expect(Number.isInteger(pid) && pid > 0).toBe(true);
|
|
await withinTest(waitForProcessExit(pid, signal), signal);
|
|
expect(readFileSync(read, "utf8")).toBe("still owned");
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
},
|
|
);
|