OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / control.js

104 lines · javascript ref:
// control.js — the pgh control surface, which is what makes OTA updates reach a RUNNING daemon.
//
// WHY THIS EXISTS (2026-10-12). favela-peer had no control surface, so the sidecar had nowhere to
// push `release-ready`: an update installed (staged, because the daemon was running) and the daemon
// kept running the old code until someone restarted it by hand. 0.0.61 was published and installed
// while tabertron and stortron both kept running 0.0.57.
//
// THE OTA CONTRACT (pgh-sidecar lib/index.js `_notifyAppsOfNewReleases`; the same contract every
// pgh daemon with a control surface follows):
//   1. The sidecar does the FULL install: tree, dep closure, materialize. A running app's install is
//      staged as <name>.next.
//   2. It pushes `release-ready` to the udsPath the app REGISTERED with `app-register`. An app that
//      never registered is never told.
//   3. We answer ok FIRST, then shut down and exit 75.
//   4. The supervisor applies the staged tree and respawns immediately (75 skips its crash backoff).
//
// ⚠ This is NOT favela.sock. That socket speaks favela's own line-JSON RPC for the CLI and the
// surface; the sidecar speaks hyper-svc. Two protocols, two sockets.
//
// FAILS SOFT. Holding and serving feeds matters more than being remotely manageable: if the socket
// cannot bind, the daemon logs it and keeps running. Registration is best-effort too, because a
// standalone `bare index.js` has no sidecar at all.

import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import process from '#node:process'

export function sidecarSockPath () {
  return process.env.PGH_SIDECAR_SOCK || join(os.homedir(), '.pgh', 'svc.sock')
}

async function registerWithSidecar ({ udsPath, name, version }) {
  const sidecarSock = sidecarSockPath()
  if (!fs.existsSync(sidecarSock)) return false
  try {
    const { Client } = await import('hyper-svc')
    const c = await Client.connectUds(sidecarSock)
    try { await c.call('app-register', { name, udsPath, pid: process.pid, runtime: 'bare', version }) } finally { await c.close() }
    return true
  } catch {
    return false
  }
}

// Register now, then again on an interval: the sidecar keeps its app registry IN MEMORY, so a
// sidecar restart silently drops us and the next release is never pushed. The sidecar socket can
// also appear after us (its cold start indexes a large store), which the interval covers too.
function maintainRegistration ({ udsPath, name, version, intervalMs = 30000 }) {
  registerWithSidecar({ udsPath, name, version })
  const timer = setInterval(() => { registerWithSidecar({ udsPath, name, version }) }, intervalMs)
  if (timer.unref) timer.unref()
  return () => clearInterval(timer)
}

/**
 * Stand up the control socket and register it with the sidecar.
 *
 * @param {object}   opts
 * @param {string}   opts.udsPath
 * @param {string}   opts.name
 * @param {string}   [opts.version]
 * @param {Function} [opts.status]     returns the `status` reply
 * @param {Function} opts.onRelease    called with the version AFTER the reply is queued; owns the
 *                                     shutdown and the exit code
 * @param {Function} [opts.log]
 * @returns {Promise<{close: Function}|null>} null when the surface could not come up
 */
export async function startControl ({ udsPath, name, version = null, status = null, onRelease, log = defaultLog }) {
  try {
    const { Service } = await import('hyper-svc')
    // A stale socket from a killed process blocks bind. index.js only gets here after refusing to
    // start beside a live daemon, so an existing file is orphaned.
    try { if (fs.existsSync(udsPath)) fs.unlinkSync(udsPath) } catch {}
    const svc = new Service({ name, uds: udsPath })
    svc.handle('status', async () => (status ? status() : { ok: true, name, version }))
    svc.handle('release-ready', async (ctx, params = {}) => {
      const ver = (params && params.version) || '?'
      log({ msg: 'ota: release-ready — sidecar installed; exiting 75 for supervisor respawn', version: ver })
      // After the reply is queued: exiting first drops the connection, and the sidecar cannot tell
      // "accepted, respawning" from "crashed".
      setImmediate(() => { onRelease(ver) })
      return { ok: true, willRespawn: true }
    })
    await svc.listen()
    const stop = maintainRegistration({ udsPath, name, version })
    log({ msg: 'control surface up — OTA respawn armed', uds: udsPath })
    return {
      async close () {
        stop()
        try { await svc.close() } catch {}
        try { fs.unlinkSync(udsPath) } catch {}
      }
    }
  } catch (err) {
    log({ msg: 'control surface unavailable (continuing to serve)', error: (err && err.message) || String(err) })
    return null
  }
}

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

export default startControl