Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134// 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<number>, 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<typeof setTimeout> | 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 };}