// The slopbox process supervisor: one explicit state machine per app. // // server: booting → starting → listening → (crashed | exited) // cli: booting → running → (crashed | done) // crashed → restarting → spawned state (auto, with backoff) // crashed → failed (more than maxCrashes inside windowMs) // any but booting → spawned state (restart(): manual, clears the crash history) // // A server is up once it listens and a clean exit leaves it `exited`; a CLI is up as // soon as it spawns and a clean exit means it finished, `done`. Crashes are handled the // same for both. It owns no runtime: `spawn()` starts the app and returns // { exited, kill }, the shell reports ports with `listening()`, and `onChange` receives // every state entered, including the momentary `crashed`. export const DEFAULT_POLICY = Object.freeze({ backoff: [250, 1000, 4000], windowMs: 30_000, maxCrashes: 3 }); /** What each kind of app enters when it spawns and when it exits 0. */ const LIFECYCLE = Object.freeze({ server: { spawned: "starting", clean: "exited" }, cli: { spawned: "running", clean: "done" }, }); /** * @typedef {{ exited: Promise, kill(): void }} Run * @typedef {{ kind: "booting" } * | { kind: "starting" } * | { kind: "running" } * | { kind: "done", code: 0 } * | { kind: "listening", port: number, url: string } * | { kind: "crashed", code: number } * | { kind: "restarting", delayMs: number } * | { kind: "exited", code: number } * | { kind: "failed", crashes: number }} State * @typedef {State & { restarts: number, lastExit: number | null }} Snapshot */ /** * @param {{ * kind?: keyof typeof LIFECYCLE, * spawn: () => Run, * onChange: (snapshot: Snapshot) => void, * policy?: { backoff: number[], windowMs: number, maxCrashes: number }, * now?: () => number, * }} options */ export function createSupervisor({ kind = "server", spawn, onChange, policy = DEFAULT_POLICY, now = Date.now }) { const lifecycle = LIFECYCLE[kind]; /** @type {State} */ let state = { kind: "booting" }; let restarts = 0; /** @type {number | null} */ let lastExit = null; /** @type {number[]} crash times inside the window */ let crashes = []; /** @type {Run | null} */ let run = null; /** @type {ReturnType | undefined} */ let timer; const snapshot = () => ({ ...state, restarts, lastExit }); /** @param {State} next */ function enter(next) { state = next; onChange(snapshot()); } function exec() { const mine = spawn(); run = mine; enter({ kind: lifecycle.spawned }); mine.exited.then((code) => { if (run === mine) exitedWith(code); }); } /** @param {number} code */ function exitedWith(code) { run = null; lastExit = code; if (code === 0) { enter({ kind: lifecycle.clean, code }); return; } const t = now(); crashes = [...crashes.filter((c) => t - c < policy.windowMs), t]; enter({ kind: "crashed", code }); if (crashes.length > policy.maxCrashes) { enter({ kind: "failed", crashes: crashes.length }); return; } const delayMs = policy.backoff[Math.min(crashes.length, policy.backoff.length) - 1] ?? 0; enter({ kind: "restarting", delayMs }); timer = setTimeout(() => { timer = undefined; restarts++; exec(); }, delayMs); } return { get state() { return snapshot(); }, start() { if (state.kind === "booting") exec(); }, /** @param {number} port @param {string} url */ listening(port, url) { if (state.kind === "starting") enter({ kind: "listening", port, url }); }, restart() { if (state.kind === "booting") return; clearTimeout(timer); timer = undefined; crashes = []; const old = run; run = null; old?.kill(); restarts++; exec(); }, }; } /** `?backoff=0,0,0` overrides the backoff ladder (tests use it to skip the waits). */ export function policyFrom(params) { const raw = params.get("backoff"); if (raw === null) return DEFAULT_POLICY; const backoff = raw.split(",").map(Number); if (backoff.length === 0 || backoff.some((ms) => !Number.isFinite(ms) || ms < 0)) return DEFAULT_POLICY; return { ...DEFAULT_POLICY, backoff }; }