import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs"; import { runCommand } from "./command"; import { repoRoot } from "./config"; const defaultSwapPath = "/swapfile"; const defaultSwapSizeBytes = 2 * 1024 * 1024 * 1024; export interface SwapArea { filename: string; type: string; sizeBytes: number; usedBytes: number; priority: number | null; } export interface SwapMemoryStatus { totalBytes: number; availableBytes: number | null; swapTotalBytes: number; swapFreeBytes: number; } export interface SwapStatus { memory: SwapMemoryStatus; activeSwaps: SwapArea[]; configuredPath: string; configuredPathExists: boolean; configuredPathMode: string | null; configuredPathSizeBytes: number | null; configuredPathActive: boolean; fstab: { path: string; writable: boolean; persisted: boolean; matchingLine: string | null; error: string | null; }; warning: string | null; } export interface SwapEnsureResult { ok: boolean; status: "ok" | "degraded" | "failed"; requested: { path: string; sizeBytes: number; }; before: SwapStatus; after: SwapStatus; actions: Array<{ action: string; ok: boolean; detail?: unknown }>; errors: Array<{ action: string; message: string; detail?: unknown }>; } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } function parseByteCount(value: string): number { const raw = value.trim(); if (/^\d+$/u.test(raw)) return Number(raw); const match = raw.match(/^([0-9]+(?:\.[0-9]+)?)([KMGTPE]?i?B?)$/iu); if (!match) return 0; const amount = Number(match[1]); const unit = match[2].toUpperCase(); const powers: Record = { K: 1, KB: 1, KIB: 1, M: 2, MB: 2, MIB: 2, G: 3, GB: 3, GIB: 3, T: 4, TB: 4, TIB: 4, P: 5, PB: 5, PIB: 5, E: 6, EB: 6, EIB: 6, }; return Math.round(amount * (1024 ** (powers[unit] ?? 0))); } function parseMeminfo(): SwapMemoryStatus { const raw = readFileSync("/proc/meminfo", "utf8"); const values = new Map(); for (const line of raw.split("\n")) { const match = line.match(/^([^:]+):\s+(\d+)\s+kB/u); if (match) values.set(match[1], Number(match[2]) * 1024); } return { totalBytes: values.get("MemTotal") ?? 0, availableBytes: values.get("MemAvailable") ?? null, swapTotalBytes: values.get("SwapTotal") ?? 0, swapFreeBytes: values.get("SwapFree") ?? 0, }; } function parseSwaps(): SwapArea[] { if (!existsSync("/proc/swaps")) return []; const lines = readFileSync("/proc/swaps", "utf8").trim().split("\n").slice(1); return lines.map((line) => line.trim().split(/\s+/u)).filter((parts) => parts.length >= 5).map(([filename, type, sizeKiB, usedKiB, priority]) => ({ filename, type, sizeBytes: Number(sizeKiB) * 1024, usedBytes: Number(usedKiB) * 1024, priority: Number.isFinite(Number(priority)) ? Number(priority) : null, })); } function fileMode(path: string): string | null { if (!existsSync(path)) return null; return (statSync(path).mode & 0o777).toString(8).padStart(3, "0"); } function fstabStatus(path: string): SwapStatus["fstab"] { const fstabPath = "/etc/fstab"; try { const raw = existsSync(fstabPath) ? readFileSync(fstabPath, "utf8") : ""; let writable = false; try { accessSync(fstabPath, constants.W_OK); writable = true; } catch { writable = false; } const matchingLine = raw.split("\n").find((line) => { const trimmed = line.trim(); if (trimmed.length === 0 || trimmed.startsWith("#")) return false; const parts = trimmed.split(/\s+/u); return parts[0] === path && parts[2] === "swap"; }) ?? null; return { path: fstabPath, writable, persisted: matchingLine !== null, matchingLine, error: null, }; } catch (error) { return { path: fstabPath, writable: false, persisted: false, matchingLine: null, error: error instanceof Error ? error.message : String(error), }; } } export function swapStatus(path = defaultSwapPath): SwapStatus { const memory = parseMeminfo(); const activeSwaps = parseSwaps(); const configuredPathExists = existsSync(path); const configuredPathSizeBytes = configuredPathExists ? statSync(path).size : null; const configuredPathActive = activeSwaps.some((swap) => swap.filename === path); const warning = memory.swapTotalBytes > 0 ? null : "swap is not active; low-memory main servers are at risk of global OOM during builds or diagnostics"; return { memory, activeSwaps, configuredPath: path, configuredPathExists, configuredPathMode: fileMode(path), configuredPathSizeBytes, configuredPathActive, fstab: fstabStatus(path), warning, }; } function pushAction( actions: SwapEnsureResult["actions"], errors: SwapEnsureResult["errors"], action: string, command: string[], ): boolean { const result = runCommand(command, repoRoot, { timeoutMs: 120_000 }); const ok = result.exitCode === 0; const detail = { command, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200), timedOut: result.timedOut, }; actions.push({ action, ok, detail }); if (!ok) { errors.push({ action, message: result.stderr.trim() || result.stdout.trim() || `command failed with exit code ${result.exitCode}`, detail, }); } return ok; } function ensureFstabLine(path: string): { ok: boolean; action: string; detail: unknown } { const line = `${path} none swap sw 0 0`; const script = [ "set -euo pipefail", "touch /etc/fstab", `grep -Eq '^${path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[[:space:]]+[^[:space:]]+[[:space:]]+swap[[:space:]]' /etc/fstab || printf '%s\\n' ${shellQuote(line)} >> /etc/fstab`, ].join("\n"); const result = runCommand(["bash", "-lc", script], repoRoot, { timeoutMs: 30_000 }); return { ok: result.exitCode === 0, action: "persist-fstab", detail: { command: ["bash", "-lc", script], exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200), timedOut: result.timedOut, }, }; } function parseSizeOption(args: string[], defaultBytes: number): number { const index = args.indexOf("--size"); const raw = index === -1 ? undefined : args[index + 1]; if (raw === undefined) return defaultBytes; const bytes = parseByteCount(raw); if (!Number.isFinite(bytes) || bytes <= 0) throw new Error("--size must be a positive byte count such as 2GiB or 4096M"); return bytes; } function parsePathOption(args: string[], defaultPath: string): string { const index = args.indexOf("--path"); if (index === -1) return defaultPath; const raw = args[index + 1]; if (raw === undefined || !raw.startsWith("/")) throw new Error("--path must be an absolute path"); return raw; } function hasFlag(args: string[], name: string): boolean { return args.includes(name); } export function runSwapCommand(args: string[]): unknown { const [action = "status"] = args; const path = parsePathOption(args, defaultSwapPath); if (action === "status") return swapStatus(path); if (action === "ensure") { const sizeBytes = parseSizeOption(args, defaultSwapSizeBytes); const dryRun = hasFlag(args, "--dry-run"); const before = swapStatus(path); const actions: SwapEnsureResult["actions"] = []; const errors: SwapEnsureResult["errors"] = []; if (before.memory.swapTotalBytes > 0) { actions.push({ action: "noop-existing-swap", ok: true, detail: { activeSwaps: before.activeSwaps } }); const after = swapStatus(path); return { ok: true, status: "ok", requested: { path, sizeBytes }, before, after, actions, errors } satisfies SwapEnsureResult; } if (dryRun) { actions.push({ action: "dry-run", ok: true, detail: { wouldCreate: path, sizeBytes, wouldPersistFstab: true } }); const after = swapStatus(path); return { ok: true, status: "degraded", requested: { path, sizeBytes }, before, after, actions, errors } satisfies SwapEnsureResult; } if (!existsSync(path)) { const sizeMiB = Math.ceil(sizeBytes / 1024 / 1024); const allocated = pushAction(actions, errors, "allocate-swapfile", ["fallocate", "-l", `${sizeMiB}M`, path]); if (!allocated) pushAction(actions, errors, "allocate-swapfile-dd-fallback", ["dd", "if=/dev/zero", `of=${path}`, "bs=1M", `count=${sizeMiB}`, "status=none"]); } else { const existingBytes = statSync(path).size; if (existingBytes < sizeBytes) { const sizeMiB = Math.ceil(sizeBytes / 1024 / 1024); const resized = pushAction(actions, errors, "resize-existing-swapfile", ["fallocate", "-l", `${sizeMiB}M`, path]); if (!resized) pushAction(actions, errors, "resize-existing-swapfile-dd-fallback", ["dd", "if=/dev/zero", `of=${path}`, "bs=1M", `count=${sizeMiB}`, "status=none"]); } else { actions.push({ action: "reuse-existing-swapfile-path", ok: true, detail: { path, sizeBytes: existingBytes } }); } } pushAction(actions, errors, "chmod-600", ["chmod", "600", path]); pushAction(actions, errors, "mkswap", ["mkswap", path]); pushAction(actions, errors, "swapon", ["swapon", path]); const persist = ensureFstabLine(path); actions.push({ action: persist.action, ok: persist.ok, detail: persist.detail }); if (!persist.ok) { errors.push({ action: persist.action, message: "swap is active but /etc/fstab could not be updated; rerun ensure as root or add the returned fstab line manually", detail: persist.detail, }); } const after = swapStatus(path); const swapActive = after.memory.swapTotalBytes > 0; const status = swapActive && after.fstab.persisted ? "ok" : swapActive ? "degraded" : "failed"; return { ok: status !== "failed", status, requested: { path, sizeBytes }, before, after, actions, errors, } satisfies SwapEnsureResult; } throw new Error("server swap command must be one of: status, ensure"); }