singularity-forge/web/lib/shutdown-gate.ts
NilsR0711 1ad4137892 fix(web): skip shutdown in daemon mode so server survives tab close (#2842)
When GSD_WEB_DAEMON_MODE=1 is set, scheduleShutdown() becomes a no-op.
The /api/shutdown endpoint still returns { ok: true } so the client
beacon fires without a network error, but process.exit() is never
called. This allows gsd --web to run as a persistent daemon behind a
reverse proxy without exiting on every browser tab close or refresh.

Closes #2835
2026-03-27 18:07:44 -06:00

69 lines
2 KiB
TypeScript

/**
* Shutdown gate — defers process.exit() so that page refreshes (which fire
* `pagehide` then immediately re-boot) don't kill the server.
*
* Flow:
* pagehide → POST /api/shutdown → scheduleShutdown() → timer starts
* refresh → GET /api/boot → cancelShutdown() → timer cleared
* tab close → timer fires → process.exit(0)
*
* When GSD_WEB_DAEMON_MODE=1, the server is running as a persistent daemon
* (e.g. behind a reverse proxy for remote access). In this mode,
* scheduleShutdown() is a no-op — no client tab should be able to exit the
* server. The /api/shutdown endpoint still returns { ok: true } so the
* client beacon doesn't produce a network error.
*/
const SHUTDOWN_DELAY_MS = 3_000;
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Returns true when the server is running in daemon mode.
* In daemon mode, shutdown requests from browser tabs are ignored.
*/
export function isDaemonMode(): boolean {
return process.env.GSD_WEB_DAEMON_MODE === "1";
}
/**
* Schedule a graceful process exit after SHUTDOWN_DELAY_MS.
* If cancelShutdown() is called before the timer fires (e.g. a page refresh
* triggers a boot request), the exit is aborted.
*
* No-op when GSD_WEB_DAEMON_MODE=1 — the server should outlive any
* individual browser session.
*/
export function scheduleShutdown(): void {
if (isDaemonMode()) {
return;
}
// Don't stack timers — reset if already scheduled
if (shutdownTimer !== null) {
clearTimeout(shutdownTimer);
}
shutdownTimer = setTimeout(() => {
shutdownTimer = null;
process.exit(0);
}, SHUTDOWN_DELAY_MS);
}
/**
* Cancel a pending shutdown. Called by any incoming API request that proves
* the client is still alive (boot, SSE reconnect, etc.).
*/
export function cancelShutdown(): void {
if (shutdownTimer !== null) {
clearTimeout(shutdownTimer);
shutdownTimer = null;
}
}
/**
* Check whether a shutdown is currently pending.
*/
export function isShutdownPending(): boolean {
return shutdownTimer !== null;
}