OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / resolve-release.js

326 lines · javascript ref:
/**
 * resolveRelease — "what is the latest release of <name>?", with or without a
 * pgh-sidecar on this machine.
 *
 * Extracted verbatim in behaviour from commands/app.js, where it was the install
 * path's private helper. It is here because it is the missing half of embedded
 * OTA: NetworkFetcher's own header says
 *
 *     getLatestRelease currently reads from the LOCAL ReleaseRegistry.
 *     Replication of the publisher's remote registry is a follow-on.
 *
 * and that follow-on turned out to already exist — as this function, reachable
 * only from `pgh app install`. A standalone app (no sidecar, no CLI) could fetch
 * trees and content over Hyperswarm but had no way to learn WHICH tree to fetch.
 *
 * TWO ORDERED PATHS, and the order is not a preference:
 *
 *   1. the sidecar's live replica, when ~/.pgh/svc.sock exists;
 *   2. otherwise replicate the publisher's registry directly by its bootstrap
 *      key, over Hyperswarm.
 *
 * The sidecar goes first because BOTH paths open the same
 * ~/.config/pgh/registries/<key16> Corestore, and that store takes an EXCLUSIVE
 * FD-LOCK. On a machine where the sidecar is also re-opening it (its in-process
 * autoupdate does), a direct open loses the race and dies with "File descriptor
 * could not be locked". runtime-factory's createRuntime routes getLatestRelease
 * through the sidecar for exactly this reason. Path 2 is for machines that have
 * no sidecar at all — which is every standalone install, and every Windows one.
 *
 * THE CALLER OWNS THE CLEANUP. `cleanup()` closes registry, swarm AND store, in
 * that order. ReleaseRegistry.close() closes only the autobase, so dropping the
 * handle without calling this orphans the store's fd-lock for the life of the
 * process and every later resolve in it fails.
 */

import process from '#node:process' // NOT a global under bare — process.env below needs it
import fs from '#node:fs'
import path from '#node:path'
import { homedir } from '#node:os'
import b4a from 'b4a'
import { Client } from 'hyper-svc'

import { ReleaseRegistry } from './release-registry.js'

const SIDECAR_SOCK = process.env.PGH_SIDECAR_SOCK ||
  path.join(homedir(), '.pgh', 'svc.sock')

/** Where a directly-replicated registry's corestore lives. */
export function registryStoreDir (registryKeyHex) {
  return path.join(homedir(), '.config', 'pgh', 'registries', registryKeyHex.slice(0, 16))
}

/**
 * @param {object} opts
 * @param {string} opts.registryKeyHex  64-char registry (autobase bootstrap) key
 * @param {string} opts.name            app name
 * @param {string} [opts.version]       exact version; omitted = latest
 * @param {boolean} [opts.preferSidecar=true]  try the sidecar's replica first
 * @param {number} [opts.settleMs]      IGNORED (was a post-flush sleep; see catchUp)
 * @param {Array} [opts.bootstrap]      DHT bootstrap nodes (tests: a local testnet)
 * @param {string} [opts.storeDir]      where to keep the registry replica. Defaults to a path under
 *   the home directory, which does not exist on Android — an embedder must pass its own.
 * @returns {Promise<{ entry: object|null, via: string, cleanup: function }>}
 */
