OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 89fcea9

stage the bundle BY NAME, never by position in the tree
materialize wrote every file entry and kept whichever landed last. That was safe only while a tree had exactly one entry, and ci.yaml already said what happens otherwise: "a two-entry tree stages a 154 MB APK as the worklet bundle. The OTA client on a handset is exactly what cannot be fixed remotely." It stopped being hypothetical this commit: CI now publishes to the release registry, `pgh publish` requires a manifest, and the published tree is verifiably { package.json, worklet.bundle } — confirmed by fetching it over the swarm. The winner depended on an ordering nothing in this codebase controls or asserts, and the first version of the new test passed BY LUCK of that ordering. Selecting by name also stops the phone downloading what it will discard: the old loop fetched every entry's content before dropping all but one. On a 38 MB bundle over a handset's connection that is not a rounding error. A tree with no worklet.bundle now throws and names what it did find, rather than silently staging something else as the code that holds the DID signing key. 6 assertions: both entry orders, the manifest never fetched, the single-entry forge tree still staging (phones in the field consume that one), and the missing-bundle error. Full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
89fcea9715bf3e0ca0cceeb94fb5b4cefb3e6386
by plankatron <fleeky@gmail.com> · 2026-08-26T00:39:24.000Z

Changed files (3) +108 -13

