/**
* 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)
}
}
}