mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-30 16:00:21 +00:00
chore(checks): serialize local heavy gates
This commit is contained in:
218
scripts/lib/local-heavy-check-runtime.mjs
Normal file
218
scripts/lib/local-heavy-check-runtime.mjs
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
91
test/scripts/local-heavy-check-runtime.test.ts
Normal file
91
test/scripts/local-heavy-check-runtime.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user