export async function resolveRelease (opts = {}) {
  const { registryKeyHex, name, version } = opts
  if (!registryKeyHex || registryKeyHex.length !== 64) {
    throw new Error('resolveRelease requires a 64-char registryKeyHex')
  }
  if (!name) throw new Error('resolveRelease requires a name')

  const preferSidecar = opts.preferSidecar !== false

  const noop = async () => {}
  let entry = null

  if (preferSidecar && fs.existsSync(SIDECAR_SOCK)) {
    let c = null
    try {
      c = await Client.connectUds(SIDECAR_SOCK)
      const res = await c.call('resolve-release', {
        registryKey: registryKeyHex, name, version: version || undefined
      })
      if (res && res.found && res.entry) entry = res.entry
    } catch {
      // Sidecar present but unhelpful (down mid-restart, older build without the
      // RPC). Fall through and replicate directly — worst case that fails on the
      // fd-lock, which is a clearer error than a silent miss.
    } finally {
      try { if (c) await c.close() } catch {}
    }
    if (entry) return { entry, via: 'sidecar', cleanup: noop }
  }

  const Corestore = (await import('corestore')).default
  const Hyperswarm = (await import('hyperswarm')).default

  let store = null
  let registry = null
  let swarm = null
  const cleanup = async () => {
    try { if (registry) await registry.close() } catch {}
    try { if (swarm) await swarm.destroy() } catch {}
    try { if (store) await store.close() } catch {}
  }

  // ⛔ CALLER-SUPPLIED OR THE DEFAULT, because the default assumes a HOME DIRECTORY and a phone has
  // none. On Android this resolved to a path that cannot be stat'd, so `resolveRelease` threw
  // ENOENT, `swarmFetcher` caught it, and OTA reported "no swarm fetcher — updates disabled this
  // boot" — a handset that could never update, with the reason visible nowhere until the app was
  // taught to report its own last OTA line.
  //
  // ⚠ Same class as a binary assumed on PATH and a vault assumed under ~: a library that picks a
  // path from the environment works everywhere the environment has one. An embedder knows where its
  // state goes; ask it.
  const dir = opts.storeDir || registryStoreDir(registryKeyHex)
  fs.mkdirSync(dir, { recursive: true })
  store = new Corestore(dir)
  try {
    registry = new ReleaseRegistry(store, { bootstrap: b4a.from(registryKeyHex, 'hex') })
    await registry.ready()
    swarm = new Hyperswarm(opts.bootstrap ? { bootstrap: opts.bootstrap } : {})
    swarm.on('connection', (conn) => store.replicate(conn))
    // client-only: we are reading someone else's registry, not serving ours.
    swarm.join(registry._base.discoveryKey, { server: false, client: true })
    await swarm.flush()
    // flush returns when peers are CONNECTED, not when the log has caught up —
    // so catch up by the protocol, never by a timer. A fixed 500 ms settle read a
    // half-synced view on stortron 2026-10-11 and resolved pear-git-v3 0.1.46
    // (April) instead of 0.1.254. `settleMs` is accepted and ignored.
    await catchUp(registry._base, registry._store)
    entry = version ? await registry.get(name, version) : await registry.latest(name)
  } catch (err) {
    await cleanup()
    throw err
  }

  return { entry, via: 'replica', cleanup }
}

/**
 * Is `candidate` an OLDER version than `installed`? Dotted numeric compare
 * (missing segment = 0). null when either is not purely numeric — no opinion,
 * the caller must not guess.
 */
export function isOlderVersion (candidate, installed) {
  const parse = (v) => (typeof v === 'string' && /^\d+(\.\d+)*$/.test(v)) ? v.split('.').map(Number) : null
  const a = parse(candidate)
  const b = parse(installed)
  if (!a || !b) return null
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
    const x = a[i] || 0
    const y = b[i] || 0
    if (x !== y) return x < y
  }
  return false
}

/**
 * Bring a freshly replicated registry view up to what the connected peers hold.
 *
 * With peers connected, `core.update({ wait: true })` resolves once every peer
 * has reported its length (replicator: `peer.remoteSynced`) — true if that
 * upgraded us. Then download up to that length, and `base.update()` applies it. Applying can reveal
 * more writers (add-writer ops), so repeat until a pass over the bootstrap core
 * plus every active writer grows nothing and reveals no new writer. With no
 * peers, update() returns false at once: we read what we have.
 */
async function catchUp (base, store) {
  const boot = store.get({ key: base.key })
  try {
    await boot.ready()
    let seen = 0
    for (;;) {
      const cores = [boot, ...[...base.activeWriters].map(w => w.core)]
      const grew = await Promise.all(cores.map(c => c.update({ wait: true }).catch(() => false)))
      // update() learns the LENGTH; autobase applies only blocks already on
      // disk and does not fetch them (measured: base.update() right after a
      // successful update() still saw nothing). Download up to that length.
      await Promise.all(cores.map(c => c.contiguousLength < c.length
        ? c.download({ start: c.contiguousLength, end: c.length }).done().catch(() => {})
        : null))
      await base.update()
      const writers = base.activeWriters.size
      if (!grew.some(Boolean) && writers === seen) return
      seen = writers
    }
  } finally {
    await boot.close()
  }
}

/**
 * A `getLatestRelease(name)` for pgh-runtime's fetcher shape, backed by the above.
 *
 * pgh-runtime polls, so this opens and CLOSES per call rather than holding the
 * store's fd-lock for the life of the app: an app that kept it would block the
 * sidecar, `pgh app install`, and its own next resolve. Polling is hourly by
 * default, so the reconnect cost is irrelevant beside that.
 */
/**
 * WATCH a registry for releases of one app, instead of asking it repeatedly.
 *
 * ⛔ WHY THIS EXISTS. `resolveRelease` opens a Corestore and a Hyperswarm, reads, and tears both
 * down — so nothing survives to emit anything, and every caller is forced to poll. Worse, a one-shot
 * read has to GUESS how long replication takes: the code above waits `settleMs` because, in its own
 * words, "flush returns when peers are connected, not when the log has caught up". That guess was
 * 500ms, which is correct for a warm replica and far too short for a cold one — measured on a
 * handset as `resolveRelease` returning null and OTA reporting "no published release with a
 * serverKey", which reads like a publishing failure and is not.
 *
 * ★ A held replica has no settle to guess. The view is built by autobase's apply as blocks arrive,
 * and autobase emits `update` when it does — so you are TOLD when the data lands, however long it
 * takes. That removes the entire class of "you did not wait long enough", rather than retuning it.
 *
 * Event-driven plus reconciliation, the same shape hyper-social #68 §2 landed for delivery: the
 * event sets the latency floor, and the initial read after flush is the reconciliation that covers
 * a replica which was already caught up before anyone subscribed.
 *
 * @returns {{ close: function, latest: function }} `latest()` is the last entry seen, or null.
 */