lib/ota.js +21 -12
……@@ -227,19 +227,28 @@ export function bundleStore ({ dir }) {
227227 async materialize (rootHash, entries, { fetchContent } = {}) {
228228 const builtAt = pendingRelease && Number(pendingRelease.builtAt) ? Number(pendingRelease.builtAt) : null
229229 this.init()
230 let file = null
231 for (const e of entries) {
232 if (!e || e.kind !== KIND_FILE) continue
233 const bytes = await fetchContent(e.childHash)
234 // Write to .part and rename: a phone is killed constantly, and a truncated bundle that hasTree
235 // then trusted would be a node that cannot boot with no way to say why.
236 const dest = path.join(dir, e.childHash + '.bundle')
237 const part = dest + '.part'
238 fs.writeFileSync(part, Buffer.from(bytes))
239 fs.renameSync(part, dest)
240 file = e.childHash + '.bundle'
230 // ⛔ BY NAME, NEVER BY POSITION. This used to write EVERY file entry and keep whichever landed
231 // last. That was safe only while a tree had exactly one entry, and the ci.yaml note said what
232 // happens otherwise: "a two-entry tree stages a 154 MB APK as the worklet bundle. The OTA client
233 // on a handset is exactly what cannot be fixed remotely."
234 //
235 // It stopped being hypothetical when CI started publishing to the release registry: `pgh publish`
236 // requires a package.json, so the published tree is { package.json, worklet.bundle } and the winner
237 // depended on an ordering nothing here controls or asserts. Selecting by name also stops the phone
238 // downloading content it will discard — the old loop fetched every entry before dropping all but one.
239 const want = (entries || []).find((e) => e && e.kind === KIND_FILE && e.name === BUNDLE_NAME)
240 if (!want) {
241 const names = (entries || []).filter((e) => e && e.kind === KIND_FILE).map((e) => e.name)
242 throw new Error('tree contains no ' + BUNDLE_NAME + ' to stage (files: ' + (names.join(', ') || 'none') + ')')
241243 }
242 if (!file) throw new Error('tree contained no file to stage')
244 const bytes = await fetchContent(want.childHash)
245 // Write to .part and rename: a phone is killed constantly, and a truncated bundle that hasTree
246 // then trusted would be a node that cannot boot with no way to say why.
247 const dest = path.join(dir, want.childHash + '.bundle')
248 const part = dest + '.part'
249 fs.writeFileSync(part, Buffer.from(bytes))
250 fs.renameSync(part, dest)
251 const file = want.childHash + '.bundle'
243252 // `builtAt` is what lets the SHELL compare a staged bundle with the one inside the APK. Without it
244253 // an APK install loses to whatever was staged before it — chooseBundle prefers staged, and tree
245254 // hashes carry no ordering, so a phone kept running the older JS until the next OTA pass caught up.
package.json +1 -1
……@@ -9,7 +9,7 @@
99 "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js",
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",
12 "test": "bare test/first-run.test.mjs && bare test/repair-store-backend.test.mjs && bare test/mint.test.mjs && 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",
12 "test": "bare test/first-run.test.mjs && bare test/bundle-by-name.test.mjs && bare test/repair-store-backend.test.mjs && bare test/mint.test.mjs && 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",
1313 "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"
test/bundle-by-name.test.mjs +86 -0
……@@ -0,0 +1,86 @@
1// The staged bundle is chosen BY NAME, never by position in the tree.
2//
3// ⛔ THIS IS THE TRAP THE CI FILE ALREADY NAMED. materialize looped every file entry and kept whichever
4// it wrote LAST as the bundle to boot. That was safe only while the tree had exactly one entry, and the
5// assemble step's own comment says what happens when it does not: "a two-entry tree stages a 154 MB APK
6// as the worklet bundle. The OTA client on a handset is exactly what cannot be fixed remotely."
7//
8// It stopped being hypothetical when CI began publishing to the release registry: `pgh publish` REQUIRES
9// a package.json, so the published tree is { package.json, worklet.bundle } and the winner depended on
10// entry order — an ordering nothing in this codebase controls or asserts.
11//
12// Selecting by name also stops the phone DOWNLOADING what it will not use. The old loop fetched every
13// entry's content before discarding all but the last.
14//
15// Run: bare test/bundle-by-name.test.mjs
16import fs from 'bare-fs'
17import os from 'bare-os'
18import path from 'bare-path'
19import { KIND_FILE } from 'hyper-overlay/tree'
20import { bundleStore } from '../lib/ota.js'
21
22let pass = 0
23const ok = (c, m) => { if (!c) throw new Error('FAIL: ' + m); console.log(' ok -', m); pass++ }
24
25const tmp = (n) => {
26 const d = path.join(os.tmpdir(), 'favela-byname-' + n + '-' + Date.now())
27 fs.mkdirSync(d, { recursive: true })
28 return d
29}
30
31const BUNDLE = Buffer.from('THE REAL WORKLET BUNDLE')
32const MANIFEST = Buffer.from('{"name":"favela-android"}')
33const h = (b) => require_hash(b)
34// hash the way the store keys files: content hash from the fetcher's own map
35function require_hash (b) { return 'h' + b.length + '_' + b.toString('hex').slice(0, 8) }
36
37const entry = (name, bytes) => ({ kind: KIND_FILE, exec: 0, name, childHash: h(bytes) })
38
39async function stage (entries, bytesByHash) {
40 const dir = tmp('s')
41 const store = bundleStore({ dir })
42 const fetched = []
43 await store.materialize('root-' + Date.now(), entries, {
44 fetchContent: async (hash) => { fetched.push(hash); return bytesByHash[hash] }
45 })
46 return { dir, store, fetched }
47}
48
49// ── the published shape: manifest FIRST, bundle second ───────────────────────────────────────────
50{
51 const es = [entry('package.json', MANIFEST), entry('worklet.bundle', BUNDLE)]
52 const { dir, fetched } = await stage(es, { [h(MANIFEST)]: MANIFEST, [h(BUNDLE)]: BUNDLE })
53 const cur = JSON.parse(fs.readFileSync(path.join(dir, 'current.json'), 'utf8'))
54 const staged = fs.readFileSync(path.join(dir, cur.file))
55 ok(staged.equals(BUNDLE), 'the WORKLET BUNDLE is staged, not the manifest')
56 ok(!fetched.includes(h(MANIFEST)), 'and the manifest was never even downloaded')
57}
58
59// ── the order that would have passed by luck ─────────────────────────────────────────────────────
60// With last-entry-wins this arrangement staged the manifest. Nothing orders a published tree, so a
61// change in pgh's entry ordering would have silently bricked every phone that took the update.
62{
63 const es = [entry('worklet.bundle', BUNDLE), entry('package.json', MANIFEST)]
64 const { dir } = await stage(es, { [h(MANIFEST)]: MANIFEST, [h(BUNDLE)]: BUNDLE })
65 const cur = JSON.parse(fs.readFileSync(path.join(dir, 'current.json'), 'utf8'))
66 ok(fs.readFileSync(path.join(dir, cur.file)).equals(BUNDLE), 'bundle first: still the bundle')
67}
68
69// ── the single-entry tree the forge publishes must keep working ──────────────────────────────────
70// tree.json (npm run gen:tree) is one entry, and phones in the field consume it. This must not regress.
71{
72 const es = [entry('worklet.bundle', BUNDLE)]
73 const { dir } = await stage(es, { [h(BUNDLE)]: BUNDLE })
74 const cur = JSON.parse(fs.readFileSync(path.join(dir, 'current.json'), 'utf8'))
75 ok(fs.readFileSync(path.join(dir, cur.file)).equals(BUNDLE), 'a one-entry tree still stages')
76}
77
78// ── a tree with NO bundle is an error, not a silently-staged something-else ──────────────────────
79{
80 let threw = null
81 try { await stage([entry('package.json', MANIFEST)], { [h(MANIFEST)]: MANIFEST }) } catch (e) { threw = e }
82 ok(threw, 'a tree with no worklet.bundle throws rather than staging the wrong file')
83 ok(/worklet\.bundle|no file/i.test(threw.message), 'and says what was missing: ' + threw.message)
84}
85
86console.log('\n' + pass + ' assertions passed')