| … | … | @@ -8,3 +8,7 @@ app/worklet.bundle |
| 8 | 8 | android/app/src/main/assets/ |
| 9 | 9 | android/app/.cxx/ |
| 10 | 10 | android/app/addon-keep.json |
| 11 | ||
| 12 | # Build artifacts staged at the repo root for `artifacts:` in .pear-git/ci.yaml. | |
| 13 | /worklet.bundle | |
| 14 | /tree.json |
| … | … | @@ -103,9 +103,21 @@ steps: |
| 103 | 103 | # The artifact path IS the public URL and the filename is what the user's browser saves, so |
| 104 | 104 | # publish it under a name a person can recognise rather than the gradle output path. |
| 105 | 105 | cp android/app/build/outputs/apk/release/app-release.apk favela-android.apk |
| 106 | ls -l favela-android.apk | |
| 106 | # The worklet bundle is published ALONGSIDE the APK so an installed phone can update its JS | |
| 107 | # without a 153 MB sideload — pgh-runtime fetches this file and verifies it against tree.json | |
| 108 | # (written by `npm run gen:tree` in the prebuild step). Native code still needs a new APK. | |
| 109 | cp android/app/src/main/assets/worklet.bundle worklet.bundle | |
| 110 | ls -l favela-android.apk worklet.bundle tree.json | |
| 107 | 111 | timeout: 2700 |
| 108 | 112 | |
| 109 | 113 | artifacts: |
| 110 | 114 | # ⛔ FILES ONLY — collectArtifacts uses readFile, with no directory walk and no globbing. |
| 111 | 115 | - favela-android.apk |
| 116 | # The OTA pair. tree.json is what makes the bundle FETCHABLE at all: pgh-runtime installs a TREE, and | |
| 117 | # hashes the entries it is handed against the root hash before it will accept them — so a release with | |
| 118 | # a bundle and no manifest is unfetchable by design, which is the state this repo shipped in. | |
| 119 | # | |
| 120 | # ⚠ ~37 MB per release on top of the 153 MB APK. `releases` was already 6.74 GiB with gc({keepN:3}) | |
| 121 | # existing and nothing scheduling it (hyper-social #47 §2); this makes that trajectory steeper. | |
| 122 | - worklet.bundle | |
| 123 | - tree.json |
| … | … | @@ -10,6 +10,7 @@ import RNFS from 'react-native-fs' |
| 10 | 10 | import { AppState, DeviceEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native' |
| 11 | 11 | import { WorkletClient } from '../lib/ipc-client' |
| 12 | 12 | import { createLifecycle } from '../lib/lifecycle' |
| 13 | import { chooseBundle } from '../lib/choose-bundle' | |
| 13 | 14 | import { describeNode } from '../lib/describe' |
| 14 | 15 | import { encodeUtf8, createUtf8Decoder } from '../lib/utf8' |
| 15 | 16 | |
| … | … | @@ -90,6 +91,46 @@ async function boot () { |
| 90 | 91 | try { await RNFS.unlink(dest) } catch {} |
| 91 | 92 | await RNFS.copyFileAssets('worklet.bundle', dest) |
| 92 | 93 | } |
| 94 | // ── WHICH BUNDLE BOOTS ──────────────────────────────────────────────────────────────────────────── | |
| 95 | // OTA stages new worklet bundles into <storage>/bundles (lib/ota.js, inside the worklet). This side | |
| 96 | // decides whether to RUN one — and it is the decision that can brick a handset, because there is no | |
| 97 | // adb on a user's phone and no UI left if the node cannot boot. | |
| 98 | // | |
| 99 | // The watchdog: record the attempt BEFORE starting, clear it once the node reports ready, and treat a | |
| 100 | // marker that survived a boot as proof that bundle does not work. chooseBundle is pure and tested | |
| 101 | // (test/ota.test.mjs); everything here is the file I/O it asks for, and every failure falls back to the | |
| 102 | // APK's own bundle, which is the one known to work because it shipped with this binary. | |
| 103 | let bundlePath = dest | |
| 104 | try { | |
| 105 | const bdir = RNFS.DocumentDirectoryPath + '/bundles' | |
| 106 | const readJson = async (p) => { try { return JSON.parse(await RNFS.readFile(p, 'utf8')) } catch { return null } } | |
| 107 | const failed = (await readJson(bdir + '/failed.json')) || [] | |
| 108 | const choice = chooseBundle({ | |
| 109 | current: await readJson(bdir + '/current.json'), | |
| 110 | pending: await readJson(bdir + '/pending.json'), | |
| 111 | failed | |
| 112 | }) | |
| 113 | ||
| 114 | if (choice.condemn) { | |
| 115 | // Last boot started this bundle and never reached ready. Remember it so it is never chosen again — | |
| 116 | // otherwise every boot picks it, dies the same way, and the phone is unrecoverable from outside. | |
| 117 | console.log('[worklet] bundle ' + choice.condemn.slice(0, 12) + '… failed to boot last time — condemned') | |
| 118 | if (!failed.includes(choice.condemn)) failed.push(choice.condemn) | |
| 119 | await RNFS.writeFile(bdir + '/failed.json', JSON.stringify(failed), 'utf8') | |
| 120 | try { await RNFS.unlink(bdir + '/pending.json') } catch {} | |
| 121 | } | |
| 122 | ||
| 123 | if (choice.use === 'staged' && await RNFS.exists(bdir + '/' + choice.file)) { | |
| 124 | await RNFS.writeFile(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash }), 'utf8') | |
| 125 | bundlePath = bdir + '/' + choice.file | |
| 126 | console.log('[worklet] booting STAGED bundle ' + choice.hash.slice(0, 12) + '… (' + choice.reason + ')') | |
| 127 | } | |
| 128 | } catch (e) { | |
| 129 | // A broken OTA directory must never stop the app starting: the APK's bundle is always there. | |
| 130 | console.log('[worklet] bundle choice failed, using the APK bundle: ' + e.message) | |
| 131 | bundlePath = dest | |
| 132 | } | |
| 133 | ||
| 93 | 134 | const source = null |
| 94 | 135 | // The worklet has NO error or exit event (react-native-bare-kit/index.js has neither), so a failure |
| 95 | 136 | // inside it is silent from here. These lines are the only evidence of what was actually handed over. |
| … | … | @@ -97,7 +138,7 @@ async function boot () { |
| 97 | 138 | (haveSize === wantSize ? ' (cached)' : ' (copied)') + ' in ' + (Date.now() - t0) + 'ms') |
| 98 | 139 | |
| 99 | 140 | // Path + null source => NativeBareKit.startFile: the runtime reads it, not us. |
| 100 | worklet.start(dest, source, [storage]) | |
| 141 | worklet.start(bundlePath, source, [storage]) | |
| 101 | 142 | console.log('[worklet] start() returned') |
| 102 | 143 | |
| 103 | 144 | // ⛔ KEEP RUNNING WITH THE SCREEN OFF. Immediately after start(), because until this returns the node |
| … | … | @@ -138,6 +179,12 @@ async function boot () { |
| 138 | 179 | // Once the node is up, let the notification say what it is actually serving rather than the generic |
| 139 | 180 | // line the service starts with. Fire-and-forget and non-blocking: boot takes ~40s, a node that cannot |
| 140 | 181 | // describe itself still runs, and nothing here may delay the UI appearing. |
| 182 | // ⛔ READY IS THE ONLY PROOF A BUNDLE WORKS. Clearing the pending marker here, and nowhere earlier, is | |
| 183 | // what makes the watchdog meaningful: a bundle that starts, throws, and dies still leaves its marker. | |
| 184 | client.whenReady() | |
| 185 | .then(async () => { try { await RNFS.unlink(RNFS.DocumentDirectoryPath + '/bundles/pending.json') } catch {} }) | |
| 186 | .catch(() => {}) | |
| 187 | ||
| 141 | 188 | client.whenReady() |
| 142 | 189 | .then(() => client.hostedDids()) |
| 143 | 190 | .then((r) => describe(describeNode(r && r.dids))) |
| … | … | @@ -0,0 +1,45 @@ |
| 1 | // Which bundle this boot runs — the staged one, or the APK's. | |
| 2 | // | |
| 3 | // ⛔ ITS OWN MODULE, WITH NO IMPORTS, because it is needed on BOTH sides of the bridge and they share no | |
| 4 | // filesystem API. The decision is made in React Native (RNFS) before the worklet starts; the staging that | |
| 5 | // feeds it happens inside the worklet (bare-fs). lib/ota.js imports bare-fs at module top level, so | |
| 6 | // importing it from the RN side would break the app on load — a mistake worth naming, because the split | |
| 7 | // is not tidiness, it is the only reason this can be called where it has to be called. | |
| 8 | // | |
| 9 | // Pure and dependency-free, like lib/ipc-client.js and lib/lifecycle.js, and testable without a handset — | |
| 10 | // which is the only place the consequences of getting this wrong actually show up. | |
| 11 | ||
| 12 | /** | |
| 13 | * @param {{current: ?{treeHash: string, file: string}, pending: ?{treeHash: string}, failed: string[]}} state | |
| 14 | * @returns {{use:'staged'|'asset', hash:?string, file:?string, markPending:boolean, condemn:?string, reason:string}} | |
| 15 | * | |
| 16 | * ⛔ A STAGED BUNDLE NOTHING RUNS IS NOT AN UPDATE, and running one blindly is how a phone gets bricked. | |
| 17 | * There is no adb on a user's handset and no UI left if the node cannot boot, so the only safe shape is a | |
| 18 | * watchdog: record the intent BEFORE starting, clear it once the node reports ready, and treat a marker | |
| 19 | * that survived a boot as proof that bundle does not work. | |
| 20 | * | |
| 21 | * The failure this exists to prevent is the retry loop — a bad bundle chosen again on every boot, dying | |
| 22 | * identically each time, with no way in from outside. | |
| 23 | */ | |
| 24 | export function chooseBundle ({ current = null, pending = null, failed = [] } = {}) { | |
| 25 | const asset = (reason, condemn = null) => ({ use: 'asset', hash: null, file: null, markPending: false, condemn, reason }) | |
| 26 | ||
| 27 | // A pending marker means the LAST boot started a bundle and never reached ready. Condemn whatever it | |
| 28 | // names — which is not necessarily what is staged now, so it is read from the marker, not from current. | |
| 29 | const condemn = pending && pending.treeHash ? pending.treeHash : null | |
| 30 | const dead = new Set([...(Array.isArray(failed) ? failed : []), ...(condemn ? [condemn] : [])]) | |
| 31 | ||
| 32 | if (!current || !current.treeHash || !current.file) return asset('nothing staged', condemn) | |
| 33 | // A newer bundle still gets its chance: refusing every staged bundle after one bad build would make the | |
| 34 | // phone permanently un-updatable by a single bad release, and the fix for a bad release is the next one. | |
| 35 | if (dead.has(current.treeHash)) return asset('staged bundle previously failed to boot', condemn) | |
| 36 | ||
| 37 | return { | |
| 38 | use: 'staged', | |
| 39 | hash: current.treeHash, | |
| 40 | file: current.file, | |
| 41 | markPending: true, | |
| 42 | condemn, | |
| 43 | reason: 'staged bundle available' | |
| 44 | } | |
| 45 | } |
| … | … | @@ -1,40 +1,93 @@ |
| 1 | // android-ota — keep the JS current on a phone, so the APK only has to change when native addons do. | |
| 1 | // android-ota — keep the JS current on a phone, so the APK only has to change when native code does. | |
| 2 | 2 | // |
| 3 | 3 | // THE SPLIT THIS EXISTS TO CREATE. An APK carries two very different things: ~77 MB of native libraries |
| 4 | // that change rarely (bare-kit + our addons), and a ~12 MB JS bundle that changes constantly. Shipping a | |
| 5 | // new APK for a JS change means a sideload or a store review every time. So the bundle updates over the | |
| 6 | // network and the APK does not. | |
| 4 | // that change rarely (bare-kit + our addons), and a worklet bundle that changes constantly. Today proved | |
| 5 | // the cost of not having this: a favela-web fix reached the handset only by rebuilding a 153 MB APK | |
| 6 | // (150s of CI) and sideloading it, because favela-android vendors favela-web at build time. | |
| 7 | 7 | // |
| 8 | // ⛔ THE TWO "RELEASE" SYSTEMS ARE NOT INTERCHANGEABLE — confirmed 2026-08-07 with the session that owns | |
| 9 | // the forge release path: | |
| 10 | // - pgh-sidecar's ReleaseRegistry: an Autobase of METADATA (name/version/treeHash/deps/signature). It | |
| 11 | // replicates P2P and is what `pgh app install` consumes. It NEVER produces a file. | |
| 12 | // - pear-git-v3's ReleaseManager: real files on local disk, served over HTTPS at | |
| 8 | // ⛔ WHAT WAS HERE BEFORE HAD NEVER RUN. pgh-runtime was `--defer`red out of the bundle and absent from | |
| 9 | // dependencies, so `startOta`'s dynamic import always failed and this file returned null after logging | |
| 10 | // "updates disabled". The code underneath was free to be wrong, and was: `getTree` returned | |
| 11 | // `{entries: []}` for every hash, and pgh-runtime's installTree hashes the entries it is handed and | |
| 12 | // rejects the tree unless they reproduce the root hash — a guard that exists so a peer cannot answer "I | |
| 13 | // don't have it" with an empty directory. So it could not have downloaded a bundle even with the | |
| 14 | // dependency present. It also took `versions[versions.length - 1]` as "newest", and this forge lists | |
| 15 | // newest FIRST, so it would have pinned a phone to the oldest build on the server and called it current. | |
| 16 | // | |
| 17 | // ⛔ THE TWO "RELEASE" SYSTEMS ARE NOT INTERCHANGEABLE: | |
| 18 | // - pgh-sidecar's ReleaseRegistry: an Autobase of METADATA. Replicates P2P, never produces a file. | |
| 19 | // - pear-git-v3's ReleaseManager: real files on disk, served over HTTPS at | |
| 13 | 20 | // /git/releases/<repo>/<version>/<file>. This is the one that can hand a phone bytes. |
| 14 | 21 | // A phone needs BYTES, so this targets the second. |
| 15 | 22 | // |
| 16 | // ⚠ ARTIFACTS DO NOT REPLICATE BETWEEN FORGE BACKENDS. Each daemon's DeployServer topic is keyed to its | |
| 17 | // own identity, so release stores are per-node and disjoint, and 7rnx.net/git is load-balanced with | |
| 18 | // stortron ACTIVE. The bundle must be published on the active backend or this fetches nothing. Same URL | |
| 19 | // can even return different bytes per backend — which is why `verify` below trusts the HASH we were | |
| 20 | // told to expect, never the host that served it. | |
| 21 | // | |
| 22 | // ⛔ pgh-runtime IS NOT A DEPENDENCY OF favela-web, DELIBERATELY. favela-web's whole "runs standalone, | |
| 23 | // zero pgh dependencies" property (spec §0) is what makes a phone build possible at all, and pulling in | |
| 24 | // pgh-runtime drags hyper-overlay with it. So this imports it DYNAMICALLY and degrades to a no-op when | |
| 25 | // absent: a node with no OTA still works, a node that fails to boot does not. Add the dep deliberately | |
| 26 | // when the OTA path is wanted, not as a side effect of shipping a node. | |
| 23 | // ⚠ Artifacts are per-daemon. 7rnx.net/git is load-balanced and a release built on one backend is not | |
| 24 | // automatically complete on the other (measured 2026-08-18: a 153 MB APK never reached the second | |
| 25 | // backend while its build.log and manifest did). So `base` must point at a backend that actually holds | |
| 26 | // the bytes, and every fetch is hash-verified rather than trusted because of where it came from. | |
| 27 | import fs from 'bare-fs' | |
| 28 | import path from 'bare-path' | |
| 29 | import crypto from 'hypercore-crypto' | |
| 30 | import { computeTreeHash, KIND_FILE } from 'hyper-overlay/tree' | |
| 27 | 31 | |
| 28 | 32 | const DEFAULT_BASE = 'https://7rnx.net/git' |
| 29 | 33 | const DEFAULT_REPO = 'favela-android' |
| 30 | 34 | const DEFAULT_INTERVAL = 6 * 60 * 60 * 1000 // 6h — a phone is not a server; do not poll it awake |
| 35 | const BUNDLE_NAME = 'worklet.bundle' | |
| 36 | ||
| 37 | const hashOf = (bytes) => crypto.data(Buffer.from(bytes)).toString('hex') | |
| 38 | ||
| 39 | /** | |
| 40 | * The one-file tree for a bundle: what CI publishes as tree.json and what the phone verifies against. | |
| 41 | * | |
| 42 | * Deliberately built with hyper-overlay's own encoder rather than by hand — pgh-runtime verifies with | |
| 43 | * `computeTreeHash`, so producing the hash any other way would be a second definition of the format that | |
| 44 | * agrees until it doesn't. | |
| 45 | */ | |
| 46 | export function treeFor (bytes, name = BUNDLE_NAME) { | |
| 47 | const contentHash = hashOf(bytes) | |
| 48 | const entries = [{ kind: KIND_FILE, exec: 0, name, childHash: contentHash }] | |
| 49 | return { entries, treeHash: computeTreeHash(entries), contentHash, size: bytes.length } | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Newest release by BUILD TIME, not by position in the list. | |
| 54 | * | |
| 55 | * The old code took the last element and commented "newest last by publish order". `pgh deploy log` | |
| 56 | * returns newest FIRST, so that read the oldest build on the server. Ordering the caller cannot see is | |
| 57 | * not a contract; the timestamp is. | |
| 58 | */ | |
| 59 | export function pickNewest (versions) { | |
| 60 | const list = (Array.isArray(versions) ? versions : []).filter(Boolean) | |
| 61 | if (!list.length) return null | |
| 62 | return list.reduce((a, b) => ((Number(b.builtAt) || 0) > (Number(a.builtAt) || 0) ? b : a)) | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * A pgh-runtime fetcher over the forge's HTTPS release routes. | |
| 67 | * | |
| 68 | * Shape required by pgh-runtime: { getLatestRelease(name), getTree(hash), getContent(hash) }. | |
| 69 | */ | |
| 70 | export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFetch }) { | |
| 71 | // treeHash -> { version, entries }, and contentHash -> { version, name }. Populated when a tree.json is | |
| 72 | // read, because installTree asks for a tree and then for content BY HASH, with no version in hand. | |
| 73 | const trees = new Map() | |
| 74 | const contents = new Map() | |
| 75 | ||
| 76 | const releaseUrl = (version, file) => `${base}/releases/${repo}/${version}/${file}` | |
| 77 | ||
| 78 | async function readTree (version) { | |
| 79 | const res = await doFetch(releaseUrl(version, 'tree.json')) | |
| 80 | if (!res || res.ok === false) throw new Error(`no tree.json for ${version}`) | |
| 81 | const body = await res.json() | |
| 82 | const entries = (body && body.entries) || [] | |
| 83 | // Trust the ENTRIES, not the advertised hash: recompute it. installTree checks this too, but a | |
| 84 | // fetcher that propagated a hash it never verified would hand the guard its own answer. | |
| 85 | const treeHash = computeTreeHash(entries) | |
| 86 | trees.set(treeHash, { version, entries }) | |
| 87 | for (const e of entries) contents.set(e.childHash, { version, name: e.name }) | |
| 88 | return { treeHash, entries } | |
| 89 | } | |
| 31 | 90 | |
| 32 | // An HTTPS fetcher conforming to pgh-runtime's documented interface. Its own header says the runtime is | |
| 33 | // "transport-agnostic — any fetcher conforming to that shape works", and store-only mode (omitting the | |
| 34 | // injected materializer) is explicitly supported. That matters here: the materializer is pgh's node one, | |
| 35 | // which HARDLINKS — and SELinux denies `link` device-wide on Android, which is what made a 269-dep | |
| 36 | // closure balloon to ~5.7 GB on celltron. Omitting it is what makes this safe on a phone. | |
| 37 | function httpsFetcher ({ base, repo, doFetch }) { | |
| 38 | 91 | return { |
| 39 | 92 | async getLatestRelease (name) { |
| 40 | 93 | const res = await doFetch(`${base}/rpc/list-repo-releases`, { |
| … | … | @@ -44,29 +97,127 @@ function httpsFetcher ({ base, repo, doFetch }) { |
| 44 | 97 | }) |
| 45 | 98 | if (!res || res.ok === false) return null |
| 46 | 99 | const body = await res.json() |
| 47 | const versions = (body && (body.versions || body.releases)) || [] | |
| 48 | if (!versions.length) return null | |
| 49 | // Newest last by publish order; the manifest carries the hash we verify against. | |
| 50 | const latest = versions[versions.length - 1] | |
| 51 | return { version: latest.version || latest, treeHash: latest.treeHash || null, name } | |
| 100 | const newest = pickNewest((body && (body.versions || body.releases)) || []) | |
| 101 | if (!newest) return null | |
| 102 | const version = newest.version || String(newest) | |
| 103 | // A release with no tree.json is one that predates OTA, not an error worth crashing a boot over. | |
| 104 | let tree | |
| 105 | try { tree = await readTree(version) } catch { return null } | |
| 106 | return { name, version, treeHash: tree.treeHash } | |
| 107 | }, | |
| 108 | ||
| 109 | // ⛔ FETCH, DO NOT JUDGE. This returns whatever the release declares and lets installTree's | |
| 110 | // computeTreeHash guard accept or reject it. An earlier cut gated here too and had to be pulled: a | |
| 111 | // fetcher that verifies is a SECOND definition of the check, and the two agree right up until they | |
| 112 | // do not — and it made the fetcher order-dependent, answering only for a tree getLatestRelease had | |
| 113 | // pre-warmed. One guard, in the layer that owns it. | |
| 114 | async getTree (rootHash) { | |
| 115 | const hit = trees.get(rootHash) | |
| 116 | if (hit) return { entries: hit.entries, size: hit.entries.length } | |
| 117 | // Not seen yet — the caller went straight to installTree. Read the newest release's tree and hand | |
| 118 | // it over; if it is not the one asked for, installTree says so in the words an operator needs. | |
| 119 | const res = await doFetch(`${base}/rpc/list-repo-releases`, { | |
| 120 | method: 'POST', | |
| 121 | headers: { 'content-type': 'application/json' }, | |
| 122 | body: JSON.stringify({ repo }) | |
| 123 | }) | |
| 124 | if (!res || res.ok === false) throw new Error('cannot list releases for ' + repo) | |
| 125 | const body = await res.json() | |
| 126 | const newest = pickNewest((body && (body.versions || body.releases)) || []) | |
| 127 | if (!newest) throw new Error('no releases for ' + repo) | |
| 128 | const { entries } = await readTree(newest.version || String(newest)) | |
| 129 | return { entries, size: entries.length } | |
| 130 | }, | |
| 131 | ||
| 132 | async getContent (contentHash) { | |
| 133 | const where = contents.get(contentHash) | |
| 134 | if (!where) throw new Error('unknown content ' + String(contentHash).slice(0, 12) + '…') | |
| 135 | const res = await doFetch(releaseUrl(where.version, where.name)) | |
| 136 | if (!res || res.ok === false) throw new Error('fetch failed: ' + where.name) | |
| 137 | const bytes = new Uint8Array(await res.arrayBuffer()) | |
| 138 | // ⛔ THE ONLY THING PROTECTING THIS PHONE. Releases are unsigned (`signed: false` in every publish) | |
| 139 | // and the forge is load-balanced HTTPS, so "it downloaded" says nothing about WHAT downloaded. The | |
| 140 | // bundle becomes the code that holds the DID signing key. Verify, or do not install. | |
| 141 | const got = hashOf(bytes) | |
| 142 | if (got !== contentHash) { | |
| 143 | throw new Error(`content hash mismatch for ${where.name}: expected ${contentHash.slice(0, 12)}…, got ${got.slice(0, 12)}…`) | |
| 144 | } | |
| 145 | return bytes | |
| 146 | } | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | /** | |
| 151 | * Store-only: write the bundle, and let the NEXT start pick it up. | |
| 152 | * | |
| 153 | * Applying a new bundle to a running node would mean swapping the code under itself. A phone restarts | |
| 154 | * constantly anyway, so "active on next start" costs nothing and avoids a class of live-reload bugs. | |
| 155 | * | |
| 156 | * ⛔ NO MATERIALIZER. pgh's node materializer HARDLINKS, and SELinux denies `link` device-wide on | |
| 157 | * Android — which is what made a 269-dep closure balloon to ~5.7 GB on celltron. One file, written | |
| 158 | * directly, is the whole of what a bundle needs. | |
| 159 | */ | |
| 160 | export function bundleStore ({ dir }) { | |
| 161 | const markerPath = (treeHash) => path.join(dir, treeHash + '.json') | |
| 162 | const readMarker = (treeHash) => { | |
| 163 | try { return JSON.parse(fs.readFileSync(markerPath(treeHash), 'utf8')) } catch { return null } | |
| 164 | } | |
| 165 | ||
| 166 | return { | |
| 167 | init () { fs.mkdirSync(dir, { recursive: true }) }, | |
| 168 | ||
| 169 | /** | |
| 170 | * ⛔ THE BYTES, NOT JUST THE MARKER. A marker written before the file it names is the same bug the | |
| 171 | * forge has been living with all week — an index advertising a release whose artifact never arrived. | |
| 172 | * So the marker is written LAST and this checks the file too. | |
| 173 | */ | |
| 174 | hasTree (treeHash) { | |
| 175 | const m = readMarker(treeHash) | |
| 176 | if (!m || !m.file) return false | |
| 177 | try { return fs.statSync(path.join(dir, m.file)).size > 0 } catch { return false } | |
| 52 | 178 | }, |
| 53 | // Single-artifact "tree": the whole app is ONE bundle (spike 1 — 11.9 MB), so there is no dep | |
| 54 | // closure to walk and none of pgh's tree machinery is needed. | |
| 55 | async getTree () { return { entries: [], size: 0 } }, | |
| 56 | async getContent (url) { | |
| 57 | const res = await doFetch(url) | |
| 58 | if (!res || res.ok === false) throw new Error('fetch failed: ' + url) | |
| 59 | return new Uint8Array(await res.arrayBuffer()) | |
| 179 | ||
| 180 | /** Where the staged bundle for this tree lives, or null. */ | |
| 181 | pathFor (treeHash) { | |
| 182 | const m = readMarker(treeHash) | |
| 183 | return m && m.file ? path.join(dir, m.file) : null | |
| 184 | }, | |
| 185 | ||
| 186 | async materialize (rootHash, entries, { fetchContent } = {}) { | |
| 187 | this.init() | |
| 188 | let file = null | |
| 189 | for (const e of entries) { | |
| 190 | if (!e || e.kind !== KIND_FILE) continue | |
| 191 | const bytes = await fetchContent(e.childHash) | |
| 192 | // Write to .part and rename: a phone is killed constantly, and a truncated bundle that hasTree | |
| 193 | // then trusted would be a node that cannot boot with no way to say why. | |
| 194 | const dest = path.join(dir, e.childHash + '.bundle') | |
| 195 | const part = dest + '.part' | |
| 196 | fs.writeFileSync(part, Buffer.from(bytes)) | |
| 197 | fs.renameSync(part, dest) | |
| 198 | file = e.childHash + '.bundle' | |
| 199 | } | |
| 200 | if (!file) throw new Error('tree contained no file to stage') | |
| 201 | fs.writeFileSync(markerPath(rootHash), JSON.stringify({ treeHash: rootHash, file }, null, 2) + '\n') | |
| 202 | // current.json is how the REACT NATIVE side learns what is staged: it runs before the worklet and | |
| 203 | // cannot read this store's code (bare-fs), only its files. Written last, after the bytes and the | |
| 204 | // marker, so it never advertises something that is not fully on disk. | |
| 205 | fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file }, null, 2) + '\n') | |
| 60 | 206 | } |
| 61 | 207 | } |
| 62 | 208 | } |
| 63 | 209 | |
| 210 | // The boot-time choice lives in its own dependency-free module so the React Native side can import | |
| 211 | // it too (this file imports bare-fs, which does not exist there). Re-exported for callers inside the | |
| 212 | // worklet, so there is one name for it. | |
| 213 | export { chooseBundle } from './choose-bundle.js' | |
| 214 | ||
| 64 | 215 | /** |
| 65 | 216 | * Start the bundle updater. Returns { stop() } or null when OTA is unavailable. |
| 66 | 217 | * |
| 67 | 218 | * Never throws: the caller boots a node either way. |
| 68 | 219 | */ |
| 69 | export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL } = {}) { | |
| 220 | export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null } = {}) { | |
| 70 | 221 | let PghRuntime = null |
| 71 | 222 | try { |
| 72 | 223 | ;({ PghRuntime } = await import('pgh-runtime')) |
| … | … | @@ -75,26 +226,16 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE, |
| 75 | 226 | return null |
| 76 | 227 | } |
| 77 | 228 | |
| 78 | const fs = await import('bare-fs') | |
| 79 | const path = await import('bare-path') | |
| 80 | 229 | if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global') |
| 81 | 230 | |
| 82 | const dir = path.default.join(storage, 'bundles') | |
| 83 | fs.default.mkdirSync(dir, { recursive: true }) | |
| 84 | ||
| 85 | // Store-only: write the bundle, verify it, and let the NEXT start pick it up. Applying a new bundle | |
| 86 | // to a running node would mean swapping the code under itself; a phone restarts constantly anyway | |
| 87 | // (spec §5), so "on next start" costs nothing and avoids a whole class of live-reload bugs. | |
| 88 | const store = { | |
| 89 | async init () {}, | |
| 90 | async hasTree (hash) { return fs.default.existsSync(path.default.join(dir, hash + '.bundle')) }, | |
| 91 | async materialize () { /* store-only; see the header */ } | |
| 92 | } | |
| 231 | const dir = path.join(storage, 'bundles') | |
| 232 | const store = bundleStore({ dir }) | |
| 233 | store.init() | |
| 93 | 234 | |
| 94 | 235 | const runtime = new PghRuntime({ |
| 95 | 236 | name: repo, |
| 96 | currentTreeHash: process.env.FAVELA_BUNDLE_HASH || null, | |
| 97 | fetcher: httpsFetcher({ base, repo, doFetch: globalThis.fetch }), | |
| 237 | currentTreeHash: currentTreeHash || process.env.FAVELA_BUNDLE_HASH || null, | |
| 238 | fetcher: bundleFetcher({ base, repo, doFetch: globalThis.fetch }), | |
| 98 | 239 | store, |
| 99 | 240 | interval |
| 100 | 241 | }) |
| … | … | @@ -105,5 +246,5 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE, |
| 105 | 246 | |
| 106 | 247 | await runtime.start() |
| 107 | 248 | say('ota: watching ' + base + '/releases/' + repo + ' every ' + Math.round(interval / 3600000) + 'h') |
| 108 | return { stop: () => runtime.stop && runtime.stop() } | |
| 249 | return { stop: () => runtime.stop && runtime.stop(), store } | |
| 109 | 250 | } |
| … | … | @@ -6,11 +6,12 @@ |
| 6 | 6 | "type": "module", |
| 7 | 7 | "main": "lib/node.js", |
| 8 | 8 | "scripts": { |
| 9 | "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --defer pgh-runtime --out android/app/src/main/assets/worklet.bundle lib/node.js", | |
| 9 | "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js", | |
| 10 | 10 | "stage:addons": "node scripts/stage-addons.mjs", |
| 11 | "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run stage:addons", | |
| 12 | "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/describe.test.mjs", | |
| 13 | "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size))\"" | |
| 11 | "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run gen:tree && npm run stage:addons", | |
| 12 | "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/describe.test.mjs && bare test/ota.test.mjs", | |
| 13 | "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size))\"", | |
| 14 | "gen:tree": "bare scripts/gen-tree-manifest.mjs" | |
| 14 | 15 | }, |
| 15 | 16 | "dependencies": { |
| 16 | 17 | "atproto-identity": "file:../atproto-identity", |
| … | … | @@ -26,7 +27,8 @@ |
| 26 | 27 | "hyper-proto-peer": "file:../hyper-proto-peer", |
| 27 | 28 | "hyperbee": "2.27.3", |
| 28 | 29 | "hyperswarm": "4.17.0", |
| 29 | "keet-identity-key": "3.2.0" | |
| 30 | "keet-identity-key": "3.2.0", | |
| 31 | "pgh-runtime": "file:../pgh-runtime" | |
| 30 | 32 | }, |
| 31 | 33 | "pgh": { |
| 32 | 34 | "note": "Not a pgh app. The deliverable is an APK, installed by Android's package manager. pgh-runtime is used INSIDE the app for worklet-bundle OTA only — see spec/architecture.md §2 and favela-web/spec/mobile-node.md §7." |
| … | … | @@ -0,0 +1,36 @@ |
| 1 | // Publish the worklet bundle as a pgh TREE, so a phone can verify what it downloaded. | |
| 2 | // | |
| 3 | // pgh-runtime does not fetch a file, it installs a TREE: it asks for the tree, hashes the entries it is | |
| 4 | // handed, and refuses them unless they reproduce the root hash. That guard exists so a peer cannot answer | |
| 5 | // "I don't have this" with an empty directory. It means a release with a bundle and no tree.json is | |
| 6 | // unfetchable — which is the state favela-android shipped in for two weeks, alongside an OTA client that | |
| 7 | // had never run. | |
| 8 | // | |
| 9 | // Emits tree.json next to the bundle at the repo root, both listed in .pear-git/ci.yaml artifacts: | |
| 10 | // | |
| 11 | // { "treeHash": "<hex>", "size": <bytes>, "entries": [ { kind, exec, name, childHash } ] } | |
| 12 | // | |
| 13 | // Built with lib/ota.js's own treeFor(), which is built with hyper-overlay's encoder — the same function | |
| 14 | // pgh-runtime verifies with. Computing the hash any other way would be a second definition of the format | |
| 15 | // that agrees until it doesn't. | |
| 16 | // | |
| 17 | // Run under BARE, not node: hyper-overlay/tree resolves #fs through bare's import map. | |
| 18 | // bare scripts/gen-tree-manifest.mjs | |
| 19 | import fs from 'bare-fs' | |
| 20 | import path from 'bare-path' | |
| 21 | import { treeFor } from '../lib/ota.js' | |
| 22 | ||
| 23 | const ROOT = path.join(path.dirname(import.meta.url.replace('file://', '')), '..') | |
| 24 | const BUNDLE = path.join(ROOT, 'android', 'app', 'src', 'main', 'assets', 'worklet.bundle') | |
| 25 | const OUT = path.join(ROOT, 'tree.json') | |
| 26 | ||
| 27 | if (!fs.existsSync(BUNDLE)) { | |
| 28 | console.error('no worklet.bundle at ' + BUNDLE + ' — run `npm run pack:worklet` first') | |
| 29 | Bare.exit(1) | |
| 30 | } | |
| 31 | ||
| 32 | const bytes = fs.readFileSync(BUNDLE) | |
| 33 | const { entries, treeHash, contentHash, size } = treeFor(bytes) | |
| 34 | ||
| 35 | fs.writeFileSync(OUT, JSON.stringify({ treeHash, size, entries }, null, 2) + '\n') | |
| 36 | console.log(`tree.json — treeHash ${treeHash.slice(0, 16)}… content ${contentHash.slice(0, 16)}… ${size} bytes`) |
| … | … | @@ -0,0 +1,242 @@ |
| 1 | // The OTA contract, against the REAL pgh-runtime — not a mock of it. | |
| 2 | // | |
| 3 | // ⛔ WHY THIS FILE EXISTS. lib/ota.js was written against pgh-runtime's documented interface and had | |
| 4 | // never once executed: `pgh-runtime` is `--defer`red in pack:worklet and absent from dependencies, so its | |
| 5 | // dynamic import always failed and `startOta` returned null after logging "updates disabled". The code | |
| 6 | // underneath was therefore free to be wrong, and it was — in three separate ways, each pinned by a | |
| 7 | // control below. Every test here drives pgh-runtime's own `installTree`, so the thing being verified is | |
| 8 | // the contract with the library, not our idea of it. | |
| 9 | import test from 'brittle' | |
| 10 | import fs from 'bare-fs' | |
| 11 | import path from 'bare-path' | |
| 12 | import os from 'bare-os' | |
| 13 | import crypto from 'hypercore-crypto' | |
| 14 | import { computeTreeHash } from 'hyper-overlay/tree' | |
| 15 | import { installTree } from 'pgh-runtime' | |
| 16 | import { treeFor, bundleFetcher, bundleStore, pickNewest, chooseBundle } from '../lib/ota.js' | |
| 17 | ||
| 18 | const BUNDLE = Buffer.from('#!/bare\n// pretend this is 19MB of worklet\n') | |
| 19 | ||
| 20 | let tmpSeq = 0 | |
| 21 | function tmpdir (name) { | |
| 22 | // A counter, not process.pid: `process` is not a bare global, and Date.now()/Math.random() would make | |
| 23 | // the suite non-reproducible for no benefit — each test wants its own empty directory, nothing more. | |
| 24 | const dir = path.join(os.tmpdir(), 'favela-ota-' + name + '-' + (++tmpSeq)) | |
| 25 | fs.rmSync(dir, { recursive: true, force: true }) | |
| 26 | fs.mkdirSync(dir, { recursive: true }) | |
| 27 | return dir | |
| 28 | } | |
| 29 | ||
| 30 | // A stand-in forge: the two routes lib/ota.js talks to, backed by memory. | |
| 31 | function fakeForge ({ versions, files }) { | |
| 32 | return async function doFetch (url, opts) { | |
| 33 | if (url.endsWith('/rpc/list-repo-releases')) { | |
| 34 | return { ok: true, async json () { return { versions } } } | |
| 35 | } | |
| 36 | const hit = Object.keys(files).find((k) => url.endsWith(k)) | |
| 37 | if (!hit) return { ok: false, status: 404 } | |
| 38 | const body = files[hit] | |
| 39 | return { | |
| 40 | ok: true, | |
| 41 | async json () { return JSON.parse(Buffer.from(body).toString('utf8')) }, | |
| 42 | async arrayBuffer () { return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) } | |
| 43 | } | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | test('CONTROL: the old fetcher could never have downloaded anything', async (t) => { | |
| 48 | // What lib/ota.js used to return from getTree, verbatim: an empty tree for every hash. pgh-runtime | |
| 49 | // hashes the entries it is given and rejects the tree when they do not reproduce the root hash — that | |
| 50 | // guard exists precisely so a peer cannot answer "I don't have it" with an empty dir. So the old code | |
| 51 | // could not have fetched a bundle even with the dependency present and a treeHash to ask for: it dies | |
| 52 | // one call earlier, every time. | |
| 53 | const old = { async getTree () { return { entries: [], size: 0 } }, async getContent () { return new Uint8Array() } } | |
| 54 | const store = { init () {}, hasTree () { return false }, async materialize () { t.fail('must not reach materialize') } } | |
| 55 | const realHash = computeTreeHash(treeFor(BUNDLE).entries) | |
| 56 | ||
| 57 | await t.exception(installTree(store, realHash, old), /root tree-hash mismatch|EHASHMISMATCH/) | |
| 58 | }) | |
| 59 | ||
| 60 | test('CONTROL: newest-by-position picks the OLDEST release from this forge', (t) => { | |
| 61 | // The old getLatestRelease took versions[versions.length - 1], commented "newest last by publish | |
| 62 | // order". The forge lists NEWEST FIRST (verified against `pgh deploy log favela-android`), so that | |
| 63 | // line would have pinned a phone to the oldest build on the server and called it an update. | |
| 64 | const versions = [ | |
| 65 | { version: 'master-6ada0eb', builtAt: 3000 }, | |
| 66 | { version: 'master-d233237', builtAt: 2000 }, | |
| 67 | { version: 'master-c73eab7', builtAt: 1000 } | |
| 68 | ] | |
| 69 | t.is(versions[versions.length - 1].version, 'master-c73eab7', 'the old rule: oldest') | |
| 70 | t.is(pickNewest(versions).version, 'master-6ada0eb', 'by builtAt, not by position') | |
| 71 | t.is(pickNewest([...versions].reverse()).version, 'master-6ada0eb', 'and order-independent') | |
| 72 | }) | |
| 73 | ||
| 74 | test('a one-file tree round-trips through the real installTree', async (t) => { | |
| 75 | const dir = tmpdir('roundtrip') | |
| 76 | const { entries, treeHash, contentHash } = treeFor(BUNDLE) | |
| 77 | const version = 'master-6ada0eb' | |
| 78 | ||
| 79 | const doFetch = fakeForge({ | |
| 80 | versions: [{ version, builtAt: 3000 }], | |
| 81 | files: { | |
| 82 | [version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })), | |
| 83 | [version + '/worklet.bundle']: BUNDLE | |
| 84 | } | |
| 85 | }) | |
| 86 | ||
| 87 | const fetcher = bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch }) | |
| 88 | const latest = await fetcher.getLatestRelease('favela-android') | |
| 89 | t.is(latest.version, version) | |
| 90 | t.is(latest.treeHash, treeHash, 'the version advertises the tree hash it will be verified against') | |
| 91 | ||
| 92 | const store = bundleStore({ dir }) | |
| 93 | const r = await installTree(store, latest.treeHash, fetcher) | |
| 94 | t.is(r.materialized, 1) | |
| 95 | ||
| 96 | const staged = store.pathFor(treeHash) | |
| 97 | t.ok(staged, 'the store can name the bundle it staged') | |
| 98 | t.alike(fs.readFileSync(staged), BUNDLE, 'byte-identical to what the forge served') | |
| 99 | t.is(crypto.data(fs.readFileSync(staged)).toString('hex'), contentHash) | |
| 100 | }) | |
| 101 | ||
| 102 | test('bytes that do not hash to the advertised content hash are REFUSED', async (t) => { | |
| 103 | // The forge is HTTPS behind a load balancer and releases are unsigned, so the only thing standing | |
| 104 | // between a phone and someone else's bundle is this check. A bundle becomes the code that holds the | |
| 105 | // DID signing key — "it downloaded fine" is not a security property. | |
| 106 | const dir = tmpdir('tamper') | |
| 107 | const { entries, treeHash } = treeFor(BUNDLE) | |
| 108 | const version = 'master-6ada0eb' | |
| 109 | ||
| 110 | const doFetch = fakeForge({ | |
| 111 | versions: [{ version, builtAt: 1 }], | |
| 112 | files: { | |
| 113 | [version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })), | |
| 114 | [version + '/worklet.bundle']: Buffer.from('#!/bare\n// somebody else’s worklet\n') | |
| 115 | } | |
| 116 | }) | |
| 117 | ||
| 118 | const fetcher = bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch }) | |
| 119 | const store = bundleStore({ dir }) | |
| 120 | ||
| 121 | await t.exception(installTree(store, treeHash, fetcher), /content hash mismatch/) | |
| 122 | t.absent(store.hasTree(treeHash), 'and nothing is left staged for the next boot to pick up') | |
| 123 | }) | |
| 124 | ||
| 125 | test('a tree.json whose entries do not reproduce its own treeHash is REFUSED', async (t) => { | |
| 126 | const dir = tmpdir('badtree') | |
| 127 | const { entries, treeHash } = treeFor(BUNDLE) | |
| 128 | const lying = treeFor(Buffer.from('different bundle')).entries | |
| 129 | const version = 'master-6ada0eb' | |
| 130 | ||
| 131 | const doFetch = fakeForge({ | |
| 132 | versions: [{ version, builtAt: 1 }], | |
| 133 | files: { | |
| 134 | [version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries: lying })), | |
| 135 | [version + '/worklet.bundle']: BUNDLE | |
| 136 | } | |
| 137 | }) | |
| 138 | ||
| 139 | const fetcher = bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch }) | |
| 140 | await t.exception(installTree(bundleStore({ dir }), treeHash, fetcher), /root tree-hash mismatch/) | |
| 141 | }) | |
| 142 | ||
| 143 | test('hasTree is only true when the BYTES are there, not just the marker', async (t) => { | |
| 144 | // The failure this repo has been living with all week, in miniature: an index that advertises a | |
| 145 | // version whose bytes never arrived. A marker written before the file is the same bug one layer down, | |
| 146 | // so the marker is written LAST and hasTree checks the file too. | |
| 147 | const dir = tmpdir('marker') | |
| 148 | const { entries, treeHash } = treeFor(BUNDLE) | |
| 149 | const version = 'master-6ada0eb' | |
| 150 | const doFetch = fakeForge({ | |
| 151 | versions: [{ version, builtAt: 1 }], | |
| 152 | files: { | |
| 153 | [version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })), | |
| 154 | [version + '/worklet.bundle']: BUNDLE | |
| 155 | } | |
| 156 | }) | |
| 157 | const store = bundleStore({ dir }) | |
| 158 | t.absent(store.hasTree(treeHash), 'nothing staged yet') | |
| 159 | ||
| 160 | await installTree(store, treeHash, bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch })) | |
| 161 | t.ok(store.hasTree(treeHash), 'staged') | |
| 162 | ||
| 163 | fs.unlinkSync(store.pathFor(treeHash)) | |
| 164 | t.absent(store.hasTree(treeHash), 'marker without bytes is NOT installed') | |
| 165 | }) | |
| 166 | ||
| 167 | test('a second install of the same tree is a no-op', async (t) => { | |
| 168 | const dir = tmpdir('dedup') | |
| 169 | const { entries, treeHash } = treeFor(BUNDLE) | |
| 170 | const version = 'master-6ada0eb' | |
| 171 | let contentFetches = 0 | |
| 172 | const base = fakeForge({ | |
| 173 | versions: [{ version, builtAt: 1 }], | |
| 174 | files: { | |
| 175 | [version + '/tree.json']: Buffer.from(JSON.stringify({ treeHash, entries })), | |
| 176 | [version + '/worklet.bundle']: BUNDLE | |
| 177 | } | |
| 178 | }) | |
| 179 | const doFetch = async (url, opts) => { if (url.endsWith('.bundle')) contentFetches++; return base(url, opts) } | |
| 180 | const fetcher = bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch }) | |
| 181 | const store = bundleStore({ dir }) | |
| 182 | ||
| 183 | await installTree(store, treeHash, fetcher) | |
| 184 | const again = await installTree(store, treeHash, fetcher) | |
| 185 | t.is(again.skipped, 1) | |
| 186 | t.is(contentFetches, 1, 'a phone does not re-download 19MB it already has') | |
| 187 | }) | |
| 188 | ||
| 189 | // ── which bundle actually BOOTS ─────────────────────────────────────────────────────────────────── | |
| 190 | // | |
| 191 | // ⛔ A STAGED BUNDLE NOTHING RUNS IS NOT AN UPDATE. Downloading and verifying is the easy half; the half | |
| 192 | // that can brick a phone is choosing to run the new code. There is no adb on a user's handset and no UI | |
| 193 | // left if the node fails to boot, so the ONLY safe shape is a watchdog: record the intent before | |
| 194 | // starting, clear it once the node reports ready, and treat a marker that survived a boot as proof that | |
| 195 | // bundle does not work. | |
| 196 | // | |
| 197 | // The failure this must not have: a bad bundle that is retried forever, each boot dying the same way. | |
| 198 | test('with nothing staged, the APK asset boots', (t) => { | |
| 199 | const c = chooseBundle({ current: null, pending: null, failed: [] }) | |
| 200 | t.is(c.use, 'asset') | |
| 201 | }) | |
| 202 | ||
| 203 | test('a staged bundle is used, and the intent is recorded BEFORE it runs', (t) => { | |
| 204 | const c = chooseBundle({ current: { treeHash: 'aaa', file: 'aaa.bundle' }, pending: null, failed: [] }) | |
| 205 | t.is(c.use, 'staged') | |
| 206 | t.is(c.hash, 'aaa') | |
| 207 | t.ok(c.markPending, 'the caller is told to record the attempt before starting') | |
| 208 | }) | |
| 209 | ||
| 210 | test('a pending marker that survived a boot condemns that bundle', (t) => { | |
| 211 | // Written last boot, never cleared ⇒ the node never reached ready on it. | |
| 212 | const c = chooseBundle({ current: { treeHash: 'aaa', file: 'aaa.bundle' }, pending: { treeHash: 'aaa' }, failed: [] }) | |
| 213 | t.is(c.use, 'asset', 'fall back to the APK, which is known to work') | |
| 214 | t.is(c.condemn, 'aaa', 'and remember which bundle did it') | |
| 215 | }) | |
| 216 | ||
| 217 | test('a condemned bundle is never retried, however many times it is offered', (t) => { | |
| 218 | const c = chooseBundle({ current: { treeHash: 'aaa', file: 'aaa.bundle' }, pending: null, failed: ['aaa'] }) | |
| 219 | t.is(c.use, 'asset', 'a bundle that failed once is not tried again') | |
| 220 | t.absent(c.condemn, 'and it is not condemned twice') | |
| 221 | }) | |
| 222 | ||
| 223 | test('a NEWER bundle is tried even after an older one failed', (t) => { | |
| 224 | // The fix for a bad release is the next release. Refusing all staged bundles after one failure would | |
| 225 | // make the phone permanently un-updatable by a single bad build. | |
| 226 | const c = chooseBundle({ current: { treeHash: 'bbb', file: 'bbb.bundle' }, pending: null, failed: ['aaa'] }) | |
| 227 | t.is(c.use, 'staged') | |
| 228 | t.is(c.hash, 'bbb') | |
| 229 | }) | |
| 230 | ||
| 231 | test('a pending marker for a DIFFERENT bundle does not condemn the current one', (t) => { | |
| 232 | // Staged 'bbb' while 'aaa' was mid-attempt: 'aaa' is what failed, not 'bbb'. | |
| 233 | const c = chooseBundle({ current: { treeHash: 'bbb', file: 'bbb.bundle' }, pending: { treeHash: 'aaa' }, failed: [] }) | |
| 234 | t.is(c.condemn, 'aaa', 'the one that was actually being attempted is condemned') | |
| 235 | t.is(c.use, 'staged', 'and the new one still gets its chance') | |
| 236 | t.is(c.hash, 'bbb') | |
| 237 | }) | |
| 238 | ||
| 239 | test('a malformed current.json is ignored rather than thrown on', (t) => { | |
| 240 | t.is(chooseBundle({ current: {}, pending: null, failed: [] }).use, 'asset') | |
| 241 | t.is(chooseBundle({ current: { treeHash: 'aaa' }, pending: null, failed: [] }).use, 'asset', 'no file, no boot') | |
| 242 | }) |