| … | … | @@ -26,6 +26,7 @@ |
| 26 | 26 | // the bytes, and every fetch is hash-verified rather than trusted because of where it came from. |
| 27 | 27 | import fs from 'bare-fs' |
| 28 | 28 | import path from 'bare-path' |
| 29 | import { nextRetryDelay } from './retry-policy.js' | |
| 29 | 30 | import crypto from 'hypercore-crypto' |
| 30 | 31 | import { computeTreeHash, KIND_FILE, KIND_DIR } from 'hyper-overlay/tree' |
| 31 | 32 | |
| … | … | @@ -560,9 +561,42 @@ export async function startOta ({ storage, apkAddons = null, say = () => {}, rep |
| 560 | 561 | let activeFetcher = injectedFetcher |
| 561 | 562 | const ownsFetcher = !injectedFetcher |
| 562 | 563 | |
| 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 | ||
| 563 | 593 | // 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. | |
| 564 | 597 | 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 } | |
| 566 | 600 | if (!activeFetcher) { |
| 567 | 601 | try { |
| 568 | 602 | // ⛔ `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 |
| 577 | 611 | })) |
| 578 | 612 | } catch (err) { |
| 579 | 613 | 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() | |
| 580 | 617 | return |
| 581 | 618 | } |
| 582 | 619 | } |
| 620 | // We have a fetcher; nothing is pending any more. | |
| 621 | clearRetry() | |
| 622 | pendingEntry = null | |
| 623 | ||
| 583 | 624 | runtime = new PghRuntime({ |
| 584 | 625 | name: repo, |
| 585 | 626 | currentTreeHash: currentTreeHash || envBundleHash() || null, |
| … | … | @@ -636,6 +677,9 @@ export async function startOta ({ storage, apkAddons = null, say = () => {}, rep |
| 636 | 677 | // ⛔ THE WATCH TOO. It holds a Hyperswarm and a Corestore, and a swarm left running is a DHT |
| 637 | 678 | // node kept awake by something the user switched off — the leak shape that reached ~205k |
| 638 | 679 | // 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() | |
| 639 | 683 | try { if (watch) await watch.close() } catch { /* stopping must not throw on the way out */ } |
| 640 | 684 | try { if (runtime && runtime.stop) await runtime.stop() } catch { /* same */ } |
| 641 | 685 | if (ownsFetcher && activeFetcher && typeof activeFetcher.close === 'function') { |
| … | … | @@ -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 | ||
| 25 | export 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 | */ | |
| 34 | export 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 | ||
| 39 | export default { RETRY_MS, nextRetryDelay } |
| … | … | @@ -1,4 +1,4 @@ |
| 1 | 1 | // GENERATED by scripts/gen-version.mjs — do not edit. |
| 2 | export const VERSION = "0.0.40" | |
| 3 | export const BUILT_AT = 1789502687385 | |
| 2 | export const VERSION = "0.0.41" | |
| 3 | export const BUILT_AT = 1789503080437 | |
| 4 | 4 | export default { VERSION, BUILT_AT } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface-android", |
| 3 | "version": "0.0.40", | |
| 3 | "version": "0.0.41", | |
| 4 | 4 | "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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "private": true, |
| … | … | @@ -0,0 +1,26 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { nextRetryDelay, RETRY_MS } from '../lib/host/retry-policy.js' | |
| 3 | ||
| 4 | test('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 | ||
| 14 | test('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 | ||
| 21 | test('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 | }) |