OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / test / lifecycle.test.js

272 lines · javascript ref:
// Who decides whether the node keeps running when the screen goes off.
//
// ⛔ THE CONTROL BELOW IS THE BUG. react-native-bare-kit ends its module body with
//
//     AppState.addEventListener('change', BareKitWorklet.update.bind(BareKitWorklet))
//
// and `update(state)` is `case 'background': return this.suspend()` — so EVERY worklet is suspended the
// moment the app is backgrounded, which on Android is every screen lock. That is a deliberate library
// policy for an app whose worklet serves a UI, and exactly wrong for one whose worklet IS a peer. It also
// means a foreground service on its own fixes nothing: it would keep a SUSPENDED node alive.
//
// So the first test replicates the library's update() verbatim to pin what we are overriding, and the
// rest assert the policy that replaces it: suspend follows whether we are ALLOWED to keep running (a
// live foreground service), not whether a screen is on.
import test from 'brittle'
import { createLifecycle } from '../lib/host/lifecycle.js'
import { chooseBundle } from '../lib/host/choose-bundle.js'

// A stand-in for react-native-bare-kit's Worklet, carrying its real suspend/resume semantics — including
// `update`, which is the method the library's AppState listener calls and the one we take over.
function fakeWorklet () {
  const w = {
    suspended: false,
    terminated: false,
    calls: [],
    suspend () {
      if (w.terminated) throw new Error('Worklet has been terminated')
      w.calls.push('suspend'); w.suspended = true
    },
    resume () {
      if (w.terminated) throw new Error('Worklet has been terminated')
      w.calls.push('resume'); w.suspended = false
    },
    // react-native-bare-kit/index.js, verbatim.
    update (state) {
      switch (state) {
        case 'active': return w.resume()
        case 'background': return w.suspend()
      }
    }
  }
  return w
}

function fakeService (startResult) {
  return {
    started: 0,
    stopped: 0,
    async start () { this.started++; return startResult },
    async stop () { this.stopped++ }
  }
}

test('CONTROL: the library suspends the node on background — this is what we are replacing', (t) => {
  const w = fakeWorklet()
  w.update('background')
  t.is(w.suspended, true, 'a screen lock suspends the node')
  t.alike(w.calls, ['suspend'])
})

test('with the foreground service running, backgrounding does NOT suspend the node', async (t) => {
  const w = fakeWorklet()
  const svc = fakeService(true)
  const lc = createLifecycle({ worklet: w, service: svc })

  t.is(await lc.start(), true, 'the service started')
  t.is(svc.started, 1)

  // Both paths: the library's own listener (which calls worklet.update) and ours.
  w.update('background')
  await lc.onAppState('background')

  t.is(w.suspended, false, 'the node keeps running with the screen off')
  t.alike(w.calls, [], 'neither suspend nor resume was called')
})

test('without the service, the library behaviour is kept — a cached process must not run a peer', async (t) => {
  const w = fakeWorklet()
  const svc = fakeService(false)
  const lc = createLifecycle({ worklet: w, service: svc })

  t.is(await lc.start(), false, 'the service refused to start')

  await lc.onAppState('background')
  t.is(w.suspended, true, 'backgrounded without a service, the node suspends as before')

  await lc.onAppState('active')
  t.is(w.suspended, false, 'and resumes when the app comes back')
})

test('backgrounded BEFORE the service answers, then the service starts: the node is resumed', async (t) => {
  const w = fakeWorklet()
  const svc = fakeService(true)
  const lc = createLifecycle({ worklet: w, service: svc })

  // The user locks the screen during the ~40s boot: the library's listener already suspended us.
  w.update('background')
  t.is(w.suspended, true)

  await lc.start()
  t.is(w.suspended, false, 'taking over means undoing a suspend we would not have made')
})

test('stopping the node stops the service, and the fallback policy comes back with it', async (t) => {
  const w = fakeWorklet()
  const svc = fakeService(true)
  const lc = createLifecycle({ worklet: w, service: svc })
  await lc.start()

  await lc.stop()
  t.is(svc.stopped, 1, 'the notification goes away')

  await lc.onAppState('background')
  t.is(w.suspended, true, 'no service, no exemption')
})

test('a terminated worklet is never suspended or resumed', async (t) => {
  const w = fakeWorklet()
  const lc = createLifecycle({ worklet: w, service: fakeService(false) })
  await lc.start()

  w.terminated = true
  await lc.onAppState('background')
  await lc.onAppState('active')

  t.alike(w.calls, [], 'no calls survive termination — they would throw')
})

