| … | … | @@ -107,6 +107,11 @@ async function boot () { |
| 107 | 107 | const t0 = Date.now() |
| 108 | 108 | const dest = RNFS.DocumentDirectoryPath + '/worklet.bundle' |
| 109 | 109 | const wantSize = Number((await RNFS.readFileAssets('worklet.bundle.size', 'utf8')).trim()) |
| 110 | // When the bundle inside THIS APK was built. chooseBundle compares it with the staged bundle's release | |
| 111 | // time so that installing a newer APK is not silently overridden by JS staged before it. Absent on an | |
| 112 | // APK built before the stamp existed, which chooseBundle reads as "no ordering available". | |
| 113 | let assetBuiltAt = null | |
| 114 | try { assetBuiltAt = Number((await RNFS.readFileAssets('worklet.bundle.built', 'utf8')).trim()) || null } catch {} | |
| 110 | 115 | let haveSize = -1 |
| 111 | 116 | try { haveSize = (await RNFS.stat(dest)).size } catch { /* first run */ } |
| 112 | 117 | if (haveSize !== wantSize) { |
| … | … | @@ -131,7 +136,8 @@ async function boot () { |
| 131 | 136 | const choice = chooseBundle({ |
| 132 | 137 | current: await readJson(bdir + '/current.json'), |
| 133 | 138 | pending: await readJson(bdir + '/pending.json'), |
| 134 | failed | |
| 139 | failed, | |
| 140 | assetBuiltAt | |
| 135 | 141 | }) |
| 136 | 142 | |
| 137 | 143 | if (choice.condemn) { |
| … | … | @@ -10,7 +10,8 @@ |
| 10 | 10 | // which is the only place the consequences of getting this wrong actually show up. |
| 11 | 11 | |
| 12 | 12 | /** |
| 13 | * @param {{current: ?{treeHash: string, file: string}, pending: ?{treeHash: string}, failed: string[]}} state | |
| 13 | * @param {{current: ?{treeHash: string, file: string, builtAt: ?number}, pending: ?{treeHash: string}, | |
| 14 | * failed: string[], assetBuiltAt: ?number}} state | |
| 14 | 15 | * @returns {{use:'staged'|'asset', hash:?string, file:?string, markPending:boolean, condemn:?string, reason:string}} |
| 15 | 16 | * |
| 16 | 17 | * ⛔ A STAGED BUNDLE NOTHING RUNS IS NOT AN UPDATE, and running one blindly is how a phone gets bricked. |
| … | … | @@ -21,7 +22,7 @@ |
| 21 | 22 | * The failure this exists to prevent is the retry loop — a bad bundle chosen again on every boot, dying |
| 22 | 23 | * identically each time, with no way in from outside. |
| 23 | 24 | */ |
| 24 | export function chooseBundle ({ current = null, pending = null, failed = [] } = {}) { | |
| 25 | export function chooseBundle ({ current = null, pending = null, failed = [], assetBuiltAt = null } = {}) { | |
| 25 | 26 | const asset = (reason, condemn = null) => ({ use: 'asset', hash: null, file: null, markPending: false, condemn, reason }) |
| 26 | 27 | |
| 27 | 28 | // A pending marker means the LAST boot started a bundle and never reached ready. Condemn whatever it |
| … | … | @@ -34,6 +35,21 @@ export function chooseBundle ({ current = null, pending = null, failed = [] } = |
| 34 | 35 | // phone permanently un-updatable by a single bad release, and the fix for a bad release is the next one. |
| 35 | 36 | if (dead.has(current.treeHash)) return asset('staged bundle previously failed to boot', condemn) |
| 36 | 37 | |
| 38 | // ⛔ AN APK INSTALL MUST SUPERSEDE AN OLDER STAGED BUNDLE. Staged used to win unconditionally, and tree | |
| 39 | // hashes carry no ordering — so installing a newer APK left the phone running the JS staged before it, | |
| 40 | // until the next OTA pass happened to catch up. Observed twice on a handset while shipping today: the | |
| 41 | // new shell booted, the new bundle sat in assets, and the old staged one ran anyway. | |
| 42 | // | |
| 43 | // Build times are the only comparable thing the two sides share: the CI stamps the APK's bundle | |
| 44 | // (`worklet.bundle.built`) and lib/ota.js records the release's build time in the staged marker. Both | |
| 45 | // absent means an older shell or an older staged marker, and the historic behaviour (staged wins) is | |
| 46 | // kept — never guess an ordering out of nothing. | |
| 47 | const staged = Number(current.builtAt) || 0 | |
| 48 | const shipped = Number(assetBuiltAt) || 0 | |
| 49 | if (shipped && staged && shipped > staged) { | |
| 50 | return asset('the app was updated more recently than the staged bundle', condemn) | |
| 51 | } | |
| 52 | ||
| 37 | 53 | return { |
| 38 | 54 | use: 'staged', |
| 39 | 55 | hash: current.treeHash, |
| … | … | @@ -192,6 +192,11 @@ export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFet |
| 192 | 192 | */ |
| 193 | 193 | export function bundleStore ({ dir }) { |
| 194 | 194 | const markerPath = (treeHash) => path.join(dir, treeHash + '.json') |
| 195 | // The release currently being installed. pgh-runtime's installTree does not pass the release down to | |
| 196 | // the store, but it emits `updating` with it immediately beforehand — so the caller notes it here and | |
| 197 | // materialize can stamp the staged marker with a comparable build time. Without that the shell has no | |
| 198 | // way to tell a staged bundle from the one inside a freshly installed APK. | |
| 199 | let pendingRelease = null | |
| 195 | 200 | const readMarker = (treeHash) => { |
| 196 | 201 | try { return JSON.parse(fs.readFileSync(markerPath(treeHash), 'utf8')) } catch { return null } |
| 197 | 202 | } |
| … | … | @@ -199,6 +204,9 @@ export function bundleStore ({ dir }) { |
| 199 | 204 | return { |
| 200 | 205 | init () { fs.mkdirSync(dir, { recursive: true }) }, |
| 201 | 206 | |
| 207 | /** Told by startOta from the `updating` event, just before installTree runs. */ | |
| 208 | noteRelease (r) { pendingRelease = r || null }, | |
| 209 | ||
| 202 | 210 | /** |
| 203 | 211 | * ⛔ THE BYTES, NOT JUST THE MARKER. A marker written before the file it names is the same bug the |
| 204 | 212 | * forge has been living with all week — an index advertising a release whose artifact never arrived. |
| … | … | @@ -217,6 +225,7 @@ export function bundleStore ({ dir }) { |
| 217 | 225 | }, |
| 218 | 226 | |
| 219 | 227 | async materialize (rootHash, entries, { fetchContent } = {}) { |
| 228 | const builtAt = pendingRelease && Number(pendingRelease.builtAt) ? Number(pendingRelease.builtAt) : null | |
| 220 | 229 | this.init() |
| 221 | 230 | let file = null |
| 222 | 231 | for (const e of entries) { |
| … | … | @@ -231,11 +240,14 @@ export function bundleStore ({ dir }) { |
| 231 | 240 | file = e.childHash + '.bundle' |
| 232 | 241 | } |
| 233 | 242 | if (!file) throw new Error('tree contained no file to stage') |
| 234 | fs.writeFileSync(markerPath(rootHash), JSON.stringify({ treeHash: rootHash, file }, null, 2) + '\n') | |
| 243 | // `builtAt` is what lets the SHELL compare a staged bundle with the one inside the APK. Without it | |
| 244 | // an APK install loses to whatever was staged before it — chooseBundle prefers staged, and tree | |
| 245 | // hashes carry no ordering, so a phone kept running the older JS until the next OTA pass caught up. | |
| 246 | fs.writeFileSync(markerPath(rootHash), JSON.stringify({ treeHash: rootHash, file, builtAt }, null, 2) + '\n') | |
| 235 | 247 | // current.json is how the REACT NATIVE side learns what is staged: it runs before the worklet and |
| 236 | 248 | // cannot read this store's code (bare-fs), only its files. Written last, after the bytes and the |
| 237 | 249 | // marker, so it never advertises something that is not fully on disk. |
| 238 | fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file }, null, 2) + '\n') | |
| 250 | fs.writeFileSync(path.join(dir, 'current.json'), JSON.stringify({ treeHash: rootHash, file, builtAt }, null, 2) + '\n') | |
| 239 | 251 | } |
| 240 | 252 | } |
| 241 | 253 | } |
| … | … | @@ -277,7 +289,7 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE, |
| 277 | 289 | interval |
| 278 | 290 | }) |
| 279 | 291 | |
| 280 | runtime.on('updating', (r) => say('ota: downloading ' + (r && r.version))) | |
| 292 | runtime.on('updating', (r) => { store.noteRelease(r); say('ota: downloading ' + (r && r.version)) }) | |
| 281 | 293 | runtime.on('updated', (i) => say('ota: staged ' + i.newTreeHash + ' — active on next start')) |
| 282 | 294 | runtime.on('error', (e) => say('ota: ' + ((e && e.message) || e))) |
| 283 | 295 |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-android", |
| 3 | "version": "0.1.2", | |
| 3 | "version": "0.1.3", | |
| 4 | 4 | "description": "favela-android — a React Native client over a bare-kit worklet running a FULL favela node (hyper-proto-peer + appview + hyperswarm) on the phone. favela-web is reference for the logic, NOT the rendering: this is a different rendering surface. See spec/architecture.md.", |
| 5 | 5 | "private": true, |
| 6 | 6 | "type": "module", |
| … | … | @@ -10,7 +10,7 @@ |
| 10 | 10 | "stage:addons": "node scripts/stage-addons.mjs", |
| 11 | 11 | "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run gen:tree && npm run stage:addons", |
| 12 | 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/roster.test.mjs && bare test/describe.test.mjs && bare test/shell.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.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))\"", | |
| 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));fs.writeFileSync(p+'.built',String(Date.now()))\"", | |
| 14 | 14 | "gen:tree": "bare scripts/gen-tree-manifest.mjs", |
| 15 | 15 | "gen:apk-tree": "bare scripts/gen-tree-manifest.mjs --in favela-android.apk --out apk.tree.json --name favela-android.apk" |
| 16 | 16 | }, |
| … | … | @@ -282,3 +282,33 @@ test('no complete release at all is null, not a crash', async (t) => { |
| 282 | 282 | const latest = await bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch }).getLatestRelease('favela-android') |
| 283 | 283 | t.is(latest, null) |
| 284 | 284 | }) |
| 285 | ||
| 286 | // ⛔ AN APK INSTALL MUST SUPERSEDE AN OLDER STAGED BUNDLE. Staged used to win unconditionally, and tree | |
| 287 | // hashes carry no ordering — so a freshly installed APK ran the JS staged BEFORE it until the next OTA | |
| 288 | // pass happened to catch up. Seen twice on a handset in one afternoon while shipping (hyper-social #53): | |
| 289 | // the new shell booted, the new bundle sat in assets, and the old staged one ran anyway. | |
| 290 | // | |
| 291 | // Build times are the only comparable thing the two sides share — CI stamps the APK's bundle, lib/ota.js | |
| 292 | // records the release's build time in the staged marker. | |
| 293 | test('an APK built after the staged bundle wins', (t) => { | |
| 294 | const staged = { treeHash: 'a'.repeat(64), file: 'a.bundle', builtAt: 1000 } | |
| 295 | t.is(chooseBundle({ current: staged, assetBuiltAt: 500 }).use, 'staged', 'a staged bundle newer than the APK still runs') | |
| 296 | t.is(chooseBundle({ current: staged, assetBuiltAt: 5000 }).use, 'asset', 'an APK built later supersedes it') | |
| 297 | t.is(chooseBundle({ current: staged, assetBuiltAt: 1000 }).use, 'staged', 'a tie keeps the staged bundle — no churn') | |
| 298 | }) | |
| 299 | ||
| 300 | // Never invent an ordering: an older shell stamps nothing and an older marker carries no builtAt, so both | |
| 301 | // must keep the historic behaviour rather than guess which side is newer. | |
| 302 | test('missing timestamps fall back to the old behaviour', (t) => { | |
| 303 | const staged = { treeHash: 'a'.repeat(64), file: 'a.bundle', builtAt: 1000 } | |
| 304 | t.is(chooseBundle({ current: staged }).use, 'staged', 'no APK stamp => staged wins, as before') | |
| 305 | t.is(chooseBundle({ current: { treeHash: 'b'.repeat(64), file: 'b.bundle' }, assetBuiltAt: 5000 }).use, 'staged', | |
| 306 | 'no staged builtAt => staged wins, as before') | |
| 307 | }) | |
| 308 | ||
| 309 | // The watchdog outranks the timestamps: a bundle that failed to boot is never resurrected by being newer. | |
| 310 | test('a condemned bundle is still refused', (t) => { | |
| 311 | const staged = { treeHash: 'a'.repeat(64), file: 'a.bundle', builtAt: 9999 } | |
| 312 | t.is(chooseBundle({ current: staged, assetBuiltAt: 1, failed: ['a'.repeat(64)] }).use, 'asset', | |
| 313 | 'condemned wins over any build-time comparison') | |
| 314 | }) |