| … | … | @@ -31,6 +31,14 @@ import { computeTreeHash, KIND_FILE } from 'hyper-overlay/tree' |
| 31 | 31 | |
| 32 | 32 | const DEFAULT_BASE = 'https://7rnx.net/git' |
| 33 | 33 | const 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. |
| 41 | const DEFAULT_REGISTRY = '1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86' |
| 34 | 42 | const DEFAULT_INTERVAL = 6 * 60 * 60 * 1000 // 6h — a phone is not a server; do not poll it awake |
| 35 | 43 | const BUNDLE_NAME = 'worklet.bundle' |
| 36 | 44 | |
| … | … | @@ -266,12 +274,62 @@ export function bundleStore ({ dir }) { |
| 266 | 274 | // worklet, so there is one name for it. |
| 267 | 275 | export { chooseBundle } from './choose-bundle.js' |
| 268 | 276 | |
| 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 | */ |
| 310 | export 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 | |
| 269 | 327 | /** |
| 270 | 328 | * Start the bundle updater. Returns { stop() } or null when OTA is unavailable. |
| 271 | 329 | * |
| 272 | 330 | * Never throws: the caller boots a node either way. |
| 273 | 331 | */ |
| 274 | | export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null } = {}) { |
| 332 | export async function startOta ({ storage, say = () => {}, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null, registryKeyHex = DEFAULT_REGISTRY, swarm = null, fetcher: injectedFetcher = null } = {}) { |
| 275 | 333 | let PghRuntime = null |
| 276 | 334 | try { |
| 277 | 335 | ;({ PghRuntime } = await import('pgh-runtime')) |
| … | … | @@ -280,12 +338,30 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE, |
| 280 | 338 | return null |
| 281 | 339 | } |
| 282 | 340 | |
| 283 | | if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global') |
| 284 | | |
| 285 | 341 | const dir = path.join(storage, 'bundles') |
| 286 | 342 | const store = bundleStore({ dir }) |
| 287 | 343 | store.init() |
| 288 | 344 | |
| 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 | |
| 289 | 365 | const runtime = new PghRuntime({ |
| 290 | 366 | name: repo, |
| 291 | 367 | // ⛔ 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, |
| 293 | 369 | // phone and threw ReferenceError everywhere else, including the first test that ever called startOta. |
| 294 | 370 | // A library used from three environments must not read a global it did not import. |
| 295 | 371 | currentTreeHash: currentTreeHash || envBundleHash() || null, |
| 296 | | fetcher: bundleFetcher({ base, repo, doFetch: globalThis.fetch }), |
| 372 | fetcher, |
| 297 | 373 | store, |
| 298 | 374 | interval |
| 299 | 375 | }) |
| … | … | @@ -303,10 +379,32 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE, |
| 303 | 379 | runtime.on('error', (e) => say('ota: ' + ((e && e.message) || e))) |
| 304 | 380 | |
| 305 | 381 | 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') |
| 307 | 383 | // ⛔ `checkNow` IS EXPOSED. The 6-hour timer is a poll on a phone that is awake for seconds at a time, |
| 308 | 384 | // so the interval is almost never when the user is actually looking — the one moment a check is worth |
| 309 | 385 | // making is when they ask. pgh-runtime already has the public method (checkNow, index.js:107); this |
| 310 | 386 | // 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 | } |
| 312 | 410 | } |