// 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')
})