import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs"; import { basename, join } from "node:path"; import { tmuxCommand } from "agent-relay-sdk/tmux-utils"; const SOCKET_PREFIX = "agent-relay-"; // #1760 — default hard bound for the per-socket liveness probe when a caller passes none. Mirrors // tmuxSocketSweepProbeTimeoutMs()'s default; kept as a module constant so the sweeper stays // dependency-free of config.ts (it is imported directly by tests and by the SDK-adjacent code). export const DEFAULT_TMUX_SOCKET_PROBE_TIMEOUT_MS = 2_000; // "live" — a tmux server answered `list-sessions` (exit 0). Keep the socket. // "dead" — tmux exited non-zero ("no server running …"): a stale socket. Remove it. // "unknown" — the probe did NOT exit on its own (killed at the timeout / by a signal): the socket is // present but unresponsive — a WEDGED server. Keep it (never yank a possibly-live // server's socket) and, crucially, never block the event loop past the bound. (#1760) export type TmuxSocketLiveness = "live" | "dead" | "unknown"; export type TmuxSocketProbe = (socketName: string, timeoutMs: number, env?: NodeJS.ProcessEnv) => TmuxSocketLiveness; interface TmuxSocketSweepResult { dir: string; scanned: number; removed: number; kept: number; // #1760 — sockets whose liveness probe hit the timeout (a wedged/unresponsive tmux server). These // are counted within `kept` and surfaced separately so a hung socket is visible in the sweep log // instead of silently inflating `kept`. timedOut: number; failed: Array<{ socket: string; error: string }>; } export function tmuxSocketDir(env: NodeJS.ProcessEnv = process.env): string { const uid = typeof process.getuid === "function" ? process.getuid() : 0; const tmuxTmp = env.TMUX_TMPDIR?.trim(); if (!tmuxTmp) return `/tmp/tmux-${uid}`; return basename(tmuxTmp) === `tmux-${uid}` ? tmuxTmp : join(tmuxTmp, `tmux-${uid}`); } export function sweepStaleTmuxSockets( input: { dir?: string; env?: NodeJS.ProcessEnv; probeTimeoutMs?: number; probe?: TmuxSocketProbe } = {}, ): TmuxSocketSweepResult { const dir = input.dir ?? tmuxSocketDir(input.env); const probeTimeoutMs = input.probeTimeoutMs ?? DEFAULT_TMUX_SOCKET_PROBE_TIMEOUT_MS; const probe = input.probe ?? tmuxSocketServerState; const result: TmuxSocketSweepResult = { dir, scanned: 0, removed: 0, kept: 0, timedOut: 0, failed: [] }; if (!existsSync(dir)) return result; for (const entry of readdirSync(dir)) { if (!entry.startsWith(SOCKET_PREFIX)) continue; const path = join(dir, entry); let removable = false; try { const stat = lstatSync(path); removable = stat.isSocket() || stat.isFile(); } catch (error) { result.failed.push({ socket: entry, error: String(error) }); continue; } if (!removable) continue; result.scanned += 1; const liveness = probe(entry, probeTimeoutMs, input.env); if (liveness !== "dead") { // "live" (a server answered) OR "unknown" (the probe hit the timeout against a wedged socket) — // either way KEEP the socket. Removing a wedged-but-present server's socket is destructive, and // we must never block the event loop waiting to be certain it is dead. (#1760) result.kept += 1; if (liveness === "unknown") result.timedOut += 1; continue; } try { rmSync(path, { force: true }); result.removed += 1; } catch (error) { result.failed.push({ socket: entry, error: String(error) }); } } return result; } export function tmuxSocketServerState(socketName: string, timeoutMs: number, env?: NodeJS.ProcessEnv): TmuxSocketLiveness { const result = Bun.spawnSync(tmuxCommand(socketName, "list-sessions"), { stdin: "ignore", stdout: "ignore", stderr: "ignore", // #1760 — the load-bearing bound: without it a wedged server's socket blocks this synchronous // call (and thus the whole event loop) indefinitely. ...(timeoutMs > 0 ? { timeout: timeoutMs } : {}), // Force-kill on timeout with SIGKILL, NOT the default SIGTERM: the `tmux list-sessions` CLIENT // traps SIGTERM and exits cleanly (observed exitCode 0 — or, on some builds, 1), which would // misread a wedged server as "live" or, worse, "dead" (→ its socket removed). SIGKILL is // uncatchable, so a timed-out probe reliably reports exitCode === null → "unknown". This only // kills the short-lived probe client; the tmux SERVER is never signalled. killSignal: "SIGKILL", ...(env ? { env } : {}), }); if (result.exitCode === 0) return "live"; // Killed at the timeout (SIGKILL) → null exit code: the socket is present but unresponsive. Treat // as unknown (keep it), never as dead — a wedged-but-live server must not lose its socket. if (result.exitCode === null) return "unknown"; return "dead"; }