OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / 50bd001

0.0.11: OTA is event-driven, and the 6h timer is gone
⛔ THE TIMER WAS NOT MERELY SLOW, IT WAS WRONG. Behind it, `resolveRelease` does a one-shot read and guesses how long replication takes — 500ms, correct for the warm replica every desktop has and far too short for a phone, which has none. So every check resolved null and reported "no published release with a serverKey": a publishing failure that never happened. MEASURED, cold store, same registry: one-shot 500ms -> null, one-shot 20s -> the entry, WATCH -> the entry in 2.8s with no settle at all. Now: `watchLatestRelease` holds the replica, the first release event builds the runtime, later events check. Verified end to end from a cold store — ota: watching favela-surface-android — releases arrive as events, not on a timer ota: downloading 0.0.10 ota: staged 77fca8e0… — active on next start ⛔ THE INTERVAL IS REMOVED, not kept as a safety net. hyper-social #68 §2 took exactly that out of delivery because an unconditional sweep CONCEALS a dead hook — the update appears to work, so nobody notices nothing fires on the event. Reconciliation is the boot-time read plus `checkNow()` as an explicit verb. ⛔ Two bugs found while wiring it, both from holding what used to be transient: the registry Corestore lock is EXCLUSIVE, so handing `swarmFetcher` the same storeDir the watch holds fails with "File descriptor could not be locked"; and `stop()` now closes the watch, because a swarm left running is a DHT node kept awake by something the user switched off. ⚠ `checkNow()` before the first event says so instead of throwing — no runtime yet means the registry has not answered, which is not an error. Needs pgh 0.1.153. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
50bd0010f261039e5ea8df6183e3fac1e4544fb2
by plankatron <fleeky@gmail.com> · 2026-09-10T17:08:29.000Z

Changed files (3) +109 -40

