OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 53b10d2

OTA that can actually update the node
lib/ota.js had never executed once. pgh-runtime was --defer'd out of the bundle and absent from dependencies, so the dynamic import always failed and startOta returned null after logging "updates disabled" — which left the code underneath free to be wrong, and it was, in three ways now pinned by controls: - getTree returned {entries: []} for every hash. installTree hashes the entries it is handed and rejects them unless they reproduce the root hash (so a peer cannot answer "I don't have it" with an empty directory), so it died one call before any download, every time. - versions[versions.length - 1] was commented "newest last". This forge lists newest FIRST, so it would have pinned a phone to the oldest build on the server and called it an update. - nothing published a tree.json, so there was nothing fetchable regardless. Now: a fetcher over the forge's HTTPS release routes, a store that writes one file, and a CI step that publishes worklet.bundle + tree.json alongside the APK. Every fetch is hash-verified — releases are unsigned (`signed: false` in every publish) and the forge is load-balanced, so where bytes came from says nothing about what they are, and this bundle becomes the code holding the DID signing key. And the half that can brick a handset: chooseBundle. Record the attempt BEFORE starting, clear it only when the node reports READY, and treat a marker that survived a boot as proof that bundle does not work. A condemned hash is never offered again — otherwise every boot picks it, dies identically, and there is no adb on a user's phone to fix it from. A NEWER bundle is still tried, because the fix for a bad release is the next release. It lives in its own dependency-free module: the decision is made in React Native before the worklet starts, and lib/ota.js imports bare-fs, which does not exist there. Measured, not assumed: un-deferring pgh-runtime grows the bundle 34,858,128 → 37,464,975 (+2.6 MB, +7.5%). An OTA update now ships 37 MB instead of a 153 MB APK — today a favela-web fix reached the handset only by rebuilding the whole APK and sideloading it. 14/14, 33 assertions, against the real pgh-runtime installTree. ⚠ The activation path is NOT exercised on a device: no release carries a tree.json yet, so nothing has ever been staged. First build after this is what proves it, and the fallback is the APK's own bundle either way. ⚠ ~37 MB per release on a releases dir already at 6.74 GiB with gc scheduled by nothing (hyper-social #47 §2). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
53b10d2bf99ca38d51700058605dee42696546f7
by plankatron <fleeky@gmail.com> · 2026-08-18T15:46:32.000Z

Changed files (8) +591 -62

