OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 9d18502

0.1.9: fetch updates over the swarm, like every other hyper-fleeky app
favela-android was the only app not using pgh's Hyperswarm fetcher. It injected an HTTPS fetcher that spoke to https://7rnx.net/git — a LOAD-BALANCED pair of forge daemons — for discovery, tree AND content, so hyper-overlay was reduced to a hashing library here while every other app used it as the transport it is. pgh-runtime never asked for that: it is transport-agnostic and REQUIRES an injected fetcher, and pgh has shipped NetworkFetcher the whole time. The cost was not theoretical. A release lands on the backend that BUILT it and replicates to the other asynchronously. Measured today: 0.1.6 replicated in ~15s; 0.1.7 and 0.1.8 had still not replicated after an hour, so the phone correctly reported no update and was stuck with the fix for its own bug sitting on the wrong machine. Measured 2026-08-18: a 153 MB APK never reached the second backend at all. Now: makeGetLatestRelease({ registryKeyHex, preferSidecar: false }) replicates the publisher's registry over Hyperswarm — resolve-release.js path 2, written for "every standalone install", which a handset is — and NetworkFetcher fetches tree + content over hyper-overlay. The topic is the resolved release's own serverKey, so adding, moving or rotating a backend changes nothing here. The one constant is the publisher registry hex, the same value in five other package.json files: an identity, not a list of hosts. A host list would have been the frontedDids anti-pattern on the one component that cannot be fixed remotely. PROVEN END TO END before shipping, sidecar-free, against the real published release: startOta resolved 0.1.8 and staged the real 38,135,946-byte bundle in 23.5s. ⛔ stop() NOW CLOSES THE SWARM IT OPENED. It used to halt only the poll loop, leaving a Hyperswarm node and corestore alive — on a phone, sockets and a DHT held awake by something the user switched off, the same shape as the hyper-svc UDS leak that reached ~205k sockets on stortron. Caught by an exit code: the probe staged in 23.5s then never exited (EXIT=124). It now exits 0. Only the fetcher WE created is closed; an injected one belongs to its caller, and that ownership rule is unit-tested. ⛔ NO HTTPS FALLBACK. A fallback means silently using the load-balanced path exactly when the swarm is the thing to debug, and two transports is two sets of failure modes on the component that cannot be fixed remotely. If no peer can be reached, OTA is off for that boot and says so; a sideloaded APK stays the recovery route, as it already is for every native change. The HTTPS fetcher stays as a TEST-ONLY export: startOta takes an injected fetcher (the same seam pgh-runtime uses), so the e2e suite still proves the runtime/store wiring — the thing the two-week "OTA never ran" bug hid in — without needing a DHT. Full suite green, 4/4 e2e. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9d1850234acefe442f89ed2f738ac215c02f705e
by plankatron <fleeky@gmail.com> · 2026-08-26T01:10:08.000Z

Changed files (3) +140 -11

