OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 9e8aa66

0.1.10: an APK install must be able to supersede a staged bundle
THIS IS WHY TONIGHT'S FIXES DID NOT REACH THE PHONE. Four APKs were installed in a row, each carrying real fixes, and none took effect: the handset kept booting a bundle staged from 0.1.6. The symptom is the worst kind — the install succeeds, the version looks right, and the code does not run. Most of an evening went into "my changes are not on the phone". Root cause: chooseBundle only let the APK win when BOTH build times were present, and the HTTPS fetcher's getLatestRelease returned { name, version, treeHash } with NO builtAt. So every bundle it ever staged is undatable, `staged` is 0, the clause cannot fire, and that bundle outranks every future APK for ever. "Never invent an ordering" is right when NEITHER side can be dated, and that case is unchanged. It was wrong when only the staged side cannot: an APK install is a physical, deliberate act by someone holding the device, and a bundle that cannot say when it was built cannot be shown to be newer than that. The tie now goes to the APK. A staged bundle that CAN say, and is newer, still wins — OTA has to keep working. An existing test asserted the old behaviour ("no staged builtAt => staged wins, as before"). It encoded the bug, so it is reversed here deliberately rather than worked around. Also fixed at the source, or the swarm fetcher would have inherited the same defect: the staged marker now records a build time from EITHER shape — a forge release's `builtAt` or the pgh ReleaseRegistry's ISO `publishedAt`. Reading only one is how a fetcher written to replace this bug would have reproduced it. chooseBundle lives in the React Native shell, which ships in the APK rather than the bundle — which is the only reason this is fixable at all from outside the deadlock. Full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9e8aa668d1ef3bce782ad3e4523a16048d3743e7
by plankatron <fleeky@gmail.com> · 2026-08-26T01:29:17.000Z

Changed files (6) +107 -8

