OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / 85f06e7

the worklet loader: preload staged addons, then run the chosen bundle
The entry that makes OTA-delivered native code actually run. Three parts: choose-bundle.js now reports WHERE a staged bundle's addons are, because both sides need that name and they share no filesystem API — the store that writes the directory runs inside the worklet (bare-fs) and the side that must hand the loader its path runs in React Native (RNFS). Spelling the convention out twice across that bridge is the failure this module already exists to prevent. Null, never '', so a loader can tell "this release has no addons" from "they did not arrive"; and null on every asset path, because booting the APK's own bundle must not preload addons staged for a different one — sonames carry versions, so a staged libbare-fs.4.9.0.so would shadow a library this binary ships. host/loader-core.js is the decision half, pure and testable without a device. Arguments are read BY TAG, not position: bare-kit calls NativeBareKit.startFile(handle, filename, args) and whether native prepends the filename to Bare.argv is settled in code not visible from JS. lib/node.js already works around that unknown by reading the LAST argv, so tagging leaves storage in last place and a bundle staged before the loader existed keeps working untouched. No --bundle= is a throw, never a guess. ⛔ Nothing is caught. worklet.js writes pending.json before starting and clears it on ready; a marker that survives a boot condemns that bundle. A loader that quietly ran the APK's bundle on failure would reach ready, clear pending.json, and the broken bundle would be chosen again for ever. The fallback already exists one layer up and only works if this layer stays honest. lib/loader.js is the shim. ⛔ It must NEVER import bare-module: that is the runtime's own module system, already live in the host binary, and resolving it from node_modules bundles a SECOND native copy. Measured — the preload ran and the process died on the next line with `bare-module.bare: undefined symbol: js_get_module_id`. Dynamic import needs none of it, and it has to be dynamic anyway: a static import is hoisted and evaluated before the preload, which is the exact ordering bug this file exists to avoid. Exercised end to end against real bundles, not only doubles. A target packed --linked, demanding sonames present nowhere on the system, fails ADDON_NOT_FOUND (exit 134) run directly and RUNS through the loader (exit 0) with those addons staged — bare-url genuinely working, storage still read correctly from the last argv. Also run: APK-only, 0 preloaded, which proves the loader is transparent for every release published so far; and hybrid, 1 staged and 1 by soname, which proves staging is additive. Not yet wired: worklet.js still starts the bundle directly, and there is no pack:loader. That is the next commit and the one that touches the boot path. Suite 68/68, 245/245. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85f06e708f6743d3f2f792a859dc7cdaed75fae4
by plankatron <fleeky@gmail.com> · 2026-09-11T00:19:15.000Z

Changed files (5) +197 -1

