OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / lib / host / choose-bundle.js

121 lines · javascript ref:
// Which bundle this boot runs — the staged one, or the APK's.
//
// ⛔ ITS OWN MODULE, WITH NO IMPORTS, because it is needed on BOTH sides of the bridge and they share no
// filesystem API. The decision is made in React Native (RNFS) before the worklet starts; the staging that
// feeds it happens inside the worklet (bare-fs). lib/ota.js imports bare-fs at module top level, so
// importing it from the RN side would break the app on load — a mistake worth naming, because the split
// is not tidiness, it is the only reason this can be called where it has to be called.
//
// Pure and dependency-free, like lib/ipc-client.js and lib/lifecycle.js, and testable without a handset —
// which is the only place the consequences of getting this wrong actually show up.

/**
 * @param {{current: ?{treeHash: string, file: string, builtAt: ?number}, pending: ?{treeHash: string},
 *          failed: string[], assetBuiltAt: ?number}} state
 * @returns {{use:'staged'|'asset', hash:?string, file:?string, markPending:boolean, condemn:?string, reason:string}}
 *
 * ⛔ A STAGED BUNDLE NOTHING RUNS IS NOT AN UPDATE, and running one blindly is how a phone gets bricked.
 * There is no adb on a user's handset and no UI left if the node cannot boot, so the only safe shape is a
 * watchdog: record the intent BEFORE starting, clear it once the node reports ready, and treat a marker
 * that survived a boot as proof that bundle does not work.
 *
 * The failure this exists to prevent is the retry loop — a bad bundle chosen again on every boot, dying
 * identically each time, with no way in from outside.
 */
export function chooseBundle ({ current = null, pending = null, failed = [], assetBuiltAt = null, apkAddons = null } = {}) {
  // ⛔ `addons: null` ON EVERY ASSET PATH. Booting the APK's own bundle must not preload addons staged
  // for a DIFFERENT one — their sonames carry versions, so a staged libbare-fs.4.9.0.so registered
  // while the APK's 4.8.1 bundle runs would shadow a library this binary actually ships.
  const asset = (reason, condemn = null) => ({ use: 'asset', hash: null, file: null, addons: null, markPending: false, condemn, reason })

  // A pending marker means the LAST boot started a bundle and never reached ready. Condemn whatever it
  // names — which is not necessarily what is staged now, so it is read from the marker, not from current.
  const condemn = pending && pending.treeHash ? pending.treeHash : null
  const dead = new Set([...(Array.isArray(failed) ? failed : []), ...(condemn ? [condemn] : [])])

  if (!current || !current.treeHash || !current.file) return asset('nothing staged', condemn)
  // A newer bundle still gets its chance: refusing every staged bundle after one bad build would make the
  // phone permanently un-updatable by a single bad release, and the fix for a bad release is the next one.
  if (dead.has(current.treeHash)) return asset('staged bundle previously failed to boot', condemn)

  // ⛔ AN APK INSTALL MUST SUPERSEDE AN OLDER STAGED BUNDLE. Staged used to win unconditionally, and tree
  // hashes carry no ordering — so installing a newer APK left the phone running the JS staged before it,
  // until the next OTA pass happened to catch up. Observed twice on a handset while shipping today: the
  // new shell booted, the new bundle sat in assets, and the old staged one ran anyway.
  //
  // Build times are the only comparable thing the two sides share: the CI stamps the APK's bundle
  // (`worklet.bundle.built`) and lib/ota.js records the release's build time in the staged marker. Both
  // absent means an older shell or an older staged marker, and the historic behaviour (staged wins) is
  // kept — never guess an ordering out of nothing.
  const staged = Number(current.builtAt) || 0
  const shipped = Number(assetBuiltAt) || 0
  if (shipped && staged && shipped > staged) {
    return asset('the app was updated more recently than the staged bundle', condemn)
  }
  // ⛔ AND AN UNDATED STAGED BUNDLE LOSES TO A STAMPED APK TOO.
  //
  // Requiring BOTH times made a whole class of staged bundles permanently unbeatable: the HTTPS fetcher's
  // getLatestRelease returned { name, version, treeHash } and no builtAt, so every bundle it ever staged
  // has a marker with no build time. `staged` is 0, the clause above cannot fire, and the phone keeps
  // running that JS through every future APK install.
  //
  // It is not hypothetical and the symptom is the worst kind — the install succeeds, the version looks
  // right, and the code does not run. Four APKs were installed on a handset on 2026-08-25, each carrying
  // fixes, and none took effect: a bundle staged from 0.1.6 won every boot. Most of an evening went into
  // diagnosing "my changes are not on the phone".
  //
  // An APK install is a PHYSICAL, DELIBERATE act by someone holding the device. A staged bundle that
  // cannot say when it was built cannot be shown to be newer than that, so the tie goes to the APK.
  // A staged bundle that CAN say, and is newer, still wins — OTA has to keep working, and that is the
  // case immediately above.
  if (shipped && !staged) {
    return asset('the staged bundle carries no build time; an installed APK is the more recent evidence', condemn)
  }

  // ⛔ THE ADDON GUARD. A `linked:` bundle NAMES the sonames it needs, and two sources can supply one:
  // the APK's jniLibs, or the addons staged beside the bundle. A demand in NEITHER means a dlopen
  // failure, a node that never reaches ready, and this tree hash condemned for good — an update
  // silently burned on a device with no adb and no way to say why. Refusing turns that into "this
  // update needs a newer app", which is a sentence someone can act on.
  //
  // ⛔ REFUSED IS NOT CONDEMNED. Nothing was tried, so nothing failed: the bundle is left alone and a
  // later APK carrying the missing library can still run it. Condemning here would destroy a perfectly
  // good update because the app was briefly too old, which is the opposite of the intent.
  //
  // ⛔ AND THE GUARD ONLY FIRES ON KNOWLEDGE. `demands` is absent from any marker written before the
  // store recorded it, and `apkAddons` is absent in any shell that does not ship its own list. Either
  // way the question cannot be answered — and refusing on missing information would strand every device
  // already holding a staged bundle. Unknown means proceed, exactly as before this existed. An empty
  // apkAddons is treated as unknown for the same reason: no APK demands nothing, so an empty list is a
  // failed read, not a fact.
  const demands = Array.isArray(current.demands) ? current.demands : null
  const apk = Array.isArray(apkAddons) && apkAddons.length > 0 ? apkAddons : null
  if (demands && apk) {
    const have = new Set(apk)
    for (const a of (Array.isArray(current.addons) ? current.addons : [])) have.add(a)
    const missing = demands.filter((d) => !have.has(d))
    if (missing.length > 0) {
      return asset('the staged bundle needs ' + missing.join(', ') + ' — in neither the APK nor the release', condemn)
    }
  }

  return {
    use: 'staged',
    hash: current.treeHash,
    file: current.file,
    // Where bundleStore put this release's native addons, or null when it carries none.
    //
    // ⛔ THE NAME LIVES HERE because both sides need it 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 exists to prevent.
    //
    // Null and never '' — a loader has to tell "this release has no addons" (every release published so
    // far, and a marker written before addons existed) from "the addons did not arrive".
    addons: Array.isArray(current.addons) && current.addons.length > 0 ? current.file + '.addons' : null,
    markPending: true,
    condemn,
    reason: 'staged bundle available'
  }
}