// Regression: app.pid must hold the REAL supervisor pid, not the setsid
// wrapper that `pgh app start` spawns.
//
// Bug:
// `pgh app start` spawns the supervisor wrapped in `/usr/bin/setsid` so it
// outlives the CLI's session. setsid (a process-group leader) forks the
// real supervisor and exits — so spawn() returns the short-lived wrapper
// pid. `start` wrote THAT pid to app.pid, so app.pid was dead the instant
// it was written: `pgh app status` read it, isPidAlive() said false, and a
// perfectly live daemon reported running:false. (`stop`'s kill of the
// process group also targeted the wrong pid, masked by an env-scan
// fallback.)
//
// Fix: the supervisor writes its OWN process.pid to app.pid at startup,
// before it signals readiness via state.childPid. app.pid is therefore the
// live process, regardless of the setsid wrapper.
//
// This test spawns the supervisor through setsid (the real path) and asserts
// app.pid is alive and is NOT the spawned (wrapper) pid.
import test from 'brittle'
import process from '#node:process'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { spawn } from '#node:child_process'
import { fileURLToPath } from '#node:url'
const SUPERVISOR = fileURLToPath(new URL('../lib/supervisor.js', import.meta.url))
function isAlive (pid) {
if (!pid) return false
try { process.kill(pid, 0); return true } catch { return false }
}
test('app.pid holds the live supervisor pid, not the setsid wrapper', { skip: !existsSync('/usr/bin/setsid') }, async (t) => {
const root = mkdtempSync(join(tmpdir(), 'sup-pid-'))
const appDir = join(root, 'app')
mkdirSync(appDir, { recursive: true })
// Long-lived child so the supervisor stays up while we inspect app.pid.
const entry = join(appDir, 'entry.js')
writeFileSync(entry, 'setInterval(() => {}, 1000)\n')
const runRoot = join(root, 'run')
const name = 'sup-pid-test'
const pidPath = join(runRoot, 'pgh', name, 'app.pid')
const statePath = join(runRoot, 'pgh', name, 'app.state.json')
const env = {
...process.env,
XDG_RUNTIME_DIR: runRoot,
PGH_STORE_ROOT: join(root, 'store'),
PGH_SUP_NAME: name,
PGH_SUP_ENTRYPOINT: entry,
PGH_SUP_RUNTIME: 'node',
PGH_SUP_RUNTIME_PATH: process.execPath,
PGH_SUP_ARGS_JSON: '[]',
PGH_SUP_CHILD_ENV: JSON.stringify(process.env)
}
// Spawn exactly like `pgh app start` does on a host with setsid: the pid we
// get back is the setsid wrapper, which forks the real supervisor.
const sup = spawn('/usr/bin/setsid', [process.execPath, SUPERVISOR], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
env
})
const wrapperPid = sup.pid
sup.unref()
// Wait for the child to come up (state.childPid set) — same signal start's
// readiness probe uses; by then app.pid is guaranteed written.
const ready = await new Promise((resolve) => {
const deadline = Date.now() + 8000
const iv = setInterval(() => {
try {
const st = JSON.parse(readFileSync(statePath, 'utf8'))
if (st && st.childPid) { clearInterval(iv); resolve(st) }
} catch {}
if (Date.now() > deadline) { clearInterval(iv); resolve(null) }
}, 100)
})
t.ok(ready, 'supervisor reached readiness (state.childPid set)')
const pidInFile = parseInt(readFileSync(pidPath, 'utf8').trim(), 10)
t.ok(isAlive(pidInFile), 'app.pid points at a LIVE process')
t.is(pidInFile, ready.supervisorPid, 'app.pid == the supervisor self-reported pid')
t.not(pidInFile, wrapperPid, 'app.pid is NOT the dead setsid wrapper pid')
// Cleanup: kill the whole session (real supervisor is the leader).
try { process.kill(-ready.supervisorPid, 'SIGTERM') } catch {}
await new Promise(r => setTimeout(r, 300))
try { process.kill(-ready.supervisorPid, 'SIGKILL') } catch {}
rmSync(root, { recursive: true, force: true })
})