OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / supervisor-pidfile.test.js

98 lines · javascript ref:
// 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 })
})