export async function watchLatestRelease (opts = {}) {
  const { registryKeyHex, name, onRelease = () => {} } = opts
  if (!registryKeyHex || registryKeyHex.length !== 64) {
    throw new Error('watchLatestRelease requires a 64-char registryKeyHex')
  }
  if (!name) throw new Error('watchLatestRelease requires a name')

  const Corestore = (await import('corestore')).default
  const Hyperswarm = (await import('hyperswarm')).default

  const dir = opts.storeDir || registryStoreDir(registryKeyHex)
  fs.mkdirSync(dir, { recursive: true })

  const store = new Corestore(dir)
  const registry = new ReleaseRegistry(store, { bootstrap: b4a.from(registryKeyHex, 'hex') })
  await registry.ready()

  const swarm = new Hyperswarm()
  swarm.on('connection', (conn) => store.replicate(conn))
  // client-only: we are reading someone else's registry, not serving ours.
  swarm.join(registry._base.discoveryKey, { server: false, client: true })

  let last = null
  let closed = false

  // ⚠ Reports only CHANGES. A view can update many times while one release replicates, and a caller
  // that rebuilt a fetcher on every tick would thrash for no reason.
  const reread = async () => {
    if (closed) return
    try {
      await registry.update()
      const entry = await registry.latest(name)
      if (!entry) return
      if (last && last.treeHash === entry.treeHash) return
      last = entry
      await onRelease(entry)
    } catch { /* a registry that cannot be read right now is not an error; the next update retries */ }
  }

  registry._base.on('update', () => { reread().catch(() => {}) })

  // Reconciliation: a replica already caught up before we subscribed emits nothing, so read once.
  // NOT awaited on the swarm — `flush()` can take as long as a DHT lookup, and a caller must not
  // block boot on it. The `update` event covers whatever arrives afterwards.
  swarm.flush().then(reread).catch(() => {})
  await reread()

  return {
    latest: () => last,
    close: async () => {
      closed = true
      try { registry._base.removeAllListeners('update') } catch {}
      // ⛔ CLOSE WHAT WE OPENED, all of it. A swarm left running is a DHT node and a set of sockets
      // held awake by something the user switched off — the same leak shape that reached ~205k
      // sockets on stortron because the thing that created a handle was not the thing that owned it.
      try { await swarm.destroy() } catch {}
      try { await registry.close() } catch {}
      try { await store.close() } catch {}
    }
  }
}

export function makeGetLatestRelease ({ registryKeyHex, preferSidecar, settleMs, storeDir, _resolve = resolveRelease } = {}) {
  // ⛔ SINGLE-FLIGHT, BECAUSE THE STORE'S LOCK IS EXCLUSIVE. Each call opens a Corestore on the same
  // `storeDir`, and RocksDB admits one holder — so two OVERLAPPING calls mean the second throws
  // "File descriptor could not be locked" and the caller sees an OTA error rather than the
  // double-open it is.
  //
  // Measured on a handset 2026-09-15, after an OTA staged a release: the release watch fired and the
  // settings page asked for update status, and those overlap by construction.
  //
  // ★ The fix already existed one layer down and said why — NetworkFetcher.ready(): "Memoize the
  // in-flight promise. Without this, concurrent callers … each run new Corestore(this._storeDir) on
  // the same path. The device-file FDLock admits only one; the loser throws …". The resolver above it
  // never got the same treatment.
  //
  // ⛔ THE IN-FLIGHT PROMISE, NEVER THE RESULT. A cached entry would go stale and a poller would stop
  // seeing new releases — the opposite failure, and a worse one because it is silent. The map entry is
  // deleted as soon as the call settles, including when it throws: memoising a failure would poison
  // every later call from one transient swarm miss.
  //
  // ⚠ Keyed by NAME. Two different apps resolve different entries and must not block each other, even
  // though they share a store — the lock is held only for the length of one resolve either way.
  const inFlight = new Map()

  return async function getLatestRelease (name) {
    const pending = inFlight.get(name)
    if (pending) return pending

    const p = (async () => {
      const { entry, cleanup } = await _resolve({
        registryKeyHex, name, preferSidecar, settleMs, storeDir
      })
      try { return entry } finally { await cleanup() }
    })()

    inFlight.set(name, p)
    try {
      return await p
    } finally {
      inFlight.delete(name)
    }
  }
}