| … | … | @@ -49,6 +49,25 @@ export function chooseBundle ({ current = null, pending = null, failed = [], ass |
| 49 | 49 | if (shipped && staged && shipped > staged) { |
| 50 | 50 | return asset('the app was updated more recently than the staged bundle', condemn) |
| 51 | 51 | } |
| 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 | } | |
| 52 | 71 | |
| 53 | 72 | return { |
| 54 | 73 | use: 'staged', |
| … | … | @@ -233,7 +233,17 @@ export function bundleStore ({ dir }) { |
| 233 | 233 | }, |
| 234 | 234 | |
| 235 | 235 | 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 | |
| 237 | 247 | this.init() |
| 238 | 248 | // ⛔ BY NAME, NEVER BY POSITION. This used to write EVERY file entry and keep whichever landed |
| 239 | 249 | // last. That was safe only while a tree had exactly one entry, and the ci.yaml note said what |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-android", |
| 3 | "version": "0.1.9", | |
| 3 | "version": "0.1.10", | |
| 4 | 4 | "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.", |
| 5 | 5 | "private": true, |
| 6 | 6 | "type": "module", |
| … | … | @@ -84,3 +84,25 @@ async function stage (entries, bytesByHash) { |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | 86 | console.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 | ||
| 108 | console.log('\n(marker provenance checked)') |
| … | … | @@ -14,6 +14,7 @@ |
| 14 | 14 | // live foreground service), not whether a screen is on. |
| 15 | 15 | import test from 'brittle' |
| 16 | 16 | import { createLifecycle } from '../lib/lifecycle.js' |
| 17 | import { chooseBundle } from '../lib/choose-bundle.js' | |
| 17 | 18 | |
| 18 | 19 | // A stand-in for react-native-bare-kit's Worklet, carrying its real suspend/resume semantics — including |
| 19 | 20 | // `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' |
| 137 | 138 | await lc.onAppState('background') |
| 138 | 139 | t.is(w.suspended, true, 'falling back to the library policy') |
| 139 | 140 | }) |
| 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. | |
| 155 | test('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 | ||
| 164 | test('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 | ||
| 172 | test('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 | }) |
| … | … | @@ -297,13 +297,22 @@ test('an APK built after the staged bundle wins', (t) => { |
| 297 | 297 | t.is(chooseBundle({ current: staged, assetBuiltAt: 1000 }).use, 'staged', 'a tie keeps the staged bundle — no churn') |
| 298 | 298 | }) |
| 299 | 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) => { | |
| 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. | |
| 311 | test('an undatable staged bundle loses to a stamped APK, but not to an unstamped one', (t) => { | |
| 303 | 312 | 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') | |
| 307 | 316 | }) |
| 308 | 317 | |
| 309 | 318 | // The watchdog outranks the timestamps: a bundle that failed to boot is never resurrected by being newer. |