lib/choose-bundle.js +19 -0
……@@ -49,6 +49,25 @@ export function chooseBundle ({ current = null, pending = null, failed = [], ass
4949 if (shipped && staged && shipped > staged) {
5050 return asset('the app was updated more recently than the staged bundle', condemn)
5151 }
52 // ⛔ AND AN UNDATED STAGED BUNDLE LOSES TO A STAMPED APK TOO.
53 //
54 // Requiring BOTH times made a whole class of staged bundles permanently unbeatable: the HTTPS fetcher's
55 // getLatestRelease returned { name, version, treeHash } and no builtAt, so every bundle it ever staged
56 // has a marker with no build time. `staged` is 0, the clause above cannot fire, and the phone keeps
57 // running that JS through every future APK install.
58 //
59 // It is not hypothetical and the symptom is the worst kind — the install succeeds, the version looks
60 // right, and the code does not run. Four APKs were installed on a handset on 2026-08-25, each carrying
61 // fixes, and none took effect: a bundle staged from 0.1.6 won every boot. Most of an evening went into
62 // diagnosing "my changes are not on the phone".
63 //
64 // An APK install is a PHYSICAL, DELIBERATE act by someone holding the device. A staged bundle that
65 // cannot say when it was built cannot be shown to be newer than that, so the tie goes to the APK.
66 // A staged bundle that CAN say, and is newer, still wins — OTA has to keep working, and that is the
67 // case immediately above.
68 if (shipped && !staged) {
69 return asset('the staged bundle carries no build time; an installed APK is the more recent evidence', condemn)
70 }
5271
5372 return {
5473 use: 'staged',
lib/ota.js +11 -1
……@@ -233,7 +233,17 @@ export function bundleStore ({ dir }) {
233233 },
234234
235235 async materialize (rootHash, entries, { fetchContent } = {}) {
236 const builtAt = pendingRelease && Number(pendingRelease.builtAt) ? Number(pendingRelease.builtAt) : null
236 // ⛔ RECORD A BUILD TIME, WHATEVER THE RELEASE CALLS IT. chooseBundle compares this against the
237 // APK's stamp, and a staged bundle with no time is one an APK install cannot supersede — the
238 // failure that left a handset running 0.1.6's JS through four installs on 2026-08-25.
239 //
240 // The two release shapes name it differently and neither is wrong: a forge release carries
241 // `builtAt`, the pgh ReleaseRegistry carries an ISO `publishedAt`. Reading only one is how the
242 // swarm fetcher would have inherited the very bug it was written to replace.
243 const builtAt = (pendingRelease && (
244 (Number(pendingRelease.builtAt) || 0) ||
245 (Date.parse(pendingRelease.publishedAt || '') || 0)
246 )) || null
237247 this.init()
238248 // ⛔ BY NAME, NEVER BY POSITION. This used to write EVERY file entry and keep whichever landed
239249 // last. That was safe only while a tree had exactly one entry, and the ci.yaml note said what
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-android",
3 "version": "0.1.9",
3 "version": "0.1.10",
44 "description": "favela-android \u2014 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",
test/bundle-by-name.test.mjs +22 -0
……@@ -84,3 +84,25 @@ async function stage (entries, bytesByHash) {
8484}
8585
8686console.log('\n' + pass + ' assertions passed')
87
88// ── the staged marker must carry a build time, whatever the release calls it ──────────────────────
89// chooseBundle compares this against the APK's stamp. A staged bundle with no time is one an APK
90// install cannot supersede — the failure that left a handset running 0.1.6's JS through four installs.
91// A forge release says `builtAt`; the pgh ReleaseRegistry says ISO `publishedAt`. Both must land.
92{
93 const es = [entry('worklet.bundle', BUNDLE)]
94 for (const [label, release, want] of [
95 ['forge builtAt', { version: 'v', builtAt: 1787708000000 }, 1787708000000],
96 ['registry publishedAt', { version: 'v', publishedAt: '2026-08-26T00:34:47.151Z' }, Date.parse('2026-08-26T00:34:47.151Z')],
97 ['neither', { version: 'v' }, null]
98 ]) {
99 const dir = tmp('mark')
100 const store = bundleStore({ dir })
101 store.noteRelease(release)
102 await store.materialize('root-' + label, es, { fetchContent: async () => BUNDLE })
103 const cur = JSON.parse(fs.readFileSync(path.join(dir, 'current.json'), 'utf8'))
104 ok(cur.builtAt === want, label + ' -> builtAt ' + cur.builtAt)
105 }
106}
107
108console.log('\n(marker provenance checked)')
test/lifecycle.test.mjs +39 -0
……@@ -14,6 +14,7 @@
1414// live foreground service), not whether a screen is on.
1515import test from 'brittle'
1616import { createLifecycle } from '../lib/lifecycle.js'
17import { chooseBundle } from '../lib/choose-bundle.js'
1718
1819// A stand-in for react-native-bare-kit's Worklet, carrying its real suspend/resume semantics — including
1920// `update`, which is the method the library's AppState listener calls and the one we take over.
……@@ -137,3 +138,41 @@ test('a service that throws is a service that did not start, not a crashed boot'
137138 await lc.onAppState('background')
138139 t.is(w.suspended, true, 'falling back to the library policy')
139140})
141
142// ⛔ A STAGED BUNDLE WITH NO PROVENANCE MUST NOT OUTRANK AN EXPLICIT APK INSTALL.
143//
144// The HTTPS fetcher's getLatestRelease returned { name, version, treeHash } and NO builtAt, so every
145// bundle it staged got a marker with no build time. chooseBundle required BOTH times to be present
146// before letting the APK win, so those bundles became permanently unbeatable: a phone would install a
147// newer APK, boot, and keep running JS staged weeks earlier.
148//
149// Observed on the handset 2026-08-25 and it cost most of an evening: four APKs in a row installed
150// cleanly and none of their fixes took effect, because a bundle staged from 0.1.6 won every boot. The
151// symptom is the worst kind — the install succeeds, the version looks right, and the code does not run.
152//
153// An APK install is a PHYSICAL, DELIBERATE act by someone holding the device. A staged bundle that
154// cannot say when it was built cannot be shown to be newer than that. So the tie now goes to the APK.
155test('a stamped APK beats a staged bundle that cannot say when it was built', (t) => {
156 const r = chooseBundle({
157 current: { treeHash: 'aaa', file: 'aaa.bundle', builtAt: null }, // staged by the HTTPS client
158 assetBuiltAt: 1787708000000
159 })
160 t.is(r.use, 'asset', 'the freshly installed APK wins')
161 t.ok(/older|no build time|updated/i.test(r.reason), 'and says why: ' + r.reason)
162})
163
164test('but a staged bundle that IS newer still wins — OTA must keep working', (t) => {
165 const r = chooseBundle({
166 current: { treeHash: 'bbb', file: 'bbb.bundle', builtAt: 1787709000000 },
167 assetBuiltAt: 1787708000000
168 })
169 t.is(r.use, 'staged', 'a genuinely newer staged bundle is still preferred')
170})
171
172test('with NO APK stamp, nothing is orderable and the historic behaviour stands', (t) => {
173 const r = chooseBundle({
174 current: { treeHash: 'ccc', file: 'ccc.bundle', builtAt: null },
175 assetBuiltAt: null
176 })
177 t.is(r.use, 'staged', 'an older shell that cannot stamp its asset does not get a surprise downgrade')
178})
test/ota.test.mjs +15 -6
……@@ -297,13 +297,22 @@ test('an APK built after the staged bundle wins', (t) => {
297297 t.is(chooseBundle({ current: staged, assetBuiltAt: 1000 }).use, 'staged', 'a tie keeps the staged bundle — no churn')
298298})
299299
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) => {
300// ⛔ THE SECOND CASE HERE WAS REVERSED ON 2026-08-25, AND IT WAS A REAL BUG, NOT A PREFERENCE.
301//
302// "Never invent an ordering" is right when NEITHER side can be dated. It was wrong when only the STAGED
303// side could not: the HTTPS fetcher's getLatestRelease returned { name, version, treeHash } and no
304// builtAt, so every bundle it staged was undatable and therefore permanently unbeatable. A handset took
305// four APK installs in a row, each carrying fixes, and ran 0.1.6's JS through all of them — the install
306// succeeds, the version looks right, and the code does not run.
307//
308// An APK install is a physical, deliberate act by someone holding the device. A staged bundle that
309// cannot say when it was built cannot be shown to be newer than that, so the tie goes to the APK. When
310// the APK cannot be dated either, nothing is orderable and the historic behaviour genuinely does stand.
311test('an undatable staged bundle loses to a stamped APK, but not to an unstamped one', (t) => {
303312 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')
313 t.is(chooseBundle({ current: staged }).use, 'staged', 'no APK stamp => staged wins; nothing is orderable')
314 t.is(chooseBundle({ current: { treeHash: 'b'.repeat(64), file: 'b.bundle' }, assetBuiltAt: 5000 }).use, 'asset',
315 'no staged builtAt but a stamped APK => the APK is the more recent evidence')
307316})
308317
309318// The watchdog outranks the timestamps: a bundle that failed to boot is never resurrected by being newer.