Files
clawdbot/src/infra/restart.ts
2026-01-19 00:15:45 +00:00

207 lines
5.9 KiB
TypeScript

import { spawnSync } from "node:child_process";
import {
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
} from "../daemon/constants.js";
export type RestartAttempt = {
ok: boolean;
method: "launchctl" | "systemd" | "supervisor";
detail?: string;
tried?: string[];
};
const SPAWN_TIMEOUT_MS = 2000;
const SIGUSR1_AUTH_GRACE_MS = 5000;
let sigusr1AuthorizedCount = 0;
let sigusr1AuthorizedUntil = 0;
let sigusr1ExternalAllowed = false;
function resetSigusr1AuthorizationIfExpired(now = Date.now()) {
if (sigusr1AuthorizedCount <= 0) return;
if (now <= sigusr1AuthorizedUntil) return;
sigusr1AuthorizedCount = 0;
sigusr1AuthorizedUntil = 0;
}
export function setGatewaySigusr1RestartPolicy(opts?: { allowExternal?: boolean }) {
sigusr1ExternalAllowed = opts?.allowExternal === true;
}
export function isGatewaySigusr1RestartExternallyAllowed() {
return sigusr1ExternalAllowed;
}
export function authorizeGatewaySigusr1Restart(delayMs = 0) {
const delay = Math.max(0, Math.floor(delayMs));
const expiresAt = Date.now() + delay + SIGUSR1_AUTH_GRACE_MS;
sigusr1AuthorizedCount += 1;
if (expiresAt > sigusr1AuthorizedUntil) {
sigusr1AuthorizedUntil = expiresAt;
}
}
export function consumeGatewaySigusr1RestartAuthorization(): boolean {
resetSigusr1AuthorizationIfExpired();
if (sigusr1AuthorizedCount <= 0) return false;
sigusr1AuthorizedCount -= 1;
if (sigusr1AuthorizedCount <= 0) {
sigusr1AuthorizedUntil = 0;
}
return true;
}
function formatSpawnDetail(result: {
error?: unknown;
status?: number | null;
stdout?: string | Buffer | null;
stderr?: string | Buffer | null;
}): string {
const clean = (value: string | Buffer | null | undefined) => {
const text = typeof value === "string" ? value : value ? value.toString() : "";
return text.replace(/\s+/g, " ").trim();
};
if (result.error) {
if (result.error instanceof Error) return result.error.message;
if (typeof result.error === "string") return result.error;
try {
return JSON.stringify(result.error);
} catch {
return "unknown error";
}
}
const stderr = clean(result.stderr);
if (stderr) return stderr;
const stdout = clean(result.stdout);
if (stdout) return stdout;
if (typeof result.status === "number") return `exit ${result.status}`;
return "unknown error";
}
function normalizeSystemdUnit(raw?: string, profile?: string): string {
const unit = raw?.trim();
if (!unit) {
return `${resolveGatewaySystemdServiceName(profile)}.service`;
}
return unit.endsWith(".service") ? unit : `${unit}.service`;
}
export function triggerClawdbotRestart(): RestartAttempt {
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return { ok: true, method: "supervisor", detail: "test mode" };
}
const tried: string[] = [];
if (process.platform !== "darwin") {
if (process.platform === "linux") {
const unit = normalizeSystemdUnit(
process.env.CLAWDBOT_SYSTEMD_UNIT,
process.env.CLAWDBOT_PROFILE,
);
const userArgs = ["--user", "restart", unit];
tried.push(`systemctl ${userArgs.join(" ")}`);
const userRestart = spawnSync("systemctl", userArgs, {
encoding: "utf8",
timeout: SPAWN_TIMEOUT_MS,
});
if (!userRestart.error && userRestart.status === 0) {
return { ok: true, method: "systemd", tried };
}
const systemArgs = ["restart", unit];
tried.push(`systemctl ${systemArgs.join(" ")}`);
const systemRestart = spawnSync("systemctl", systemArgs, {
encoding: "utf8",
timeout: SPAWN_TIMEOUT_MS,
});
if (!systemRestart.error && systemRestart.status === 0) {
return { ok: true, method: "systemd", tried };
}
const detail = [
`user: ${formatSpawnDetail(userRestart)}`,
`system: ${formatSpawnDetail(systemRestart)}`,
].join("; ");
return { ok: false, method: "systemd", detail, tried };
}
return {
ok: false,
method: "supervisor",
detail: "unsupported platform restart",
};
}
const label =
process.env.CLAWDBOT_LAUNCHD_LABEL ||
resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE);
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
const target = uid !== undefined ? `gui/${uid}/${label}` : label;
const args = ["kickstart", "-k", target];
tried.push(`launchctl ${args.join(" ")}`);
const res = spawnSync("launchctl", args, {
encoding: "utf8",
timeout: SPAWN_TIMEOUT_MS,
});
if (!res.error && res.status === 0) {
return { ok: true, method: "launchctl", tried };
}
return {
ok: false,
method: "launchctl",
detail: formatSpawnDetail(res),
tried,
};
}
export type ScheduledRestart = {
ok: boolean;
pid: number;
signal: "SIGUSR1";
delayMs: number;
reason?: string;
mode: "emit" | "signal";
};
export function scheduleGatewaySigusr1Restart(opts?: {
delayMs?: number;
reason?: string;
}): ScheduledRestart {
const delayMsRaw =
typeof opts?.delayMs === "number" && Number.isFinite(opts.delayMs)
? Math.floor(opts.delayMs)
: 2000;
const delayMs = Math.min(Math.max(delayMsRaw, 0), 60_000);
const reason =
typeof opts?.reason === "string" && opts.reason.trim()
? opts.reason.trim().slice(0, 200)
: undefined;
authorizeGatewaySigusr1Restart(delayMs);
const pid = process.pid;
const hasListener = process.listenerCount("SIGUSR1") > 0;
setTimeout(() => {
try {
if (hasListener) {
process.emit("SIGUSR1");
} else {
process.kill(pid, "SIGUSR1");
}
} catch {
/* ignore */
}
}, delayMs);
return {
ok: true,
pid,
signal: "SIGUSR1",
delayMs,
reason,
mode: hasListener ? "emit" : "signal",
};
}
export const __testing = {
resetSigusr1State() {
sigusr1AuthorizedCount = 0;
sigusr1AuthorizedUntil = 0;
sigusr1ExternalAllowed = false;
},
};