OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / cdeb22d

0.0.41: a transient DHT miss no longer kills OTA for the whole session
⛔ MEASURED END TO END ON A HANDSET, 2026-09-15, and the sequence IS the bug report: 15:58:12 ota: no swarm fetcher (NetworkFetcher: no peer within 120000ms) …ten minutes of nothing… 16:08:39 ota: downloading 0.0.40 ← only because a release was published 16:08:59 ota: staged bcfc167d… The miss was TRANSIENT — the fetcher worked first try when it next ran. But nothing retried it: `wire()` returned on failure, `activeFetcher` stayed null, `started` stayed false, and the watch only fires on a NEW release. So a 120-second DHT timeout left the phone unable to update for the rest of the session, and the only cure was somebody publishing again. Which is what happened, by accident, because I was shipping. The entry the watch delivered is now HELD, and a failed wire is retried with backoff — 15s, 30s, 60s, 2m, then 5m, capped and never exhausted. A client that gave up for good would need a manual restart to ever update again, which is the state this ends. ⛔ THIS IS NOT THE INTERVAL ota.js REFUSES, and that refusal still stands: "a timer beside it would be an unconditional sweep, and #68 §2 removed exactly that because a sweep CONCEALS a dead hook". A sweep polls for news and hides a dead watch. This never runs unless the watch ALREADY fired and handed us a release we failed to act on — no pending entry, no timer. The watch remains the only source of news. ⚠ The teardown clears the timer FIRST: it calls back into wire(), which builds a swarm fetcher, so a stop that left it armed would create the very handle the rest of the teardown is closing. 122/122, 390/390. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cdeb22d32a911fc0adbaeb8037353d49a027a241
by plankatron <fleeky@gmail.com> · 2026-09-15T20:11:26.000Z

Changed files (5) +113 -4

