cf20646290
- 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
164 lines
5.1 KiB
TypeScript
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;
|
|
}
|