| … | … | @@ -23,7 +23,10 @@ |
| 23 | 23 | * identically each time, with no way in from outside. |
| 24 | 24 | */ |
| 25 | 25 | export 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 }) | |
| 27 | 30 | |
| 28 | 31 | // A pending marker means the LAST boot started a bundle and never reached ready. Condemn whatever it |
| 29 | 32 | // 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 |
| 73 | 76 | use: 'staged', |
| 74 | 77 | hash: current.treeHash, |
| 75 | 78 | 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, | |
| 76 | 89 | markPending: true, |
| 77 | 90 | condemn, |
| 78 | 91 | reason: 'staged bundle available' |
| … | … | @@ -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 | ||
| 7 | const BUNDLE_FLAG = '--bundle=' | |
| 8 | const 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 | */ | |
| 20 | export 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 | */ | |
| 42 | export 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 | } |
| … | … | @@ -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. | |
| 12 | import { runLoader } from './host/loader-core.js' | |
| 13 | import { 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. | |
| 20 | await 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 | }) |
| … | … | @@ -176,3 +176,34 @@ test('with NO APK stamp, nothing is orderable and the historic behaviour stands' |
| 176 | 176 | }) |
| 177 | 177 | t.is(r.use, 'staged', 'an older shell that cannot stamp its asset does not get a surprise downgrade') |
| 178 | 178 | }) |
| 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. | |
| 186 | test('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 | ||
| 193 | test('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 | ||
| 202 | test('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 | }) |
| … | … | @@ -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. | |
| 7 | import test from 'brittle' | |
| 8 | import { loaderArgs, runLoader } from '../lib/host/loader-core.js' | |
| 9 | ||
| 10 | const argv = (...a) => a | |
| 11 | ||
| 12 | test('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 | ||
| 22 | test('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 | ||
| 31 | test('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 | ||
| 37 | test('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 | ||
| 43 | test('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 | ||
| 53 | test('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 | ||
| 64 | test('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 | }) |