OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / c2abf1c

OTA end to end — and the bug that only an e2e could find
test/ota.test.mjs drives installTree against an in-memory doFetch. That proves the contract and nothing about the plumbing: bare-fetch on a large body, the global fetch shim startOta installs, whether PghRuntime is actually wired to our fetcher and store, whether a single byte reaches disk. All of it untested, and all of it where integrations break. So: a real bare-http1 forge serving the two routes pear-git-v3 serves, a real 5 MB bundle, real files. 3 tests, 17 assertions. It immediately found one. lib/ota.js read `process.env.FAVELA_BUNDLE_HASH`, and `process` is not a Bare global — it exists in the worklet ONLY because lib/node.js imports favela-web's bare-globals shim before anything else. So that line worked on the phone and threw ReferenceError everywhere else, including in the first test that ever called startOta. A library used from three environments must not read a global it did not import; now guarded. That is the same shape as the bug this whole file exists to correct: code that had never executed, in a path nothing exercised, kept alive by an accident of what happened to be loaded first. The three cases: - downloads, verifies, stages, writes current.json, and chooseBundle picks it up — the handoff between the two halves, which is where the OTA client meets the boot path - a forge serving the WRONG bytes stages nothing and SAYS why (releases are unsigned, so this hash check is the only thing between the phone and somebody else's code) - an unreachable forge is a no-op, not a crashed boot — what a phone on a train does Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c2abf1c1a45eb7e0b69463b2dfef0407a190781a
by plankatron <fleeky@gmail.com> · 2026-08-18T18:45:06.000Z

Changed files (3) +157 -2

