OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge / pds / lib / shutdown.js

64 lines · javascript ref:
// createShutdown — the membrane's stop sequence, as a testable unit.
//
// ⛔ THE INCIDENT (0.9.50 OTA, 2026-10-12). release-ready logged "exiting 75"; the process then took
// ~50 s to exit while pds.7rnx.net answered 503, and it exited 0. Two defects:
//   1. shutdown() called process.exit(0) itself, so release-ready's `.then(exit 75)` never ran. The
//      supervisor happened to respawn on 0 too, which is the only reason the OTA landed at all.
//   2. Nothing bounded or timed the steps, so a slow one stretched the outage and left no record of
//      which it was. (Not server.close: test/bare/shutdown.test.js measures that an in-flight GET does
//      not hold it.)
// So: the caller names the exit code, every step is timed, and a deadline exits anyway and names the
// step it was stuck on. The next slow OTA then says where the time went.

export const DEFAULT_SHUTDOWN_DEADLINE_MS = 15000

/**
 * @param {object}   opts
 * @param {Array<[string, Function]>} opts.steps  run in order; a throw is logged and skipped past
 * @param {Function} opts.exit         called exactly once, with the first caller's code
 * @param {number}   [opts.deadlineMs] overall budget; past it, exit is called with the steps unfinished
 * @param {Function} [opts.log]        receives one object per line
 * @returns {(code: number) => Promise<void>}
 */
export function createShutdown ({ steps, exit, deadlineMs = DEFAULT_SHUTDOWN_DEADLINE_MS, log = defaultLog }) {
  let started = null

  return function shutdown (code = 0) {
    // A second caller (SIGTERM arriving mid-OTA) joins the first: the steps run once, its code wins.
    if (started) return started
    started = run(code)
    return started
  }

  async function run (code) {
    const t0 = Date.now()
    let current = null
    let exited = false
    const finish = () => { if (exited) return; exited = true; clearTimeout(timer); exit(code) }

    // NOT unref'd: an unref'd deadline is skipped exactly when nothing else holds the loop open.
    const timer = setTimeout(() => {
      log({ msg: 'shutdown: deadline reached, exiting with steps unfinished', stuck: current, deadlineMs, code })
      finish()
    }, deadlineMs)

    for (const [name, fn] of steps) {
      if (exited) return
      current = name
      const s = Date.now()
      let error = null
      try { await fn() } catch (err) { error = err && err.message ? err.message : String(err) }
      const line = { msg: 'shutdown: step', step: name, ms: Date.now() - s }
      if (error) line.error = error
      log(line)
    }
    current = null
    log({ msg: 'shutdown: done', ms: Date.now() - t0, code })
    finish()
  }
}

function defaultLog (o) { console.log(JSON.stringify(o)) }

export default createShutdown