lib/host/ota.js +45 -1
……@@ -26,6 +26,7 @@
2626// the bytes, and every fetch is hash-verified rather than trusted because of where it came from.
2727import fs from 'bare-fs'
2828import path from 'bare-path'
29import { nextRetryDelay } from './retry-policy.js'
2930import crypto from 'hypercore-crypto'
3031import { computeTreeHash, KIND_FILE, KIND_DIR } from 'hyper-overlay/tree'
3132
……@@ -560,9 +561,42 @@ export async function startOta ({ storage, apkAddons = null, say = () => {}, rep
560561 let activeFetcher = injectedFetcher
561562 const ownsFetcher = !injectedFetcher
562563
564 // ⛔ THE ENTRY THE WATCH ALREADY DELIVERED, held so a FAILED wire can be retried. Without this a
565 // transient DHT miss disabled OTA for the whole session: `wire` returned, activeFetcher stayed
566 // null, and nothing tried again until somebody published. Measured on a handset 2026-09-15 —
567 // "no swarm fetcher (no peer within 120000ms)" at 15:58, ten minutes of silence, then a download
568 // at 16:08 that worked first try, and only because a release was published to fire the watch.
569 //
570 // ⚠ This is what keeps the retry from being the sweep ota.js refuses: no pending entry, no timer.
571 // The watch is still the only thing that produces news; this only re-attempts news already given.
572 let pendingEntry = null
573 let retryTimer = null
574 let retryAttempt = 0
575
576 const clearRetry = () => {
577 if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
578 retryAttempt = 0
579 }
580
581 const scheduleRetry = () => {
582 if (retryTimer || !pendingEntry) return
583 const ms = nextRetryDelay(retryAttempt++)
584 say('ota: no fetcher yet — retrying in ' + Math.round(ms / 1000) + 's')
585 retryTimer = setTimeout(() => {
586 retryTimer = null
587 wire(null).catch((e) => say('ota: ' + ((e && e.message) || e)))
588 }, ms)
589 // ⚠ A retry must not be the reason a process stays alive.
590 if (retryTimer && typeof retryTimer.unref === 'function') retryTimer.unref()
591 }
592
563593 // Build the runtime once we have a release to point it at. Idempotent: later events only check.
594 //
595 // ⚠ `entry` is null on a RETRY — the pending one is used. A retry is not a new event and must not
596 // look like one.
564597 const wire = async (entry) => {
565 if (started) { try { await runtime.checkNow() } catch { /* the next event retries */ } ; return }
598 if (entry) pendingEntry = entry
599 if (started) { clearRetry(); try { await runtime.checkNow() } catch { /* the next event retries */ } ; return }
566600 if (!activeFetcher) {
567601 try {
568602 // ⛔ `storeDir` UNDER THIS APP'S STORAGE. The default derives from the home directory, which
……@@ -577,9 +611,16 @@ export async function startOta ({ storage, apkAddons = null, say = () => {}, rep
577611 }))
578612 } catch (err) {
579613 say('ota: no swarm fetcher (' + ((err && err.message) || err) + ')' + frame(err))
614 // ⛔ AND TRY AGAIN. Returning here was the whole defect: a 120-second DHT timeout became a
615 // dead OTA client until the next publish.
616 scheduleRetry()
580617 return
581618 }
582619 }
620 // We have a fetcher; nothing is pending any more.
621 clearRetry()
622 pendingEntry = null
623
583624 runtime = new PghRuntime({
584625 name: repo,
585626 currentTreeHash: currentTreeHash || envBundleHash() || null,
……@@ -636,6 +677,9 @@ export async function startOta ({ storage, apkAddons = null, say = () => {}, rep
636677 // ⛔ THE WATCH TOO. It holds a Hyperswarm and a Corestore, and a swarm left running is a DHT
637678 // node kept awake by something the user switched off — the leak shape that reached ~205k
638679 // sockets on stortron because the thing that created a handle was not the thing that owned it.
680 // ⛔ THE RETRY TIMER FIRST. It calls back into wire(), which builds a swarm fetcher — a stop
681 // that left it armed would create the very handle the rest of this teardown is closing.
682 clearRetry()
639683 try { if (watch) await watch.close() } catch { /* stopping must not throw on the way out */ }
640684 try { if (runtime && runtime.stop) await runtime.stop() } catch { /* same */ }
641685 if (ownsFetcher && activeFetcher && typeof activeFetcher.close === 'function') {
lib/host/retry-policy.js +39 -0
……@@ -0,0 +1,39 @@
1// How long to wait before trying a FAILED fetcher construction again.
2//
3// ⛔ WHY THIS IS NOT THE INTERVAL ota.js REFUSES. Its comment is right and stands:
4//
5// ⛔ NO INTERVAL. The watch is the signal; a timer beside it would be an unconditional sweep,
6// and hyper-social #68 §2 removed exactly that from delivery because a sweep CONCEALS a dead
7// hook — the update appears to work, so nobody notices nothing fires on the event.
8//
9// A SWEEP polls for news and hides a dead watch. This is not that. The watch has ALREADY fired and
10// handed us a release; we failed to build a fetcher for it. Retrying that one known-pending entry is
11// recovery from a named failure, and it cannot conceal a dead hook because it never runs unless the
12// hook fired. The guard that keeps the distinction honest is in ota.js: no pending entry, no retry.
13//
14// ⛔ MEASURED, WHICH IS WHY THIS EXISTS. Handset, 2026-09-15:
15// 15:58:12 ota: no swarm fetcher (NetworkFetcher: no peer within 120000ms)
16// …ten minutes of nothing…
17// 16:08:39 ota: downloading 0.0.40 ← only because a release was published
18// 16:08:59 ota: staged …
19// The DHT miss was transient and the fetcher worked first try once retried. But nothing retried: a
20// transient miss disabled OTA for the whole session, and the only cure was somebody publishing again.
21//
22// Backoff, not a tight loop: the failure mode being recovered from is a 120-SECOND timeout, so an
23// eager retry would spend the battery holding a swarm open for a DHT that is not answering.
24
25export const RETRY_MS = [15_000, 30_000, 60_000, 120_000, 300_000]
26
27/**
28 * Delay before attempt `n` (0-based), capped at the last step.
29 *
30 * ⚠ CAPPED, NOT EXHAUSTED. There is deliberately no attempt limit: a phone that gave up for good
31 * would need a manual restart to ever update again, which is the state this whole change exists to
32 * end. Five minutes apart is cheap enough to keep trying indefinitely.
33 */
34export function nextRetryDelay (n) {
35 const i = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
36 return RETRY_MS[Math.min(i, RETRY_MS.length - 1)]
37}
38
39export default { RETRY_MS, nextRetryDelay }
lib/version.js +2 -2
……@@ -1,4 +1,4 @@
11// GENERATED by scripts/gen-version.mjs — do not edit.
2export const VERSION = "0.0.40"
3export const BUILT_AT = 1789502687385
2export const VERSION = "0.0.41"
3export const BUILT_AT = 1789503080437
44export default { VERSION, BUILT_AT }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface-android",
3 "version": "0.0.40",
3 "version": "0.0.41",
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,
test/retry-policy.test.js +26 -0
……@@ -0,0 +1,26 @@
1import test from 'brittle'
2import { nextRetryDelay, RETRY_MS } from '../lib/host/retry-policy.js'
3
4test('backs off, then holds — it never gives up', (t) => {
5 t.is(nextRetryDelay(0), 15_000)
6 t.is(nextRetryDelay(1), 30_000)
7 t.is(nextRetryDelay(4), 300_000)
8 // ⚠ A phone that gave up for good would need a manual restart to ever update again — the exact
9 // state this exists to end. Capped, never exhausted.
10 t.is(nextRetryDelay(50), 300_000)
11 t.is(nextRetryDelay(5000), RETRY_MS[RETRY_MS.length - 1])
12})
13
14test('a nonsense attempt count does not produce a nonsense delay', (t) => {
15 for (const bad of [-1, NaN, undefined, null, 'x', Infinity]) {
16 const d = nextRetryDelay(bad)
17 t.ok(RETRY_MS.includes(d), String(bad) + ' → a real step (' + d + ')')
18 }
19})
20
21test('every step is long enough to be recovering, not spinning', (t) => {
22 // The failure being recovered from is a 120s timeout; a tight retry would hold a swarm open
23 // against a DHT that is not answering and spend the battery doing it.
24 t.ok(RETRY_MS.every((ms) => ms >= 15_000))
25 t.ok(RETRY_MS.every((ms, i, a) => i === 0 || ms > a[i - 1]), 'monotonic')
26})