jevstrudel.git / tools / dev / supervise.mjs
1// The jevstrudel Worker under `wrangler dev`, supervised: shared by dev.mjs
2// (`--env dev`, with the MCP hub) and preview.mjs (production, with the
3// static build as its assets).
4//
5// It runs worker.mjs inside `op-env-run`, which supplies the Jev key, and
6// restarts it when it exits or stops answering HTTP. Both have happened
7// (2026-09-24): wrangler exited with an empty error, and after wrangler
8// restarted its own crashed runtime the Worker listened but answered
9// nothing. Either way the Jev relay was silently down. The empty-error exit
10// was wrangler 4.129.0 dying when a client dropped a request; the flake's
11// wrangler no longer does (worker/CLAUDE.md), and this stays for the rest.
12import { execFileSync, spawn } from 'node:child_process';
13
14export const has = (tool) => {
15  try {
16    execFileSync('sh', ['-c', `command -v ${tool}`], { stdio: 'ignore' });
17    return true;
18  } catch {
19    return false;
20  }
21};
22
23const GRACE_MS = 60_000; // wrangler builds and boots before the first check
24const CHECK_MS = 5_000;
25const MISSES_ALLOWED = 3;
26const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
27
28async function answers(port) {
29  try {
30    await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(3000) });
31    return true; // any HTTP answer, 404 included
32  } catch {
33    return false;
34  }
35}
36
37// `args` go to `wrangler dev` after `--config worker/wrangler.json`; `port`
38// is where the Worker answers. Returns { stop }.
39export function superviseWorker({ port, args, name = 'worker' }) {
40  const keyed = has('op-env-run');
41  if (!keyed) console.error(`${name}: no op-env-run; the Worker runs without a Jev key and the relay will refuse`);
42  let worker;
43  let stopping = false;
44
45  // worker.mjs inside op-env-run, in its own process group so a hung Worker
46  // is stopped whole (op, wrangler, and the workerd runtime under it).
47  // --pdeathsig stops it if this process is killed outright.
48  function stopWorker() {
49    if (!worker || worker.exitCode !== null) return;
50    try {
51      process.kill(-worker.pid, 'SIGTERM');
52    } catch {}
53    const group = worker.pid;
54    setTimeout(() => {
55      try {
56        process.kill(-group, 'SIGKILL');
57      } catch {}
58    }, 3000).unref();
59  }
60
61  (async () => {
62    while (!stopping) {
63      const cmd = ['setpriv', '--pdeathsig', 'TERM', '--'];
64      if (keyed) cmd.push('op-env-run', '--');
65      cmd.push(process.execPath, 'tools/dev/worker.mjs', ...args);
66      worker = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit', detached: true });
67      const exited = new Promise((r) => worker.once('exit', r));
68
69      const start = Date.now();
70      while (worker.exitCode === null && Date.now() - start < GRACE_MS && !(await answers(port))) await sleep(2000);
71      let misses = 0;
72      while (worker.exitCode === null && !stopping) {
73        misses = (await answers(port)) ? 0 : misses + 1;
74        if (misses >= MISSES_ALLOWED) {
75          console.error(`${name}: the Worker stopped answering on :${port}; restarting it`);
76          stopWorker();
77          break;
78        }
79        await sleep(CHECK_MS);
80      }
81      await exited;
82      if (!stopping) {
83        console.error(`${name}: the Worker stopped; starting it again in 2 s`);
84        await sleep(2000);
85      }
86    }
87  })();
88
89  return {
90    stop() {
91      stopping = true;
92      stopWorker();
93    },
94  };
95}