test('a service that throws is a service that did not start, not a crashed boot', async (t) => {
  const w = fakeWorklet()
  const said = []
  const svc = { async start () { throw new Error('FGS start not allowed') }, async stop () {} }
  const lc = createLifecycle({ worklet: w, service: svc, log: (m) => said.push(m) })

  t.is(await lc.start(), false)
  t.ok(said.some((m) => /FGS start not allowed/.test(m)), 'and it SAYS why, or nobody will ever know')

  await lc.onAppState('background')
  t.is(w.suspended, true, 'falling back to the library policy')
})

// ⛔ A STAGED BUNDLE WITH NO PROVENANCE MUST NOT OUTRANK AN EXPLICIT APK INSTALL.
//
// The HTTPS fetcher's getLatestRelease returned { name, version, treeHash } and NO builtAt, so every
// bundle it staged got a marker with no build time. chooseBundle required BOTH times to be present
// before letting the APK win, so those bundles became permanently unbeatable: a phone would install a
// newer APK, boot, and keep running JS staged weeks earlier.
//
// Observed on the handset 2026-08-25 and it cost most of an evening: four APKs in a row installed
// cleanly and none of their fixes took effect, because a bundle staged from 0.1.6 won every boot. The
// symptom is the worst kind — the install succeeds, the version looks right, and the code does not run.
//
// 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 now goes to the APK.
test('a stamped APK beats a staged bundle that cannot say when it was built', (t) => {
  const r = chooseBundle({
    current: { treeHash: 'aaa', file: 'aaa.bundle', builtAt: null }, // staged by the HTTPS client
    assetBuiltAt: 1787708000000
  })
  t.is(r.use, 'asset', 'the freshly installed APK wins')
  t.ok(/older|no build time|updated/i.test(r.reason), 'and says why: ' + r.reason)
})

test('but a staged bundle that IS newer still wins — OTA must keep working', (t) => {
  const r = chooseBundle({
    current: { treeHash: 'bbb', file: 'bbb.bundle', builtAt: 1787709000000 },
    assetBuiltAt: 1787708000000
  })
  t.is(r.use, 'staged', 'a genuinely newer staged bundle is still preferred')
})

test('with NO APK stamp, nothing is orderable and the historic behaviour stands', (t) => {
  const r = chooseBundle({
    current: { treeHash: 'ccc', file: 'ccc.bundle', builtAt: null },
    assetBuiltAt: null
  })
  t.is(r.use, 'staged', 'an older shell that cannot stamp its asset does not get a surprise downgrade')
})

// ── where a staged bundle's addons live ───────────────────────────────────────────────────────────
//
// ⛔ THE NAME BELONGS HERE, in the one module BOTH sides import. The RN side has to hand the loader a
// directory it cannot compute without bare-fs, and the store that writes it is inside the worklet — so
// the convention would otherwise be spelled out twice across a bridge, which is precisely the split
// this module exists to prevent.
test('a staged bundle carrying addons says where they are', (t) => {
  const staged = { treeHash: 'a'.repeat(64), file: 'abc.bundle', builtAt: 2000, addons: ['libbare-fs.4.8.1.so'] }
  const r = chooseBundle({ current: staged, assetBuiltAt: 1000 })
  t.is(r.use, 'staged')
  t.is(r.addons, 'abc.bundle.addons', 'named off the BUNDLE FILE, so a staged bundle can derive it itself')
})

test('a staged bundle with no addons reports none', (t) => {
  // Every release published so far. A loader must be able to tell "this release has none" from "they
  // did not arrive", so this is null and never an empty string.
  const staged = { treeHash: 'b'.repeat(64), file: 'abc.bundle', builtAt: 2000, addons: [] }
  t.is(chooseBundle({ current: staged, assetBuiltAt: 1000 }).addons, null, 'empty list')
  const older = { treeHash: 'c'.repeat(64), file: 'abc.bundle', builtAt: 2000 }
  t.is(chooseBundle({ current: older, assetBuiltAt: 1000 }).addons, null, 'a marker written before addons existed')
})

