chore(checks): serialize local heavy gates

This commit is contained in:
Vincent Koc
2026-04-03 01:45:50 +09:00
parent 96b55821bc
commit 47f5d72931
4 changed files with 353 additions and 61 deletions

View File

@@ -0,0 +1,218 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const DEFAULT_LOCAL_GO_GC = "30";
const DEFAULT_LOCAL_GO_MEMORY_LIMIT = "3GiB";
const DEFAULT_LOCK_TIMEOUT_MS = 10 * 60 * 1000;
const DEFAULT_LOCK_POLL_MS = 500;
const DEFAULT_STALE_LOCK_MS = 30 * 1000;
const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
export function isLocalCheckEnabled(env) {
const raw = env.OPENCLAW_LOCAL_CHECK?.trim().toLowerCase();
return raw !== "0" && raw !== "false";
}
export function hasFlag(args, name) {
return args.some((arg) => arg === name || arg.startsWith(`${name}=`));
}
export function applyLocalTsgoPolicy(args, env) {
const nextEnv = { ...env };
const nextArgs = [...args];
if (!isLocalCheckEnabled(nextEnv)) {
return { env: nextEnv, args: nextArgs };
}
insertBeforeSeparator(nextArgs, "--singleThreaded");
insertBeforeSeparator(nextArgs, "--checkers", "1");
if (!nextEnv.GOGC) {
nextEnv.GOGC = DEFAULT_LOCAL_GO_GC;
}
if (!nextEnv.GOMEMLIMIT) {
nextEnv.GOMEMLIMIT = DEFAULT_LOCAL_GO_MEMORY_LIMIT;
}
if (nextEnv.OPENCLAW_TSGO_PPROF_DIR && !hasFlag(nextArgs, "--pprofDir")) {
insertBeforeSeparator(nextArgs, "--pprofDir", nextEnv.OPENCLAW_TSGO_PPROF_DIR);
}
return { env: nextEnv, args: nextArgs };
}
export function applyLocalOxlintPolicy(args, env) {
const nextEnv = { ...env };
const nextArgs = [...args];
insertBeforeSeparator(nextArgs, "--type-aware");
insertBeforeSeparator(nextArgs, "--tsconfig", "tsconfig.oxlint.json");
if (isLocalCheckEnabled(nextEnv)) {
insertBeforeSeparator(nextArgs, "--threads=1");
}
return { env: nextEnv, args: nextArgs };
}
export function acquireLocalHeavyCheckLockSync(params) {
const env = params.env ?? process.env;
if (!isLocalCheckEnabled(env)) {
return () => {};
}
const commonDir = resolveGitCommonDir(params.cwd);
const locksDir = path.join(commonDir, "openclaw-local-checks");
const lockDir = path.join(locksDir, `${params.lockName ?? "heavy-check"}.lock`);
const ownerPath = path.join(lockDir, "owner.json");
const timeoutMs = readPositiveInt(
env.OPENCLAW_HEAVY_CHECK_LOCK_TIMEOUT_MS,
DEFAULT_LOCK_TIMEOUT_MS,
);
const pollMs = readPositiveInt(env.OPENCLAW_HEAVY_CHECK_LOCK_POLL_MS, DEFAULT_LOCK_POLL_MS);
const staleLockMs = readPositiveInt(
env.OPENCLAW_HEAVY_CHECK_STALE_LOCK_MS,
DEFAULT_STALE_LOCK_MS,
);
const startedAt = Date.now();
let waitingLogged = false;
fs.mkdirSync(locksDir, { recursive: true });
for (;;) {
try {
fs.mkdirSync(lockDir);
writeOwnerFile(ownerPath, {
pid: process.pid,
tool: params.toolName,
cwd: params.cwd,
hostname: os.hostname(),
createdAt: new Date().toISOString(),
});
return () => {
fs.rmSync(lockDir, { recursive: true, force: true });
};
} catch (error) {
if (!isAlreadyExistsError(error)) {
throw error;
}
const owner = readOwnerFile(ownerPath);
if (shouldReclaimLock({ owner, lockDir, staleLockMs })) {
fs.rmSync(lockDir, { recursive: true, force: true });
continue;
}
const elapsedMs = Date.now() - startedAt;
if (elapsedMs >= timeoutMs) {
const ownerLabel = describeOwner(owner);
throw new Error(
`[${params.toolName}] timed out waiting for the local heavy-check lock at ${lockDir}${
ownerLabel ? ` (${ownerLabel})` : ""
}. If no local heavy checks are still running, remove the stale lock and retry.`,
{ cause: error },
);
}
if (!waitingLogged) {
const ownerLabel = describeOwner(owner);
console.error(
`[${params.toolName}] waiting for the local heavy-check lock${
ownerLabel ? ` held by ${ownerLabel}` : ""
}...`,
);
waitingLogged = true;
}
sleepSync(pollMs);
}
}
}
export function resolveGitCommonDir(cwd) {
const result = spawnSync("git", ["rev-parse", "--git-common-dir"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status === 0) {
const raw = result.stdout.trim();
if (raw.length > 0) {
return path.resolve(cwd, raw);
}
}
return path.join(cwd, ".git");
}
function insertBeforeSeparator(args, ...items) {
if (items.length > 0 && hasFlag(args, items[0])) {
return;
}
const separatorIndex = args.indexOf("--");
const insertIndex = separatorIndex === -1 ? args.length : separatorIndex;
args.splice(insertIndex, 0, ...items);
}
function readPositiveInt(rawValue, fallback) {
const parsed = Number.parseInt(rawValue ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function writeOwnerFile(ownerPath, owner) {
fs.writeFileSync(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, "utf8");
}
function readOwnerFile(ownerPath) {
try {
return JSON.parse(fs.readFileSync(ownerPath, "utf8"));
} catch {
return null;
}
}
function isAlreadyExistsError(error) {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
}
function shouldReclaimLock({ owner, lockDir, staleLockMs }) {
if (owner && typeof owner.pid === "number") {
return !isProcessAlive(owner.pid);
}
try {
const stats = fs.statSync(lockDir);
return Date.now() - stats.mtimeMs >= staleLockMs;
} catch {
return true;
}
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "EPERM");
}
}
function describeOwner(owner) {
if (!owner || typeof owner !== "object") {
return "";
}
const tool = typeof owner.tool === "string" ? owner.tool : "unknown-tool";
const pid = typeof owner.pid === "number" ? `pid ${owner.pid}` : "unknown pid";
const cwd = typeof owner.cwd === "string" ? owner.cwd : "unknown cwd";
return `${tool}, ${pid}, cwd ${cwd}`;
}
function sleepSync(ms) {
Atomics.wait(SLEEP_BUFFER, 0, 0, ms);
}

View File

@@ -1,42 +1,31 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import {
acquireLocalHeavyCheckLockSync,
applyLocalOxlintPolicy,
} from "./lib/local-heavy-check-runtime.mjs";
const isLocalCheckEnabled = (env) => {
const raw = env.OPENCLAW_LOCAL_CHECK?.trim().toLowerCase();
return raw !== "0" && raw !== "false";
};
const hasFlag = (args, name) => args.some((arg) => arg === name || arg.startsWith(`${name}=`));
const args = process.argv.slice(2);
const env = { ...process.env };
const finalArgs = [...args];
const separatorIndex = finalArgs.indexOf("--");
const insertBeforeSeparator = (...items) => {
const index = separatorIndex === -1 ? finalArgs.length : separatorIndex;
finalArgs.splice(index, 0, ...items);
};
if (!hasFlag(finalArgs, "--type-aware")) {
insertBeforeSeparator("--type-aware");
}
if (!hasFlag(finalArgs, "--tsconfig")) {
insertBeforeSeparator("--tsconfig", "tsconfig.oxlint.json");
}
if (isLocalCheckEnabled(env) && !hasFlag(finalArgs, "--threads")) {
insertBeforeSeparator("--threads=1");
}
const { args: finalArgs, env } = applyLocalOxlintPolicy(process.argv.slice(2), process.env);
const oxlintPath = path.resolve("node_modules", ".bin", "oxlint");
const result = spawnSync(oxlintPath, finalArgs, {
stdio: "inherit",
const releaseLock = acquireLocalHeavyCheckLockSync({
cwd: process.cwd(),
env,
shell: process.platform === "win32",
toolName: "oxlint",
});
if (result.error) {
throw result.error;
}
try {
const result = spawnSync(oxlintPath, finalArgs, {
stdio: "inherit",
env,
shell: process.platform === "win32",
});
process.exit(result.status ?? 1);
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 1);
} finally {
releaseLock();
}

View File

@@ -1,37 +1,31 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import {
acquireLocalHeavyCheckLockSync,
applyLocalTsgoPolicy,
} from "./lib/local-heavy-check-runtime.mjs";
const isLocalCheckEnabled = (env) => {
const raw = env.OPENCLAW_LOCAL_CHECK?.trim().toLowerCase();
return raw !== "0" && raw !== "false";
};
const args = process.argv.slice(2);
const env = { ...process.env };
const finalArgs = [...args];
const separatorIndex = finalArgs.indexOf("--");
const insertBeforeSeparator = (...items) => {
const index = separatorIndex === -1 ? finalArgs.length : separatorIndex;
finalArgs.splice(index, 0, ...items);
};
if (isLocalCheckEnabled(env) && !finalArgs.includes("--singleThreaded")) {
insertBeforeSeparator("--singleThreaded");
if (!env.GOGC) {
env.GOGC = "30";
}
}
const { args: finalArgs, env } = applyLocalTsgoPolicy(process.argv.slice(2), process.env);
const tsgoPath = path.resolve("node_modules", ".bin", "tsgo");
const result = spawnSync(tsgoPath, finalArgs, {
stdio: "inherit",
const releaseLock = acquireLocalHeavyCheckLockSync({
cwd: process.cwd(),
env,
shell: process.platform === "win32",
toolName: "tsgo",
});
if (result.error) {
throw result.error;
}
try {
const result = spawnSync(tsgoPath, finalArgs, {
stdio: "inherit",
env,
shell: process.platform === "win32",
});
process.exit(result.status ?? 1);
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 1);
} finally {
releaseLock();
}

View File

@@ -0,0 +1,91 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
acquireLocalHeavyCheckLockSync,
applyLocalOxlintPolicy,
applyLocalTsgoPolicy,
} from "../../scripts/lib/local-heavy-check-runtime.mjs";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function makeTempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-heavy-check-"));
tempDirs.push(dir);
return dir;
}
function makeEnv(overrides: Record<string, string | undefined> = {}) {
return {
...process.env,
OPENCLAW_LOCAL_CHECK: "1",
...overrides,
};
}
describe("local-heavy-check-runtime", () => {
it("tightens local tsgo runs to a single checker with a Go memory limit", () => {
const { args, env } = applyLocalTsgoPolicy([], makeEnv());
expect(args).toEqual(["--singleThreaded", "--checkers", "1"]);
expect(env.GOGC).toBe("30");
expect(env.GOMEMLIMIT).toBe("3GiB");
});
it("keeps explicit tsgo flags and Go env overrides intact", () => {
const { args, env } = applyLocalTsgoPolicy(
["--checkers", "4", "--singleThreaded", "--pprofDir", "/tmp/existing"],
makeEnv({
GOGC: "80",
GOMEMLIMIT: "5GiB",
OPENCLAW_TSGO_PPROF_DIR: "/tmp/profile",
}),
);
expect(args).toEqual(["--checkers", "4", "--singleThreaded", "--pprofDir", "/tmp/existing"]);
expect(env.GOGC).toBe("80");
expect(env.GOMEMLIMIT).toBe("5GiB");
});
it("serializes local oxlint runs onto one thread", () => {
const { args } = applyLocalOxlintPolicy([], makeEnv());
expect(args).toEqual(["--type-aware", "--tsconfig", "tsconfig.oxlint.json", "--threads=1"]);
});
it("reclaims stale local heavy-check locks from dead pids", () => {
const cwd = makeTempDir();
const commonDir = path.join(cwd, ".git");
const lockDir = path.join(commonDir, "openclaw-local-checks", "heavy-check.lock");
fs.mkdirSync(lockDir, { recursive: true });
fs.writeFileSync(
path.join(lockDir, "owner.json"),
`${JSON.stringify({
pid: 999_999_999,
tool: "tsgo",
cwd,
})}\n`,
"utf8",
);
const release = acquireLocalHeavyCheckLockSync({
cwd,
env: makeEnv(),
toolName: "oxlint",
});
const owner = JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8"));
expect(owner.pid).toBe(process.pid);
expect(owner.tool).toBe("oxlint");
release();
expect(fs.existsSync(lockDir)).toBe(false);
});
});