On 2026-09-29, 41 serve-strict processes had outlived the gates that started them: 279 MB resident, the oldest 5.3 days, spread across eight worktrees. A gate that crashes, is killed, or is refused by the browser-slot guard's process.exit cannot run its own srv.kill(), and _serve-reaped's handlers cover signals and normal exit but not a SIGKILLed parent. serve-strict takes --exit-with-parent: it records the pid that started it and exits within 2 s once that process is gone. Opt-in, so a server someone deliberately left running is never taken down. Every internal/ gate and tool that spawns it now passes the flag (14 files, plus _serve-reaped, the shared helper). Proven: a parent that spawns the server with the flag and exits abruptly (as the guard does) leaves it alive for a moment and gone within 3 s. The orphans already running belong to other worktrees' runs and are left to their owners; plugin-local scripts that spawn serve-strict should pass the flag too.
62 lines
2.6 KiB
JavaScript
62 lines
2.6 KiB
JavaScript
/**
|
|
* _serve-reaped.mjs — spawn a dev server that cannot outlive the process that started it.
|
|
*
|
|
* THE LEAK, MEASURED. Twelve gates in this repo spawn `serve-strict`; eleven of them reap it
|
|
* only on the happy path — `srv.kill()` at the end of the script. That works until the script
|
|
* does not reach the end. When the box ran low on memory it killed several gate processes
|
|
* mid-run, and every server those gates had spawned was orphaned. Counted at the time: ten
|
|
* orphans in one worktree, four in another. Each orphan holds memory, which makes the next
|
|
* kill more likely, which orphans more servers. The leak feeds the condition that causes it.
|
|
*
|
|
* I caught this by leaking two myself, minutes after describing the loop to someone else —
|
|
* so "my gate kills its server" is not evidence. The kill has to survive the killing.
|
|
*
|
|
* WHAT THIS DOES. Registers the child against SIGTERM, SIGINT, SIGHUP and normal exit, and
|
|
* unregisters cleanly when you stop it yourself. `exit` fires for a plain process.exit() and
|
|
* for an uncaught throw; the signals cover the memory killer and Ctrl-C. Nothing covers
|
|
* SIGKILL — nothing can — which is why the port is also reported, so a sweep can find it.
|
|
*
|
|
* const srv = serveReaped(PUBLIC_DIR, port)
|
|
* ... use it ...
|
|
* srv.stop() // idempotent; safe to call from a finally block
|
|
*/
|
|
import { spawn } from 'node:child_process'
|
|
import { resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
|
|
/**
|
|
* @param {string} directory what to serve
|
|
* @param {number} port
|
|
* @returns {{ child: import('node:child_process').ChildProcess, port: number, stop: () => void }}
|
|
*/
|
|
export function serveReaped(directory, port) {
|
|
const child = spawn(
|
|
process.execPath,
|
|
[resolve(HERE, 'serve-strict.mjs'), '--port', String(port), '--directory', directory, '--exit-with-parent'],
|
|
{ stdio: 'ignore' },
|
|
)
|
|
|
|
let stopped = false
|
|
const stop = () => {
|
|
if (stopped) return
|
|
stopped = true
|
|
try { child.kill() } catch { /* already gone */ }
|
|
process.off('exit', onExit)
|
|
for (const sig of SIGNALS) process.off(sig, onSignal)
|
|
}
|
|
|
|
const onExit = () => { try { child.kill() } catch { /* already gone */ } }
|
|
/* Re-raise after cleaning up, so the caller still sees the signal's exit status. */
|
|
const onSignal = (sig) => {
|
|
onExit()
|
|
process.exit(sig === 'SIGINT' ? 130 : 143)
|
|
}
|
|
const SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP']
|
|
|
|
process.on('exit', onExit)
|
|
for (const sig of SIGNALS) process.on(sig, onSignal)
|
|
|
|
return { child, port, stop }
|
|
}
|