OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / eec9c62

0.1.3: installing a newer APK no longer loses to an older staged bundle
Watched this twice in one afternoon while shipping: install the new APK, restart, and the phone boots the bundle staged BEFORE the install — the new shell running older JS, until an OTA pass happens to catch up and a second restart applies it. chooseBundle preferred `current` whenever it existed, and tree hashes carry no ordering, so there was nothing to compare. Now both sides carry a build time: - CI stamps the APK's own bundle (`worklet.bundle.built`, beside the existing .size stamp) and app/worklet.js reads it. - lib/ota.js records the RELEASE's build time in the staged marker. pgh-runtime does not pass the release down to the store, but it emits `updating` with it immediately beforehand — so startOta notes it and materialize stamps it. ⛔ NEVER INVENT AN ORDERING. An older shell stamps nothing and an older staged marker carries no builtAt; both cases keep the historic behaviour (staged wins) rather than guessing which side is newer. A tie keeps staged too — no churn. The watchdog still outranks all of it: a bundle that failed to boot is never resurrected by being newer. 6 new assertions in test/ota.test.mjs; suite green (19 tests, 42 asserts). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eec9c62a676b8ccc5a7b0902e425caf192ade4fe
by plankatron <fleeky@gmail.com> · 2026-08-25T18:34:09.000Z

Changed files (5) +72 -8

app/worklet.js +7 -1
……@@ -107,6 +107,11 @@ async function boot () {
107107 const t0 = Date.now()
108108 const dest = RNFS.DocumentDirectoryPath + '/worklet.bundle'
109109 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 {}
110115 let haveSize = -1
111116 try { haveSize = (await RNFS.stat(dest)).size } catch { /* first run */ }
112117 if (haveSize !== wantSize) {
……@@ -131,7 +136,8 @@ async function boot () {
131136 const choice = chooseBundle({
132137 current: await readJson(bdir + '/current.json'),
133138 pending: await readJson(bdir + '/pending.json'),
134 failed
139 failed,
140 assetBuiltAt
135141 })
136142
137143 if (choice.condemn) {
lib/choose-bundle.js +18 -2
……@@ -10,7 +10,8 @@
1010// which is the only place the consequences of getting this wrong actually show up.
1111
1212/**
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
1415 * @returns {{use:'staged'|'asset', hash:?string, file:?string, markPending:boolean, condemn:?string, reason:string}}
1516 *
1617 * ⛔ A STAGED BUNDLE NOTHING RUNS IS NOT AN UPDATE, and running one blindly is how a phone gets bricked.
……@@ -21,7 +22,7 @@
2122 * The failure this exists to prevent is the retry loop — a bad bundle chosen again on every boot, dying
2223 * identically each time, with no way in from outside.
2324 */
24export function chooseBundle ({ current = null, pending = null, failed = [] } = {}) {
25export function chooseBundle ({ current = null, pending = null, failed = [], assetBuiltAt = null } = {}) {
2526 const asset = (reason, condemn = null) => ({ use: 'asset', hash: null, file: null, markPending: false, condemn, reason })
2627
2728 // 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 = [] } =
3435 // phone permanently un-updatable by a single bad release, and the fix for a bad release is the next one.
3536 if (dead.has(current.treeHash)) return asset('staged bundle previously failed to boot', condemn)
3637
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
3753 return {
3854 use: 'staged',
3955 hash: current.treeHash,
lib/ota.js +15 -3
……@@ -192,6 +192,11 @@ export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFet
192192 */
193193export function bundleStore ({ dir }) {
194194 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
195200 const readMarker = (treeHash) => {
196201 try { return JSON.parse(fs.readFileSync(markerPath(treeHash), 'utf8')) } catch { return null }
197202 }
……@@ -199,6 +204,9 @@ export function bundleStore ({ dir }) {
199204 return {
200205 init () { fs.mkdirSync(dir, { recursive: true }) },
201206
207 /** Told by startOta from the `updating` event, just before installTree runs. */
208 noteRelease (r) { pendingRelease = r || null },
209
202210 /**
203211 * ⛔ THE BYTES, NOT JUST THE MARKER. A marker written before the file it names is the same bug the
204212 * forge has been living with all week — an index advertising a release whose artifact never arrived.
……@@ -217,6 +225,7 @@ export function bundleStore ({ dir }) {
217225 },
218226
219227 async materialize (rootHash, entries, { fetchContent } = {}) {
228 const builtAt = pendingRelease && Number(pendingRelease.builtAt) ? Number(pendingRelease.builtAt) : null
220229 this.init()
221230 let file = null
222231 for (const e of entries) {
……@@ -231,11 +240,14 @@ export function bundleStore ({ dir }) {
231240 file = e.childHash + '.bundle'
232241 }
233242 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')
235247 // current.json is how the REACT NATIVE side learns what is staged: it runs before the worklet and
236248 // cannot read this store's code (bare-fs), only its files. Written last, after the bytes and the
237249 // 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')
239251 }
240252 }
241253}
……@@ -277,7 +289,7 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE,
277289 interval
278290 })
279291
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)) })
281293 runtime.on('updated', (i) => say('ota: staged ' + i.newTreeHash + ' — active on next start'))
282294 runtime.on('error', (e) => say('ota: ' + ((e && e.message) || e)))
283295
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-android",
3 "version": "0.1.2",
3 "version": "0.1.3",
44 "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.",
55 "private": true,
66 "type": "module",
……@@ -10,7 +10,7 @@
1010 "stage:addons": "node scripts/stage-addons.mjs",
1111 "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run gen:tree && npm run stage:addons",
1212 "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()))\"",
1414 "gen:tree": "bare scripts/gen-tree-manifest.mjs",
1515 "gen:apk-tree": "bare scripts/gen-tree-manifest.mjs --in favela-android.apk --out apk.tree.json --name favela-android.apk"
1616 },
test/ota.test.mjs +30 -0
……@@ -282,3 +282,33 @@ test('no complete release at all is null, not a crash', async (t) => {
282282 const latest = await bundleFetcher({ base: 'https://forge/git', repo: 'favela-android', doFetch }).getLatestRelease('favela-android')
283283 t.is(latest, null)
284284})
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.
293test('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.
302test('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.
310test('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})