lib/host/choose-bundle.js +14 -1
……@@ -23,7 +23,10 @@
2323 * identically each time, with no way in from outside.
2424 */
2525export function chooseBundle ({ current = null, pending = null, failed = [], assetBuiltAt = null } = {}) {
26 const asset = (reason, condemn = null) => ({ use: 'asset', hash: null, file: null, markPending: false, condemn, reason })
26 // ⛔ `addons: null` ON EVERY ASSET PATH. Booting the APK's own bundle must not preload addons staged
27 // for a DIFFERENT one — their sonames carry versions, so a staged libbare-fs.4.9.0.so registered
28 // while the APK's 4.8.1 bundle runs would shadow a library this binary actually ships.
29 const asset = (reason, condemn = null) => ({ use: 'asset', hash: null, file: null, addons: null, markPending: false, condemn, reason })
2730
2831 // A pending marker means the LAST boot started a bundle and never reached ready. Condemn whatever it
2932 // names — which is not necessarily what is staged now, so it is read from the marker, not from current.
……@@ -73,6 +76,16 @@ export function chooseBundle ({ current = null, pending = null, failed = [], ass
7376 use: 'staged',
7477 hash: current.treeHash,
7578 file: current.file,
79 // Where bundleStore put this release's native addons, or null when it carries none.
80 //
81 // ⛔ THE NAME LIVES HERE because both sides need it and they share no filesystem API: the store that
82 // writes the directory runs inside the worklet (bare-fs), and the side that must hand the loader its
83 // path runs in React Native (RNFS). Spelling the convention out twice across that bridge is the
84 // failure this module exists to prevent.
85 //
86 // Null and never '' — a loader has to tell "this release has no addons" (every release published so
87 // far, and a marker written before addons existed) from "the addons did not arrive".
88 addons: Array.isArray(current.addons) && current.addons.length > 0 ? current.treeHash + '.addons' : null,
7689 markPending: true,
7790 condemn,
7891 reason: 'staged bundle available'
lib/host/loader-core.js +47 -0
……@@ -0,0 +1,47 @@
1// The decision half of the worklet loader: what to preload, what to run, and in which order.
2//
3// Pure and dependency-free like choose-bundle.js, for the same reason — this is the code path that
4// decides what EXECUTES on a device with no adb, so it has to be testable without one.
5// lib/loader.cjs is the shim that supplies the real preloader and the real require.
6
7const BUNDLE_FLAG = '--bundle='
8const ADDONS_FLAG = '--addons='
9
10/**
11 * Read the loader's inputs out of argv.
12 *
13 * ⛔ BY TAG, NOT BY POSITION. bare-kit calls NativeBareKit.startFile(handle, filename, args) and whether
14 * the native side prepends the filename to Bare.argv is settled in native code that is not visible from
15 * JS. lib/node.js works around the same unknown by reading the LAST argv; tagging makes this immune from
16 * both ends — and leaves storage in last place, so a bundle staged before the loader existed still finds
17 * it unchanged. That backwards compatibility is what makes the loader safe to ship to a device that is
18 * already holding a staged bundle.
19 */
20export function loaderArgs (argv) {
21 const find = (flag) => {
22 const hit = (argv || []).find((v) => typeof v === 'string' && v.startsWith(flag))
23 return hit ? hit.slice(flag.length) : null
24 }
25 const bundle = find(BUNDLE_FLAG)
26 // ⛔ NO DEFAULT. Guessing a path here would run something nobody chose, on the one code path that
27 // decides what executes. A throw reaches the watchdog; a guess reaches the user.
28 if (!bundle) throw new Error('loader: no ' + BUNDLE_FLAG + ' in argv')
29 return { bundle, addons: find(ADDONS_FLAG) || null }
30}
31
32/**
33 * Preload the staged addons, then run the bundle.
34 *
35 * ⛔ NOTHING IS CAUGHT. worklet.js writes pending.json before starting and clears it once the node
36 * reports ready; a marker that survives a boot condemns that bundle for good. If a failure here were
37 * swallowed and the APK's bundle run instead, the node WOULD reach ready, pending.json WOULD be cleared,
38 * and the broken bundle would be chosen again on every boot for ever. Failing loudly is the thing that
39 * makes the existing watchdog work — the fallback already exists, one layer up, and it only works if
40 * this layer stays honest.
41 */
42export function runLoader ({ argv, preload, load, say = () => {} }) {
43 const { bundle, addons } = loaderArgs(argv)
44 const n = preload({ dir: addons, say })
45 say('preloaded ' + n + ' staged addon(s); starting ' + bundle)
46 return load(bundle)
47}
lib/loader.js +30 -0
……@@ -0,0 +1,30 @@
1// The APK's worklet entry. Preloads any OTA-staged native addons, then runs the chosen bundle.
2//
3// ⛔ WHY THE ENTRY MOVED HERE. worklet.js hands bare-kit a path with a null source, so Bare loads that
4// file as its own entry — there is no "before" inside it. And preload code placed inside the staged
5// bundle would be circular anyway: its own import of bare-fs resolves to
6// `linked:libbare-fs.<staged version>.so`, the very soname that might be the one missing. So the entry
7// is this file, which ships in the APK and is therefore packed against the APK's addon set, and the
8// staged bundle is loaded afterwards — by which time its addons are already in Addon.cache.
9//
10// Everything here is wiring. The decisions are in host/loader-core.js and the loading is in
11// host/preload-addons.js, both pure or injectable and both tested without a device.
12import { runLoader } from './host/loader-core.js'
13import { preloadAddons } from './host/preload-addons.js'
14
15// ⛔ NEVER `import 'bare-module'` HERE. It is the runtime's OWN module system, already live inside the
16// host binary — resolving it from node_modules bundles a SECOND native copy, and dlopening that copy
17// dies with `bare-module.bare: undefined symbol: js_get_module_id` because its bindings are compiled
18// against a different build of Bare. Measured: the preload ran and the process died on the next line.
19// Dynamic import needs none of it, and a file: URL built by hand needs no bare-url either.
20await runLoader({
21 argv: Bare.argv,
22 preload: preloadAddons,
23 // ⛔ DYNAMIC import, not a static one: the whole point is that this resolves AFTER preloadAddons has
24 // populated Addon.cache. A static import would be hoisted and evaluated first, which is the ordering
25 // bug this file exists to avoid.
26 load: (bundlePath) => import('file://' + bundlePath),
27 // The worklet has no error or exit event on the RN side, so these lines are the only account of what
28 // the loader did. Cheap, and the difference between "0 preloaded" and a crash is the whole diagnosis.
29 say: (m) => console.log('[loader] ' + m)
30})
test/lifecycle.test.js +31 -0
……@@ -176,3 +176,34 @@ test('with NO APK stamp, nothing is orderable and the historic behaviour stands'
176176 })
177177 t.is(r.use, 'staged', 'an older shell that cannot stamp its asset does not get a surprise downgrade')
178178})
179
180// ── where a staged bundle's addons live ───────────────────────────────────────────────────────────
181//
182// ⛔ THE NAME BELONGS HERE, in the one module BOTH sides import. The RN side has to hand the loader a
183// directory it cannot compute without bare-fs, and the store that writes it is inside the worklet — so
184// the convention would otherwise be spelled out twice across a bridge, which is precisely the split
185// this module exists to prevent.
186test('a staged bundle carrying addons says where they are', (t) => {
187 const staged = { treeHash: 'a'.repeat(64), file: 'abc.bundle', builtAt: 2000, addons: ['libbare-fs.4.8.1.so'] }
188 const r = chooseBundle({ current: staged, assetBuiltAt: 1000 })
189 t.is(r.use, 'staged')
190 t.is(r.addons, 'a'.repeat(64) + '.addons', 'named off the TREE hash, as bundleStore writes it')
191})
192
193test('a staged bundle with no addons reports none', (t) => {
194 // Every release published so far. A loader must be able to tell "this release has none" from "they
195 // did not arrive", so this is null and never an empty string.
196 const staged = { treeHash: 'b'.repeat(64), file: 'abc.bundle', builtAt: 2000, addons: [] }
197 t.is(chooseBundle({ current: staged, assetBuiltAt: 1000 }).addons, null, 'empty list')
198 const older = { treeHash: 'c'.repeat(64), file: 'abc.bundle', builtAt: 2000 }
199 t.is(chooseBundle({ current: older, assetBuiltAt: 1000 }).addons, null, 'a marker written before addons existed')
200})
201
202test('the APK asset never reports staged addons', (t) => {
203 // Booting the APK's own bundle must not preload addons staged for a DIFFERENT one — their sonames
204 // could differ by version and would then shadow the libraries this APK actually ships.
205 const staged = { treeHash: 'd'.repeat(64), file: 'abc.bundle', builtAt: 1000, addons: ['libx.1.0.0.so'] }
206 const r = chooseBundle({ current: staged, assetBuiltAt: 5000 })
207 t.is(r.use, 'asset', 'newer APK wins')
208 t.is(r.addons, null, 'and brings no staged addons with it')
209})
test/loader.test.js +75 -0
……@@ -0,0 +1,75 @@
1// The worklet entry that preloads OTA-staged addons and then runs the real bundle.
2//
3// ⛔ ORDER IS THE WHOLE POINT. Addons must be in Addon.cache BEFORE the bundle's modules evaluate —
4// a `require.addon` reached first reads the cache, misses, and falls straight through to
5// dlopen-by-soname against the APK. That is not recoverable afterwards, so the ordering is asserted
6// here rather than assumed from the shape of the code.
7import test from 'brittle'
8import { loaderArgs, runLoader } from '../lib/host/loader-core.js'
9
10const argv = (...a) => a
11
12test('the bundle and addon paths are read by TAG, not position', (t) => {
13 // ⛔ NOT POSITIONAL. bare-kit calls NativeBareKit.startFile(handle, filename, args) and whether the
14 // native side prepends the filename to Bare.argv is decided in native code we cannot see from JS.
15 // lib/node.js already works around this by reading the LAST argv; tags make the loader immune to it
16 // in both directions.
17 const a = loaderArgs(argv('/some/loader.bundle', '--bundle=/s/bundles/abc.bundle', '--addons=/s/bundles/dd.addons', '/storage'))
18 t.is(a.bundle, '/s/bundles/abc.bundle')
19 t.is(a.addons, '/s/bundles/dd.addons')
20})
21
22test('storage stays LAST so every bundle, old or new, still finds it', (t) => {
23 // lib/node.js reads Bare.argv[Bare.argv.length - 1]. A bundle staged before the loader existed does
24 // the same, so it keeps working unchanged under the loader — which is what makes this safe to ship
25 // to devices holding older staged bundles.
26 const args = ['--bundle=/s/abc.bundle', '--addons=/s/dd.addons', '/data/user/0/app/files']
27 t.is(args[args.length - 1], '/data/user/0/app/files', 'storage is last')
28 t.is(loaderArgs(argv(...args)).bundle, '/s/abc.bundle', 'and the tags still parse')
29})
30
31test('no --addons= is null, not a crash', (t) => {
32 const a = loaderArgs(argv('--bundle=/s/abc.bundle', '/storage'))
33 t.is(a.bundle, '/s/abc.bundle')
34 t.is(a.addons, null)
35})
36
37test('no --bundle= is a THROW, never a guess', (t) => {
38 // There is no safe default. Guessing a path here would run something nobody chose, on the one code
39 // path that decides what executes on the device.
40 t.exception(() => loaderArgs(argv('/storage')), /no --bundle=/)
41})
42
43test('addons are preloaded BEFORE the bundle is loaded', (t) => {
44 const order = []
45 runLoader({
46 argv: argv('--bundle=/s/abc.bundle', '--addons=/s/dd.addons', '/storage'),
47 preload: ({ dir }) => { order.push('preload:' + dir); return 3 },
48 load: (p) => { order.push('load:' + p) }
49 })
50 t.alike(order, ['preload:/s/dd.addons', 'load:/s/abc.bundle'], 'preload first, then load')
51})
52
53test('a release with no addons still loads its bundle', (t) => {
54 // Every release published so far. The loader runs on EVERY boot, including the APK's own bundle.
55 const order = []
56 runLoader({
57 argv: argv('--bundle=/s/worklet.bundle', '/storage'),
58 preload: ({ dir }) => { order.push('preload:' + String(dir)); return 0 },
59 load: (p) => { order.push('load:' + p) }
60 })
61 t.alike(order, ['preload:null', 'load:/s/worklet.bundle'])
62})
63
64test('a bundle that throws is NOT caught — the watchdog must see it', (t) => {
65 // ⛔ NO FALLBACK HERE, DELIBERATELY. worklet.js writes pending.json before starting and clears it on
66 // ready; a marker that survives a boot condemns that bundle. If the loader quietly ran the APK's
67 // bundle instead, the node WOULD reach ready, pending.json would be cleared, and the broken bundle
68 // would never be condemned — it would be chosen again on every boot, for ever. Failing loudly is what
69 // makes the existing watchdog work.
70 t.exception(() => runLoader({
71 argv: argv('--bundle=/s/abc.bundle', '/storage'),
72 preload: () => 0,
73 load: () => { throw new Error('bundle is corrupt') }
74 }), /bundle is corrupt/)
75})