OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / lib / host / preload-addons.js

91 lines · javascript ref:
// Register OTA-staged native addons so a bundle can demand a soname the APK does not carry.
//
// THE PROBLEM THIS SOLVES. `bare-pack --preset android` sets `linked: true`, so the worklet bundle does
// not contain native code — it NAMES the libraries the APK must supply (`linked:librocksdb-native.3.17.2.so`).
// That is why a JS-only OTA works and a native change has needed a 154 MB sideload: a staged bundle whose
// addon set the APK cannot satisfy dies at dlopen, on a device with no adb and no UI left to say why.
//
// THE WAY OUT IS BARE'S OWN ADDON CLASS. From bare/src/addon.js:
//
//   - `new Addon(url)` accepts `file:` and loads from an ABSOLUTE PATH — no linker search involved.
//   - `Addon.cache` is a live object keyed by `url.href`.
//   - `Addon.resolve()`'s `linked:` branch returns `Addon.load(resolution).url`, and `Addon.load` checks
//     that cache BEFORE constructing anything.
//
// So an addon whose bytes arrived over the swarm can be loaded from app storage and registered under the
// `linked:` URL the bundle will ask for. It then wins, and anything NOT staged still falls through to
// dlopen-by-soname and the APK. Staging is additive, which is what lets a release ship the one addon that
// changed instead of all 58.
//
// ⛔ THIS MUST NOT BE PART OF THE STAGED BUNDLE. worklet.js hands bare-kit a path with a null source, so
// Bare loads the staged bundle as its own entry — anything inside it runs too late, and its own import of
// bare-fs would resolve to `linked:libbare-fs.<staged version>.so`, the very soname that might be missing.
// The loader is its own bundle, packed against the APK's addon set, and loads the real one afterwards.
//
// ⚠ Addon.cache / Addon.load / Addon.resolve are all marked @deprecated upstream while bare-module still
// calls them on every `require.addon`. If they are ever removed, the replacement is the same idea one
// layer down — construct the Addon and hand it to whatever resolves addons then.
// ⛔ ONE ADDON DEPENDENCY, DELIBERATELY. This runs BEFORE the staged addons are registered, so every
// addon it imports must already be satisfiable by the APK on the device. bare-fs is foundational and
// its soname changes only when we bump it; bare-path and bare-url were dropped for string joins and a
// hand-built file: URL precisely to keep this surface at one. The fewer sonames the preloader needs,
// the more releases can carry new native code without a new APK.
import fs from 'bare-fs'

// ⛔ THE FILENAME IS THE WHOLE CONTRACT. Each staged file is named after the soname it satisfies, so the
// directory listing IS the mapping — no manifest to fall out of step with the bytes, and no parsing of
// the bundle header on a phone. CI names them from that header, which is the same list stage-addons.mjs
// already reads to build the APK's jniLibs.
export function addonHref (filename) {
  return 'linked:' + filename
}

/**
 * Load every staged addon and register it under its linked: URL. Returns how many were registered.
 *
 * Never throws: this runs on every boot, and the overwhelmingly common case is a release with no addons
 * at all. A loader that died on an empty or missing directory would break the path it exists to protect.
 *
 * @param {object} opts
 * @param {?string} opts.dir    the staged addon directory (bundleStore.addonsFor), or null for none
 * @param {object}  [opts.cache] addon cache to populate — defaults to Bare's
 * @param {function}[opts.Addon] addon constructor — defaults to Bare's
 * @param {function}[opts.say]   diagnostic sink
 */
export function preloadAddons ({ dir, cache = Bare.Addon.cache, Addon = Bare.Addon, say = () => {} } = {}) {
  if (!dir) return 0

  let names
  try {
    names = fs.readdirSync(dir)
  } catch {
    // Not an error: a release with no addons has no directory, and bundleStore.addonsFor returns null
    // for exactly that case. Reaching here means the dir was removed under us, which the next boot's
    // hasTree check will notice properly.
    return 0
  }

  let n = 0
  for (const name of names) {
    // `.so` and nothing else. A `.part` is an interrupted write — the store renames into place, so a
    // leftover one means a run that died, and dlopening a truncated ELF is a crash rather than an error.
    if (!name.endsWith('.so')) continue
    // ⛔ FIRST REGISTRATION WINS. Two preloaders can run in one process — the APK's loader before the
    // bundle starts, and the bundle's own bootstrap once it does — and both read the same directory.
    // A second `new Addon(sameUrl)` would dlopen the same file and re-run its init. The cache is keyed
    // by href, so this check is exact.
    if (cache[addonHref(name)]) continue
    const file = dir + '/' + name
    try {
      // ⛔ ONE addon failing must not take the boot with it. The bundle still cannot run without it, but
      // this is not the place that discovers that — the failure surfaces at the require that needs it,
      // where the message names the module rather than the loader.
      cache[addonHref(name)] = new Addon(new URL('file://' + file))
      n++
    } catch (err) {
      say('preload: ' + name + ' failed to load — ' + ((err && err.message) || err))
    }
  }
  return n
}