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
This commit is contained in:
@@ -1,14 +1,22 @@
|
|||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
|
|
||||||
|
/** Shape of the JSON body returned by `GET /health`. */
|
||||||
interface HealthStatus {
|
interface HealthStatus {
|
||||||
|
/** Overall status of the process. Set to `"degraded"` on graceful shutdown. */
|
||||||
status: "ok" | "degraded" | "error";
|
status: "ok" | "degraded" | "error";
|
||||||
|
/** Seconds elapsed since the process started (computed at request time). */
|
||||||
uptime: number;
|
uptime: number;
|
||||||
|
/** ISO-8601 timestamp of when the process started. */
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
|
/** `true` once the {@link TaskLoop} has been started successfully. */
|
||||||
taskLoop: boolean;
|
taskLoop: boolean;
|
||||||
|
/** `true` once the {@link ImprovementLoop} has been started successfully. */
|
||||||
improvementLoop: boolean;
|
improvementLoop: boolean;
|
||||||
|
/** ISO-8601 timestamp of the most recent Asana poll, if any. */
|
||||||
lastPoll?: string;
|
lastPoll?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** In-memory snapshot of the current health state. */
|
||||||
let healthStatus: HealthStatus = {
|
let healthStatus: HealthStatus = {
|
||||||
status: "ok",
|
status: "ok",
|
||||||
uptime: 0,
|
uptime: 0,
|
||||||
@@ -17,10 +25,99 @@ let healthStatus: HealthStatus = {
|
|||||||
improvementLoop: 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>) {
|
export function updateHealth(updates: Partial<HealthStatus>) {
|
||||||
healthStatus = { ...healthStatus, ...updates };
|
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) {
|
export function startHealthServer(port: number) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
@@ -29,23 +126,38 @@ export function startHealthServer(port: number) {
|
|||||||
fetch(req) {
|
fetch(req) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
|
||||||
if (url.pathname === "/health") {
|
// Only allow safe read-only methods on this server.
|
||||||
return Response.json({
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||||
...healthStatus,
|
return new Response("Method Not Allowed", {
|
||||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
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 === "/") {
|
if (url.pathname === "/") {
|
||||||
return new Response("Pi Worker is running. GET /health for status.", {
|
return new Response("Pi Worker is running. GET /health for status.", {
|
||||||
headers: { "Content-Type": "text/plain" },
|
headers: { ...SECURITY_HEADERS, "Content-Type": "text/plain" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response("Not Found", { status: 404 });
|
return new Response("Not Found", { status: 404, headers: SECURITY_HEADERS });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info("health", `Health server listening on :${port}`);
|
logger.info("health", `Health server listening on :${port}`);
|
||||||
|
|
||||||
|
// Start systemd watchdog after health server is up
|
||||||
|
startWatchdog(port);
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,112 @@
|
|||||||
import { writeFileSync, unlinkSync, readFileSync, existsSync } from "fs";
|
import { writeFileSync, unlinkSync, readFileSync, existsSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple file-based mutex used to prevent concurrent Pi CLI executions.
|
||||||
|
*
|
||||||
|
* The lock file stores the acquisition timestamp (milliseconds since epoch)
|
||||||
|
* as plain text. Any lock older than {@link STALE_LOCK_MS} is automatically
|
||||||
|
* treated as stale and removed, ensuring a crashed process can never
|
||||||
|
* permanently block future runs.
|
||||||
|
*
|
||||||
|
* Lock files are stored in `dir` (default `/tmp`) as
|
||||||
|
* `pi-worker-<name>.lock`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const lock = new FileLock("task-loop");
|
||||||
|
* if (!lock.acquire()) return; // another execution in progress
|
||||||
|
* try {
|
||||||
|
* await doWork();
|
||||||
|
* } finally {
|
||||||
|
* lock.release();
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
export class FileLock {
|
export class FileLock {
|
||||||
private lockPath: string;
|
private lockPath: string;
|
||||||
|
private held = false;
|
||||||
|
|
||||||
|
/** Locks older than this (ms) are considered stale and are removed on the next acquire(). */
|
||||||
|
private static readonly STALE_LOCK_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param name - Logical name for this lock (e.g. `"task-loop"`).
|
||||||
|
* Combined with the prefix to form the lock filename.
|
||||||
|
* @param dir - Directory in which to create the lock file. Defaults to `/tmp`.
|
||||||
|
*/
|
||||||
constructor(name: string, dir: string = "/tmp") {
|
constructor(name: string, dir: string = "/tmp") {
|
||||||
this.lockPath = join(dir, `pi-worker-${name}.lock`);
|
this.lockPath = join(dir, `pi-worker-${name}.lock`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to acquire the lock.
|
||||||
|
*
|
||||||
|
* If a lock file already exists and is **not** stale, returns `false`
|
||||||
|
* immediately (non-blocking). Stale lock files are removed and the
|
||||||
|
* acquisition is retried once.
|
||||||
|
*
|
||||||
|
* Uses the `wx` (exclusive create) flag so that two processes racing to
|
||||||
|
* acquire the lock cannot both succeed.
|
||||||
|
*
|
||||||
|
* @returns `true` if the lock was acquired, `false` if it is already held.
|
||||||
|
*/
|
||||||
acquire(): boolean {
|
acquire(): boolean {
|
||||||
// Check for stale lock first
|
// Check for stale lock first
|
||||||
if (existsSync(this.lockPath)) {
|
if (existsSync(this.lockPath)) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(this.lockPath, "utf-8");
|
const content = readFileSync(this.lockPath, "utf-8");
|
||||||
const lockTime = parseInt(content);
|
const lockTime = parseInt(content);
|
||||||
if (Date.now() - lockTime > 15 * 60 * 1000) {
|
if (Date.now() - lockTime > FileLock.STALE_LOCK_MS) {
|
||||||
// Stale lock, remove it
|
// Stale lock — remove it
|
||||||
try { unlinkSync(this.lockPath); } catch {}
|
try { unlinkSync(this.lockPath); } catch (e) {
|
||||||
|
process.stderr.write(`[lock] Failed to remove stale lock ${this.lockPath}: ${e}\n`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Corrupted lock file, remove it
|
// Corrupted lock file — remove it so we don't get permanently stuck
|
||||||
|
process.stderr.write(`[lock] Corrupted lock file ${this.lockPath}, removing: ${e}\n`);
|
||||||
try { unlinkSync(this.lockPath); } catch {}
|
try { unlinkSync(this.lockPath); } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic create
|
// Atomic create — fails with EEXIST if another process won the race
|
||||||
try {
|
try {
|
||||||
writeFileSync(this.lockPath, Date.now().toString(), { flag: 'wx' });
|
writeFileSync(this.lockPath, Date.now().toString(), { flag: 'wx' });
|
||||||
|
this.held = true;
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false; // Another process got it first
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases the lock by deleting the lock file.
|
||||||
|
*
|
||||||
|
* Only deletes if this instance actually holds the lock. Safe to call
|
||||||
|
* even if the lock is not currently held (no-op in that case).
|
||||||
|
*/
|
||||||
release() {
|
release() {
|
||||||
|
if (!this.held) return;
|
||||||
try {
|
try {
|
||||||
unlinkSync(this.lockPath);
|
unlinkSync(this.lockPath);
|
||||||
} catch {}
|
} catch (e) {
|
||||||
|
// Suppress ENOENT — lock was already cleaned up (e.g. by stale detection)
|
||||||
|
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||||
|
process.stderr.write(`[lock] Failed to release lock ${this.lockPath}: ${e}\n`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.held = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` if a lock file exists on disk (regardless of whether it
|
||||||
|
* is stale). Useful for diagnostics; prefer {@link acquire} for actual
|
||||||
|
* mutual-exclusion logic.
|
||||||
|
*/
|
||||||
isLocked(): boolean {
|
isLocked(): boolean {
|
||||||
return existsSync(this.lockPath);
|
return existsSync(this.lockPath);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user