Files
openclaw/test/scripts/check-extension-package-tsc-boundary.test.ts
Peter SteinbergerandGitHub 51fa35c94d test: fix three pid/signal races behind CI flakes (batch 3) (#109877)
A. check-extension-package-tsc-boundary "force-kills aborted async node
step process groups" (run 29568640449): two mechanisms behind the
expected-false-to-be-true at the child-alive assertion.
- Pid-file existence polls can observe writeFileSync's open-truncate
  0-byte window, parse NaN, and process.kill(NaN, 0) throws, so
  isProcessAlive reads false (the #109140 class, same as tsdown-build's
  b1e6c6883b). Added waitForPidFile (poll until content parses to a
  positive pid) and applied it to all three pid-read sites in the file.
- The fail-fast step exited on a 150ms fuse after the sibling's pid file
  appeared, racing the test's aliveness assertion: a worker descheduled
  >150ms observes the abort chain's SIGKILL first. The step now exits
  only after the test writes abort.ack, which happens strictly after the
  assertion (event-driven; the step's 5s timeout bounds a wedged run).
  Forced proof: injecting a 300ms stall between pid-file wait and the
  assertion reproduced the exact CI failure, and passed post-fix with
  the same stall in place.

B. telegram-user-crabbox-proof "stops Crabbox recording when the desktop
probe fails" (waitFor timeout on recorder.term): the fake recorder
published its pid file before installing its SIGTERM handler. The probe
throws as soon as the pid file exists and recordProbeVideo SIGTERMs the
recorder in its finally, so a recorder preempted between publish and
process.on dies to the default disposition and never writes
recorder.term. Handler now arms before the pid publish. Forced proof: a
100ms spin between publish and process.on reproduced the exact CI
failure deterministically; moving the spin after the reordered handler
passes. Applied the same handler-before-readiness ordering to the three
sibling fakes in the file, and made the gateway-grandchild pid read
parse-validated inside its poll (a NaN pid skipped both the dead-check
and the finally SIGKILL cleanup, leaking the grandchild).

C. openclaw-live-updater "treats an owner file creation race as a normal
overlap" (run 29569279237): prod bug in acquireMaintenanceLock. Only
ENOENT got the bounded creation-window retry; a reader hitting the
racing writer's open-truncate window read an empty owner.json, JSON.parse
threw SyntaxError, and the script escalated to invalid_lock instead of
overlap. Any owner read/parse failure now re-reads within the same
bounded budget before declaring the lock invalid. New regression test
freezes the window (pre-created empty owner.json, delayed writer):
fails with the exact CI error pre-fix, passes post-fix.

Proof: focused runs green; 10x per-file stress at
OPENCLAW_VITEST_MAX_WORKERS=6 green for all three files (30/30).
2026-07-17 11:01:38 +01:00

786 lines
27 KiB
TypeScript

// Check Extension Package Tsc Boundary tests cover check extension package tsc boundary script behavior.
import { spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
acquireBoundaryCheckLock,
appendBoundedStepOutput,
cleanupCanaryArtifactsForExtensions,
formatBoundaryCheckSuccessSummary,
formatSlowCompileSummary,
formatSkippedCompileProgress,
formatStepFailure,
installCanaryArtifactCleanup,
isBoundaryCompileFresh,
resolveCompileConcurrency,
resolveBoundaryCheckLockPath,
resolveCanaryArtifactPaths,
runNodeStepAsync,
runNodeStepsWithConcurrency,
} from "../../scripts/check-extension-package-tsc-boundary.mjs";
const tempRoots = new Set<string>();
function createTempExtensionRoot(extensionId = "demo") {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-boundary-canary-"));
tempRoots.add(rootDir);
const extensionRoot = path.join(rootDir, "extensions", extensionId);
fs.mkdirSync(extensionRoot, { recursive: true });
return { rootDir, extensionRoot };
}
function writeCanaryArtifacts(rootDir: string, extensionId = "demo") {
const { canaryPath, tsconfigPath } = resolveCanaryArtifactPaths(extensionId, rootDir);
fs.writeFileSync(canaryPath, "export {};\n", "utf8");
fs.writeFileSync(tsconfigPath, '{ "extends": "./tsconfig.json" }\n', "utf8");
return { canaryPath, tsconfigPath };
}
function createMockPipe() {
const pipe = new EventEmitter() as EventEmitter & {
setEncoding: (encoding: string) => void;
};
pipe.setEncoding = () => {};
return pipe;
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function waitForFile(filePath: string, timeoutMs: number): Promise<void> {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
if (fs.existsSync(filePath)) {
return;
}
await sleep(5);
}
throw new Error(`timeout waiting for ${filePath}`);
}
// Pid files are written with plain writeFileSync, so an existence poll can
// observe the open-truncate 0-byte window and parse NaN (the #109140 flake
// class). Wait until the content parses to a real pid, not just for the file.
async function waitForPidFile(filePath: string, timeoutMs: number): Promise<number> {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
if (fs.existsSync(filePath)) {
const pid = Number.parseInt(fs.readFileSync(filePath, "utf8"), 10);
if (Number.isInteger(pid) && pid > 0) {
return pid;
}
}
await sleep(5);
}
throw new Error(`timed out waiting for pid in ${filePath}`);
}
async function waitForDead(pid: number, timeoutMs: number): Promise<void> {
const deadlineAt = Date.now() + timeoutMs;
while (Date.now() < deadlineAt) {
if (!isProcessAlive(pid)) {
return;
}
await sleep(5);
}
throw new Error(`process still alive: ${pid}`);
}
function waitForChildClose(
child: ReturnType<typeof spawn>,
timeoutMs = 5_000,
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("child did not close before timeout"));
}, timeoutMs);
child.once("close", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
afterEach(() => {
for (const rootDir of tempRoots) {
fs.rmSync(rootDir, { force: true, recursive: true });
}
tempRoots.clear();
});
describe("check-extension-package-tsc-boundary", () => {
it("keeps a bounded tail of captured step output", () => {
const first = appendBoundedStepOutput({ text: "", truncatedChars: 0 }, "abcdef", 5);
const second = appendBoundedStepOutput(first, "ghij", 5);
expect(first).toEqual({ text: "bcdef", truncatedChars: 1 });
expect(second).toEqual({ text: "fghij", truncatedChars: 5 });
});
it("removes stale canary artifacts across extensions", () => {
const { rootDir } = createTempExtensionRoot();
const { canaryPath, tsconfigPath } = writeCanaryArtifacts(rootDir);
cleanupCanaryArtifactsForExtensions(["demo"], rootDir);
expect(fs.existsSync(canaryPath)).toBe(false);
expect(fs.existsSync(tsconfigPath)).toBe(false);
});
it("cleans canary artifacts again on process exit", () => {
const { rootDir } = createTempExtensionRoot();
const { canaryPath, tsconfigPath } = writeCanaryArtifacts(rootDir);
const processObject = new EventEmitter();
const teardown = installCanaryArtifactCleanup(["demo"], { processObject, rootDir });
processObject.emit("exit");
teardown();
expect(fs.existsSync(canaryPath)).toBe(false);
expect(fs.existsSync(tsconfigPath)).toBe(false);
});
it("cleans stale artifacts for every extension id passed to the cleanup hook", () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-boundary-canary-"));
tempRoots.add(rootDir);
fs.mkdirSync(path.join(rootDir, "extensions", "demo-a"), { recursive: true });
fs.mkdirSync(path.join(rootDir, "extensions", "demo-b"), { recursive: true });
const demoA = writeCanaryArtifacts(rootDir, "demo-a");
const demoB = writeCanaryArtifacts(rootDir, "demo-b");
const processObject = new EventEmitter();
const teardown = installCanaryArtifactCleanup(["demo-a", "demo-b"], {
processObject,
rootDir,
});
processObject.emit("exit");
teardown();
expect(fs.existsSync(demoA.canaryPath)).toBe(false);
expect(fs.existsSync(demoA.tsconfigPath)).toBe(false);
expect(fs.existsSync(demoB.canaryPath)).toBe(false);
expect(fs.existsSync(demoB.tsconfigPath)).toBe(false);
});
it("parses extension boundary compile concurrency strictly", () => {
expect(resolveCompileConcurrency({ OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: "4" }, 32)).toBe(4);
expect(resolveCompileConcurrency({}, 12)).toBe(6);
expect(resolveCompileConcurrency({}, 3)).toBe(1);
for (const value of ["4x", "0", "1e3"]) {
expect(() =>
resolveCompileConcurrency({ OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: value }, 32),
).toThrow("OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY must be a positive integer");
}
});
it("blocks concurrent boundary checks in the same checkout", () => {
const { rootDir } = createTempExtensionRoot();
const processObject = new EventEmitter();
const release = acquireBoundaryCheckLock({ processObject, rootDir });
let thrownError = null;
try {
acquireBoundaryCheckLock({ rootDir });
} catch (error) {
thrownError = error;
}
expect(thrownError).toBeInstanceOf(Error);
if (!(thrownError instanceof Error)) {
throw new Error("expected boundary lock contention to throw an Error");
}
expect(thrownError.message).toContain("kind: lock-contention");
expect(thrownError.message).toContain(
"another extension package boundary check is already running",
);
expect((thrownError as { fullOutput?: unknown }).fullOutput).toContain(
"another extension package boundary check is already running",
);
expect((thrownError as { kind?: unknown }).kind).toBe("lock-contention");
release();
const lockPath = resolveBoundaryCheckLockPath(rootDir);
expect(fs.existsSync(lockPath)).toBe(false);
});
it("summarizes long failure output with the useful tail", () => {
const stdout = Array.from({ length: 45 }, (_, index) => `stdout ${index + 1}`).join("\n");
const stderr = Array.from({ length: 3 }, (_, index) => `stderr ${index + 1}`).join("\n");
const message = formatStepFailure("demo-plugin", {
stdout,
stderr,
kind: "timeout",
elapsedMs: 4_321,
note: "demo-plugin timed out after 5000ms",
});
const messageLines = message.split("\n");
expect(message).toContain("demo-plugin");
expect(message).toContain("[... 5 earlier lines omitted ...]");
expect(message).toContain("kind: timeout");
expect(message).toContain("elapsed: 4321ms");
expect(message).toContain("stdout 45");
expect(messageLines).not.toContain("stdout 1");
expect(message).toContain("stderr:\nstderr 1\nstderr 2\nstderr 3");
expect(message).toContain("demo-plugin timed out after 5000ms");
});
it("formats a success summary with counts and elapsed time", () => {
expect(
formatBoundaryCheckSuccessSummary({
mode: "all",
compileCount: 84,
skippedCompileCount: 13,
canaryCount: 12,
prepElapsedMs: 12_345,
compileElapsedMs: 54_321,
canaryElapsedMs: 6_789,
elapsedMs: 54_321,
}),
).toBe(
[
"extension package boundary check passed",
"mode: all",
"compiled plugins: 84",
"skipped plugins: 13",
"canary plugins: 12",
"prep elapsed: 12345ms",
"compile elapsed: 54321ms",
"canary elapsed: 6789ms",
"elapsed: 54321ms",
"",
].join("\n"),
);
});
it("omits phase timings that never ran", () => {
expect(
formatBoundaryCheckSuccessSummary({
mode: "compile",
compileCount: 97,
skippedCompileCount: 0,
canaryCount: 0,
prepElapsedMs: 12_345,
compileElapsedMs: 54_321,
canaryElapsedMs: 0,
elapsedMs: 66_666,
}),
).toBe(
[
"extension package boundary check passed",
"mode: compile",
"compiled plugins: 97",
"canary plugins: 0",
"prep elapsed: 12345ms",
"compile elapsed: 54321ms",
"elapsed: 66666ms",
"",
].join("\n"),
);
});
it("formats skipped compile progress concisely", () => {
expect(
formatSkippedCompileProgress({
skippedCount: 13,
totalCount: 97,
}),
).toBe("skipped 13 fresh plugin compiles before running 84 stale plugin checks\n");
expect(
formatSkippedCompileProgress({
skippedCount: 97,
totalCount: 97,
}),
).toBe("skipped 97 fresh plugin compiles\n");
});
it("formats the slowest plugin compiles in descending order", () => {
expect(
formatSlowCompileSummary({
compileTimings: [
{ extensionId: "quick", elapsedMs: 40 },
{ extensionId: "slow", elapsedMs: 900 },
{ extensionId: "medium", elapsedMs: 250 },
],
limit: 2,
}),
).toBe(["slowest plugin compiles:", "- slow: 900ms", "- medium: 250ms", ""].join("\n"));
});
it("treats a plugin compile as fresh only when its outputs are newer than plugin and shared sdk inputs", () => {
const { rootDir, extensionRoot } = createTempExtensionRoot();
const extensionSourcePath = path.join(extensionRoot, "index.ts");
const extensionTsconfigPath = path.join(extensionRoot, "tsconfig.json");
const stampPath = path.join(extensionRoot, "dist", ".boundary-tsc.stamp");
const rootSdkTypePath = path.join(rootDir, "dist", "plugin-sdk", "core.d.ts");
const packageSdkTypePath = path.join(
rootDir,
"packages",
"plugin-sdk",
"dist",
"src",
"plugin-sdk",
"core.d.ts",
);
fs.mkdirSync(path.dirname(extensionSourcePath), { recursive: true });
fs.mkdirSync(path.dirname(stampPath), { recursive: true });
fs.mkdirSync(path.dirname(rootSdkTypePath), { recursive: true });
fs.mkdirSync(path.dirname(packageSdkTypePath), { recursive: true });
fs.writeFileSync(extensionSourcePath, "export const demo = 1;\n", "utf8");
fs.writeFileSync(
extensionTsconfigPath,
'{ "extends": "../tsconfig.package-boundary.base.json" }\n',
"utf8",
);
fs.writeFileSync(stampPath, "ok\n", "utf8");
fs.writeFileSync(rootSdkTypePath, "export {};\n", "utf8");
fs.writeFileSync(packageSdkTypePath, "export {};\n", "utf8");
fs.utimesSync(extensionSourcePath, new Date(1_000), new Date(1_000));
fs.utimesSync(extensionTsconfigPath, new Date(1_000), new Date(1_000));
fs.utimesSync(rootSdkTypePath, new Date(500), new Date(500));
fs.utimesSync(packageSdkTypePath, new Date(2_000), new Date(2_000));
fs.utimesSync(stampPath, new Date(3_000), new Date(3_000));
expect(isBoundaryCompileFresh("demo", { rootDir })).toBe(true);
fs.utimesSync(rootSdkTypePath, new Date(500), new Date(500));
fs.utimesSync(packageSdkTypePath, new Date(500), new Date(500));
expect(isBoundaryCompileFresh("demo", { rootDir })).toBe(true);
fs.utimesSync(rootSdkTypePath, new Date(4_000), new Date(4_000));
expect(isBoundaryCompileFresh("demo", { rootDir })).toBe(false);
});
it("accepts cached input mtimes for freshness checks", () => {
const { rootDir, extensionRoot } = createTempExtensionRoot();
const extensionSourcePath = path.join(extensionRoot, "index.ts");
const stampPath = path.join(extensionRoot, "dist", ".boundary-tsc.stamp");
fs.mkdirSync(path.dirname(extensionSourcePath), { recursive: true });
fs.mkdirSync(path.dirname(stampPath), { recursive: true });
fs.writeFileSync(extensionSourcePath, "export const demo = 1;\n", "utf8");
fs.writeFileSync(stampPath, "ok\n", "utf8");
fs.utimesSync(extensionSourcePath, new Date(1_000), new Date(1_000));
fs.utimesSync(stampPath, new Date(3_000), new Date(3_000));
expect(
isBoundaryCompileFresh("demo", {
rootDir,
extensionNewestInputMtimeMs: 1_000,
sharedNewestInputMtimeMs: 2_000,
}),
).toBe(true);
expect(
isBoundaryCompileFresh("demo", {
rootDir,
extensionNewestInputMtimeMs: 1_000,
sharedNewestInputMtimeMs: 4_000,
}),
).toBe(false);
});
it("keeps full failure output on the thrown error for canary detection", async () => {
const failure = await runNodeStepAsync(
"demo-plugin",
[
"--eval",
[
"console.log('src/plugins/contracts/rootdir-boundary-canary.ts');",
"for (let index = 1; index <= 45; index += 1) console.log(`stdout ${index}`);",
"console.error('TS6059');",
"process.exit(2);",
].join(" "),
],
20_000,
).then(
() => {
throw new Error("expected demo-plugin step to fail");
},
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(Error);
if (!(failure instanceof Error)) {
throw new Error("expected failed canary step to reject with an Error");
}
expect(failure.message).toContain("[... 6 earlier lines omitted ...]");
const failureMetadata = failure as {
elapsedMs?: unknown;
fullOutput?: unknown;
kind?: unknown;
status?: unknown;
};
expect(failureMetadata.fullOutput).toContain(
"src/plugins/contracts/rootdir-boundary-canary.ts",
);
expect(failureMetadata.kind).toBe("nonzero-exit");
expect(failureMetadata.status).toBeUndefined();
const elapsedMs = failureMetadata.elapsedMs;
expect(typeof elapsedMs).toBe("number");
if (typeof elapsedMs !== "number") {
throw new Error("expected failure elapsedMs to be a number");
}
expect(elapsedMs).toBeGreaterThanOrEqual(0);
}, 30_000);
it("clamps oversized async node step timers before scheduling", async () => {
await expect(
runNodeStepAsync(
"slow-success",
["--eval", "setTimeout(() => process.exit(0), 25);"],
Number.MAX_SAFE_INTEGER,
),
).resolves.toMatchObject({
stderr: "",
stdout: "",
});
});
it("keeps async node step failure output bounded", async () => {
const child = new EventEmitter() as EventEmitter & {
kill: (signal?: NodeJS.Signals | number) => boolean;
stderr: ReturnType<typeof createMockPipe>;
stdout: ReturnType<typeof createMockPipe>;
};
child.stdout = createMockPipe();
child.stderr = createMockPipe();
child.kill = () => true;
const failure = await runNodeStepAsync("noisy-plugin", ["--eval", "process.exit(2)"], 20_000, {
spawnImpl() {
setImmediate(() => {
child.stdout.emit("data", `stdout-begin-${"x".repeat(300_000)}-stdout-end`);
child.stderr.emit("data", `stderr-begin-${"y".repeat(300_000)}-stderr-end`);
child.emit("close", 2);
});
return child;
},
}).then(
() => {
throw new Error("expected noisy-plugin step to fail");
},
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(Error);
if (!(failure instanceof Error)) {
throw new Error("expected failed noisy step to reject with an Error");
}
expect(failure.message).toContain("[output truncated");
expect(failure.message).toContain("stdout-end");
expect(failure.message).toContain("stderr-end");
expect(failure.message).not.toContain("stdout-begin");
expect(failure.message).not.toContain("stderr-begin");
const fullOutput = (failure as { fullOutput?: unknown }).fullOutput;
expect(typeof fullOutput).toBe("string");
if (typeof fullOutput !== "string") {
throw new Error("expected failure fullOutput to be a string");
}
expect(fullOutput.length).toBeLessThan(600_000);
}, 30_000);
it("hard-kills timed out async node steps", async () => {
const processSignals: Array<[number, NodeJS.Signals | number | undefined]> = [];
let processGroupAlive = true;
const child = new EventEmitter() as EventEmitter & {
kill: (signal?: NodeJS.Signals | number) => boolean;
pid: number;
stderr: ReturnType<typeof createMockPipe>;
stdout: ReturnType<typeof createMockPipe>;
};
child.pid = 1234;
child.stdout = createMockPipe();
child.stderr = createMockPipe();
child.kill = () => true;
const failure = await runNodeStepAsync(
"hung-plugin",
["--eval", "setTimeout(() => {}, 60_000)"],
5,
{
spawnImpl(command: string, args: string[]) {
expect(command).toBe(process.execPath);
expect(args).toEqual(["--eval", "setTimeout(() => {}, 60_000)"]);
return child;
},
killProcess(pid: number, signal?: NodeJS.Signals | number) {
if (signal === "SIGKILL") {
processGroupAlive = false;
}
if (signal === 0 && !processGroupAlive) {
processSignals.push([pid, signal]);
throw Object.assign(new Error("gone"), { code: "ESRCH" });
}
processSignals.push([pid, signal]);
return true;
},
platform: "darwin",
},
).then(
() => {
throw new Error("expected hung-plugin step to time out");
},
(error: unknown) => error,
);
expect(processSignals).toEqual([
[-1234, "SIGKILL"],
[-1234, 0],
]);
expect(failure).toBeInstanceOf(Error);
if (!(failure instanceof Error)) {
throw new Error("expected timeout failure to reject with an Error");
}
expect(failure.message).toContain("hung-plugin timed out after 5ms");
expect((failure as { kind?: unknown }).kind).toBe("timeout");
});
it.skipIf(process.platform === "win32")(
"waits for timed-out async node step process groups",
async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-extension-tsc-timeout-"));
tempRoots.add(root);
const childPidPath = path.join(root, "child.pid");
let childPid = 0;
const childScript = ["process.on('SIGTERM', () => {});", "setInterval(() => {}, 1000);"].join(
"",
);
const parentScript = [
"const { spawn } = require('node:child_process');",
"const fs = require('node:fs');",
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
`fs.writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`,
"setInterval(() => {}, 1000);",
].join("");
try {
const failurePromise = runNodeStepAsync("hung-step-group", ["--eval", parentScript], 100, {
spawnImpl(command: string, args: string[], options: unknown) {
return spawn(command, args, options as Parameters<typeof spawn>[2]);
},
}).then(
() => {
throw new Error("expected hung-step-group to time out");
},
(error: unknown) => error,
);
childPid = await waitForPidFile(childPidPath, 2_000);
expect(isProcessAlive(childPid)).toBe(true);
const failure = await failurePromise;
expect(failure).toBeInstanceOf(Error);
await waitForDead(childPid, 2_000);
} finally {
if (childPid && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
}
},
);
it("aborts concurrent sibling steps after the first failure", async () => {
const startedAt = Date.now();
const slowStepTimeoutMs = 60_000;
const abortBudgetMs = 30_000;
await expect(
runNodeStepsWithConcurrency(
[
{
label: "fail-fast",
args: ["--eval", "process.exit(2)"],
timeoutMs: slowStepTimeoutMs,
},
{
label: "slow-step",
args: ["--eval", "setTimeout(() => {}, 60_000)"],
timeoutMs: slowStepTimeoutMs,
},
],
2,
),
).rejects.toThrow("fail-fast");
expect(Date.now() - startedAt).toBeLessThan(abortBudgetMs);
}, 45_000);
it.skipIf(process.platform === "win32")(
"force-kills aborted async node step process groups",
async () => {
const { rootDir: root } = createTempExtensionRoot("abort-group");
const childPidPath = path.join(root, "child.pid");
const abortAckPath = path.join(root, "abort.ack");
let childPid = 0;
const childScript = ["process.on('SIGTERM', () => {});", "setInterval(() => {}, 1000);"].join(
"",
);
const parentScript = [
"const { spawn } = require('node:child_process');",
"const fs = require('node:fs');",
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
`fs.writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid));`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("");
// fail-fast exits only after the test writes abort.ack, which happens
// strictly after the child-alive assertion below. A time-based fuse here
// races that assertion: a descheduled worker can observe the abort chain
// already SIGKILLing the group. The step's 5s timeout bounds a wedged run.
const failAfterTestAckScript = [
"const fs = require('node:fs');",
`const ackPath = ${JSON.stringify(abortAckPath)};`,
"const wait = () => {",
" if (fs.existsSync(ackPath)) {",
" process.exit(2);",
" return;",
" }",
" setTimeout(wait, 10);",
"};",
"wait();",
].join("");
try {
const command = runNodeStepsWithConcurrency(
[
{
label: "fail-fast",
args: ["--eval", failAfterTestAckScript],
timeoutMs: 5_000,
},
{
label: "aborted-step-group",
args: ["--eval", parentScript],
timeoutMs: 60_000,
},
],
2,
);
childPid = await waitForPidFile(childPidPath, 2_000);
expect(isProcessAlive(childPid)).toBe(true);
fs.writeFileSync(abortAckPath, "go");
await expect(command).rejects.toThrow("fail-fast");
await waitForDead(childPid, 2_000);
} finally {
if (childPid && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
}
},
);
it.skipIf(process.platform === "win32")(
"cleans active async node step descendants before forwarding parent SIGTERM",
async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-extension-tsc-signal-"));
tempRoots.add(root);
const childPidPath = path.join(root, "child.pid");
const readyPath = path.join(root, "child.ready");
const scriptUrl = pathToFileURL(
path.resolve("scripts/check-extension-package-tsc-boundary.mjs"),
).href;
let childPid = 0;
let runner: ReturnType<typeof spawn> | undefined;
const childScript = [
"const fs = require('node:fs');",
"process.on('SIGTERM', () => {});",
// Write the pid atomically: writeFileSync makes the file visible at open() (0 bytes)
// before the content lands, so an existsSync-then-read poller can catch an empty file
// and parse NaN. Rename only publishes the path once the pid is fully written.
`const pidPath = ${JSON.stringify(childPidPath)};`,
"fs.writeFileSync(pidPath + '.tmp', String(process.pid));",
"fs.renameSync(pidPath + '.tmp', pidPath);",
"setInterval(() => {}, 1000);",
].join("");
const parentScript = [
"const { spawn } = require('node:child_process');",
`spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
`require('node:fs').writeFileSync(${JSON.stringify(readyPath)}, 'ready');`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("");
const runnerScript = [
`import { runNodeStepAsync } from ${JSON.stringify(scriptUrl)};`,
`await runNodeStepAsync('parent-signal-step-group', ['--eval', ${JSON.stringify(
parentScript,
)}], 60_000);`,
].join("\n");
try {
runner = spawn(process.execPath, ["--input-type=module", "-e", runnerScript], {
cwd: process.cwd(),
stdio: ["ignore", "ignore", "pipe"],
});
await waitForFile(readyPath, 2_000);
childPid = await waitForPidFile(childPidPath, 2_000);
expect(isProcessAlive(childPid)).toBe(true);
runner.kill("SIGTERM");
await expect(waitForChildClose(runner)).resolves.toEqual({
code: null,
signal: "SIGTERM",
});
await waitForDead(childPid, 2_000);
} finally {
if (runner?.pid && isProcessAlive(runner.pid)) {
runner.kill("SIGKILL");
}
if (childPid && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
}
},
);
it("passes successful step timing metadata to onSuccess handlers", async () => {
const elapsedTimes: number[] = [];
await runNodeStepsWithConcurrency(
[
{
label: "demo-step",
args: ["--eval", "process.exit(0)"],
timeoutMs: 20_000,
onSuccess(result: { elapsedMs: number }) {
elapsedTimes.push(result.elapsedMs);
},
},
],
1,
);
expect(elapsedTimes).toHaveLength(1);
expect(elapsedTimes[0]).toBeGreaterThanOrEqual(0);
}, 30_000);
});