lib/ota.js +104 -6
……@@ -31,6 +31,14 @@ import { computeTreeHash, KIND_FILE } from 'hyper-overlay/tree'
3131
3232const DEFAULT_BASE = 'https://7rnx.net/git'
3333const DEFAULT_REPO = 'favela-android'
34// The publisher's release registry — the hex in every hyper-fleeky app's `pgh.upgrade`
35// (hyper-proto-peer, atproto-bridge, hypernote, favela-web, pgh-sidecar all carry this exact value).
36//
37// ⛔ A CONSTANT, AND DELIBERATELY NOT A LIST. This is a publisher IDENTITY, not a set of hosts: the
38// serving topic comes from the resolved release's own serverKey, so adding, moving or rotating a backend
39// changes nothing here. A hand-maintained list of hosts would be the frontedDids anti-pattern
40// (hyper-social #54 §8) on the one component that cannot be fixed remotely.
41const DEFAULT_REGISTRY = '1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86'
3442const DEFAULT_INTERVAL = 6 * 60 * 60 * 1000 // 6h — a phone is not a server; do not poll it awake
3543const BUNDLE_NAME = 'worklet.bundle'
3644
……@@ -266,12 +274,62 @@ export function bundleStore ({ dir }) {
266274// worklet, so there is one name for it.
267275export { chooseBundle } from './choose-bundle.js'
268276
277/**
278 * THE FETCHER EVERY OTHER hyper-fleeky APP USES — Hyperswarm + hyper-overlay. No HTTPS, no backend.
279 *
280 * ⛔ WHAT THIS REPLACES, AND WHY IT WAS WRONG. The HTTPS fetcher spoke to https://7rnx.net/git — a
281 * LOAD-BALANCED pair of forge daemons — for discovery, tree AND content. pgh-runtime never asked for
282 * that: it is transport-agnostic and REQUIRES an injected fetcher, and pgh has shipped the Hyperswarm
283 * implementation the whole time. favela-android was the only app not using it, so hyper-overlay was
284 * reduced to a hashing library here while every other app used it as the transport it is.
285 *
286 * The cost was not theoretical. A release lands on the backend that BUILT it and replicates to the other
287 * asynchronously; the phone asks the load balancer and gets whichever. Measured 2026-08-25: 0.1.6
288 * replicated in ~15s, 0.1.7 had NOT replicated after 600s — the phone correctly reported no update and
289 * was stuck. Measured 2026-08-18: a 153 MB APK never reached the second backend at all.
290 *
291 * TWO PIECES, both already exported by pgh:
292 * makeGetLatestRelease({ registryKeyHex }) replicates the PUBLISHER's registry over Hyperswarm —
293 * resolve-release.js path 2, written for exactly this case
294 * ("every standalone install"), which a handset is.
295 * NetworkFetcher({ topic: serverKey }) tree + content over hyper-overlay's protocol.
296 *
297 * MEASURED end-to-end against the real published release before this shipped: resolve 13.2s cold,
298 * getTree 26ms, and the 38,135,946-byte worklet bundle in 8.4s.
299 *
300 * ⛔ RESOLVE ONCE. The 13s is registry replication from cold and it dominates everything else; fetches
301 * after it are milliseconds. A phone that re-resolved per wake would pay it every time, so the topic is
302 * learned once per boot — the same thing pgh's own runtime-factory does. A publisher that moves to a new
303 * serverKey is a restart, not a case worth carrying state for.
304 *
305 * ANDROID: `preferSidecar: false`. resolve-release tries a pgh-sidecar UDS first because on a normal
306 * machine both paths open the same registry corestore and a direct open loses an fd-lock race. A handset
307 * has no sidecar and never will, so the probe is pure latency — and SELinux makes pathname sockets their
308 * own hazard here.
309 */
310export async function swarmFetcher ({ registryKeyHex = DEFAULT_REGISTRY, repo = DEFAULT_REPO, timeout = 120000, destDir = null, swarm = null } = {}) {
311 const { makeGetLatestRelease } = await import('pgh/resolve-release')
312 const { NetworkFetcher } = await import('pgh/network-fetcher')
313
314 const getLatestRelease = makeGetLatestRelease({ registryKeyHex, preferSidecar: false })
315 const latest = await getLatestRelease(repo)
316 if (!latest || !latest.serverKey) throw new Error('no published release with a serverKey for ' + repo)
317
318 const fetcher = new NetworkFetcher({ topic: latest.serverKey, timeout, destDir, swarm })
319 // NetworkFetcher's own getLatestRelease reads a LOCAL registry, which a phone does not have. Its header
320 // calls the replacement a follow-on; the follow-on is the resolver above, and this is the same
321 // substitution runtime-factory makes for the same reason.
322 fetcher.getLatestRelease = getLatestRelease
323 await fetcher.ready()
324 return { fetcher, latest }
325}
326
269327/**
270328 * Start the bundle updater. Returns { stop() } or null when OTA is unavailable.
271329 *
272330 * Never throws: the caller boots a node either way.
273331 */
274export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null } = {}) {
332export async function startOta ({ storage, say = () => {}, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null, registryKeyHex = DEFAULT_REGISTRY, swarm = null, fetcher: injectedFetcher = null } = {}) {
275333 let PghRuntime = null
276334 try {
277335 ;({ PghRuntime } = await import('pgh-runtime'))
……@@ -280,12 +338,30 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE,
280338 return null
281339 }
282340
283 if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
284
285341 const dir = path.join(storage, 'bundles')
286342 const store = bundleStore({ dir })
287343 store.init()
288344
345 // THE FETCHER IS INJECTED, exactly as pgh-runtime injects one into PghRuntime — one transport seam, in
346 // the layer that owns it. Production passes nothing and gets the swarm; tests pass a double and can
347 // exercise the store/runtime wiring without a DHT, which is the coverage that caught the two-week
348 // "OTA never ran" bug and must not be lost with the transport.
349 //
350 // ⛔ NO HTTPS FALLBACK, DELIBERATELY. A fallback means the phone silently keeps using the load-balanced
351 // path whenever the swarm is slow — which is precisely when the swarm is the thing to debug, and
352 // precisely the failure HTTPS already has. Two transports is also two sets of failure modes on the one
353 // component that cannot be fixed remotely. If this cannot reach a peer, OTA is off for that boot and
354 // says so; a sideloaded APK stays the recovery route, as it already is for every native change.
355 let fetcher = injectedFetcher
356 if (!fetcher) {
357 try {
358 ;({ fetcher } = await swarmFetcher({ registryKeyHex, repo, destDir: path.join(dir, '.fetch'), swarm }))
359 } catch (err) {
360 say('ota: no swarm fetcher — updates disabled this boot (' + ((err && err.message) || err) + ')')
361 return null
362 }
363 }
364
289365 const runtime = new PghRuntime({
290366 name: repo,
291367 // ⛔ NOT `process.env.X` BARE. `process` is not a Bare global — it exists in the worklet only because
……@@ -293,7 +369,7 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE,
293369 // phone and threw ReferenceError everywhere else, including the first test that ever called startOta.
294370 // A library used from three environments must not read a global it did not import.
295371 currentTreeHash: currentTreeHash || envBundleHash() || null,
296 fetcher: bundleFetcher({ base, repo, doFetch: globalThis.fetch }),
372 fetcher,
297373 store,
298374 interval
299375 })
……@@ -303,10 +379,32 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE,
303379 runtime.on('error', (e) => say('ota: ' + ((e && e.message) || e)))
304380
305381 await runtime.start()
306 say('ota: watching ' + base + '/releases/' + repo + ' every ' + Math.round(interval / 3600000) + 'h')
382 say('ota: watching ' + repo + ' over the swarm every ' + Math.round(interval / 3600000) + 'h')
307383 // ⛔ `checkNow` IS EXPOSED. The 6-hour timer is a poll on a phone that is awake for seconds at a time,
308384 // so the interval is almost never when the user is actually looking — the one moment a check is worth
309385 // making is when they ask. pgh-runtime already has the public method (checkNow, index.js:107); this
310386 // just stops throwing the handle away, which is the whole reason the app had no update control.
311 return { stop: () => runtime.stop && runtime.stop(), store, checkNow: () => runtime.checkNow() }
387 // ⛔ CLOSE THE SWARM WE OPENED. stop() used to stop only the poll loop, leaving NetworkFetcher's
388 // Hyperswarm node and corestore open for the life of the process. On a server that is untidy; on a
389 // phone it is a DHT node and a set of sockets held awake by something the user has switched off. It is
390 // the same shape as the hyper-svc UDS leak that reached ~205k sockets and ~14 GB on stortron — a
391 // handle nobody closed because the thing that created it was not the thing that owned it.
392 //
393 // Caught empirically: the real end-to-end probe finished staging in 23.5s and then never exited, and
394 // `timeout` killed it at 600s (EXIT=124). A stop() that leaves the process alive is not a stop.
395 //
396 // OWNERSHIP: only close what we created. An injected fetcher belongs to the caller — a test owns its
397 // double's lifecycle, and closing someone else's handle is how you get a use-after-close in the suite
398 // that was meant to be catching this.
399 const ownsFetcher = !injectedFetcher
400 return {
401 stop: async () => {
402 try { if (runtime.stop) await runtime.stop() } catch { /* stopping must not throw on the way out */ }
403 if (ownsFetcher && fetcher && typeof fetcher.close === 'function') {
404 try { await fetcher.close() } catch { /* same */ }
405 }
406 },
407 store,
408 checkNow: () => runtime.checkNow()
409 }
312410}
package.json +2 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-android",
3 "version": "0.1.8",
3 "version": "0.1.9",
44 "description": "favela-android \u2014 a React Native client over a bare-kit worklet running a FULL favela node (hyper-proto-peer + appview + hyperswarm) on the phone. favela-web is reference for the logic, NOT the rendering: this is a different rendering surface. See spec/architecture.md.",
55 "private": true,
66 "type": "module",
……@@ -30,6 +30,7 @@
3030 "hyperbee": "2.27.3",
3131 "hyperswarm": "4.17.0",
3232 "keet-identity-key": "3.2.0",
33 "pgh": "file:../pgh",
3334 "pgh-runtime": "file:../pgh-runtime"
3435 },
3536 "pgh": {
test/ota-e2e.test.mjs +34 -4
……@@ -17,7 +17,12 @@ import fs from 'bare-fs'
1717import path from 'bare-path'
1818import os from 'bare-os'
1919import http from 'bare-http1'
20import { treeFor, bundleStore, startOta } from '../lib/ota.js'
20import { treeFor, bundleStore, startOta, bundleFetcher } from '../lib/ota.js'
21// startOta takes an INJECTED fetcher now — the same seam pgh-runtime uses. Production builds a swarm
22// one (Hyperswarm + hyper-overlay); this suite injects HTTP so the runtime/store wiring is exercised
23// without a DHT. That wiring is what the two-week 'OTA never ran' bug hid in, and it is transport-
24// independent, so testing it over a local forge is faster and hermetic.
25await import('bare-fetch/global') // startOta no longer installs a fetch shim; it has no HTTP path
2126import { chooseBundle } from '../lib/choose-bundle.js'
2227
2328// Multi-megabyte, so the transfer is a real streamed body rather than a string that fits in one chunk.
……@@ -80,7 +85,7 @@ test('e2e: startOta downloads, verifies and STAGES a real bundle', async (t) =>
8085 const said = []
8186 const ota = await startOta({
8287 storage,
83 base: `http://127.0.0.1:${port}`,
88 fetcher: bundleFetcher({ base: `http://127.0.0.1:${port}`, repo, doFetch: globalThis.fetch }),
8489 repo,
8590 interval: 0,
8691 say: (m) => said.push(m)
……@@ -124,7 +129,7 @@ test('e2e: a forge serving the WRONG bytes stages nothing', async (t) => {
124129 t.teardown(() => new Promise((resolve) => server.close(resolve)))
125130
126131 const said = []
127 await startOta({ storage, base: `http://127.0.0.1:${port}`, repo, interval: 0, say: (m) => said.push(m) })
132 await startOta({ storage, fetcher: bundleFetcher({ base: `http://127.0.0.1:${port}`, repo, doFetch: globalThis.fetch }), repo, interval: 0, say: (m) => said.push(m) })
128133
129134 t.ok(said.some((m) => /content hash mismatch/.test(m)), 'the mismatch is REPORTED, not swallowed: ' + JSON.stringify(said))
130135 t.absent(said.some((m) => /staged/.test(m)), 'and nothing claims to have staged')
……@@ -140,7 +145,7 @@ test('e2e: an unreachable forge is a no-op, not a crashed boot', async (t) => {
140145 const said = []
141146 const ota = await startOta({
142147 storage,
143 base: 'http://127.0.0.1:1', // nothing listens here
148 fetcher: bundleFetcher({ base: 'http://127.0.0.1:1', repo: 'favela-android', doFetch: globalThis.fetch }), // nothing listens here
144149 repo: 'favela-android',
145150 interval: 0,
146151 say: (m) => said.push(m)
……@@ -149,3 +154,28 @@ test('e2e: an unreachable forge is a no-op, not a crashed boot', async (t) => {
149154 t.absent(said.some((m) => /staged/.test(m)), 'nothing staged')
150155 t.absent(fs.existsSync(path.join(storage, 'bundles', 'current.json')), 'and nothing is advertised to the next boot')
151156})
157
158// ⛔ stop() MUST RELEASE WHAT startOta OPENED, and must not release what it did not.
159//
160// The production fetcher is a Hyperswarm node plus a corestore. stop() used to halt only the poll loop,
161// so a phone that switched updates off kept a DHT node and its sockets alive — the same shape as the
162// hyper-svc UDS leak that reached ~205k sockets on stortron. Caught empirically: the real swarm probe
163// staged its bundle in 23.5s and then never exited (timeout killed it at 600s).
164//
165// The owned-fetcher path needs a real DHT, so it is proven by that probe exiting cleanly. What IS
166// unit-testable is the ownership rule, and it is the half that silently corrupts a test suite: closing a
167// handle the caller still owns.
168test('stop() does not close an INJECTED fetcher — the caller owns it', async (t) => {
169 const storage = tmp('ownership')
170 let closed = 0
171 const inert = {
172 async getLatestRelease () { return null },
173 async getTree () { throw new Error('not used') },
174 async getContent () { throw new Error('not used') },
175 async close () { closed++ }
176 }
177 const ota = await startOta({ storage, fetcher: inert, repo: 'favela-android', interval: 0, say: () => {} })
178 t.ok(ota, 'startOta returned a handle with an injected fetcher')
179 await ota.stop()
180 t.is(closed, 0, 'the injected fetcher was NOT closed — its owner closes it')
181})