watch the dev server, because pm2 cannot tell if it is serving

fofos-dev sat for eighteen hours reporting `online`, holding its port open and
answering nothing. pm2 was not wrong — the process was alive and the socket was
bound. It was `bun --hot` wedged after a day of edits, sitting in ep_poll and
never completing a request. dev.fofosfluffybastards.com was down the whole time
and nothing said so.

servers.ts already knows those two answers disagree; that is why `reachable` is
asked separately from pm2's status, and its own comment says so. This asks the
same question on a timer.

Not an hourly restart. That bounces a healthy server fifty times to catch one
wedge, drops whatever anybody was doing each time, and still leaves the site
broken for up to an hour where a probe catches it in two minutes.

It acts only when pm2 says online AND the port does not answer, which is a lie
worth correcting. A server somebody STOPPED is a decision and is left alone.
Two strikes a minute apart, because --hot legitimately goes quiet while it
rebuilds. Five minutes of cooldown after acting. Three failed restarts and it
gives up and says to go read the logs, because a watchdog that restarts forever
is a crash loop with a nicer name.

State is on its own route rather than folded into /_config/servers, which answers
with a bare array that two screens read positionally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 13:21:47 +01:00
co-authored by Claude Opus 5
parent 7e8353a100
commit 8da02e1ef6
2 changed files with 171 additions and 0 deletions
+24
View File
@@ -12,6 +12,7 @@ import { listOriginals, originalsDir, reconcile, safeScope } from './derive';
import { createDatabases, pushSchema, readDatabases } from './databases';
import { readBackups, restoreBackup, runBackup, saveBackups, schedule, testDestination } from './backups';
import { watchMailbox } from './messages';
import { watchDevServer, watchdogState } from './dev-watchdog';
import { watchOriginals } from './originals-watch';
import { readServers, savePorts, siteSecrets, startServer, stopServer } from './servers';
import { buildSite, readStatic, startStatic, stopStatic } from './static-site';
@@ -564,6 +565,19 @@ router.post('/_config/backups/run', async (ctx) => {
*/
router.get('/_config/servers', async (ctx) => ctx.json(await readServers()));
/*
The watchdog's own state, on its own route.
NOT folded into `/_config/servers`, which answers with a bare array that SitePreview and
ServersBlock both read positionally. Wrapping it to make room here would have broken two screens to
save one request.
`lastCheckedAt` is the point of exposing any of this: a timer that has died looks exactly like a
timer with nothing to do, and a stale timestamp is the only thing that tells them apart without
going to read a log.
*/
router.get('/_config/servers/watchdog', (ctx) => ctx.json(watchdogState()));
router.put('/_config/servers', async (ctx) => {
const { development, production } = (ctx.get('body') ?? {}) as { development?: number; production?: number };
try {
@@ -800,3 +814,13 @@ void watchMailbox().catch(() => {});
to know about.
*/
void watchOriginals().catch(() => {});
/*
Watch the DEVELOPMENT server, because pm2 cannot tell whether it is serving.
It reported `online` for eighteen hours while holding its port open and answering nothing — a wedged
`bun --hot`, and the site was down the whole time with nothing to say so. servers.ts already asks
`reachable` separately from pm2's status for exactly this reason; this asks it on a timer and
restarts only when the two disagree. See dev-watchdog.ts.
*/
watchDevServer();