lib/host/ota.js +106 -37
……@@ -46,6 +46,8 @@ const DEFAULT_REPO = 'favela-surface-android'
4646// (hyper-social #54 §8) on the one component that cannot be fixed remotely.
4747const DEFAULT_REGISTRY = '1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86'
4848const DEFAULT_INTERVAL = 6 * 60 * 60 * 1000 // 6h — a phone is not a server; do not poll it awake
49// How long to let a COLD registry replica settle before reading it. See swarmFetcher.
50const COLD_SETTLE_MS = 30000
4951const BUNDLE_NAME = 'worklet.bundle'
5052
5153const hashOf = (bytes) => crypto.data(Buffer.from(bytes)).toString('hex')
……@@ -323,7 +325,7 @@ export { chooseBundle } from './choose-bundle.js'
323325 * has no sidecar and never will, so the probe is pure latency — and SELinux makes pathname sockets their
324326 * own hazard here.
325327 */
326export async function swarmFetcher ({ registryKeyHex = DEFAULT_REGISTRY, repo = DEFAULT_REPO, timeout = 120000, destDir = null, swarm = null, storeDir = null } = {}) {
328export async function swarmFetcher ({ registryKeyHex = DEFAULT_REGISTRY, repo = DEFAULT_REPO, timeout = 120000, destDir = null, swarm = null, storeDir = null, latest: opts_latest = null } = {}) {
327329 const { makeGetLatestRelease } = await import('pgh/resolve-release')
328330 const { NetworkFetcher } = await import('pgh/network-fetcher')
329331
……@@ -331,8 +333,24 @@ export async function swarmFetcher ({ registryKeyHex = DEFAULT_REGISTRY, repo =
331333 // Measured on a handset: ENOENT stat'ing the default path, caught into "no swarm fetcher —
332334 // updates disabled this boot". The phone could never update and said so only once the app was
333335 // taught to surface its own last OTA line.
334 const getLatestRelease = makeGetLatestRelease({ registryKeyHex, preferSidecar: false, storeDir })
335 const latest = await getLatestRelease(repo)
336 // ⛔ settleMs, AND IT IS THE REASON A PHONE NEVER UPDATED. The default is 500ms — correct for a
337 // WARM replica, which is what every machine that has ever run pgh has at the default path. A
338 // handset has no replica: it must pull the registry autobase from the swarm on every boot, and
339 // 500ms is not enough to see a single block. `resolveRelease` then returns null and this throws
340 // "no published release with a serverKey", which reads like a publishing problem and is not.
341 //
342 // ⛔ MEASURED, and the measurement is the whole point: with the SAME key and a cold store,
343 // settleMs 500 -> null, settleMs 20000 -> the real entry with its serverKey. Every earlier
344 // "OTA works" check I ran was resolving from tabertron's warm ~/.config/pgh replica and never
345 // touched the swarm at all — a green light from an instrument pointed at the wrong thing.
346 //
347 // ⚠ The cost is a slow check, not a slow boot: startOta is awaited but non-fatal, the interval is
348 // hours, and a check that answers in 30s having actually replicated beats one that answers in
349 // 500ms having seen nothing.
350 const getLatestRelease = makeGetLatestRelease({
351 registryKeyHex, preferSidecar: false, storeDir, settleMs: COLD_SETTLE_MS
352 })
353 const latest = opts_latest || await getLatestRelease(repo)
336354 if (!latest || !latest.serverKey) throw new Error('no published release with a serverKey for ' + repo)
337355
338356 const fetcher = new NetworkFetcher({ topic: latest.serverKey, timeout, destDir, swarm })
……@@ -372,40 +390,83 @@ export async function startOta ({ storage, say = () => {}, repo = DEFAULT_REPO,
372390 // precisely the failure HTTPS already has. Two transports is also two sets of failure modes on the one
373391 // component that cannot be fixed remotely. If this cannot reach a peer, OTA is off for that boot and
374392 // says so; a sideloaded APK stays the recovery route, as it already is for every native change.
375 let fetcher = injectedFetcher
376 if (!fetcher) {
393 // ⛔ EVENT-DRIVEN, NOT POLLED — and the poll was not merely slow, it was WRONG. A one-shot resolve
394 // has to guess how long replication takes: `resolveRelease` waits `settleMs` because "flush
395 // returns when peers are connected, not when the log has caught up". That guess is 500ms, correct
396 // for a warm replica and far too short for a cold one, so a handset resolved null every boot and
397 // reported "no published release with a serverKey" — a publishing failure that never happened.
398 //
399 // MEASURED against the same registry with a cold store: one-shot settleMs 500 -> null, settleMs
400 // 20000 -> the entry, WATCH -> the entry in 2777ms with no settle at all. A held replica is TOLD
401 // when blocks land; it does not estimate. That removes the class, rather than retuning the guess.
402 //
403 // ⚠ Nothing here blocks boot. The watch is opened, `startOta` returns, and the runtime is built by
404 // the first release event whenever it arrives — which is also the reconciliation, since the watch
405 // reads once itself for a replica that was already caught up.
406 let watch = null
407 let runtime = null
408 let started = null
409 // Hoisted: `wire()` builds it, `stop()` has to close it, and only what WE created (an injected
410 // fetcher belongs to its caller — closing someone else's handle is a use-after-close in the suite
411 // that was meant to catch exactly that).
412 let activeFetcher = injectedFetcher
413 const ownsFetcher = !injectedFetcher
414
415 // Build the runtime once we have a release to point it at. Idempotent: later events only check.
416 const wire = async (entry) => {
417 if (started) { try { await runtime.checkNow() } catch { /* the next event retries */ } ; return }
418 if (!activeFetcher) {
419 try {
420 // ⛔ `storeDir` UNDER THIS APP'S STORAGE. The default derives from the home directory, which
421 // Android does not have — measured on a handset as ENOENT, surfacing as "no swarm fetcher".
422 // ⛔ NO storeDir WHEN WE ALREADY HOLD THE ENTRY. The watch has that Corestore open and
423 // RocksDB's lock is EXCLUSIVE — resolve-release's own header says the registry store "takes
424 // an EXCLUSIVE" lock. Opening it a second time fails with "File descriptor could not be
425 // locked", which surfaces as an OTA error rather than as the double-open it is. With
426 // `latest` supplied there is nothing left to resolve, so there is nothing to open.
427 ;({ fetcher: activeFetcher } = await swarmFetcher({
428 registryKeyHex, repo, destDir: path.join(dir, '.fetch'), swarm, latest: entry
429 }))
430 } catch (err) {
431 say('ota: no swarm fetcher (' + ((err && err.message) || err) + ')')
432 return
433 }
434 }
435 runtime = new PghRuntime({
436 name: repo,
437 currentTreeHash: currentTreeHash || envBundleHash() || null,
438 fetcher: activeFetcher,
439 store,
440 // ⛔ NO INTERVAL. The watch is the signal; a timer beside it would be an unconditional sweep,
441 // and hyper-social #68 §2 removed exactly that from delivery because a sweep CONCEALS a dead
442 // hook — the update appears to work, so nobody notices nothing fires on the event.
443 interval: 0
444 })
445 runtime.on('updating', (r) => { store.noteRelease(r); say('ota: downloading ' + (r && r.version)) })
446 runtime.on('updated', (i) => say('ota: staged ' + i.newTreeHash + ' — active on next start'))
447 runtime.on('error', (e) => say('ota: ' + ((e && e.message) || e)))
448 started = runtime.start()
449 await started
450 }
451
452 if (injectedFetcher) {
453 // A test double: no registry to watch, so wire it straight away and keep the old shape.
454 await wire(null)
455 } else {
456 const { watchLatestRelease } = await import('pgh/resolve-release')
377457 try {
378 // ⛔ `storeDir` UNDER THIS APP'S STORAGE. Without it the registry replica goes to a path
379 // derived from the home directory, which Android does not have — measured on a handset as
380 // ENOENT, surfacing as "no swarm fetcher — updates disabled this boot". A phone that could
381 // never update, for a reason nothing on the device could show until now.
382 ;({ fetcher } = await swarmFetcher({
383 registryKeyHex, repo, destDir: path.join(dir, '.fetch'), swarm, storeDir: path.join(dir, 'registry')
384 }))
458 watch = await watchLatestRelease({
459 registryKeyHex,
460 name: repo,
461 storeDir: path.join(dir, 'registry'),
462 onRelease: (entry) => wire(entry).catch((e) => say('ota: ' + ((e && e.message) || e)))
463 })
464 say('ota: watching ' + repo + ' — releases arrive as events, not on a timer')
385465 } catch (err) {
386 say('ota: no swarm fetcher — updates disabled this boot (' + ((err && err.message) || err) + ')')
466 say('ota: cannot watch the registry (' + ((err && err.message) || err) + ')')
387467 return null
388468 }
389469 }
390
391 const runtime = new PghRuntime({
392 name: repo,
393 // ⛔ NOT `process.env.X` BARE. `process` is not a Bare global — it exists in the worklet only because
394 // lib/node.js imports favela-web's bare-globals shim before anything else. So this line worked on the
395 // phone and threw ReferenceError everywhere else, including the first test that ever called startOta.
396 // A library used from three environments must not read a global it did not import.
397 currentTreeHash: currentTreeHash || envBundleHash() || null,
398 fetcher,
399 store,
400 interval
401 })
402
403 runtime.on('updating', (r) => { store.noteRelease(r); say('ota: downloading ' + (r && r.version)) })
404 runtime.on('updated', (i) => say('ota: staged ' + i.newTreeHash + ' — active on next start'))
405 runtime.on('error', (e) => say('ota: ' + ((e && e.message) || e)))
406
407 await runtime.start()
408 say('ota: watching ' + repo + ' over the swarm every ' + Math.round(interval / 3600000) + 'h')
409470 // ⛔ `checkNow` IS EXPOSED. The 6-hour timer is a poll on a phone that is awake for seconds at a time,
410471 // so the interval is almost never when the user is actually looking — the one moment a check is worth
411472 // making is when they ask. pgh-runtime already has the public method (checkNow, index.js:107); this
……@@ -422,15 +483,23 @@ export async function startOta ({ storage, say = () => {}, repo = DEFAULT_REPO,
422483 // OWNERSHIP: only close what we created. An injected fetcher belongs to the caller — a test owns its
423484 // double's lifecycle, and closing someone else's handle is how you get a use-after-close in the suite
424485 // that was meant to be catching this.
425 const ownsFetcher = !injectedFetcher
426486 return {
427487 stop: async () => {
428 try { if (runtime.stop) await runtime.stop() } catch { /* stopping must not throw on the way out */ }
429 if (ownsFetcher && fetcher && typeof fetcher.close === 'function') {
430 try { await fetcher.close() } catch { /* same */ }
488 // ⛔ THE WATCH TOO. It holds a Hyperswarm and a Corestore, and a swarm left running is a DHT
489 // node kept awake by something the user switched off — the leak shape that reached ~205k
490 // sockets on stortron because the thing that created a handle was not the thing that owned it.
491 try { if (watch) await watch.close() } catch { /* stopping must not throw on the way out */ }
492 try { if (runtime && runtime.stop) await runtime.stop() } catch { /* same */ }
493 if (ownsFetcher && activeFetcher && typeof activeFetcher.close === 'function') {
494 try { await activeFetcher.close() } catch { /* same */ }
431495 }
432496 },
433497 store,
434 checkNow: () => runtime.checkNow()
498 // ⚠ Before the first release event there is no runtime, and that is not an error — it means the
499 // registry has not answered yet. Saying so beats throwing at whoever pressed the button.
500 checkNow: async () => {
501 if (!runtime) { say('ota: no release seen yet — still waiting on the registry'); return null }
502 return runtime.checkNow()
503 }
435504 }
436505}
lib/version.js +2 -2
……@@ -1,4 +1,4 @@
11// GENERATED by scripts/gen-version.mjs — do not edit.
2export const VERSION = "0.0.10"
3export const BUILT_AT = 1789058363744
2export const VERSION = "0.0.11"
3export const BUILT_AT = 1789060099935
44export default { VERSION, BUILT_AT }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface-android",
3 "version": "0.0.10",
3 "version": "0.0.11",
44 "description": "favela-surface-android \u2014 a React Native shell over a bare-kit worklet running a full favela-peer node (markdown records, no atproto) with favela-surface rendering its pages in a WebView.",
55 "type": "module",
66 "private": true,