Files
clinera-site/pi-worker/src/health/server.ts
T
Azreen Jamal cf20646290 fix: pi-worker systemd integration + lock file bug
- Fix FileLock: track 'held' state, only release if acquired, suppress ENOENT
- Upgrade systemd service: Restart=always (was on-failure, caused 2-day outage),
  StartLimitBurst, WatchdogSec=120, MemoryMax=512M, KillMode=mixed
- Add systemd watchdog: health server self-checks at :8787, notifies sd_notify
- Kill orphaned nohup process, restart cleanly via systemd
2026-03-09 01:08:40 +08:00

164 lines
5.1 KiB
TypeScript

import { logger } from "../logger.js";
/** Shape of the JSON body returned by `GET /health`. */
interface HealthStatus {
/** Overall status of the process. Set to `"degraded"` on graceful shutdown. */
status: "ok" | "degraded" | "error";
/** Seconds elapsed since the process started (computed at request time). */
uptime: number;
/** ISO-8601 timestamp of when the process started. */
startedAt: string;
/** `true` once the {@link TaskLoop} has been started successfully. */
taskLoop: boolean;
/** `true` once the {@link ImprovementLoop} has been started successfully. */
improvementLoop: boolean;
/** ISO-8601 timestamp of the most recent Asana poll, if any. */
lastPoll?: string;
}
/** In-memory snapshot of the current health state. */
let healthStatus: HealthStatus = {
status: "ok",
uptime: 0,
startedAt: new Date().toISOString(),
taskLoop: false,
improvementLoop: false,
};
/**
* Merges `updates` into the in-memory health snapshot.
* Call this whenever a subsystem changes state (e.g. after starting a loop
* or during graceful shutdown).
*
* @param updates - Partial fields to overwrite in the current health status.
*/
export function updateHealth(updates: Partial<HealthStatus>) {
healthStatus = { ...healthStatus, ...updates };
}
/**
* Security headers applied to every response from the health server.
*/
const SECURITY_HEADERS: Record<string, string> = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Cache-Control": "no-store",
};
/**
* Starts a systemd watchdog heartbeat if WatchdogSec is configured.
*
* sd_notify is done by writing "WATCHDOG=1" to the NOTIFY_SOCKET.
* Bun doesn't have native sd_notify, so we use a simple UDP/unix datagram.
* Fallback: we just curl the health endpoint and if it responds, we're alive.
*/
let watchdogTimer: ReturnType<typeof setInterval> | null = null;
function startWatchdog(healthPort: number) {
const watchdogUsec = process.env.WATCHDOG_USEC;
if (!watchdogUsec) {
logger.debug("health", "No WATCHDOG_USEC set, skipping systemd watchdog");
return;
}
const intervalMs = Math.floor(parseInt(watchdogUsec) / 1000 / 2); // ping at half the deadline
logger.info("health", `Systemd watchdog enabled, pinging every ${intervalMs}ms`);
watchdogTimer = setInterval(async () => {
try {
// Self-check: hit our own health endpoint
const res = await fetch(`http://127.0.0.1:${healthPort}/health`);
if (!res.ok) {
logger.error("health", `Watchdog self-check failed: ${res.status}`);
return; // Don't notify systemd — let it kill us
}
const data = (await res.json()) as HealthStatus;
if (data.status !== "ok") {
logger.warn("health", `Watchdog: status is ${data.status}, not notifying systemd`);
return;
}
// Notify systemd via NOTIFY_SOCKET
const notifySocket = process.env.NOTIFY_SOCKET;
if (notifySocket) {
const { createSocket } = await import("dgram");
const socketPath = notifySocket.startsWith("@")
? `\0${notifySocket.slice(1)}`
: notifySocket;
const client = createSocket("unix_dgram" as any);
const msg = Buffer.from("WATCHDOG=1");
client.send(msg, 0, msg.length, socketPath, () => client.close());
}
} catch (e) {
logger.error("health", `Watchdog ping failed: ${e}`);
// Don't notify — let systemd restart us
}
}, intervalMs);
}
export function stopWatchdog() {
if (watchdogTimer) {
clearInterval(watchdogTimer);
watchdogTimer = null;
}
}
/**
* Starts the HTTP health server on the given `port`.
*
* ### Endpoints
*
* | Method | Path | Response |
* |--------|-----------|----------|
* | GET | `/health` | `200 application/json` — {@link HealthStatus} with live `uptime` |
* | GET | `/` | `200 text/plain` — simple alive message |
* | * | * | `404 Not Found` |
*
* @param port - TCP port to listen on.
* @returns The `Bun.Server` instance (rarely needed by callers).
*/
export function startHealthServer(port: number) {
const startTime = Date.now();
const server = Bun.serve({
port,
fetch(req) {
const url = new URL(req.url);
// Only allow safe read-only methods on this server.
if (req.method !== "GET" && req.method !== "HEAD") {
return new Response("Method Not Allowed", {
status: 405,
headers: { ...SECURITY_HEADERS, Allow: "GET, HEAD" },
});
}
if (url.pathname === "/health") {
return Response.json(
{
...healthStatus,
uptime: Math.floor((Date.now() - startTime) / 1000),
},
{ headers: SECURITY_HEADERS }
);
}
if (url.pathname === "/") {
return new Response("Pi Worker is running. GET /health for status.", {
headers: { ...SECURITY_HEADERS, "Content-Type": "text/plain" },
});
}
return new Response("Not Found", { status: 404, headers: SECURITY_HEADERS });
},
});
logger.info("health", `Health server listening on :${port}`);
// Start systemd watchdog after health server is up
startWatchdog(port);
return server;
}