import { sanitizeFsName } from "agent-relay-sdk/fs-name"; import { isPidAlive } from "agent-relay-sdk/process-utils"; import { execProcess } from "../process"; import { MANAGER_CMD_TIMEOUT_MS } from "../self-upgrade-guard"; export type SystemdUnitLiveness = "alive" | "dead" | "unknown"; export interface SystemdUnitDiagnostics { unit: string; loadState?: string; activeState?: string; subState?: string; result?: string; execMainCode?: string; execMainStatus?: string; mainPid?: number; unavailable?: string; } export function systemdUnitName(session: string): string { const safe = sanitizeFsName(session, { replacement: "-", trimEdge: true, fallback: "agent" }); return `agent-relay-managed-${safe}`.slice(0, 180); } const SYSTEMCTL_SHOW_PROPERTIES = ["LoadState", "ActiveState", "SubState", "Result", "ExecMainCode", "ExecMainStatus", "MainPID"]; // A transient unit systemd has already garbage-collected (or one that never existed) // reports LoadState=not-found alongside *default* values for every other property — // including Result=success and ExecMainStatus=0. Verified locally: `systemctl --user // show a-unit-that-never-existed.service -p ... ` exits 0 and prints exactly that. // Trusting those defaults is the root cause of #1317's false "success exit=0/0" // reports for units that actually died status=1 and were reaped before the // diagnostics query ran. Treat not-found as unavailable, never as success. export function parseSystemctlShowOutput(unit: string, stdout: string): SystemdUnitDiagnostics { const props = new Map(); for (const line of stdout.split("\n")) { const index = line.indexOf("="); if (index <= 0) continue; props.set(line.slice(0, index), line.slice(index + 1)); } const loadState = props.get("LoadState") || undefined; if (loadState === "not-found") { return { unit, loadState, unavailable: "systemd unit not found (already garbage-collected)" }; } const mainPid = Number(props.get("MainPID")); return { unit, loadState, activeState: props.get("ActiveState") || undefined, subState: props.get("SubState") || undefined, result: props.get("Result") || undefined, execMainCode: props.get("ExecMainCode") || undefined, execMainStatus: props.get("ExecMainStatus") || undefined, mainPid: Number.isFinite(mainPid) && mainPid > 0 ? mainPid : undefined, }; } export function systemdUnitDiagnostics(unit: string): SystemdUnitDiagnostics { const result = Bun.spawnSync([ "systemctl", "--user", "show", `${unit}.service`, ...SYSTEMCTL_SHOW_PROPERTIES.flatMap((prop) => ["-p", prop]), ], { stdin: "ignore", stdout: "pipe", stderr: "pipe", // #1509 r11 (Finding 6): hard-bounded — a hung systemctl must not wedge the event loop; a // timeout reports a nonzero exit, which flows into the existing `unavailable` (unknown) path. timeout: MANAGER_CMD_TIMEOUT_MS, killSignal: "SIGKILL", }); if (result.exitCode !== 0) { return { unit, unavailable: result.stderr.toString().trim() || `systemctl show exited with ${result.exitCode}`, }; } return parseSystemctlShowOutput(unit, result.stdout.toString()); } /** * #1509 r12 (Finding 4): hard-bounded like the sync variant — this runs on the spawn supervisor's * MainPID-wait and exit-diagnostics paths, where an UNBOUNDED hung systemctl defeated the nominal * 2s MainPID wait and hung the whole spawn workflow (execProcess without timeoutMs waits forever). * On overrun execProcess deadline-kills the process tree and reports ok:false, which flows into the * existing `unavailable` (unknown) path — fail closed, never a guess. `timeoutMs` is injectable so * tests can prove the deadline fires without waiting out the production bound. */ export async function systemdUnitDiagnosticsAsync(unit: string, timeoutMs: number = MANAGER_CMD_TIMEOUT_MS): Promise { const result = await execProcess([ "systemctl", "--user", "show", `${unit}.service`, ...SYSTEMCTL_SHOW_PROPERTIES.flatMap((prop) => ["-p", prop]), ], { timeoutMs, timeoutLabel: `systemctl show ${unit}.service` }); if (!result.ok) { return { unit, unavailable: result.stderr || `systemctl show exited with ${result.exitCode}`, }; } return parseSystemctlShowOutput(unit, result.stdout); } export function systemdUnitLivenessFromDiagnostics( diagnostics: SystemdUnitDiagnostics, isAlive: (pid: number) => boolean, // Last known main pid for this session (from our own spawn record), consulted only // when systemd's own record is unavailable (e.g. a `--collect`ed transient unit). // A direct /proc check on it is the only way to still tell "dead" from "unknown" // once systemd itself has forgotten the unit — same trick the non-systemd // supervisor already uses. We still never fabricate an exit status in that case. fallbackPid?: number, ): SystemdUnitLiveness { if (diagnostics.mainPid && isAlive(diagnostics.mainPid)) return "alive"; if (diagnostics.unavailable) { if (fallbackPid && !isAlive(fallbackPid)) return "dead"; return "unknown"; } const activeState = diagnostics.activeState?.toLowerCase(); const subState = diagnostics.subState?.toLowerCase(); if (activeState === "inactive" || activeState === "failed" || subState === "dead" || subState === "failed") { return "dead"; } if (activeState === "active" || activeState === "activating" || activeState === "reloading" || activeState === "deactivating") { return "unknown"; } return "unknown"; } export function systemdUnitLiveness(unit: string, fallbackPid?: number): SystemdUnitLiveness { return systemdUnitLivenessFromDiagnostics(systemdUnitDiagnostics(unit), isPidAlive, fallbackPid); } export function systemdMainPid(unit: string): number { return systemdUnitDiagnostics(unit).mainPid ?? 0; } export async function systemdMainPidAsync(unit: string): Promise { return (await systemdUnitDiagnosticsAsync(unit)).mainPid ?? 0; }