.gitignore +4 -0
……@@ -8,3 +8,7 @@ app/worklet.bundle
88android/app/src/main/assets/
99android/app/.cxx/
1010android/app/addon-keep.json
11
12# Build artifacts staged at the repo root for `artifacts:` in .pear-git/ci.yaml.
13/worklet.bundle
14/tree.json
.pear-git/ci.yaml +13 -1
……@@ -103,9 +103,21 @@ steps:
103103 # The artifact path IS the public URL and the filename is what the user's browser saves, so
104104 # publish it under a name a person can recognise rather than the gradle output path.
105105 cp android/app/build/outputs/apk/release/app-release.apk favela-android.apk
106 ls -l favela-android.apk
106 # The worklet bundle is published ALONGSIDE the APK so an installed phone can update its JS
107 # without a 153 MB sideload — pgh-runtime fetches this file and verifies it against tree.json
108 # (written by `npm run gen:tree` in the prebuild step). Native code still needs a new APK.
109 cp android/app/src/main/assets/worklet.bundle worklet.bundle
110 ls -l favela-android.apk worklet.bundle tree.json
107111 timeout: 2700
108112
109113artifacts:
110114 # ⛔ FILES ONLY — collectArtifacts uses readFile, with no directory walk and no globbing.
111115 - favela-android.apk
116 # The OTA pair. tree.json is what makes the bundle FETCHABLE at all: pgh-runtime installs a TREE, and
117 # hashes the entries it is handed against the root hash before it will accept them — so a release with
118 # a bundle and no manifest is unfetchable by design, which is the state this repo shipped in.
119 #
120 # ⚠ ~37 MB per release on top of the 153 MB APK. `releases` was already 6.74 GiB with gc({keepN:3})
121 # existing and nothing scheduling it (hyper-social #47 §2); this makes that trajectory steeper.
122 - worklet.bundle
123 - tree.json
app/worklet.js +48 -1
……@@ -10,6 +10,7 @@ import RNFS from 'react-native-fs'
1010import { AppState, DeviceEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native'
1111import { WorkletClient } from '../lib/ipc-client'
1212import { createLifecycle } from '../lib/lifecycle'
13import { chooseBundle } from '../lib/choose-bundle'
1314import { describeNode } from '../lib/describe'
1415import { encodeUtf8, createUtf8Decoder } from '../lib/utf8'
1516
……@@ -90,6 +91,46 @@ async function boot () {
9091 try { await RNFS.unlink(dest) } catch {}
9192 await RNFS.copyFileAssets('worklet.bundle', dest)
9293 }
94 // ── WHICH BUNDLE BOOTS ────────────────────────────────────────────────────────────────────────────
95 // OTA stages new worklet bundles into <storage>/bundles (lib/ota.js, inside the worklet). This side
96 // decides whether to RUN one — and it is the decision that can brick a handset, because there is no
97 // adb on a user's phone and no UI left if the node cannot boot.
98 //
99 // The watchdog: record the attempt BEFORE starting, clear it once the node reports ready, and treat a
100 // marker that survived a boot as proof that bundle does not work. chooseBundle is pure and tested
101 // (test/ota.test.mjs); everything here is the file I/O it asks for, and every failure falls back to the
102 // APK's own bundle, which is the one known to work because it shipped with this binary.
103 let bundlePath = dest
104 try {
105 const bdir = RNFS.DocumentDirectoryPath + '/bundles'
106 const readJson = async (p) => { try { return JSON.parse(await RNFS.readFile(p, 'utf8')) } catch { return null } }
107 const failed = (await readJson(bdir + '/failed.json')) || []
108 const choice = chooseBundle({
109 current: await readJson(bdir + '/current.json'),
110 pending: await readJson(bdir + '/pending.json'),
111 failed
112 })
113
114 if (choice.condemn) {
115 // Last boot started this bundle and never reached ready. Remember it so it is never chosen again —
116 // otherwise every boot picks it, dies the same way, and the phone is unrecoverable from outside.
117 console.log('[worklet] bundle ' + choice.condemn.slice(0, 12) + '… failed to boot last time — condemned')
118 if (!failed.includes(choice.condemn)) failed.push(choice.condemn)
119 await RNFS.writeFile(bdir + '/failed.json', JSON.stringify(failed), 'utf8')
120 try { await RNFS.unlink(bdir + '/pending.json') } catch {}
121 }
122
123 if (choice.use === 'staged' && await RNFS.exists(bdir + '/' + choice.file)) {
124 await RNFS.writeFile(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash }), 'utf8')
125 bundlePath = bdir + '/' + choice.file
126 console.log('[worklet] booting STAGED bundle ' + choice.hash.slice(0, 12) + '… (' + choice.reason + ')')
127 }
128 } catch (e) {
129 // A broken OTA directory must never stop the app starting: the APK's bundle is always there.
130 console.log('[worklet] bundle choice failed, using the APK bundle: ' + e.message)
131 bundlePath = dest
132 }
133
93134 const source = null
94135 // The worklet has NO error or exit event (react-native-bare-kit/index.js has neither), so a failure
95136 // inside it is silent from here. These lines are the only evidence of what was actually handed over.
……@@ -97,7 +138,7 @@ async function boot () {
97138 (haveSize === wantSize ? ' (cached)' : ' (copied)') + ' in ' + (Date.now() - t0) + 'ms')
98139
99140 // Path + null source => NativeBareKit.startFile: the runtime reads it, not us.
100 worklet.start(dest, source, [storage])
141 worklet.start(bundlePath, source, [storage])
101142 console.log('[worklet] start() returned')
102143
103144 // ⛔ KEEP RUNNING WITH THE SCREEN OFF. Immediately after start(), because until this returns the node
……@@ -138,6 +179,12 @@ async function boot () {
138179 // Once the node is up, let the notification say what it is actually serving rather than the generic
139180 // line the service starts with. Fire-and-forget and non-blocking: boot takes ~40s, a node that cannot
140181 // describe itself still runs, and nothing here may delay the UI appearing.
182 // ⛔ READY IS THE ONLY PROOF A BUNDLE WORKS. Clearing the pending marker here, and nowhere earlier, is
183 // what makes the watchdog meaningful: a bundle that starts, throws, and dies still leaves its marker.
184 client.whenReady()
185 .then(async () => { try { await RNFS.unlink(RNFS.DocumentDirectoryPath + '/bundles/pending.json') } catch {} })
186 .catch(() => {})
187
141188 client.whenReady()
142189 .then(() => client.hostedDids())
143190 .then((r) => describe(describeNode(r && r.dids)))
lib/choose-bundle.js +45 -0
……@@ -0,0 +1,45 @@
1// Which bundle this boot runs — the staged one, or the APK's.
2//
3// ⛔ ITS OWN MODULE, WITH NO IMPORTS, because it is needed on BOTH sides of the bridge and they share no
4// filesystem API. The decision is made in React Native (RNFS) before the worklet starts; the staging that
5// feeds it happens inside the worklet (bare-fs). lib/ota.js imports bare-fs at module top level, so
6// importing it from the RN side would break the app on load — a mistake worth naming, because the split
7// is not tidiness, it is the only reason this can be called where it has to be called.
8//
9// Pure and dependency-free, like lib/ipc-client.js and lib/lifecycle.js, and testable without a handset —
10// which is the only place the consequences of getting this wrong actually show up.
11
12/**
13 * @param {{current: ?{treeHash: string, file: string}, pending: ?{treeHash: string}, failed: string[]}} state
14 * @returns {{use:'staged'|'asset', hash:?string, file:?string, markPending:boolean, condemn:?string, reason:string}}
15 *
16 * ⛔ A STAGED BUNDLE NOTHING RUNS IS NOT AN UPDATE, and running one blindly is how a phone gets bricked.
17 * There is no adb on a user's handset and no UI left if the node cannot boot, so the only safe shape is a
18 * watchdog: record the intent BEFORE starting, clear it once the node reports ready, and treat a marker
19 * that survived a boot as proof that bundle does not work.
20 *
21 * The failure this exists to prevent is the retry loop — a bad bundle chosen again on every boot, dying
22 * identically each time, with no way in from outside.
23 */
24export function chooseBundle ({ current = null, pending = null, failed = [] } = {}) {
25 const asset = (reason, condemn = null) => ({ use: 'asset', hash: null, file: null, markPending: false, condemn, reason })
26
27 // A pending marker means the LAST boot started a bundle and never reached ready. Condemn whatever it
28 // names — which is not necessarily what is staged now, so it is read from the marker, not from current.
29 const condemn = pending && pending.treeHash ? pending.treeHash : null
30 const dead = new Set([...(Array.isArray(failed) ? failed : []), ...(condemn ? [condemn] : [])])
31
32 if (!current || !current.treeHash || !current.file) return asset('nothing staged', condemn)
33 // A newer bundle still gets its chance: refusing every staged bundle after one bad build would make the
34 // phone permanently un-updatable by a single bad release, and the fix for a bad release is the next one.
35 if (dead.has(current.treeHash)) return asset('staged bundle previously failed to boot', condemn)
36
37 return {
38 use: 'staged',
39 hash: current.treeHash,
40 file: current.file,
41 markPending: true,
42 condemn,
43 reason: 'staged bundle available'
44 }
45}
lib/ota.js +196 -55
……@@ -1,40 +1,93 @@
1// android-ota — keep the JS current on a phone, so the APK only has to change when native addons do.
1// android-ota — keep the JS current on a phone, so the APK only has to change when native code does.
22//
33// THE SPLIT THIS EXISTS TO CREATE. An APK carries two very different things: ~77 MB of native libraries
4// that change rarely (bare-kit + our addons), and a ~12 MB JS bundle that changes constantly. Shipping a
5// new APK for a JS change means a sideload or a store review every time. So the bundle updates over the
6// network and the APK does not.
4// that change rarely (bare-kit + our addons), and a worklet bundle that changes constantly. Today proved
5// the cost of not having this: a favela-web fix reached the handset only by rebuilding a 153 MB APK
6// (150s of CI) and sideloading it, because favela-android vendors favela-web at build time.
77//
8// ⛔ THE TWO "RELEASE" SYSTEMS ARE NOT INTERCHANGEABLE — confirmed 2026-08-07 with the session that owns
9// the forge release path:
10// - pgh-sidecar's ReleaseRegistry: an Autobase of METADATA (name/version/treeHash/deps/signature). It
11// replicates P2P and is what `pgh app install` consumes. It NEVER produces a file.
12// - pear-git-v3's ReleaseManager: real files on local disk, served over HTTPS at
8// ⛔ WHAT WAS HERE BEFORE HAD NEVER RUN. pgh-runtime was `--defer`red out of the bundle and absent from
9// dependencies, so `startOta`'s dynamic import always failed and this file returned null after logging
10// "updates disabled". The code underneath was free to be wrong, and was: `getTree` returned
11// `{entries: []}` for every hash, and pgh-runtime's installTree hashes the entries it is handed and
12// rejects the tree unless they reproduce the root hash — a guard that exists so a peer cannot answer "I
13// don't have it" with an empty directory. So it could not have downloaded a bundle even with the
14// dependency present. It also took `versions[versions.length - 1]` as "newest", and this forge lists
15// newest FIRST, so it would have pinned a phone to the oldest build on the server and called it current.
16//
17// ⛔ THE TWO "RELEASE" SYSTEMS ARE NOT INTERCHANGEABLE:
18// - pgh-sidecar's ReleaseRegistry: an Autobase of METADATA. Replicates P2P, never produces a file.
19// - pear-git-v3's ReleaseManager: real files on disk, served over HTTPS at
1320// /git/releases/<repo>/<version>/<file>. This is the one that can hand a phone bytes.
1421// A phone needs BYTES, so this targets the second.
1522//
16// ⚠ ARTIFACTS DO NOT REPLICATE BETWEEN FORGE BACKENDS. Each daemon's DeployServer topic is keyed to its
17// own identity, so release stores are per-node and disjoint, and 7rnx.net/git is load-balanced with
18// stortron ACTIVE. The bundle must be published on the active backend or this fetches nothing. Same URL
19// can even return different bytes per backend — which is why `verify` below trusts the HASH we were
20// told to expect, never the host that served it.
21//
22// ⛔ pgh-runtime IS NOT A DEPENDENCY OF favela-web, DELIBERATELY. favela-web's whole "runs standalone,
23// zero pgh dependencies" property (spec §0) is what makes a phone build possible at all, and pulling in
24// pgh-runtime drags hyper-overlay with it. So this imports it DYNAMICALLY and degrades to a no-op when
25// absent: a node with no OTA still works, a node that fails to boot does not. Add the dep deliberately
26// when the OTA path is wanted, not as a side effect of shipping a node.
23// ⚠ Artifacts are per-daemon. 7rnx.net/git is load-balanced and a release built on one backend is not
24// automatically complete on the other (measured 2026-08-18: a 153 MB APK never reached the second
25// backend while its build.log and manifest did). So `base` must point at a backend that actually holds
26// the bytes, and every fetch is hash-verified rather than trusted because of where it came from.
27import fs from 'bare-fs'
28import path from 'bare-path'
29import crypto from 'hypercore-crypto'
30import { computeTreeHash, KIND_FILE } from 'hyper-overlay/tree'
2731
2832const DEFAULT_BASE = 'https://7rnx.net/git'
2933const DEFAULT_REPO = 'favela-android'
3034const DEFAULT_INTERVAL = 6 * 60 * 60 * 1000 // 6h — a phone is not a server; do not poll it awake
35const BUNDLE_NAME = 'worklet.bundle'
36
37const hashOf = (bytes) => crypto.data(Buffer.from(bytes)).toString('hex')
38
39/**
40 * The one-file tree for a bundle: what CI publishes as tree.json and what the phone verifies against.
41 *
42 * Deliberately built with hyper-overlay's own encoder rather than by hand — pgh-runtime verifies with
43 * `computeTreeHash`, so producing the hash any other way would be a second definition of the format that
44 * agrees until it doesn't.
45 */
46export function treeFor (bytes, name = BUNDLE_NAME) {
47 const contentHash = hashOf(bytes)
48 const entries = [{ kind: KIND_FILE, exec: 0, name, childHash: contentHash }]
49 return { entries, treeHash: computeTreeHash(entries), contentHash, size: bytes.length }
50}
51
52/**
53 * Newest release by BUILD TIME, not by position in the list.
54 *
55 * The old code took the last element and commented "newest last by publish order". `pgh deploy log`
56 * returns newest FIRST, so that read the oldest build on the server. Ordering the caller cannot see is
57 * not a contract; the timestamp is.
58 */
59export function pickNewest (versions) {
60 const list = (Array.isArray(versions) ? versions : []).filter(Boolean)
61 if (!list.length) return null
62 return list.reduce((a, b) => ((Number(b.builtAt) || 0) > (Number(a.builtAt) || 0) ? b : a))
63}
64
65/**
66 * A pgh-runtime fetcher over the forge's HTTPS release routes.
67 *
68 * Shape required by pgh-runtime: { getLatestRelease(name), getTree(hash), getContent(hash) }.
69 */
70export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFetch }) {
71 // treeHash -> { version, entries }, and contentHash -> { version, name }. Populated when a tree.json is
72 // read, because installTree asks for a tree and then for content BY HASH, with no version in hand.
73 const trees = new Map()
74 const contents = new Map()
75
76 const releaseUrl = (version, file) => `${base}/releases/${repo}/${version}/${file}`
77
78 async function readTree (version) {
79 const res = await doFetch(releaseUrl(version, 'tree.json'))
80 if (!res || res.ok === false) throw new Error(`no tree.json for ${version}`)
81 const body = await res.json()
82 const entries = (body && body.entries) || []
83 // Trust the ENTRIES, not the advertised hash: recompute it. installTree checks this too, but a
84 // fetcher that propagated a hash it never verified would hand the guard its own answer.
85 const treeHash = computeTreeHash(entries)
86 trees.set(treeHash, { version, entries })
87 for (const e of entries) contents.set(e.childHash, { version, name: e.name })
88 return { treeHash, entries }
89 }
3190
32// An HTTPS fetcher conforming to pgh-runtime's documented interface. Its own header says the runtime is
33// "transport-agnostic — any fetcher conforming to that shape works", and store-only mode (omitting the
34// injected materializer) is explicitly supported. That matters here: the materializer is pgh's node one,
35// which HARDLINKS — and SELinux denies `link` device-wide on Android, which is what made a 269-dep
36// closure balloon to ~5.7 GB on celltron. Omitting it is what makes this safe on a phone.
37function httpsFetcher ({ base, repo, doFetch }) {
3891 return {
3992 async getLatestRelease (name) {
4093 const res = await doFetch(`${base}/rpc/list-repo-releases`, {
……@@ -44,29 +97,127 @@ function httpsFetcher ({ base, repo, doFetch }) {
4497 })
4598 if (!res || res.ok === false) return null
4699 const body = await res.json()
47 const versions = (body && (body.versions || body.releases)) || []
48 if (!versions.length) return null
49 // Newest last by publish order; the manifest carries the hash we verify against.
50 const latest = versions[versions.length - 1]
51 return { version: latest.version || latest, treeHash: latest.treeHash || null, name }
100 const newest = pickNewest((body && (body.versions || body.releases)) || [])
101 if (!newest) return null
102 const version = newest.version || String(newest)
103 // A release with no tree.json is one that predates OTA, not an error worth crashing a boot over.
104 let tree
105 try { tree = await readTree(version) } catch { return null }
106 return { name, version, treeHash: tree.treeHash }
107 },
108
109 // ⛔ FETCH, DO NOT JUDGE. This returns whatever the release declares and lets installTree's
110 // computeTreeHash guard accept or reject it. An earlier cut gated here too and had to be pulled: a
111 // fetcher that verifies is a SECOND definition of the check, and the two agree right up until they
112 // do not — and it made the fetcher order-dependent, answering only for a tree getLatestRelease had
113 // pre-warmed. One guard, in the layer that owns it.
114 async getTree (rootHash) {
115 const hit = trees.get(rootHash)
116 if (hit) return { entries: hit.entries, size: hit.entries.length }
117 // Not seen yet — the caller went straight to installTree. Read the newest release's tree and hand
118 // it over; if it is not the one asked for, installTree says so in the words an operator needs.
119 const res = await doFetch(`${base}/rpc/list-repo-releases`, {
120 method: 'POST',
121 headers: { 'content-type': 'application/json' },
122 body: JSON.stringify({ repo })
123 })
124 if (!res || res.ok === false) throw new Error('cannot list releases for ' + repo)
125 const body = await res.json()
126 const newest = pickNewest((body && (body.versions || body.releases)) || [])
127 if (!newest) throw new Error('no releases for ' + repo)
128 const { entries } = await readTree(newest.version || String(newest))
129 return { entries, size: entries.length }
130 },
131
132 async getContent (contentHash) {
133 const where = contents.get(contentHash)
134 if (!where) throw new Error('unknown content ' + String(contentHash).slice(0, 12) + '…')
135 const res = await doFetch(releaseUrl(where.version, where.name))
136 if (!res || res.ok === false) throw new Error('fetch failed: ' + where.name)
137 const bytes = new Uint8Array(await res.arrayBuffer())
138 // ⛔ THE ONLY THING PROTECTING THIS PHONE. Releases are unsigned (`signed: false` in every publish)
139 // and the forge is load-balanced HTTPS, so "it downloaded" says nothing about WHAT downloaded. The
140 // bundle becomes the code that holds the DID signing key. Verify, or do not install.
141 const got = hashOf(bytes)
142 if (got !== contentHash) {
143 throw new Error(`content hash mismatch for ${where.name}: expected ${contentHash.slice(0, 12)}…, got ${got.slice(0, 12)}…`)
144 }
145 return bytes
146 }
147 }
148}
149
150/**
151 * Store-only: write the bundle, and let the NEXT start pick it up.
152 *
153 * Applying a new bundle to a running node would mean swapping the code under itself. A phone restarts
154 * constantly anyway, so "active on next start" costs nothing and avoids a class of live-reload bugs.
155 *
156 * ⛔ NO MATERIALIZER. pgh's node materializer HARDLINKS, and SELinux denies `link` device-wide on
157 * Android — which is what made a 269-dep closure balloon to ~5.7 GB on celltron. One file, written
158 * directly, is the whole of what a bundle needs.
159 */
160export function bundleStore ({ dir }) {
161 const markerPath = (treeHash) => path.join(dir, treeHash + '.json')
162 const readMarker = (treeHash) => {
163 try { return JSON.parse(fs.readFileSync(markerPath(treeHash), 'utf8')) } catch { return null }
164 }
165
166 return {
167 init () { fs.mkdirSync(dir, { recursive: true }) },
168
169 /**
170 * ⛔ THE BYTES, NOT JUST THE MARKER. A marker written before the file it names is the same bug the
171 * forge has been living with all week — an index advertising a release whose artifact never arrived.
172 * So the marker is written LAST and this checks the file too.
173 */
174 hasTree (treeHash) {
175 const m = readMarker(treeHash)
176 if (!m || !m.file) return false
177 try { return fs.statSync(path.join(dir, m.file)).size > 0 } catch { return false }
52178 },
53 // Single-artifact "tree": the whole app is ONE bundle (spike 1 — 11.9 MB), so there is no dep
54 // closure to walk and none of pgh's tree machinery is needed.
55 async getTree () { return { entries: [], size: 0 } },
56 async getContent (url) {
57 const res = await doFetch(url)
58 if (!res || res.ok === false) throw new Error('fetch failed: ' + url)
59 return new Uint8Array(await res.arrayBuffer())
179
180 /** Where the staged bundle for this tree lives, or null. */
181 pathFor (treeHash) {
182 const m = readMarker(treeHash)
183 return m && m.file ? path.join(dir, m.file) : null
184 },
185
186 async materialize (rootHash, entries, { fetchContent } = {}) {
187 this.init()
188 let file = null
189 for (const e of entries) {
190 if (!e || e.kind !== KIND_FILE) continue
191 const bytes = await fetchContent(e.childHash)
192 // Write to .part and rename: a phone is killed constantly, and a truncated bundle that hasTree
193 // then trusted would be a node that cannot boot with no way to say why.
194 const dest = path.join(dir, e.childHash + '.bundle')
195 const part = dest + '.part'
196 fs.writeFileSync(part, Buffer.from(bytes))
197 fs.renameSync(part, dest)
198 file = e.childHash + '.bundle'
199 }
200 if (!file) throw new Error('tree contained no file to stage')
201 fs.writeFileSync(markerPath(rootHash), JSON.stringify({ treeHash: rootHash, file }, null, 2) + '\n')
202 // current.json is how the REACT NATIVE side learns what is staged: it runs before the worklet and
203 // cannot read this store's code (bare-fs), only its files. Written last, after the bytes and the
204 // marker, so it never advertises something that is not fully on disk.
205 fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file }, null, 2) + '\n')
60206 }
61207 }
62208}
63209
210// The boot-time choice lives in its own dependency-free module so the React Native side can import
211// it too (this file imports bare-fs, which does not exist there). Re-exported for callers inside the
212// worklet, so there is one name for it.
213export { chooseBundle } from './choose-bundle.js'
214
64215/**
65216 * Start the bundle updater. Returns { stop() } or null when OTA is unavailable.
66217 *
67218 * Never throws: the caller boots a node either way.
68219 */
69export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL } = {}) {
220export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null } = {}) {
70221 let PghRuntime = null
71222 try {
72223 ;({ PghRuntime } = await import('pgh-runtime'))
……@@ -75,26 +226,16 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE,
75226 return null
76227 }
77228
78 const fs = await import('bare-fs')
79 const path = await import('bare-path')
80229 if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
81230
82 const dir = path.default.join(storage, 'bundles')
83 fs.default.mkdirSync(dir, { recursive: true })
84
85 // Store-only: write the bundle, verify it, and let the NEXT start pick it up. Applying a new bundle
86 // to a running node would mean swapping the code under itself; a phone restarts constantly anyway
87 // (spec §5), so "on next start" costs nothing and avoids a whole class of live-reload bugs.
88 const store = {
89 async init () {},
90 async hasTree (hash) { return fs.default.existsSync(path.default.join(dir, hash + '.bundle')) },
91 async materialize () { /* store-only; see the header */ }
92 }
231 const dir = path.join(storage, 'bundles')
232 const store = bundleStore({ dir })
233 store.init()
93234
94235 const runtime = new PghRuntime({
95236 name: repo,
96 currentTreeHash: process.env.FAVELA_BUNDLE_HASH || null,
97 fetcher: httpsFetcher({ base, repo, doFetch: globalThis.fetch }),
237 currentTreeHash: currentTreeHash || process.env.FAVELA_BUNDLE_HASH || null,
238 fetcher: bundleFetcher({ base, repo, doFetch: globalThis.fetch }),
98239 store,
99240 interval
100241 })
……@@ -105,5 +246,5 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE,
105246
106247 await runtime.start()
107248 say('ota: watching ' + base + '/releases/' + repo + ' every ' + Math.round(interval / 3600000) + 'h')
108 return { stop: () => runtime.stop && runtime.stop() }
249 return { stop: () => runtime.stop && runtime.stop(), store }
109250}
package.json +7 -5
……@@ -6,11 +6,12 @@
66 "type": "module",
77 "main": "lib/node.js",
88 "scripts": {
9 "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --defer pgh-runtime --out android/app/src/main/assets/worklet.bundle lib/node.js",
9 "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js",
1010 "stage:addons": "node scripts/stage-addons.mjs",
11 "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run stage:addons",
12 "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/describe.test.mjs",
13 "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size))\""
11 "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run gen:tree && npm run stage:addons",
12 "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/describe.test.mjs && bare test/ota.test.mjs",
13 "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size))\"",
14 "gen:tree": "bare scripts/gen-tree-manifest.mjs"
1415 },
1516 "dependencies": {
1617 "atproto-identity": "file:../atproto-identity",
……@@ -26,7 +27,8 @@
2627 "hyper-proto-peer": "file:../hyper-proto-peer",
2728 "hyperbee": "2.27.3",
2829 "hyperswarm": "4.17.0",
29 "keet-identity-key": "3.2.0"
30 "keet-identity-key": "3.2.0",
31 "pgh-runtime": "file:../pgh-runtime"
3032 },
3133 "pgh": {
3234 "note": "Not a pgh app. The deliverable is an APK, installed by Android's package manager. pgh-runtime is used INSIDE the app for worklet-bundle OTA only — see spec/architecture.md §2 and favela-web/spec/mobile-node.md §7."
scripts/gen-tree-manifest.mjs +36 -0
……@@ -0,0 +1,36 @@
1// Publish the worklet bundle as a pgh TREE, so a phone can verify what it downloaded.
2//
3// pgh-runtime does not fetch a file, it installs a TREE: it asks for the tree, hashes the entries it is
4// handed, and refuses them unless they reproduce the root hash. That guard exists so a peer cannot answer
5// "I don't have this" with an empty directory. It means a release with a bundle and no tree.json is
6// unfetchable — which is the state favela-android shipped in for two weeks, alongside an OTA client that
7// had never run.
8//
9// Emits tree.json next to the bundle at the repo root, both listed in .pear-git/ci.yaml artifacts:
10//
11// { "treeHash": "<hex>", "size": <bytes>, "entries": [ { kind, exec, name, childHash } ] }
12//
13// Built with lib/ota.js's own treeFor(), which is built with hyper-overlay's encoder — the same function
14// pgh-runtime verifies with. Computing the hash any other way would be a second definition of the format
15// that agrees until it doesn't.
16//
17// Run under BARE, not node: hyper-overlay/tree resolves #fs through bare's import map.
18// bare scripts/gen-tree-manifest.mjs
19import fs from 'bare-fs'
20import path from 'bare-path'
21import { treeFor } from '../lib/ota.js'
22
23const ROOT = path.join(path.dirname(import.meta.url.replace('file://', '')), '..')
24const BUNDLE = path.join(ROOT, 'android', 'app', 'src', 'main', 'assets', 'worklet.bundle')
25const OUT = path.join(ROOT, 'tree.json')
26
27if (!fs.existsSync(BUNDLE)) {
28 console.error('no worklet.bundle at ' + BUNDLE + ' — run `npm run pack:worklet` first')
29 Bare.exit(1)
30}
31
32const bytes = fs.readFileSync(BUNDLE)
33const { entries, treeHash, contentHash, size } = treeFor(bytes)
34
35fs.writeFileSync(OUT, JSON.stringify({ treeHash, size, entries }, null, 2) + '\n')
36console.log(`tree.json — treeHash ${treeHash.slice(0, 16)}… content ${contentHash.slice(0, 16)}… ${size} bytes`)
test/ota.test.mjs +242 -0
……@@ -0,0 +1,242 @@
1// The OTA contract, against the REAL pgh-runtime — not a mock of it.
2//
3// ⛔ WHY THIS FILE EXISTS. lib/ota.js was written against pgh-runtime's documented interface and had
4// never once executed: `pgh-runtime` is `--defer`red in pack:worklet and absent from dependencies, so its
5// dynamic import always failed and `startOta` returned null after logging "updates disabled". The code
6// underneath was therefore free to be wrong, and it was — in three separate ways, each pinned by a
7// control below. Every test here drives pgh-runtime's own `installTree`, so the thing being verified is
8// the contract with the library, not our idea of it.
9import test from 'brittle'
10import fs from 'bare-fs'
11import path from 'bare-path'
12import os from 'bare-os'
13import crypto from 'hypercore-crypto'
14import { computeTreeHash } from 'hyper-overlay/tree'
15import { installTree } from 'pgh-runtime'
16import { treeFor, bundleFetcher, bundleStore, pickNewest, chooseBundle } from '../lib/ota.js'
17
18const BUNDLE = Buffer.from('#!/bare\n// pretend this is 19MB of worklet\n')
19
20let tmpSeq = 0
21function tmpdir (name) {
22 // A counter, not process.pid: `process` is not a bare global, and Date.now()/Math.random() would make
23 // the suite non-reproducible for no benefit — each test wants its own empty directory, nothing more.
24 const dir = path.join(os.tmpdir(), 'favela-ota-' + name + '-' + (++tmpSeq))
25 fs.rmSync(dir, { recursive: true, force: true })
26 fs.mkdirSync(dir, { recursive: true })
27 return dir
28}
29
30// A stand-in forge: the two routes lib/ota.js talks to, backed by memory.
31function fakeForge ({ versions, files }) {
32 return async function doFetch (url, opts) {
33 if (url.endsWith('/rpc/list-repo-releases')) {
34 return { ok: true, async json () { return { versions } } }
35 }
36 const hit = Object.keys(files).find((k) => url.endsWith(k))
37 if (!hit) return { ok: false, status: 404 }
38 const body = files[hit]
39 return {
40 ok: true,
41 async json () { return JSON.parse(Buffer.from(body).toString('utf8')) },
42 async arrayBuffer () { return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) }
43 }
44 }
45}
46
47test('CONTROL: the old fetcher could never have downloaded anything', async (t) => {
48 // What lib/ota.js used to return from getTree, verbatim: an empty tree for every hash. pgh-runtime
49 // hashes the entries it is given and rejects the tree when they do not reproduce the root hash — that
50 // guard exists precisely so a peer cannot answer "I don't have it" with an empty dir. So the old code
51 // could not have fetched a bundle even with the dependency present and a treeHash to ask for: it dies
52 // one call earlier, every time.
53 const old = { async getTree () { return { entries: [], size: 0 } }, async getContent () { return new Uint8Array() } }
54 const store = { init () {}, hasTree () { return false }, async materialize () { t.fail('must not reach materialize') } }
55 const realHash = computeTreeHash(treeFor(BUNDLE).entries)
56
57 await t.exception(installTree(store, realHash, old), /root tree-hash mismatch|EHASHMISMATCH/)
58})
59
60test('CONTROL: newest-by-position picks the OLDEST release from this forge', (t) => {
61 // The old getLatestRelease took versions[versions.length - 1], commented "newest last by publish
62 // order". The forge lists NEWEST FIRST (verified against `pgh deploy log favela-android`), so that
63 // line would have pinned a phone to the oldest build on the server and called it an update.
64 const versions = [
65 { version: 'master-6ada0eb', builtAt: 3000 },
66 { version: 'master-d233237', builtAt: 2000 },
67 { version: 'master-c73eab7', builtAt: 1000 }
68 ]
69 t.is(versions[versions.length - 1].version, 'master-c73eab7', 'the old rule: oldest')
70 t.is(pickNewest(versions).version, 'master-6ada0eb', 'by builtAt, not by position')
71 t.is(pickNewest([...versions].reverse()).version, 'master-6ada0eb', 'and order-independent')
72})
73
74test('a one-file tree round-trips through the real installTree', async (t) => {
75 const dir = tmpdir('roundtrip')
76 const { entries, treeHash, contentHash } = treeFor(BUNDLE)
77 const version = 'master-6ada0eb'
78
79 const doFetch = fakeForge({
80 versions: [{ version, builtAt: 3000 }],
81 files: {
82 [version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })),
83 [version + '/worklet.bundle']: BUNDLE
84 }
85 })
86
87 const fetcher = bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch })
88 const latest = await fetcher.getLatestRelease('favela-android')
89 t.is(latest.version, version)
90 t.is(latest.treeHash, treeHash, 'the version advertises the tree hash it will be verified against')
91
92 const store = bundleStore({ dir })
93 const r = await installTree(store, latest.treeHash, fetcher)
94 t.is(r.materialized, 1)
95
96 const staged = store.pathFor(treeHash)
97 t.ok(staged, 'the store can name the bundle it staged')
98 t.alike(fs.readFileSync(staged), BUNDLE, 'byte-identical to what the forge served')
99 t.is(crypto.data(fs.readFileSync(staged)).toString('hex'), contentHash)
100})
101
102test('bytes that do not hash to the advertised content hash are REFUSED', async (t) => {
103 // The forge is HTTPS behind a load balancer and releases are unsigned, so the only thing standing
104 // between a phone and someone else's bundle is this check. A bundle becomes the code that holds the
105 // DID signing key — "it downloaded fine" is not a security property.
106 const dir = tmpdir('tamper')
107 const { entries, treeHash } = treeFor(BUNDLE)
108 const version = 'master-6ada0eb'
109
110 const doFetch = fakeForge({
111 versions: [{ version, builtAt: 1 }],
112 files: {
113 [version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })),
114 [version + '/worklet.bundle']: Buffer.from('#!/bare\n// somebody else’s worklet\n')
115 }
116 })
117
118 const fetcher = bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch })
119 const store = bundleStore({ dir })
120
121 await t.exception(installTree(store, treeHash, fetcher), /content hash mismatch/)
122 t.absent(store.hasTree(treeHash), 'and nothing is left staged for the next boot to pick up')
123})
124
125test('a tree.json whose entries do not reproduce its own treeHash is REFUSED', async (t) => {
126 const dir = tmpdir('badtree')
127 const { entries, treeHash } = treeFor(BUNDLE)
128 const lying = treeFor(Buffer.from('different bundle')).entries
129 const version = 'master-6ada0eb'
130
131 const doFetch = fakeForge({
132 versions: [{ version, builtAt: 1 }],
133 files: {
134 [version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries: lying })),
135 [version + '/worklet.bundle']: BUNDLE
136 }
137 })
138
139 const fetcher = bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch })
140 await t.exception(installTree(bundleStore({ dir }), treeHash, fetcher), /root tree-hash mismatch/)
141})
142
143test('hasTree is only true when the BYTES are there, not just the marker', async (t) => {
144 // The failure this repo has been living with all week, in miniature: an index that advertises a
145 // version whose bytes never arrived. A marker written before the file is the same bug one layer down,
146 // so the marker is written LAST and hasTree checks the file too.
147 const dir = tmpdir('marker')
148 const { entries, treeHash } = treeFor(BUNDLE)
149 const version = 'master-6ada0eb'
150 const doFetch = fakeForge({
151 versions: [{ version, builtAt: 1 }],
152 files: {
153 [version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })),
154 [version + '/worklet.bundle']: BUNDLE
155 }
156 })
157 const store = bundleStore({ dir })
158 t.absent(store.hasTree(treeHash), 'nothing staged yet')
159
160 await installTree(store, treeHash, bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch }))
161 t.ok(store.hasTree(treeHash), 'staged')
162
163 fs.unlinkSync(store.pathFor(treeHash))
164 t.absent(store.hasTree(treeHash), 'marker without bytes is NOT installed')
165})
166
167test('a second install of the same tree is a no-op', async (t) => {
168 const dir = tmpdir('dedup')
169 const { entries, treeHash } = treeFor(BUNDLE)
170 const version = 'master-6ada0eb'
171 let contentFetches = 0
172 const base = fakeForge({
173 versions: [{ version, builtAt: 1 }],
174 files: {
175 [version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })),
176 [version + '/worklet.bundle']: BUNDLE
177 }
178 })
179 const doFetch = async (url, opts) => { if (url.endsWith('.bundle')) contentFetches++; return base(url, opts) }
180 const fetcher = bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch })
181 const store = bundleStore({ dir })
182
183 await installTree(store, treeHash, fetcher)
184 const again = await installTree(store, treeHash, fetcher)
185 t.is(again.skipped, 1)
186 t.is(contentFetches, 1, 'a phone does not re-download 19MB it already has')
187})
188
189// ── which bundle actually BOOTS ───────────────────────────────────────────────────────────────────
190//
191// ⛔ A STAGED BUNDLE NOTHING RUNS IS NOT AN UPDATE. Downloading and verifying is the easy half; the half
192// that can brick a phone is choosing to run the new code. There is no adb on a user's handset and no UI
193// left if the node fails to boot, so the ONLY safe shape is a watchdog: record the intent before
194// starting, clear it once the node reports ready, and treat a marker that survived a boot as proof that
195// bundle does not work.
196//
197// The failure this must not have: a bad bundle that is retried forever, each boot dying the same way.
198test('with nothing staged, the APK asset boots', (t) => {
199 const c = chooseBundle({ current: null, pending: null, failed: [] })
200 t.is(c.use, 'asset')
201})
202
203test('a staged bundle is used, and the intent is recorded BEFORE it runs', (t) => {
204 const c = chooseBundle({ current: { treeHash: 'aaa', file: 'aaa.bundle' }, pending: null, failed: [] })
205 t.is(c.use, 'staged')
206 t.is(c.hash, 'aaa')
207 t.ok(c.markPending, 'the caller is told to record the attempt before starting')
208})
209
210test('a pending marker that survived a boot condemns that bundle', (t) => {
211 // Written last boot, never cleared ⇒ the node never reached ready on it.
212 const c = chooseBundle({ current: { treeHash: 'aaa', file: 'aaa.bundle' }, pending: { treeHash: 'aaa' }, failed: [] })
213 t.is(c.use, 'asset', 'fall back to the APK, which is known to work')
214 t.is(c.condemn, 'aaa', 'and remember which bundle did it')
215})
216
217test('a condemned bundle is never retried, however many times it is offered', (t) => {
218 const c = chooseBundle({ current: { treeHash: 'aaa', file: 'aaa.bundle' }, pending: null, failed: ['aaa'] })
219 t.is(c.use, 'asset', 'a bundle that failed once is not tried again')
220 t.absent(c.condemn, 'and it is not condemned twice')
221})
222
223test('a NEWER bundle is tried even after an older one failed', (t) => {
224 // The fix for a bad release is the next release. Refusing all staged bundles after one failure would
225 // make the phone permanently un-updatable by a single bad build.
226 const c = chooseBundle({ current: { treeHash: 'bbb', file: 'bbb.bundle' }, pending: null, failed: ['aaa'] })
227 t.is(c.use, 'staged')
228 t.is(c.hash, 'bbb')
229})
230
231test('a pending marker for a DIFFERENT bundle does not condemn the current one', (t) => {
232 // Staged 'bbb' while 'aaa' was mid-attempt: 'aaa' is what failed, not 'bbb'.
233 const c = chooseBundle({ current: { treeHash: 'bbb', file: 'bbb.bundle' }, pending: { treeHash: 'aaa' }, failed: [] })
234 t.is(c.condemn, 'aaa', 'the one that was actually being attempted is condemned')
235 t.is(c.use, 'staged', 'and the new one still gets its chance')
236 t.is(c.hash, 'bbb')
237})
238
239test('a malformed current.json is ignored rather than thrown on', (t) => {
240 t.is(chooseBundle({ current: {}, pending: null, failed: [] }).use, 'asset')
241 t.is(chooseBundle({ current: { treeHash: 'aaa' }, pending: null, failed: [] }).use, 'asset', 'no file, no boot')
242})