// android-ota — keep the JS current on a phone, so the APK only has to change when native code does.
//
// THE SPLIT THIS EXISTS TO CREATE. An APK carries two very different things: ~77 MB of native libraries
// that change rarely (bare-kit + our addons), and a worklet bundle that changes constantly. Today proved
// the cost of not having this: a favela-web fix reached the handset only by rebuilding a 153 MB APK
// (150s of CI) and sideloading it, because favela-android vendors favela-web at build time.
//
// ⛔ WHAT WAS HERE BEFORE HAD NEVER RUN. pgh-runtime was `--defer`red out of the bundle and absent from
// dependencies, so `startOta`'s dynamic import always failed and this file returned null after logging
// "updates disabled". The code underneath was free to be wrong, and was: `getTree` returned
// `{entries: []}` for every hash, and pgh-runtime's installTree hashes the entries it is handed and
// rejects the tree unless they reproduce the root hash — a guard that exists so a peer cannot answer "I
// don't have it" with an empty directory. So it could not have downloaded a bundle even with the
// dependency present. It also took `versions[versions.length - 1]` as "newest", and this forge lists
// newest FIRST, so it would have pinned a phone to the oldest build on the server and called it current.
//
// ⛔ THE TWO "RELEASE" SYSTEMS ARE NOT INTERCHANGEABLE:
// - pgh-sidecar's ReleaseRegistry: an Autobase of METADATA. Replicates P2P, never produces a file.
// - pear-git-v3's ReleaseManager: real files on disk, served over HTTPS at
// /git/releases/<repo>/<version>/<file>. This is the one that can hand a phone bytes.
// A phone needs BYTES, so this targets the second.
//
// ⚠ Artifacts are per-daemon. 7rnx.net/git is load-balanced and a release built on one backend is not
// automatically complete on the other (measured 2026-08-18: a 153 MB APK never reached the second
// backend while its build.log and manifest did). So `base` must point at a backend that actually holds
// the bytes, and every fetch is hash-verified rather than trusted because of where it came from.
import fs from 'bare-fs'
import path from 'bare-path'
import { nextRetryDelay } from './retry-policy.js'
import crypto from 'hypercore-crypto'
import { computeTreeHash, KIND_FILE, KIND_DIR } from 'hyper-overlay/tree'
const DEFAULT_BASE = 'https://7rnx.net/git'
// ⛔ THIS REPO'S OWN NAME, and the reason is not tidiness. This file was copied verbatim from
// favela-android, where the default is `favela-android` — and an OTA client that keeps the source
// repo's name fetches ANOTHER APP'S worklet bundle and stages it as its own. That bundle boots
// hyper-proto-peer and favela-web against this app's storage, which is a different node entirely.
// The registry below is a publisher identity shared by every hyper-fleeky app, so the repo name is
// the ONLY thing distinguishing one app's releases from another's here. Wrong default, wrong app.
const DEFAULT_REPO = 'favela-surface-android'
// The publisher's release registry — the hex in every hyper-fleeky app's `pgh.upgrade`
// (hyper-proto-peer, atproto-bridge, hypernote, favela-web, pgh-sidecar all carry this exact value).
//
// ⛔ A CONSTANT, AND DELIBERATELY NOT A LIST. This is a publisher IDENTITY, not a set of hosts: the
// serving topic comes from the resolved release's own serverKey, so adding, moving or rotating a backend
// changes nothing here. A hand-maintained list of hosts would be the frontedDids anti-pattern
// (hyper-social #54 §8) on the one component that cannot be fixed remotely.
const DEFAULT_REGISTRY = '1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86'
const DEFAULT_INTERVAL = 6 * 60 * 60 * 1000 // 6h — a phone is not a server; do not poll it awake
// How long to let a COLD registry replica settle before reading it. See swarmFetcher.
const COLD_SETTLE_MS = 30000
const BUNDLE_NAME = 'worklet.bundle'
// The directory a release puts its native addons in. CI publishes it beside the bundle.
const ADDONS_NAME = 'addons'
// ⛔ THE STACK, NOT JUST THE MESSAGE. Three separate homedir defaults have now produced the SAME
// ENOENT on a handset, each masked by the one before it, and each cost a build-and-sideload cycle to
// guess at. `err.message` names the path and not the caller, so it cannot distinguish them. A device
// with no log anyone can read has to carry the frame itself.
const frame = (err) => {
const st = (err && err.stack) || ''
// ⛔ SKIP THE RUNTIME'S OWN FRAMES. `slice(1, 4)` reported three frames of bare-fs's RECURSIVE
// mkdir and never reached the caller — the instrument named the failing primitive, which was
// already in the message, and hid the only thing that was not. A recursive helper can produce
// arbitrarily many internal frames, so a fixed depth is always wrong; filter by origin instead.
const ours = String(st).split('\n').slice(1)
.map((l) => l.trim().replace(/^at\s+/, ''))
.filter((l) => l && !/bare-fs|bare-module|bare-path|^bare:|node_modules\/bare-/.test(l))
return ours.length ? ' @ ' + ours.slice(0, 4).join(' < ') : ' @ ' + String(st).split('\n').slice(1, 3).join(' < ')
}
const hashOf = (bytes) => crypto.data(Buffer.from(bytes)).toString('hex')
// The running bundle's hash, if the environment happens to say. Guarded because `process` is a shim in
// the worklet and absent everywhere else; an OTA client is not the place to discover that.
function envBundleHash () {
try { return (typeof process !== 'undefined' && process.env) ? process.env.FAVELA_BUNDLE_HASH : null } catch { return null }
}
/**
* The one-file tree for a bundle: what CI publishes as tree.json and what the phone verifies against.
*
* Deliberately built with hyper-overlay's own encoder rather than by hand — pgh-runtime verifies with
* `computeTreeHash`, so producing the hash any other way would be a second definition of the format that
* agrees until it doesn't.
*/
export function treeFor (bytes, name = BUNDLE_NAME) {
const contentHash = hashOf(bytes)
const entries = [{ kind: KIND_FILE, exec: 0, name, childHash: contentHash }]
return { entries, treeHash: computeTreeHash(entries), contentHash, size: bytes.length }
}
/**
* The sonames a packed bundle demands, or null when they cannot be read.
*
* bare-bundle's container is an ASCII decimal header length, a newline, then that many bytes of JSON —
* the length measured FROM the newline. Parsed by hand, exactly as scripts/stage-addons.mjs does at
* build time, so the phone needs no bundle library to answer the one question chooseBundle asks.
*
* ⛔ NULL, NEVER []. chooseBundle refuses a staged bundle whose demands nothing can satisfy, and an
* empty list would read as "demands nothing" — a bundle that always passes the guard. Unknown has to
* DISABLE the guard, not sail through it. And an unreadable header is not a reason to reject a bundle
* here: it is one fact missing, not a bad download, and the bytes were content-verified upstream.
*/
export function demandedAddons (bytes) {
try {
const buf = Buffer.from(bytes)
const nl = buf.indexOf(10)
if (nl === -1) return null
const len = parseInt(buf.subarray(0, nl).toString(), 10)
if (!Number.isInteger(len) || len <= 0) return null
const header = JSON.parse(buf.subarray(nl, nl + len).toString().trim())
if (!Array.isArray(header.addons)) return null
return header.addons.map((a) => String(a).replace(/^linked:/, ''))
} catch {
return null
}
}
/**
* Newest release by BUILD TIME, not by position in the list.
*
* The old code took the last element and commented "newest last by publish order". `pgh deploy log`
* returns newest FIRST, so that read the oldest build on the server. Ordering the caller cannot see is
* not a contract; the timestamp is.
*/
export function pickNewest (versions) {
const list = (Array.isArray(versions) ? versions : []).filter(Boolean)
if (!list.length) return null
return list.reduce((a, b) => ((Number(b.builtAt) || 0) > (Number(a.builtAt) || 0) ? b : a))
}
/**
* A pgh-runtime fetcher over the forge's HTTPS release routes.
*
* Shape required by pgh-runtime: { getLatestRelease(name), getTree(hash), getContent(hash) }.
*/
export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFetch }) {
// treeHash -> { version, entries }, and contentHash -> { version, name }. Populated when a tree.json is
// read, because installTree asks for a tree and then for content BY HASH, with no version in hand.
const trees = new Map()
const contents = new Map()
const releaseUrl = (version, file) => `${base}/releases/${repo}/${version}/${file}`
async function readTree (version) {
const res = await doFetch(releaseUrl(version, 'tree.json'))
if (!res || res.ok === false) throw new Error(`no tree.json for ${version}`)
const body = await res.json()
const entries = (body && body.entries) || []
// Trust the ENTRIES, not the advertised hash: recompute it. installTree checks this too, but a
// fetcher that propagated a hash it never verified would hand the guard its own answer.
const treeHash = computeTreeHash(entries)
trees.set(treeHash, { version, entries })
for (const e of entries) contents.set(e.childHash, { version, name: e.name })
return { treeHash, entries }
}
// Named, because getTree's fallback calls back into getLatestRelease — one discovery path, not two.
const api = {
// ⛔ `release-index`, NOT `list-repo-releases`. The latter is behind the anonymous-RPC gate (pear-git
// org #12: the whole /rpc/ surface was world-callable until it became opt-in), so a phone — anonymous
// by construction — gets 403 forever. Measured on the handset: the check SUCCEEDS having found
// nothing, prints no error, and is indistinguishable from being up to date.
//
// release-index is already in ANONYMOUS_METHODS, and for the same use: "Release discovery for a
// ReleasePuller… The handler filters its response by repoAccess for anonymous callers, so a private
// repo's versions and hashes stay hidden." A phone updating itself is a puller. Opening a second
// method would have been inventing a path around one that already exists.
async getLatestRelease (name) {
const res = await doFetch(`${base}/rpc/release-index`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}'
})
if (!res || res.ok === false) return null
const body = await res.json()
// hyper-svc wraps HTTP /rpc/ responses in { result }. The old code read body.versions and would
// have found undefined even if the gate had let it through.
const index = (body && body.result) || body || {}
// Group this repo's files by version. The index carries every artifact, so it can answer the
// question that matters: which versions are actually COMPLETE?
const byVersion = new Map()
for (const f of index.files || []) {
if (!f || f.repo !== repo || !f.version) continue
if (!byVersion.has(f.version)) byVersion.set(f.version, { version: f.version, builtAt: 0, names: new Set() })
const v = byVersion.get(f.version)
v.names.add(String(f.path || '').split('/').pop())
if (Number(f.mtimeMs) > v.builtAt) v.builtAt = Number(f.mtimeMs)
}
// ⛔ SKIP INCOMPLETE RELEASES. Artifacts replicate per-file between forge backends and partial
// arrivals are real — measured 2026-08-18: master-5d6d7e4 served build.log, manifest.json and the
// APK while tree.json and worklet.bundle never arrived. Taking "newest" blindly would pin the phone
// to a version it can never install, and retry it forever. Newest COMPLETE is the honest rule.
const complete = [...byVersion.values()].filter((v) => v.names.has('tree.json') && v.names.has(BUNDLE_NAME))
const newest = pickNewest(complete)
if (!newest) return null
// A release with no readable tree is not an error worth crashing a boot over.
let tree
try { tree = await readTree(newest.version) } catch { return null }
return { name, version: newest.version, treeHash: tree.treeHash }
},
// ⛔ FETCH, DO NOT JUDGE. This returns whatever the release declares and lets installTree's
// computeTreeHash guard accept or reject it. An earlier cut gated here too and had to be pulled: a
// fetcher that verifies is a SECOND definition of the check, and the two agree right up until they
// do not — and it made the fetcher order-dependent, answering only for a tree getLatestRelease had
// pre-warmed. One guard, in the layer that owns it.
async getTree (rootHash) {
const hit = trees.get(rootHash)
if (hit) return { entries: hit.entries, size: hit.entries.length }
// Not seen yet — the caller went straight to installTree. Read the newest release's tree and hand
// it over; if it is not the one asked for, installTree says so in the words an operator needs.
// Same discovery path as getLatestRelease — the gated route would 403 here too.
const latest = await api.getLatestRelease(repo)
if (!latest) throw new Error('no complete release for ' + repo)
const again = trees.get(latest.treeHash)
if (!again) throw new Error('no tree for ' + latest.version)
return { entries: again.entries, size: again.entries.length }
},
async getContent (contentHash) {
const where = contents.get(contentHash)
if (!where) throw new Error('unknown content ' + String(contentHash).slice(0, 12) + '…')
const res = await doFetch(releaseUrl(where.version, where.name))
if (!res || res.ok === false) throw new Error('fetch failed: ' + where.name)
const bytes = new Uint8Array(await res.arrayBuffer())
// ⛔ THE ONLY THING PROTECTING THIS PHONE. Releases are unsigned (`signed: false` in every publish)
// and the forge is load-balanced HTTPS, so "it downloaded" says nothing about WHAT downloaded. The
// bundle becomes the code that holds the DID signing key. Verify, or do not install.
const got = hashOf(bytes)
if (got !== contentHash) {
throw new Error(`content hash mismatch for ${where.name}: expected ${contentHash.slice(0, 12)}…, got ${got.slice(0, 12)}…`)
}
return bytes
}
}
return api
}
/**
* Store-only: write the bundle, and let the NEXT start pick it up.
*
* Applying a new bundle to a running node would mean swapping the code under itself. A phone restarts
* constantly anyway, so "active on next start" costs nothing and avoids a class of live-reload bugs.
*
* ⛔ NO MATERIALIZER. pgh's node materializer HARDLINKS, and SELinux denies `link` device-wide on
* Android — which is what made a 269-dep closure balloon to ~5.7 GB on celltron. One file, written
* directly, is the whole of what a bundle needs.
*/
export function bundleStore ({ dir, apkAddons = null }) {
// ⛔ WHAT THE APK ALREADY PROVIDES IS NOT WORTH DOWNLOADING. Measured on the real bundle: it demands
// 58 addons and the installed APK carries 28 of them, so fetching the lot costs 39.7 MB where 27.8 MB
// is needed. A soname the APK provides resolves by dlopen anyway, so the bytes would buy a
// byte-identical library.
//
// ⚠ NULL MEANS SKIP NOTHING. An older shell sends no list, and a soname wrongly assumed present is a
// dlopen failure on a device with no way back in. Absence of knowledge must cost bandwidth, never
// correctness.
const apkHas = Array.isArray(apkAddons) && apkAddons.length > 0 ? new Set(apkAddons) : null
const markerPath = (treeHash) => path.join(dir, treeHash + '.json')
// ⛔ NAMED OFF THE BUNDLE FILE, NOT THE TREE HASH. The staged bundle derives this itself from
// import.meta.url (host/loader-core.js addonsDirFor), which is what lets the preloader ship as an
// ordinary JS release to the APK already on the device. A tree-hash name would have to be handed in
// by the shell, and a shell old enough to predate this cannot hand in anything.
const addonsDir = (file) => path.join(dir, file + '.addons')
// The release currently being installed. pgh-runtime's installTree does not pass the release down to
// the store, but it emits `updating` with it immediately beforehand — so the caller notes it here and
// materialize can stamp the staged marker with a comparable build time. Without that the shell has no
// way to tell a staged bundle from the one inside a freshly installed APK.
let pendingRelease = null
const readMarker = (treeHash) => {
try { return JSON.parse(fs.readFileSync(markerPath(treeHash), 'utf8')) } catch { return null }
}
return {
init () { fs.mkdirSync(dir, { recursive: true }) },
/** Told by startOta from the `updating` event, just before installTree runs. */
noteRelease (r) { pendingRelease = r || null },
/**
* ⛔ THE BYTES, NOT JUST THE MARKER. A marker written before the file it names is the same bug the
* forge has been living with all week — an index advertising a release whose artifact never arrived.
* So the marker is written LAST and this checks the file too.
*/
hasTree (treeHash) {
const m = readMarker(treeHash)
if (!m || !m.file) return false
try { if (fs.statSync(path.join(dir, m.file)).size === 0) return false } catch { return false }
// ⛔ AND EVERY ADDON. installTree SHORT-CIRCUITS on hasTree, so a run that wrote the bundle and
// died before the addons would report complete for ever — and the bundle that then boots demands
// a soname nothing on the device provides. Same "marker before the bytes" bug as above, one level
// up, and the level where it cannot be fixed remotely.
const addons = Array.isArray(m.addons) ? m.addons : []
for (const name of addons) {
try { if (fs.statSync(path.join(addonsDir(m.file), name)).size === 0) return false } catch { return false }
}
return true
},
/** Where the staged bundle for this tree lives, or null. */
pathFor (treeHash) {
const m = readMarker(treeHash)
return m && m.file ? path.join(dir, m.file) : null
},
/**
* Where this tree's staged addons live, or null when the release carries none.
*
* The loader registers each of these under the `linked:` URL the bundle asks for — Bare's
* `Addon.cache` is keyed by url.href and the `linked:` branch of `Addon.resolve` goes through
* `Addon.load`, so a staged addon wins before any dlopen-by-soname and an unstaged one still falls
* through to the APK. Null, never an empty dir: a loader must be able to tell "no addons in this
* release" from "addons that did not arrive".
*/
addonsFor (treeHash) {
const m = readMarker(treeHash)
if (!m || !Array.isArray(m.addons) || m.addons.length === 0) return null
return addonsDir(m.file)
},
async materialize (rootHash, entries, { fetchContent, fetchTree } = {}) {
// ⛔ RECORD A BUILD TIME, WHATEVER THE RELEASE CALLS IT. chooseBundle compares this against the
// APK's stamp, and a staged bundle with no time is one an APK install cannot supersede — the
// failure that left a handset running 0.1.6's JS through four installs on 2026-08-25.
//
// The two release shapes name it differently and neither is wrong: a forge release carries
// `builtAt`, the pgh ReleaseRegistry carries an ISO `publishedAt`. Reading only one is how the
// swarm fetcher would have inherited the very bug it was written to replace.
const builtAt = (pendingRelease && (
(Number(pendingRelease.builtAt) || 0) ||
(Date.parse(pendingRelease.publishedAt || '') || 0)
)) || null
this.init()
// ⛔ BY NAME, NEVER BY POSITION. This used to write EVERY file entry and keep whichever landed
// last. That was safe only while a tree had exactly one entry, and the ci.yaml note said what
// happens otherwise: "a two-entry tree stages a 154 MB APK as the worklet bundle. The OTA client
// on a handset is exactly what cannot be fixed remotely."
//
// It stopped being hypothetical when CI started publishing to the release registry: `pgh publish`
// requires a package.json, so the published tree is { package.json, worklet.bundle } and the winner
// depended on an ordering nothing here controls or asserts. Selecting by name also stops the phone
// downloading content it will discard — the old loop fetched every entry before dropping all but one.
const want = (entries || []).find((e) => e && e.kind === KIND_FILE && e.name === BUNDLE_NAME)
if (!want) {
const names = (entries || []).filter((e) => e && e.kind === KIND_FILE).map((e) => e.name)
throw new Error('tree contains no ' + BUNDLE_NAME + ' to stage (files: ' + (names.join(', ') || 'none') + ')')
}
const bytes = await fetchContent(want.childHash)
// Write to .part and rename: a phone is killed constantly, and a truncated bundle that hasTree
// then trusted would be a node that cannot boot with no way to say why.
const dest = path.join(dir, want.childHash + '.bundle')
const part = dest + '.part'
fs.writeFileSync(part, Buffer.from(bytes))
fs.renameSync(part, dest)
const file = want.childHash + '.bundle'
// Read while the bytes are in hand. The alternative is the React Native side re-opening a 32 MB
// file at every boot to re-read a header that never changes.
const demands = demandedAddons(bytes)
// ⛔ THE ADDONS, BEFORE THE MARKER. A `linked:` bundle names the sonames the APK must supply, and
// until now a bundle demanding one the APK lacks could only die at dlopen — on a device with no
// adb and no UI left to say why. Bare's Addon class is the way out: `new Addon(fileURL)` loads
// from an absolute path, so an addon delivered here can be registered under the `linked:` name
// the bundle asks for and win before any dlopen-by-soname.
//
// BY NAME AND BY KIND, for the same reason the bundle is: a release tree's shape is not ours to
// assume. Absent `addons/`, this is exactly the single-file store it has always been — every
// release published so far is { package.json, worklet.bundle } and must keep working untouched.
const addonDirEntry = (entries || []).find((e) => e && e.kind === KIND_DIR && e.name === ADDONS_NAME)
const addons = []
if (addonDirEntry) {
if (typeof fetchTree !== 'function') {
throw new Error('release carries ' + ADDONS_NAME + '/ but no fetchTree to read it with')
}
const adir = addonsDir(want.childHash + '.bundle')
fs.mkdirSync(adir, { recursive: true })
const sub = await fetchTree(addonDirEntry.childHash)
for (const e of (sub || [])) {
if (!e || e.kind !== KIND_FILE) continue
// Skipped BEFORE fetchContent, which is where the saving is — the old loop downloaded every
// entry and only then decided what to keep.
if (apkHas && apkHas.has(e.name)) continue
const b = await fetchContent(e.childHash)
const d = path.join(adir, e.name)
const pt = d + '.part'
fs.writeFileSync(pt, Buffer.from(b))
// ⛔ THE EXEC BIT IS CARRIED IN THE TREE and it is part of the entry's identity hash, so it
// cannot drift from what was published. Android needs execute on the FILE OBJECT to dlopen
// it out of app storage; a mode that silently defaulted to 0644 would fail at load with an
// error naming the path and not the permission.
if (e.exec === 1) fs.chmodSync(pt, 0o755)
fs.renameSync(pt, d)
addons.push(e.name)
}
}
// `builtAt` is what lets the SHELL compare a staged bundle with the one inside the APK. Without it
// an APK install loses to whatever was staged before it — chooseBundle prefers staged, and tree
// hashes carry no ordering, so a phone kept running the older JS until the next OTA pass caught up.
fs.writeFileSync(markerPath(rootHash), JSON.stringify({ treeHash: rootHash, file, builtAt, addons, demands }, null, 2) + '\n')
// current.json is how the REACT NATIVE side learns what is staged: it runs before the worklet and
// cannot read this store's code (bare-fs), only its files. Written last, after the bytes and the
// marker, so it never advertises something that is not fully on disk.
fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file, builtAt, addons, demands }, null, 2) + '\n')
}
}
}
// The boot-time choice lives in its own dependency-free module so the React Native side can import
// it too (this file imports bare-fs, which does not exist there). Re-exported for callers inside the
// worklet, so there is one name for it.
export { chooseBundle } from './choose-bundle.js'
/**
* THE FETCHER EVERY OTHER hyper-fleeky APP USES — Hyperswarm + hyper-overlay. No HTTPS, no backend.
*
* ⛔ WHAT THIS REPLACES, AND WHY IT WAS WRONG. The HTTPS fetcher spoke to https://7rnx.net/git — a
* LOAD-BALANCED pair of forge daemons — for discovery, tree AND content. pgh-runtime never asked for
* that: it is transport-agnostic and REQUIRES an injected fetcher, and pgh has shipped the Hyperswarm
* implementation the whole time. favela-android was the only app not using it, so hyper-overlay was
* reduced to a hashing library here while every other app used it as the transport it is.
*
* The cost was not theoretical. A release lands on the backend that BUILT it and replicates to the other
* asynchronously; the phone asks the load balancer and gets whichever. Measured 2026-08-25: 0.1.6
* replicated in ~15s, 0.1.7 had NOT replicated after 600s — the phone correctly reported no update and
* was stuck. Measured 2026-08-18: a 153 MB APK never reached the second backend at all.
*
* TWO PIECES, both already exported by pgh:
* makeGetLatestRelease({ registryKeyHex }) replicates the PUBLISHER's registry over Hyperswarm —
* resolve-release.js path 2, written for exactly this case
* ("every standalone install"), which a handset is.
* NetworkFetcher({ topic: serverKey }) tree + content over hyper-overlay's protocol.
*
* MEASURED end-to-end against the real published release before this shipped: resolve 13.2s cold,
* getTree 26ms, and the 38,135,946-byte worklet bundle in 8.4s.
*
* ⛔ RESOLVE ONCE. The 13s is registry replication from cold and it dominates everything else; fetches
* after it are milliseconds. A phone that re-resolved per wake would pay it every time, so the topic is
* learned once per boot — the same thing pgh's own runtime-factory does. A publisher that moves to a new
* serverKey is a restart, not a case worth carrying state for.
*
* ANDROID: `preferSidecar: false`. resolve-release tries a pgh-sidecar UDS first because on a normal
* machine both paths open the same registry corestore and a direct open loses an fd-lock race. A handset
* has no sidecar and never will, so the probe is pure latency — and SELinux makes pathname sockets their
* own hazard here.
*/
export async function swarmFetcher ({ registryKeyHex = DEFAULT_REGISTRY, repo = DEFAULT_REPO, timeout = 120000, destDir = null, swarm = null, storeDir = null, latest: opts_latest = null } = {}) {
const { makeGetLatestRelease } = await import('pgh/resolve-release')
const { NetworkFetcher } = await import('pgh/network-fetcher')
// ⛔ storeDir, OR THE REGISTRY REPLICA GOES TO A HOME DIRECTORY THAT DOES NOT EXIST HERE.
// Measured on a handset: ENOENT stat'ing the default path, caught into "no swarm fetcher —
// updates disabled this boot". The phone could never update and said so only once the app was
// taught to surface its own last OTA line.
// ⛔ settleMs, AND IT IS THE REASON A PHONE NEVER UPDATED. The default is 500ms — correct for a
// WARM replica, which is what every machine that has ever run pgh has at the default path. A
// handset has no replica: it must pull the registry autobase from the swarm on every boot, and
// 500ms is not enough to see a single block. `resolveRelease` then returns null and this throws
// "no published release with a serverKey", which reads like a publishing problem and is not.
//
// ⛔ MEASURED, and the measurement is the whole point: with the SAME key and a cold store,
// settleMs 500 -> null, settleMs 20000 -> the real entry with its serverKey. Every earlier
// "OTA works" check I ran was resolving from tabertron's warm ~/.config/pgh replica and never
// touched the swarm at all — a green light from an instrument pointed at the wrong thing.
//
// ⚠ The cost is a slow check, not a slow boot: startOta is awaited but non-fatal, the interval is
// hours, and a check that answers in 30s having actually replicated beats one that answers in
// 500ms having seen nothing.
// ⛔ ITS OWN storeDir, AND IT MUST HAVE ONE. This closure is not only used here: swarmFetcher
// REPLACES `fetcher.getLatestRelease` with it, and pgh-runtime calls that on every `_check()` —
// so it resolves long after this function returns. Passing `latest` skips the call HERE and does
// nothing for the calls pgh-runtime makes later.
//
// ⛔ AND IT MUST NOT BE THE WATCH'S DIRECTORY. RocksDB's lock is exclusive; sharing the path the
// watch holds fails with "File descriptor could not be locked". Dropping storeDir entirely to
// avoid that — which is what I did — sent it to `homedir()/.config/pgh/registries`, and on Android
// that cannot be created, so every `_check()` threw ENOENT. A separate directory satisfies both.
//
// MEASURED with HOME pointed at an uncreatable path, which reproduces the handset exactly:
// resolveRelease < NetworkFetcher.getLatestRelease < PghRuntime._check < PghRuntime.start
const getLatestRelease = makeGetLatestRelease({
registryKeyHex,
preferSidecar: false,
storeDir: storeDir || (destDir ? path.join(destDir, '..', 'registry-check') : undefined),
settleMs: COLD_SETTLE_MS
})
const latest = opts_latest || await getLatestRelease(repo)
if (!latest || !latest.serverKey) throw new Error('no published release with a serverKey for ' + repo)
// ⛔ localRegistryDir, THE SECOND HOMEDIR IN THIS PATH. NetworkFetcher defaults it to
// `homedir()/.config/pgh/release-registry` and stats it — on Android that is `/data/config/…`,
// which an unprivileged app cannot stat at all, so it throws rather than returning false.
//
// ⚠ IT WAS MASKED. In 0.0.10 `resolveRelease`'s own homedir default failed FIRST, so fixing that
// one simply moved the ENOENT here and it looked like the same bug returning. Two independent
// defaults, one symptom — which is why the fix has to be "no homedir anywhere in this path"
// rather than "fix the one that threw".
const fetcher = new NetworkFetcher({
topic: latest.serverKey, timeout, destDir, swarm, localRegistryDir: path.join(destDir, '..', 'registry-local')
})
// NetworkFetcher's own getLatestRelease reads a LOCAL registry, which a phone does not have. Its header
// calls the replacement a follow-on; the follow-on is the resolver above, and this is the same
// substitution runtime-factory makes for the same reason.
fetcher.getLatestRelease = getLatestRelease
await fetcher.ready()
return { fetcher, latest }
}
/**
* Start the bundle updater. Returns { stop() } or null when OTA is unavailable.
*
* Never throws: the caller boots a node either way.
*/
export async function startOta ({ storage, apkAddons = null, say = () => {}, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null, registryKeyHex = DEFAULT_REGISTRY, swarm = null, fetcher: injectedFetcher = null } = {}) {
let PghRuntime = null
try {
;({ PghRuntime } = await import('pgh-runtime'))
} catch {
say('ota: pgh-runtime not bundled — updates disabled (add the dep to enable)')
return null
}
const dir = path.join(storage, 'bundles')
const store = bundleStore({ dir, apkAddons })
store.init()
// THE FETCHER IS INJECTED, exactly as pgh-runtime injects one into PghRuntime — one transport seam, in
// the layer that owns it. Production passes nothing and gets the swarm; tests pass a double and can
// exercise the store/runtime wiring without a DHT, which is the coverage that caught the two-week
// "OTA never ran" bug and must not be lost with the transport.
//
// ⛔ NO HTTPS FALLBACK, DELIBERATELY. A fallback means the phone silently keeps using the load-balanced
// path whenever the swarm is slow — which is precisely when the swarm is the thing to debug, and
// precisely the failure HTTPS already has. Two transports is also two sets of failure modes on the one
// component that cannot be fixed remotely. If this cannot reach a peer, OTA is off for that boot and
// says so; a sideloaded APK stays the recovery route, as it already is for every native change.
// ⛔ EVENT-DRIVEN, NOT POLLED — and the poll was not merely slow, it was WRONG. A one-shot resolve
// has to guess how long replication takes: `resolveRelease` waits `settleMs` because "flush
// returns when peers are connected, not when the log has caught up". That guess is 500ms, correct
// for a warm replica and far too short for a cold one, so a handset resolved null every boot and
// reported "no published release with a serverKey" — a publishing failure that never happened.
//
// MEASURED against the same registry with a cold store: one-shot settleMs 500 -> null, settleMs
// 20000 -> the entry, WATCH -> the entry in 2777ms with no settle at all. A held replica is TOLD
// when blocks land; it does not estimate. That removes the class, rather than retuning the guess.
//
// ⚠ Nothing here blocks boot. The watch is opened, `startOta` returns, and the runtime is built by
// the first release event whenever it arrives — which is also the reconciliation, since the watch
// reads once itself for a replica that was already caught up.
let watch = null
let runtime = null
let started = null
// Hoisted: `wire()` builds it, `stop()` has to close it, and only what WE created (an injected
// fetcher belongs to its caller — closing someone else's handle is a use-after-close in the suite
// that was meant to catch exactly that).
let activeFetcher = injectedFetcher
const ownsFetcher = !injectedFetcher
// ⛔ THE ENTRY THE WATCH ALREADY DELIVERED, held so a FAILED wire can be retried. Without this a
// transient DHT miss disabled OTA for the whole session: `wire` returned, activeFetcher stayed
// null, and nothing tried again until somebody published. Measured on a handset 2026-09-15 —
// "no swarm fetcher (no peer within 120000ms)" at 15:58, ten minutes of silence, then a download
// at 16:08 that worked first try, and only because a release was published to fire the watch.
//
// ⚠ This is what keeps the retry from being the sweep ota.js refuses: no pending entry, no timer.
// The watch is still the only thing that produces news; this only re-attempts news already given.
let pendingEntry = null
let retryTimer = null
let retryAttempt = 0
const clearRetry = () => {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
retryAttempt = 0
}
const scheduleRetry = () => {
if (retryTimer || !pendingEntry) return
const ms = nextRetryDelay(retryAttempt++)
say('ota: no fetcher yet — retrying in ' + Math.round(ms / 1000) + 's')
retryTimer = setTimeout(() => {
retryTimer = null
wire(null).catch((e) => say('ota: ' + ((e && e.message) || e)))
}, ms)
// ⚠ A retry must not be the reason a process stays alive.
if (retryTimer && typeof retryTimer.unref === 'function') retryTimer.unref()
}
// Build the runtime once we have a release to point it at. Idempotent: later events only check.
//
// ⚠ `entry` is null on a RETRY — the pending one is used. A retry is not a new event and must not
// look like one.
const wire = async (entry) => {
if (entry) pendingEntry = entry
if (started) { clearRetry(); try { await runtime.checkNow() } catch { /* the next event retries */ } ; return }
if (!activeFetcher) {
try {
// ⛔ `storeDir` UNDER THIS APP'S STORAGE. The default derives from the home directory, which
// Android does not have — measured on a handset as ENOENT, surfacing as "no swarm fetcher".
// ⛔ NO storeDir WHEN WE ALREADY HOLD THE ENTRY. The watch has that Corestore open and
// RocksDB's lock is EXCLUSIVE — resolve-release's own header says the registry store "takes
// an EXCLUSIVE" lock. Opening it a second time fails with "File descriptor could not be
// locked", which surfaces as an OTA error rather than as the double-open it is. With
// `latest` supplied there is nothing left to resolve, so there is nothing to open.
;({ fetcher: activeFetcher } = await swarmFetcher({
registryKeyHex, repo, destDir: path.join(dir, '.fetch'), swarm, latest: entry
}))
} catch (err) {
say('ota: no swarm fetcher (' + ((err && err.message) || err) + ')' + frame(err))
// ⛔ AND TRY AGAIN. Returning here was the whole defect: a 120-second DHT timeout became a
// dead OTA client until the next publish.
scheduleRetry()
return
}
}
// We have a fetcher; nothing is pending any more.
clearRetry()
pendingEntry = null
runtime = new PghRuntime({
name: repo,
currentTreeHash: currentTreeHash || envBundleHash() || null,
fetcher: activeFetcher,
store,
// ⛔ NO INTERVAL. The watch is the signal; a timer beside it would be an unconditional sweep,
// and hyper-social #68 §2 removed exactly that from delivery because a sweep CONCEALS a dead
// hook — the update appears to work, so nobody notices nothing fires on the event.
interval: 0
})
runtime.on('updating', (r) => { store.noteRelease(r); say('ota: downloading ' + (r && r.version)) })
runtime.on('updated', (i) => say('ota: staged ' + i.newTreeHash + ' — active on next start'))
runtime.on('error', (e) => say('ota: ' + ((e && e.message) || e) + frame(e)))
started = runtime.start()
await started
}
if (injectedFetcher) {
// A test double: no registry to watch, so wire it straight away and keep the old shape.
await wire(null)
} else {
const { watchLatestRelease } = await import('pgh/resolve-release')
try {
watch = await watchLatestRelease({
registryKeyHex,
name: repo,
storeDir: path.join(dir, 'registry'),
onRelease: (entry) => wire(entry).catch((e) => say('ota: ' + ((e && e.message) || e)))
})
say('ota: watching ' + repo + ' — releases arrive as events, not on a timer')
} catch (err) {
say('ota: cannot watch the registry (' + ((err && err.message) || err) + ')' + frame(err))
return null
}
}
// ⛔ `checkNow` IS EXPOSED. The 6-hour timer is a poll on a phone that is awake for seconds at a time,
// so the interval is almost never when the user is actually looking — the one moment a check is worth
// making is when they ask. pgh-runtime already has the public method (checkNow, index.js:107); this
// just stops throwing the handle away, which is the whole reason the app had no update control.
// ⛔ CLOSE THE SWARM WE OPENED. stop() used to stop only the poll loop, leaving NetworkFetcher's
// Hyperswarm node and corestore open for the life of the process. On a server that is untidy; on a
// phone it is a DHT node and a set of sockets held awake by something the user has switched off. It is
// the same shape as the hyper-svc UDS leak that reached ~205k sockets and ~14 GB on stortron — a
// handle nobody closed because the thing that created it was not the thing that owned it.
//
// Caught empirically: the real end-to-end probe finished staging in 23.5s and then never exited, and
// `timeout` killed it at 600s (EXIT=124). A stop() that leaves the process alive is not a stop.
//
// OWNERSHIP: only close what we created. An injected fetcher belongs to the caller — a test owns its
// double's lifecycle, and closing someone else's handle is how you get a use-after-close in the suite
// that was meant to be catching this.
return {
stop: async () => {
// ⛔ THE WATCH TOO. It holds a Hyperswarm and a Corestore, and a swarm left running is a DHT
// node kept awake by something the user switched off — the leak shape that reached ~205k
// sockets on stortron because the thing that created a handle was not the thing that owned it.
// ⛔ THE RETRY TIMER FIRST. It calls back into wire(), which builds a swarm fetcher — a stop
// that left it armed would create the very handle the rest of this teardown is closing.
clearRetry()
try { if (watch) await watch.close() } catch { /* stopping must not throw on the way out */ }
try { if (runtime && runtime.stop) await runtime.stop() } catch { /* same */ }
if (ownsFetcher && activeFetcher && typeof activeFetcher.close === 'function') {
try { await activeFetcher.close() } catch { /* same */ }
}
},
store,
// ⚠ Before the first release event there is no runtime, and that is not an error — it means the
// registry has not answered yet. Saying so beats throwing at whoever pressed the button.
checkNow: async () => {
if (!runtime) { say('ota: no release seen yet — still waiting on the registry'); return null }
return runtime.checkNow()
}
}
}