Files
openclaw/src/entry.compile-cache.test.ts
Kevin Lin 592998ae0e fix: clean up orphaned child processes (#77481)
* fix: forward launcher respawn signals

* docs: explain respawn signal exit timer

* fix: centralize launcher respawn supervision

* fix: include respawn helper in duplicate scan

* fix: keep launcher respawn bridge local
2026-05-04 15:28:49 -07:00

234 lines
7.9 KiB
TypeScript

import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanupTempDirs, makeTempDir } from "../test/helpers/temp-dir.js";
import {
buildOpenClawCompileCacheRespawnPlan,
isSourceCheckoutInstallRoot,
resolveOpenClawCompileCacheDirectory,
resolveEntryInstallRoot,
runOpenClawCompileCacheRespawnPlan,
shouldEnableOpenClawCompileCache,
} from "./entry.compile-cache.js";
describe("entry compile cache", () => {
const tempDirs: string[] = [];
afterEach(() => {
cleanupTempDirs(tempDirs);
});
it("resolves install roots from source and dist entry paths", () => {
expect(resolveEntryInstallRoot("/repo/openclaw/src/entry.ts")).toBe("/repo/openclaw");
expect(resolveEntryInstallRoot("/repo/openclaw/dist/entry.js")).toBe("/repo/openclaw");
expect(resolveEntryInstallRoot("/pkg/openclaw/entry.js")).toBe("/pkg/openclaw");
});
it("treats git and source entry markers as source checkouts", async () => {
const root = makeTempDir(tempDirs, "openclaw-compile-cache-source-");
await fs.writeFile(path.join(root, ".git"), "gitdir: .git/worktrees/openclaw\n", "utf8");
expect(isSourceCheckoutInstallRoot(root)).toBe(true);
});
it("disables compile cache for source-checkout installs", async () => {
const root = makeTempDir(tempDirs, "openclaw-compile-cache-src-entry-");
await fs.mkdir(path.join(root, "src"), { recursive: true });
await fs.writeFile(path.join(root, "src", "entry.ts"), "export {};\n", "utf8");
expect(
shouldEnableOpenClawCompileCache({
env: {},
installRoot: root,
}),
).toBe(false);
});
it("keeps compile cache enabled for packaged installs unless disabled by env", () => {
const root = makeTempDir(tempDirs, "openclaw-compile-cache-package-");
expect(shouldEnableOpenClawCompileCache({ env: {}, installRoot: root })).toBe(true);
expect(
shouldEnableOpenClawCompileCache({
env: { NODE_DISABLE_COMPILE_CACHE: "1" },
installRoot: root,
}),
).toBe(false);
});
it("scopes packaged compile cache by package install metadata", async () => {
const root = makeTempDir(tempDirs, "openclaw-compile-cache-package-key-");
const packageJsonPath = path.join(root, "package.json");
await fs.writeFile(packageJsonPath, '{"version":"2026.4.29"}\n', "utf8");
const directory = resolveOpenClawCompileCacheDirectory({
env: { NODE_COMPILE_CACHE: path.join(root, ".node-cache") },
installRoot: root,
});
expect(directory).toContain(path.join(".node-cache", "openclaw"));
expect(directory).toContain("2026.4.29");
expect(path.basename(directory)).toMatch(/^\d+-\d+$/);
});
it("builds a one-shot no-cache respawn plan when source checkout inherits NODE_COMPILE_CACHE", async () => {
const root = makeTempDir(tempDirs, "openclaw-compile-cache-respawn-");
await fs.mkdir(path.join(root, "src"), { recursive: true });
await fs.writeFile(path.join(root, "src", "entry.ts"), "export {};\n", "utf8");
const plan = buildOpenClawCompileCacheRespawnPlan({
currentFile: path.join(root, "dist", "entry.js"),
env: { NODE_COMPILE_CACHE: "/tmp/openclaw-cache" },
execArgv: ["--no-warnings"],
execPath: "/usr/bin/node",
installRoot: root,
argv: ["/usr/bin/node", path.join(root, "dist", "entry.js"), "status", "--json"],
});
expect(plan).toEqual({
command: "/usr/bin/node",
args: ["--no-warnings", path.join(root, "dist", "entry.js"), "status", "--json"],
env: {
NODE_DISABLE_COMPILE_CACHE: "1",
OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED: "1",
},
});
});
it("does not respawn packaged installs when NODE_COMPILE_CACHE is configured", () => {
const root = makeTempDir(tempDirs, "openclaw-compile-cache-package-respawn-");
expect(
buildOpenClawCompileCacheRespawnPlan({
currentFile: path.join(root, "dist", "entry.js"),
env: { NODE_COMPILE_CACHE: "/tmp/openclaw-cache" },
installRoot: root,
}),
).toBeUndefined();
});
it("does not respawn source checkouts twice", async () => {
const root = makeTempDir(tempDirs, "openclaw-compile-cache-respawn-once-");
await fs.mkdir(path.join(root, "src"), { recursive: true });
await fs.writeFile(path.join(root, "src", "entry.ts"), "export {};\n", "utf8");
expect(
buildOpenClawCompileCacheRespawnPlan({
currentFile: path.join(root, "dist", "entry.js"),
env: {
NODE_COMPILE_CACHE: "/tmp/openclaw-cache",
OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED: "1",
},
installRoot: root,
}),
).toBeUndefined();
});
it("runs compile-cache respawn plans with the child-process bridge", () => {
const child = new EventEmitter() as ChildProcess;
const spawn = vi.fn(() => child);
const attachChildProcessBridge = vi.fn();
const exit = vi.fn();
const writeError = vi.fn();
runOpenClawCompileCacheRespawnPlan(
{
command: "/usr/bin/node",
args: ["/repo/openclaw/dist/entry.js", "status"],
env: { NODE_DISABLE_COMPILE_CACHE: "1" },
},
{
spawn: spawn as unknown as typeof import("node:child_process").spawn,
attachChildProcessBridge,
exit: exit as unknown as (code?: number) => never,
writeError,
},
);
expect(spawn).toHaveBeenCalledWith(
"/usr/bin/node",
["/repo/openclaw/dist/entry.js", "status"],
{
stdio: "inherit",
env: { NODE_DISABLE_COMPILE_CACHE: "1" },
},
);
expect(attachChildProcessBridge).toHaveBeenCalledWith(child, {
onSignal: expect.any(Function),
});
child.emit("exit", 0, null);
expect(exit).toHaveBeenCalledWith(0);
expect(writeError).not.toHaveBeenCalled();
});
it("marks signal-terminated compile-cache respawn children as failed without forcing another exit", () => {
const child = new EventEmitter() as ChildProcess;
const spawn = vi.fn(() => child);
const exit = vi.fn();
runOpenClawCompileCacheRespawnPlan(
{
command: "/usr/bin/node",
args: ["/repo/openclaw/dist/entry.js"],
env: {},
},
{
spawn: spawn as unknown as typeof import("node:child_process").spawn,
attachChildProcessBridge: vi.fn(),
exit: exit as unknown as (code?: number) => never,
writeError: vi.fn(),
},
);
child.emit("exit", null, "SIGTERM");
expect(exit).toHaveBeenCalledWith(1);
});
it("terminates before force-killing a signaled compile-cache respawn child", () => {
vi.useFakeTimers();
const child = new EventEmitter() as ChildProcess;
const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => true);
child.kill = kill as ChildProcess["kill"];
const spawn = vi.fn(() => child);
const exit = vi.fn();
let onSignal: ((signal: NodeJS.Signals) => void) | undefined;
try {
runOpenClawCompileCacheRespawnPlan(
{
command: "/usr/bin/node",
args: ["/repo/openclaw/dist/entry.js"],
env: {},
},
{
spawn: spawn as unknown as typeof import("node:child_process").spawn,
attachChildProcessBridge: vi.fn((_child, options) => {
onSignal = options?.onSignal;
return { detach: vi.fn() };
}),
exit: exit as unknown as (code?: number) => never,
writeError: vi.fn(),
},
);
onSignal?.("SIGTERM");
vi.advanceTimersByTime(1_000);
expect(kill).toHaveBeenCalledWith("SIGTERM");
expect(exit).not.toHaveBeenCalled();
vi.advanceTimersByTime(1_000);
expect(kill).toHaveBeenCalledWith(process.platform === "win32" ? "SIGTERM" : "SIGKILL");
expect(exit).toHaveBeenCalledWith(1);
} finally {
vi.useRealTimers();
}
});
});