| … | … | @@ -227,19 +227,28 @@ export function bundleStore ({ dir }) { |
| 227 | 227 | async materialize (rootHash, entries, { fetchContent } = {}) { |
| 228 | 228 | const builtAt = pendingRelease && Number(pendingRelease.builtAt) ? Number(pendingRelease.builtAt) : null |
| 229 | 229 | 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') + ')') | |
| 241 | 243 | } |
| 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' | |
| 243 | 252 | // `builtAt` is what lets the SHELL compare a staged bundle with the one inside the APK. Without it |
| 244 | 253 | // an APK install loses to whatever was staged before it — chooseBundle prefers staged, and tree |
| 245 | 254 | // hashes carry no ordering, so a phone kept running the older JS until the next OTA pass caught up. |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js", |
| 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 | "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", | |
| 13 | 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" |
| … | … | @@ -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 | |
| 16 | import fs from 'bare-fs' | |
| 17 | import os from 'bare-os' | |
| 18 | import path from 'bare-path' | |
| 19 | import { KIND_FILE } from 'hyper-overlay/tree' | |
| 20 | import { bundleStore } from '../lib/ota.js' | |
| 21 | ||
| 22 | let pass = 0 | |
| 23 | const ok = (c, m) => { if (!c) throw new Error('FAIL: ' + m); console.log(' ok -', m); pass++ } | |
| 24 | ||
| 25 | const 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 | ||
| 31 | const BUNDLE = Buffer.from('THE REAL WORKLET BUNDLE') | |
| 32 | const MANIFEST = Buffer.from('{"name":"favela-android"}') | |
| 33 | const h = (b) => require_hash(b) | |
| 34 | // hash the way the store keys files: content hash from the fetcher's own map | |
| 35 | function require_hash (b) { return 'h' + b.length + '_' + b.toString('hex').slice(0, 8) } | |
| 36 | ||
| 37 | const entry = (name, bytes) => ({ kind: KIND_FILE, exec: 0, name, childHash: h(bytes) }) | |
| 38 | ||
| 39 | async 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 | ||
| 86 | console.log('\n' + pass + ' assertions passed') |