test('the APK asset never reports staged addons', (t) => {
  // Booting the APK's own bundle must not preload addons staged for a DIFFERENT one — their sonames
  // could differ by version and would then shadow the libraries this APK actually ships.
  const staged = { treeHash: 'd'.repeat(64), file: 'abc.bundle', builtAt: 1000, addons: ['libx.1.0.0.so'] }
  const r = chooseBundle({ current: staged, assetBuiltAt: 5000 })
  t.is(r.use, 'asset', 'newer APK wins')
  t.is(r.addons, null, 'and brings no staged addons with it')
})

// ── the addon guard ───────────────────────────────────────────────────────────────────────────────
//
// A `linked:` bundle names the sonames it needs. Two sources can satisfy one: the APK's jniLibs, or the
// addons staged beside the bundle. If a demand is in NEITHER, running it means a dlopen failure, a node
// that never reaches ready, and a tree hash condemned for good — the update silently burned.
//
// ⛔ AND THE GUARD ONLY FIRES ON KNOWLEDGE. A marker written before `demands` existed, or a shell with
// no list of its own addons, means the question cannot be answered — and refusing on missing information
// would strand every device holding an older staged bundle. Unknown means proceed, exactly as today.
const APK = ['libbare-fs.4.8.1.so', 'libbare-path.3.1.2.so']

test('a staged bundle whose demands the APK satisfies runs', (t) => {
  const current = { treeHash: 'a'.repeat(64), file: 'x.bundle', builtAt: 2000, demands: ['libbare-fs.4.8.1.so'] }
  t.is(chooseBundle({ current, assetBuiltAt: 1000, apkAddons: APK }).use, 'staged')
})

test('a staged bundle whose demands only the STAGED addons satisfy runs', (t) => {
  // The whole point of the route: a release can bring an addon the APK has never carried.
  const current = {
    treeHash: 'b'.repeat(64),
    file: 'x.bundle',
    builtAt: 2000,
    addons: ['librocksdb-native.9.9.9.so'],
    demands: ['libbare-fs.4.8.1.so', 'librocksdb-native.9.9.9.so']
  }
  const r = chooseBundle({ current, assetBuiltAt: 1000, apkAddons: APK })
  t.is(r.use, 'staged', 'the new addon came with the release')
  t.is(r.addons, 'x.bundle.addons', 'and its directory is reported')
})

test('a demand in NEITHER source is refused, and says which', (t) => {
  // ⛔ This is the whole ⛔ item: today nothing refuses, so such a bundle is staged, boots, dies at
  // dlopen and is condemned permanently — an update burned with no diagnosis on a device with no adb.
  const current = {
    treeHash: 'c'.repeat(64),
    file: 'x.bundle',
    builtAt: 2000,
    addons: ['libbare-fs.4.8.1.so'],
    demands: ['libbare-fs.4.8.1.so', 'libsodium-native.5.1.0.so']
  }
  const r = chooseBundle({ current, assetBuiltAt: 1000, apkAddons: APK })
  t.is(r.use, 'asset', 'the APK bundle runs instead')
  t.ok(r.reason.includes('libsodium-native.5.1.0.so'), 'names the addon: ' + r.reason)
  t.absent(r.condemn, 'REFUSED, not condemned — nothing was tried, so nothing failed')
})

test('a refusal does not mark the bundle pending', (t) => {
  // markPending is the watchdog arming itself before a risky boot. A bundle we declined to run must not
  // arm it, or the next boot condemns a tree that never executed and a later APK could have fixed.
  const current = { treeHash: 'd'.repeat(64), file: 'x.bundle', builtAt: 2000, demands: ['libmissing.1.0.0.so'] }
  t.absent(chooseBundle({ current, assetBuiltAt: 1000, apkAddons: APK }).markPending)
})

test('unknown demands or unknown APK addons mean PROCEED, never refuse', (t) => {
  const noDemands = { treeHash: 'e'.repeat(64), file: 'x.bundle', builtAt: 2000 }
  t.is(chooseBundle({ current: noDemands, assetBuiltAt: 1000, apkAddons: APK }).use, 'staged', 'marker predates demands')

  const withDemands = { treeHash: 'f'.repeat(64), file: 'x.bundle', builtAt: 2000, demands: ['libanything.1.0.0.so'] }
  t.is(chooseBundle({ current: withDemands, assetBuiltAt: 1000 }).use, 'staged', 'shell does not know its own addons')
  t.is(chooseBundle({ current: withDemands, assetBuiltAt: 1000, apkAddons: [] }).use, 'staged', 'an EMPTY list is not knowledge')
})