OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / dep-graph.js

139 lines · javascript ref:
/**
 * 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
}