lib/ota.js +11 -1
……@@ -36,6 +36,12 @@ const BUNDLE_NAME = 'worklet.bundle'
3636
3737const hashOf = (bytes) => crypto.data(Buffer.from(bytes)).toString('hex')
3838
39// The running bundle's hash, if the environment happens to say. Guarded because `process` is a shim in
40// the worklet and absent everywhere else; an OTA client is not the place to discover that.
41function envBundleHash () {
42 try { return (typeof process !== 'undefined' && process.env) ? process.env.FAVELA_BUNDLE_HASH : null } catch { return null }
43}
44
3945/**
4046 * The one-file tree for a bundle: what CI publishes as tree.json and what the phone verifies against.
4147 *
……@@ -234,7 +240,11 @@ export async function startOta ({ storage, say = () => {}, base = DEFAULT_BASE,
234240
235241 const runtime = new PghRuntime({
236242 name: repo,
237 currentTreeHash: currentTreeHash || process.env.FAVELA_BUNDLE_HASH || null,
243 // ⛔ NOT `process.env.X` BARE. `process` is not a Bare global — it exists in the worklet only because
244 // lib/node.js imports favela-web's bare-globals shim before anything else. So this line worked on the
245 // phone and threw ReferenceError everywhere else, including the first test that ever called startOta.
246 // A library used from three environments must not read a global it did not import.
247 currentTreeHash: currentTreeHash || envBundleHash() || null,
238248 fetcher: bundleFetcher({ base, repo, doFetch: globalThis.fetch }),
239249 store,
240250 interval
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/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/describe.test.mjs && bare test/ota.test.mjs",
12 "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/describe.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))\"",
1414 "gen:tree": "bare scripts/gen-tree-manifest.mjs"
1515 },
test/ota-e2e.test.mjs +145 -0
……@@ -0,0 +1,145 @@
1// OTA end to end: a real HTTP forge, the real pgh-runtime, a real multi-megabyte bundle, real files.
2//
3// ⛔ WHY A SECOND OTA TEST. test/ota.test.mjs drives installTree against an in-memory `doFetch`, which
4// proves the CONTRACT and nothing about the plumbing: bare-fetch's behaviour on a large body, the global
5// fetch shim startOta installs, whether PghRuntime is wired to our fetcher and store correctly, whether
6// anything is actually written to disk. Every one of those is where an integration breaks, and none of
7// them are exercised by a fake fetch.
8//
9// It matters more than usual here because the code under test SHIPPED BROKEN AND SILENT for two weeks:
10// pgh-runtime was deferred out of the bundle, so `startOta` returned null after logging "updates
11// disabled" and nothing below it had ever executed. A test that stops at the module boundary is how that
12// survives a second time.
13//
14// The forge served here implements exactly the two routes lib/ota.js calls, and nothing else.
15import test from 'brittle'
16import fs from 'bare-fs'
17import path from 'bare-path'
18import os from 'bare-os'
19import http from 'bare-http1'
20import { treeFor, bundleStore, startOta } from '../lib/ota.js'
21import { chooseBundle } from '../lib/choose-bundle.js'
22
23// Multi-megabyte, so the transfer is a real streamed body rather than a string that fits in one chunk.
24// Deterministic content: the suite must not depend on randomness to be reproducible.
25function bigBundle (mb = 5) {
26 const buf = Buffer.alloc(mb * 1024 * 1024)
27 for (let i = 0; i < buf.length; i++) buf[i] = (i * 31 + 7) & 0xff
28 return buf
29}
30
31let seq = 0
32function tmp (name) {
33 const dir = path.join(os.tmpdir(), 'favela-ota-e2e-' + name + '-' + (++seq))
34 fs.rmSync(dir, { recursive: true, force: true })
35 fs.mkdirSync(dir, { recursive: true })
36 return dir
37}
38
39// A forge with the two routes and nothing else — the same shape pear-git-v3 serves.
40async function forge ({ repo, version, bundle, tree }) {
41 const server = http.createServer((req, res) => {
42 if (req.method === 'POST' && req.url.endsWith('/rpc/list-repo-releases')) {
43 const body = JSON.stringify({ versions: [{ version, builtAt: 2000 }, { version: 'older', builtAt: 1000 }] })
44 res.writeHead(200, { 'content-type': 'application/json' })
45 return res.end(body)
46 }
47 if (req.url.endsWith(`/releases/${repo}/${version}/tree.json`)) {
48 res.writeHead(200, { 'content-type': 'application/json' })
49 return res.end(JSON.stringify(tree))
50 }
51 if (req.url.endsWith(`/releases/${repo}/${version}/worklet.bundle`)) {
52 res.writeHead(200, { 'content-type': 'application/octet-stream' })
53 return res.end(bundle)
54 }
55 res.writeHead(404, { 'content-type': 'application/json' })
56 res.end('{"error":"not found"}')
57 })
58 await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
59 return { server, port: server.address().port }
60}
61
62test('e2e: startOta downloads, verifies and STAGES a real bundle', async (t) => {
63 const storage = tmp('stage')
64 const repo = 'favela-android'
65 const version = 'master-deadbee'
66 const bundle = bigBundle(5)
67 const { entries, treeHash, contentHash } = treeFor(bundle)
68
69 const { server, port } = await forge({ repo, version, bundle, tree: { treeHash, size: bundle.length, entries } })
70 t.teardown(() => new Promise((resolve) => server.close(resolve)))
71
72 // interval 0 disables the repeating timer; startImmediately (default true) still does one check, which
73 // is the whole flow a phone runs at boot.
74 const said = []
75 const ota = await startOta({
76 storage,
77 base: `http://127.0.0.1:${port}`,
78 repo,
79 interval: 0,
80 say: (m) => said.push(m)
81 })
82 t.ok(ota, 'pgh-runtime is bundled and startOta returned a handle — the thing that was false for two weeks')
83
84 t.ok(said.some((m) => /staged/.test(m)), 'it reports staging, not silence: ' + JSON.stringify(said))
85
86 // The bytes are on disk, byte-identical, under their content hash.
87 const store = bundleStore({ dir: path.join(storage, 'bundles') })
88 t.ok(store.hasTree(treeHash), 'the store reports the tree as installed')
89 const staged = store.pathFor(treeHash)
90 t.is(path.basename(staged), contentHash + '.bundle', 'staged under its content hash')
91 t.alike(fs.readFileSync(staged), bundle, 'byte-identical to what the forge served')
92
93 // current.json is what the React Native side reads before starting the worklet. Without it the download
94 // is invisible to the only code that can act on it.
95 const current = JSON.parse(fs.readFileSync(path.join(storage, 'bundles', 'current.json'), 'utf8'))
96 t.is(current.treeHash, treeHash, 'current.json names the staged tree')
97 t.is(current.file, contentHash + '.bundle')
98
99 // …and the boot-time decision picks it up. This is the handoff between the two halves.
100 const choice = chooseBundle({ current, pending: null, failed: [] })
101 t.is(choice.use, 'staged', 'the next boot would run it')
102 t.is(choice.hash, treeHash)
103 t.ok(choice.markPending, 'and would record the attempt first')
104})
105
106test('e2e: a forge serving the WRONG bytes stages nothing', async (t) => {
107 // The load-balanced-forge case, made concrete: the manifest is honest, the bundle is not. Releases are
108 // unsigned, so this hash check is the only thing standing between the phone and someone else's code.
109 const storage = tmp('tamper')
110 const repo = 'favela-android'
111 const version = 'master-deadbee'
112 const real = bigBundle(2)
113 const { entries, treeHash } = treeFor(real)
114
115 const { server, port } = await forge({
116 repo, version, bundle: bigBundle(3), tree: { treeHash, size: real.length, entries }
117 })
118 t.teardown(() => new Promise((resolve) => server.close(resolve)))
119
120 const said = []
121 await startOta({ storage, base: `http://127.0.0.1:${port}`, repo, interval: 0, say: (m) => said.push(m) })
122
123 t.ok(said.some((m) => /content hash mismatch/.test(m)), 'the mismatch is REPORTED, not swallowed: ' + JSON.stringify(said))
124 t.absent(said.some((m) => /staged/.test(m)), 'and nothing claims to have staged')
125
126 const store = bundleStore({ dir: path.join(storage, 'bundles') })
127 t.absent(store.hasTree(treeHash), 'nothing installed')
128 t.absent(fs.existsSync(path.join(storage, 'bundles', 'current.json')), 'and the next boot is told nothing')
129})
130
131test('e2e: an unreachable forge is a no-op, not a crashed boot', async (t) => {
132 // What a phone on a train does. `startOta` must never take the node down with it.
133 const storage = tmp('offline')
134 const said = []
135 const ota = await startOta({
136 storage,
137 base: 'http://127.0.0.1:1', // nothing listens here
138 repo: 'favela-android',
139 interval: 0,
140 say: (m) => said.push(m)
141 })
142 t.ok(ota, 'still returns a handle')
143 t.absent(said.some((m) => /staged/.test(m)), 'nothing staged')
144 t.absent(fs.existsSync(path.join(storage, 'bundles', 'current.json')), 'and nothing is advertised to the next boot')
145})