/**
* Dependency ordering + readiness probes for `pgh app boot/shutdown`.
*
* Apps declare `pgh.depends: [...]` in their package.json and their
* readiness check as `pgh.readiness: { type, path?, port?, timeoutMs? }`.
* This module does the topo-sort and runs the probes.
*/
import net from '#node:net'
import fs from '#node:fs'
import { readInstallManifest, readPackageJsonPgh, expandEnv } from './app-state.js'
/**
* Topological sort. Returns names in dependency order (deps first).
* Throws on cycles. Missing dependencies are logged but don't fail —
* the boot of dependents will just skip their readiness wait on the
* missing dep.
*/
export function topoSort (names) {
const depsOf = new Map()
for (const n of names) {
const m = readInstallManifest(n) || {}
const pkg = readPackageJsonPgh(n)
const depends = Array.isArray(m.depends) ? m.depends : (Array.isArray(pkg.depends) ? pkg.depends : [])
depsOf.set(n, depends)
}
const visited = new Set()
const visiting = new Set()
const order = []
function visit (n) {
if (visited.has(n)) return
if (visiting.has(n)) throw new Error('dependency cycle involving "' + n + '"')
visiting.add(n)
const deps = depsOf.get(n) || []
for (const d of deps) {
if (names.includes(d)) visit(d)
}
visiting.delete(n)
visited.add(n)
order.push(n)
}
for (const n of names) visit(n)
return order
}
/**
* Wait for an app's readiness probe to succeed. Returns when ready,
* throws on timeout. Supported types:
*
* { type: 'uds', path: '...', timeoutMs } — socket exists + connects
* { type: 'http', port, timeoutMs } — GET / returns any response
* { type: 'pidfile', timeoutMs } — pid in app.pid is alive
* { type: 'none' } — immediate
*
* Paths/ports may contain ${ENV_VAR} references; they're expanded using
* the app's effective env.
*/
export async function waitReady (readiness, env) {
const type = (readiness && readiness.type) || 'pidfile'
const timeoutMs = (readiness && readiness.timeoutMs) || 15000
const deadline = Date.now() + timeoutMs
const intervalMs = 250
if (type === 'none') return
while (Date.now() < deadline) {
if (await probeOnce(type, readiness, env)) return
await sleep(intervalMs)
}
throw new Error('readiness probe (' + type + ') did not succeed within ' + timeoutMs + 'ms')
}
async function probeOnce (type, readiness, env) {
try {
if (type === 'uds') {
const p = expandEnv(readiness.path, env)
if (!p || !fs.existsSync(p)) return false
return await tryConnectUds(p)
}
if (type === 'http') {
const port = parseInt(expandEnv(String(readiness.port || ''), env), 10)
if (!Number.isFinite(port)) return false
return await tryConnectTcp('127.0.0.1', port)
}
if (type === 'pidfile') {
// pidfile readiness is trivially satisfied by `start` having
// written the pid — but we give the supervisor a beat to actually
// spawn the child. Checking the state file's childPid is better.
const { readState } = await import('./app-state.js')
const st = readState(readiness.name || env.PGH_SUP_NAME || '')
return !!(st && st.childPid)
}
} catch {}
return false
}
function tryConnectUds (p) {
return new Promise((resolve) => {
const s = net.createConnection(p)
const done = (ok) => { try { s.destroy() } catch {}; resolve(ok) }
s.once('connect', () => done(true))
s.once('error', () => done(false))
setTimeout(() => done(false), 1000)
})
}
function tryConnectTcp (host, port) {
return new Promise((resolve) => {
const s = net.createConnection({ host, port })
const done = (ok) => { try { s.destroy() } catch {}; resolve(ok) }
s.once('connect', () => done(true))
s.once('error', () => done(false))
setTimeout(() => done(false), 1000)
})
}
function sleep (ms) { return new Promise(r => setTimeout(r, ms)) }
// Port-conflict boot planning: given the topo-ordered app names and a portOf(name) -> port|null,
// return a Map of name -> skip-reason for every app whose declared service port was ALREADY claimed
// by an earlier app in the order. Pure (no I/O) so it is unit-testable; cmdBoot supplies portOf.
// The earlier app in topo-order owns the port; a later same-port app is skipped rather than started
// (which would only crash-loop on EADDRINUSE). Apps with no declared port never conflict.
export function portConflictSkips (order, portOf) {
const claimed = new Map() // port -> owning name
const skips = new Map() // name -> reason
for (const name of order) {
const port = portOf(name)
if (port == null) continue
if (claimed.has(port)) { skips.set(name, `port ${port} already claimed by ${claimed.get(port)}`); continue }
claimed.set(port, name)
}
return skips
}