diff --git a/internal/_serve-reaped.mjs b/internal/_serve-reaped.mjs index 95395de20..0eb5a4ae0 100644 --- a/internal/_serve-reaped.mjs +++ b/internal/_serve-reaped.mjs @@ -34,7 +34,7 @@ const HERE = dirname(fileURLToPath(import.meta.url)) export function serveReaped(directory, port) { const child = spawn( process.execPath, - [resolve(HERE, 'serve-strict.mjs'), '--port', String(port), '--directory', directory], + [resolve(HERE, 'serve-strict.mjs'), '--port', String(port), '--directory', directory, '--exit-with-parent'], { stdio: 'ignore' }, ) diff --git a/internal/check-auto-engine.mjs b/internal/check-auto-engine.mjs index cdfbbf21c..c97dc2694 100644 --- a/internal/check-auto-engine.mjs +++ b/internal/check-auto-engine.mjs @@ -50,7 +50,7 @@ const FAKE_TOKEN = ['pk', b64({ u: 'auto-engine-check', a: 'not-a-real-token' }) if (!existsSync(join(DIR, TREE_NAME, LEAF, 'index.html'))) { console.log(`AUTO-ENGINE: NOT MEASURED — no ${LEAF} under ${DIR}/${TREE_NAME}/`); process.exit(75) } const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const n = s.address().port; s.close(() => ok(n)) }) }) -const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' }) +const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR, '--exit-with-parent'], { stdio: 'ignore' }) const stop = () => { try { srv.kill() } catch { /* gone */ } } let up = false for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/${TREE_NAME}/manifest.json`).then((r) => r.ok).catch(() => false) } diff --git a/internal/check-basemap-labels-localized.mjs b/internal/check-basemap-labels-localized.mjs index 442981d17..841765ec7 100644 --- a/internal/check-basemap-labels-localized.mjs +++ b/internal/check-basemap-labels-localized.mjs @@ -40,7 +40,7 @@ const LEAF = arg('--leaf', 'doxa-simple-map/doxa-simple-map') const PAIRS = { Germany: 'Германия', Ukraine: 'Украина', Turkey: 'Турция', Egypt: 'Египет', Kazakhstan: 'Казахстан', Poland: 'Польша' } const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) }) -const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' }) +const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR, '--exit-with-parent'], { stdio: 'ignore' }) await new Promise((r) => setTimeout(r, 900)) const up = await fetch(`http://127.0.0.1:${port}/${TREE_NAME}/${LEAF}/index.html`).then((r) => r.ok).catch(() => false) if (!up) { srv.kill(); console.log(`BASEMAP-LABELS: NOT MEASURED — could not serve ${DIR}`); process.exit(2) } diff --git a/internal/check-composite-embed-knobs.mjs b/internal/check-composite-embed-knobs.mjs index d4e347e75..150ef38bd 100644 --- a/internal/check-composite-embed-knobs.mjs +++ b/internal/check-composite-embed-knobs.mjs @@ -134,7 +134,7 @@ const HOST_PORT = await freePort() const TREE = `http://127.0.0.1:${TREE_PORT}/${TREE_NAME}` let pageHtml = 'nothing yet' -const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT)], { cwd: ROOT, stdio: 'ignore' }) +const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT), '--exit-with-parent'], { cwd: ROOT, stdio: 'ignore' }) const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(pageHtml) }).listen(HOST_PORT, 'localhost') const cleanup = () => { try { tree.kill() } catch { /* already gone */ } try { host.close() } catch { /* already closed */ } } await new Promise((r) => setTimeout(r, 900)) diff --git a/internal/check-element-census-vs-live.mjs b/internal/check-element-census-vs-live.mjs index 5799da819..fa5ec9efb 100644 --- a/internal/check-element-census-vs-live.mjs +++ b/internal/check-element-census-vs-live.mjs @@ -99,7 +99,7 @@ for (const [, p] of presets) for (const pg of p.pages) { // ── our side: this checkout's tree on an OS-assigned port ───────────────────────────────────────── const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const n = s.address().port; s.close(() => ok(n)) }) }) -const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' }) +const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR, '--exit-with-parent'], { stdio: 'ignore' }) const stopSrv = () => { try { srv.kill() } catch { /* already gone */ } } let up = false for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/${TREE_NAME}/manifest.json`).then((r) => r.ok).catch(() => false) } diff --git a/internal/check-experimental-shelf.mjs b/internal/check-experimental-shelf.mjs index 332572495..bae86a062 100644 --- a/internal/check-experimental-shelf.mjs +++ b/internal/check-experimental-shelf.mjs @@ -105,7 +105,7 @@ const SEED = { // ── our own server, on a port nobody else can be holding ────────────────────── const port = await new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) }) -const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', 'public'], { cwd: ROOT, stdio: 'ignore' }) +const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', 'public', '--exit-with-parent'], { cwd: ROOT, stdio: 'ignore' }) const reap = () => { try { srv.kill() } catch (_) {} } process.on('exit', reap); for (const s of ['SIGINT', 'SIGTERM']) process.on(s, () => { reap(); process.exit(130) }) const BASE = `http://127.0.0.1:${port}/${TREE_NAME}` diff --git a/internal/check-fits-the-screen.mjs b/internal/check-fits-the-screen.mjs index 49017a7cf..5e441413e 100644 --- a/internal/check-fits-the-screen.mjs +++ b/internal/check-fits-the-screen.mjs @@ -124,7 +124,7 @@ for (const b of mf.bundles || []) { } // ── 2. SERVE THE BUILT TREE the way production serves it (directories 404) ───────────────────── -const server = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT), '--directory', 'public'], { +const server = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT), '--directory', 'public', '--exit-with-parent'], { cwd: ROOT, stdio: 'pipe', }) let serverErr = '' diff --git a/internal/check-host-locale-contract.mjs b/internal/check-host-locale-contract.mjs index 2684c1c91..6bd8bc6ca 100644 --- a/internal/check-host-locale-contract.mjs +++ b/internal/check-host-locale-contract.mjs @@ -63,7 +63,7 @@ for (const b of manifest.bundles || []) for (const p of b.profiles || []) { if (!leaves.includes(CONTROL)) { console.log(`HOST-LOCALE: NOT MEASURED — the control ${CONTROL} is not in this tree`); process.exit(75) } const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) }) -const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' }) +const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR, '--exit-with-parent'], { stdio: 'ignore' }) const stop = () => { try { srv.kill() } catch { /* already gone */ } } let up = false for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/${TREE_NAME}/manifest.json`).then((r) => r.ok).catch(() => false) } diff --git a/internal/check-leaf-engine-per-provider.mjs b/internal/check-leaf-engine-per-provider.mjs index ef0efa4ec..3fb82dbb9 100644 --- a/internal/check-leaf-engine-per-provider.mjs +++ b/internal/check-leaf-engine-per-provider.mjs @@ -62,7 +62,7 @@ if (!leaves.length) { console.log('LEAF-ENGINE: NOT MEASURED — no leaves match async function serve(dir) { const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const n = s.address().port; s.close(() => ok(n)) }) }) - const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', dir], { stdio: 'ignore' }) + const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', dir, '--exit-with-parent'], { stdio: 'ignore' }) let up = false for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/${TREE_NAME}/manifest.json`).then((r) => r.ok).catch(() => false) } if (!up) { try { srv.kill() } catch { /* gone */ } return null } diff --git a/internal/check-map-embed-cells.mjs b/internal/check-map-embed-cells.mjs index d323adf13..9a7622b42 100644 --- a/internal/check-map-embed-cells.mjs +++ b/internal/check-map-embed-cells.mjs @@ -70,7 +70,7 @@ const TREE_PORT = await freePort() const HOST_PORT = await freePort() const TREE = `http://127.0.0.1:${TREE_PORT}/${TREE_NAME}` let pageHtml = 'hostnothing' -const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT)], { cwd: ROOT, stdio: 'ignore' }) +const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT), '--exit-with-parent'], { cwd: ROOT, stdio: 'ignore' }) const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); r.end(pageHtml) }).listen(HOST_PORT, 'localhost') const cleanup = () => { try { tree.kill() } catch (_) {} try { host.close() } catch (_) {} } await new Promise((r) => setTimeout(r, 900)) diff --git a/internal/check-mapbox-drew.mjs b/internal/check-mapbox-drew.mjs index 55993778d..22ea77ed8 100644 --- a/internal/check-mapbox-drew.mjs +++ b/internal/check-mapbox-drew.mjs @@ -93,7 +93,7 @@ if (!leaves.length) { console.log('MAPBOX-DREW: NOT MEASURED — no composed map async function serve(dir) { const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const n = s.address().port; s.close(() => ok(n)) }) }) - const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', dir], { stdio: 'ignore' }) + const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', dir, '--exit-with-parent'], { stdio: 'ignore' }) await new Promise((r) => setTimeout(r, 600)) return { port, stop: () => { try { srv.kill() } catch (_) {} } } } diff --git a/internal/check-prayer-walk-planner.mjs b/internal/check-prayer-walk-planner.mjs index 8bd13ac1b..bd46e7591 100644 --- a/internal/check-prayer-walk-planner.mjs +++ b/internal/check-prayer-walk-planner.mjs @@ -34,7 +34,7 @@ try { ({ chromium } = await import('./lib/playwright-guarded.mjs')) } catch { co const OUT = process.env.OUT || '/tmp' const free = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) }) const TP = await free(), HP = await free(), TREE = `http://127.0.0.1:${TP}/${TREE_NAME}` -const srv = spawn('node', ['internal/serve-strict.mjs', '--port', String(TP)], { cwd: ROOT, stdio: 'ignore' }) +const srv = spawn('node', ['internal/serve-strict.mjs', '--port', String(TP), '--exit-with-parent'], { cwd: ROOT, stdio: 'ignore' }) const host = createServer((q, r) => { const l = new URL(q.url, 'http://x').searchParams.get('l') || 'en'; r.writeHead(200, { 'content-type': 'text/html' }); r.end(``) }).listen(HP, 'localhost') await new Promise(r => setTimeout(r, 800)) diff --git a/internal/check-research-gallery-embed.mjs b/internal/check-research-gallery-embed.mjs index 90b2d7a29..8813fe524 100644 --- a/internal/check-research-gallery-embed.mjs +++ b/internal/check-research-gallery-embed.mjs @@ -139,7 +139,7 @@ const hostPage = () => {
HOST HEADER
${embed}
` } -const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT)], { cwd: ROOT, stdio: 'ignore', env: { ...process.env, LIVE_CORS: LIVE_CORS ? '1' : '' } }) +const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT), '--exit-with-parent'], { cwd: ROOT, stdio: 'ignore', env: { ...process.env, LIVE_CORS: LIVE_CORS ? '1' : '' } }) const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(hostPage()) }).listen(HOST_PORT, 'localhost') const cleanup = () => { try { tree.kill() } catch {} try { host.close() } catch {} } await new Promise(r => setTimeout(r, 800)) diff --git a/internal/measure-map-load.mjs b/internal/measure-map-load.mjs index 853730f9f..0de36ccd7 100644 --- a/internal/measure-map-load.mjs +++ b/internal/measure-map-load.mjs @@ -29,7 +29,7 @@ const ONLY = arg('--only', '') ? arg('--only', '').split(',') : DEFAULT const manifest = JSON.parse(readFileSync(`public/${TREE_NAME}/manifest.json`, 'utf8')) const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) }) -const srv = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(port)], { stdio: 'ignore' }) +const srv = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(port), '--exit-with-parent'], { stdio: 'ignore' }) const hostPort = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) }) let hostHtml = '' const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); r.end(hostHtml) }).listen(hostPort, 'localhost') diff --git a/internal/serve-strict.mjs b/internal/serve-strict.mjs index 8d6b3c60a..197467733 100644 --- a/internal/serve-strict.mjs +++ b/internal/serve-strict.mjs @@ -33,7 +33,11 @@ * HTTP 200 on the box's PUBLIC IP while a browser gate was running. Override with HOST only if you * deliberately want another host to reach it. * - * Usage: node internal/serve-strict.mjs [--port 4173] [--directory public] + * Usage: node internal/serve-strict.mjs [--port 4173] [--directory public] [--exit-with-parent] + * --exit-with-parent for a gate that spawns this server: exit once the process that started it is gone. + * A gate that crashes or is refused by the browser-slot guard cannot kill its child, and + * on 2026-09-29 41 such servers had outlived their gates (279 MB, the oldest 5 days). + * Opt-in, so a server someone deliberately left running is never taken down. */ import { createServer } from 'node:http' import { createReadStream, statSync } from 'node:fs' @@ -49,6 +53,10 @@ const argOf = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 && a const PORT = Number(process.env.PORT || argOf('--port', '4173')) const HOST = process.env.HOST || '127.0.0.1' const DIR = resolve(ROOT, argOf('--directory', 'public')) +if (argv.includes('--exit-with-parent')) { + const parent = process.ppid + setInterval(() => { if (process.ppid !== parent) process.exit(0) }, 2000).unref() +} const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', diff --git a/internal/verify-gospel-map.mjs b/internal/verify-gospel-map.mjs index 8378c3559..7727619a6 100644 --- a/internal/verify-gospel-map.mjs +++ b/internal/verify-gospel-map.mjs @@ -117,7 +117,7 @@ if (adopted !== true) { // Ephemeral port so two gates running at once cannot collide on 4173. const port = 5100 + Math.floor(Math.random() * 800) base = `http://127.0.0.1:${port}` - server = spawn(process.execPath, [resolve(HERE, 'serve-strict.mjs'), '--port', String(port), '--directory', resolve(REPO, 'public')], { stdio: 'ignore', detached: false }) + server = spawn(process.execPath, [resolve(HERE, 'serve-strict.mjs'), '--port', String(port), '--directory', resolve(REPO, 'public'), '--exit-with-parent'], { stdio: 'ignore', detached: false }) const until = Date.now() + 15000 while (Date.now() < until && !(await alive(base))) await new Promise((r) => setTimeout(r, 250)) if (!(await alive(base))) {