OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / b53b369

0.0.34: ship the libraries our libraries link against
The handset crash-looped on its OWN bundle, on 0.0.32 and again on 0.0.33: ADDON_NOT_FOUND: Cannot find addon '.' imported from .../hyper-proto-peer/node_modules/bare-signals/binding.js Candidates: - linked:libbare-signals.4.2.0.so and named a library that was in the APK. bare reports a failed dlopen as a failed RESOLUTION, so the message accuses the addon that was found. The real fault was one step down: libbare-signals.4.2.0.so declares NEEDED libbare-os.3.8.7.so, these addons carry no RUNPATH, and that file was not in the APK. libbare-fs.4.7.2.so had the same hole behind it (libbare-url.2.4.1.so) waiting for the next boot to get that far. ⛔ THE KEEP-LIST WAS RIGHT AND PACKAGING DISAGREED WITH IT. stage-addons.mjs already computes the transitive ELF NEEDED closure per ABI and kept both libraries for arm64 — the build log says so. app/build.gradle then collected the excluded FILE NAMES into one flat set across all four staged ABIs and excluded each as `**/<name>`, which matches every ABI. armeabi-v7a, x86 and x86_64 did not keep those two, so arm64 lost them. Exclusions are now qualified `**/<abi>/<name>` in both branches that build them. ⚠ AND IT WAS INTERMITTENT. The leak fires only when the four staging dirs disagree, and they disagree only when the unshipped ABIs hold stale leftovers — restaging made all four agree and the same source produced a working APK. So a green build proves nothing about the packaging, which is the argument for checking the artifact: scripts/verify-apk-addons.mjs opens the built APK and walks every shipped library's NEEDED entries. No allowlist of system libraries — one would go stale silently. A dep that is staged but absent from the APK is a packaging error; a dep in neither, carrying a bare-link version soname, is an addon nothing staged. It runs in CI after assemble, and it FAILS when readelf is missing rather than passing unverified: the opposite direction from stage-addons.mjs, which over-ships when it cannot compute the closure. Over-shipping is safe; a false green on the last step before a phone is not. Run against the 0.0.33 APK it names exactly the two crashes, the second before anyone had seen it. Against 0.0.34 it is clean, and the device boots: node ready, announced 5, signed in, atproto up with 1 DID. The OTA addon set is published from the keep-list too, for the same reason — a staged addon's DT_NEEDED soname is resolved out of the app namespace or from what is already loaded, and lib/loader.js dlopens the staged set before the bundle runs. Verified on device (SM-S906U): installed over 0.0.33 with data intact, boots clean. 114/114 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b53b369cf4c688b00426dc863dd413a2cdc281db
by plankatron <fleeky@gmail.com> · 2026-09-11T21:33:43.000Z

Changed files (6) +214 -8

.pear-git/ci.yaml +23 -1
……@@ -123,6 +123,16 @@ steps:
123123 # entry it writes as the bundle to boot, so a two-entry tree stages a 154 MB APK as the worklet
124124 # bundle. The OTA client on a handset is exactly what cannot be fixed remotely.
125125 npm run gen:apk-tree
126 # ⛔ VERIFY THE ARTIFACT, NOT THE INTENT. Every check before this point inspects a DECISION —
127 # what stage-addons staged, what the keep-list kept, what gradle says it excluded. All three
128 # reported success for 0.0.32 and 0.0.33 and produced an APK that crash-looped on its own bundle:
129 # a kept library was excluded anyway by a cross-ABI pattern, and the addon that linked against it
130 # could not dlopen. bare reports that as ADDON_NOT_FOUND against the addon that was present, so
131 # the handset names the wrong library and there is no adb on it to say otherwise.
132 #
133 # This opens the APK and walks every shipped lib's ELF NEEDED entries. It is the last thing
134 # between a build and a phone, and it fails the build rather than the device.
135 node scripts/verify-apk-addons.mjs favela-surface-android.apk
126136 ls -l favela-surface-android.apk worklet.bundle tree.json apk.tree.json
127137 timeout: 2700
128138
……@@ -179,7 +189,19 @@ steps:
179189 const buf = fs.readFileSync("worklet.bundle")
180190 const nl = buf.indexOf(10)
181191 const len = parseInt(buf.subarray(0, nl).toString(), 10)
182 const want = JSON.parse(buf.subarray(nl, nl + len).toString().trim()).addons.map(a => a.replace(/^linked:/, ""))
192 const demanded = JSON.parse(buf.subarray(nl, nl + len).toString().trim()).addons.map(a => a.replace(/^linked:/, ""))
193 // ⛔ THE KEEP-LIST WHEN THERE IS ONE, because the header names only what the JS module graph
194 // imports and an addon can LINK against a .so no module imports. These addons carry no RUNPATH,
195 // so a DT_NEEDED soname is resolved out of the app namespace — the APK, plus whatever is already
196 // loaded. lib/loader.js dlopens the staged set by absolute path before the bundle runs, so a
197 // dependency staged here is in the process by soname when its dependent needs it; one that is
198 // missing is a dlopen failure the device reports against the wrong library.
199 const keepPath = "android/app/addon-keep.json"
200 let want = demanded
201 if (fs.existsSync(keepPath)) {
202 const keep = JSON.parse(fs.readFileSync(keepPath, "utf8"))["arm64-v8a"]
203 if (Array.isArray(keep) && keep.length) want = [...new Set([...demanded, ...keep])]
204 }
183205 const src = "node_modules/react-native-bare-kit/android/src/main/addons/arm64-v8a"
184206 let n = 0
185207 for (const name of want) {
android/app/build.gradle +16 -3
……@@ -50,7 +50,9 @@ def computeUndemandedAddons() {
5050 def excess = [] as Set
5151 addonsDir.eachDir { abi ->
5252 abi.eachFileMatch(~/.*\.so/) { so ->
53 if (!demanded.contains(so.name)) excess << so.name
53 // ABI-qualified for the same reason as the keep-list branch above, so both branches hand
54 // `jniLibs.excludes` the same shape and it never has to know which one it got.
55 if (!demanded.contains(so.name)) excess << ("**/" + abi.name + "/" + so.name)
5456 }
5557 }
5658 logger.lifecycle("addons: ${demanded.size()} demanded by bundle, ${excess.size()} excluded")
……@@ -75,7 +77,16 @@ if (keepFile.exists()) {
7577 if (addonsDir.exists()) {
7678 addonsDir.eachDir { abi ->
7779 def keepSet = (keep[abi.name] ?: []) as Set
78 abi.eachFileMatch(~/.*\.so/) { so -> if (!keepSet.contains(so.name)) excess << so.name }
80 // ⛔ QUALIFIED BY ABI, AND THIS IS THE WHOLE BUG. `excess` used to collect bare FILE NAMES
81 // across all four staged ABIs, and the exclude below wrote them as `**/<name>` — a pattern
82 // that matches every ABI. The keep-list is computed PER ABI (a library's ELF NEEDED closure
83 // differs between them), so a lib arm64 keeps and x86 does not was excluded from arm64 too.
84 //
85 // Measured on 0.0.33: arm64 kept libbare-os.3.8.7.so and libbare-url.2.4.1.so as native
86 // dependencies, the other three ABIs did not, and the APK shipped neither — so
87 // libbare-signals.4.2.0.so and libbare-fs.4.7.2.so could not dlopen. The app crash-looped
88 // on its own bundle, reporting ADDON_NOT_FOUND against bare-signals, which was present.
89 abi.eachFileMatch(~/.*\.so/) { so -> if (!keepSet.contains(so.name)) excess << ("**/" + abi.name + "/" + so.name) }
7990 }
8091 }
8192 logger.lifecycle("addons: keep-list ${keep.values().collect { it.size() }.max() ?: 0} kept, ${excess.size()} excluded (native closure included)")
……@@ -170,7 +181,9 @@ android {
170181 pickFirst "**/libc++_shared.so"
171182
172183 jniLibs {
173 excludes += undemandedAddons.collect { "**/" + it }
184 // Already full `**/<abi>/<name>` patterns — see the note where they are built. Re-prefixing
185 // them here is what produced a cross-ABI exclude.
186 excludes += undemandedAddons
174187 }
175188 }
176189}
lib/version.js +2 -2
……@@ -1,4 +1,4 @@
11// GENERATED by scripts/gen-version.mjs — do not edit.
2export const VERSION = "0.0.24"
3export const BUILT_AT = 1789092276908
2export const VERSION = "0.0.34"
3export const BUILT_AT = 1789162178833
44export default { VERSION, BUILT_AT }
package.json +2 -2
……@@ -1,7 +1,7 @@
11{
22 "name": "favela-surface-android",
3 "version": "0.0.33",
4 "description": "favela-surface-android \u2014 a React Native shell over a bare-kit worklet running a full favela-peer node (markdown records, no atproto) with favela-surface rendering its pages in a WebView.",
3 "version": "0.0.34",
4 "description": "favela-surface-android — a React Native shell over a bare-kit worklet running a full favela-peer node (markdown records, no atproto) with favela-surface rendering its pages in a WebView.",
55 "type": "module",
66 "private": true,
77 "scripts": {
scripts/verify-apk-addons.mjs +104 -0
……@@ -0,0 +1,104 @@
1// Verify the BUILT APK, not the intent to build it.
2//
3// ⛔ WHY THIS EXISTS. Every other check in this repo asks whether the right decision was made:
4// stage-addons.mjs asserts the demanded set is staged and writes a keep-list that includes the
5// transitive ELF closure; app/build.gradle excludes what the keep-list omits. Both reported success on
6// 0.0.32 and 0.0.33 — "58/58 demanded staged", "+3 kept as NATIVE dependencies", "61 kept, 10
7// excluded" — and the APK they produced could not boot:
8//
9// ADDON_NOT_FOUND: Cannot find addon '.' imported from .../hyper-proto-peer/node_modules/bare-signals/binding.js
10// Candidates:
11// - linked:libbare-signals.4.2.0.so
12//
13// The candidate resolved. `libbare-signals.4.2.0.so` was in the APK. It declares
14// `NEEDED libbare-os.3.8.7.so`, that file was NOT in the APK, dlopen failed on the missing dependency,
15// and bare reports a failed load as a failed resolution. The app crash-looped on its own bundle, on a
16// handset, where there is no adb and no UI left to say why.
17//
18// The keep-list HAD libbare-os.3.8.7.so. build.gradle dropped it anyway, because it collected excluded
19// names into one flat set across all four ABIs and excluded them as `**/<name>` — so a library arm64
20// keeps and x86 does not is excluded from arm64 too. Nothing that inspects a decision could have caught
21// that: the decision was right and the packaging disagreed with it.
22//
23// So this reads the artifact. It is the last step between a build and a phone.
24//
25// ⚠ NO ALLOWLIST OF SYSTEM LIBRARIES, deliberately — one would go stale silently, which is the failure
26// mode this repo keeps designing against. Both rules below are grounded in files that exist:
27//
28// 1. A NEEDED entry naming a file that IS staged in bare-kit's addons dir but is NOT in the APK.
29// That is a packaging error, exactly the one above. libc.so and friends are not in that dir, so
30// they cannot trip it.
31// 2. A NEEDED entry that is in neither, and carries a bare-link version soname (`lib<x>.1.2.3.so`).
32// That is an addon nothing ever staged. No platform library is named that way.
33//
34// Usage: node scripts/verify-apk-addons.mjs <apk> [--abi arm64-v8a]
35import fs from 'fs'
36import os from 'os'
37import path from 'path'
38import { execFileSync } from 'child_process'
39import { fileURLToPath } from 'url'
40
41const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
42const apk = process.argv[2]
43const abiArg = process.argv.indexOf('--abi')
44const abi = abiArg === -1 ? 'arm64-v8a' : process.argv[abiArg + 1]
45
46if (!apk || !fs.existsSync(apk)) {
47 console.error('usage: node scripts/verify-apk-addons.mjs <apk> [--abi arm64-v8a]')
48 process.exit(1)
49}
50
51// The staging dir bare-kit packages FROM — the population this APK was drawn out of. A name present
52// here and absent from the APK was removed by packaging, which is rule 1.
53const stagedDir = path.join(root, 'node_modules/react-native-bare-kit/android/src/main/addons', abi)
54const staged = fs.existsSync(stagedDir)
55 ? new Set(fs.readdirSync(stagedDir).filter((f) => f.endsWith('.so')))
56 : new Set()
57
58const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-apk-addons-'))
59try {
60 execFileSync('unzip', ['-o', '-q', apk, `lib/${abi}/*`, '-d', tmp], { stdio: 'inherit' })
61} catch {
62 console.error(`no lib/${abi}/ entries in ${path.basename(apk)} — nothing to verify, which is itself wrong`)
63 process.exit(1)
64}
65
66const libDir = path.join(tmp, 'lib', abi)
67if (!fs.existsSync(libDir)) {
68 console.error(`no lib/${abi}/ in the APK`)
69 process.exit(1)
70}
71const present = new Set(fs.readdirSync(libDir).filter((f) => f.endsWith('.so')))
72
73// ⛔ readelf ABSENT MUST FAIL, not pass. stage-addons.mjs may keep everything when it cannot compute the
74// closure — a safe direction, because it over-ships. Here the safe direction is the opposite: a check
75// that cannot run has proven nothing, and saying so is the whole point of the file.
76try { execFileSync('readelf', ['--version'], { stdio: 'ignore' }) } catch {
77 console.error('readelf unavailable — cannot verify the APK\'s native closure, and an unverified APK is what this step exists to stop')
78 process.exit(1)
79}
80
81const VERSIONED = /^lib.+\.\d+\.\d+\.\d+\.so$/
82
83const broken = []
84for (const so of [...present].sort()) {
85 const out = execFileSync('readelf', ['-d', path.join(libDir, so)], { encoding: 'utf8' })
86 for (const m of out.matchAll(/NEEDED\).*?\[([^\]]+)\]/g)) {
87 const dep = m[1]
88 if (present.has(dep)) continue
89 if (staged.has(dep)) broken.push({ so, dep, why: 'staged for this build but excluded from the APK' })
90 else if (VERSIONED.test(dep)) broken.push({ so, dep, why: 'never staged — no prebuild for it anywhere' })
91 }
92}
93
94console.log(`verify-apk-addons: ${present.size} native libs in lib/${abi}/, ${staged.size} staged`)
95
96if (broken.length) {
97 console.error('\nUNSATISFIED NEEDED — these WILL fail to dlopen on a device:')
98 for (const b of broken) console.error(` ${b.so} NEEDS ${b.dep} — ${b.why}`)
99 console.error('\nbare reports a failed dlopen as ADDON_NOT_FOUND on the addon that pulled it in, so the')
100 console.error('symptom on the handset is a crash loop naming the WRONG library. Do not ship this APK.')
101 process.exit(1)
102}
103
104console.log(' every NEEDED entry is satisfied inside the APK')
test/apk-packaging.test.js +67 -0
……@@ -0,0 +1,67 @@
1// The APK must ship every library the libraries it ships link against.
2//
3// ⛔ THE BUG THIS PINS. app/build.gradle collected the addons to exclude into ONE FLAT SET OF FILE
4// NAMES across all four staged ABIs, then excluded each as `**/<name>` — a pattern that matches every
5// ABI. The keep-list is computed PER ABI, because a library's ELF NEEDED closure differs between them,
6// so a library arm64 keeps and x86 does not was excluded from arm64 as well.
7//
8// Measured on 0.0.33: the keep-list kept libbare-os.3.8.7.so and libbare-url.2.4.1.so for arm64, the
9// other three ABIs did not keep them, and the APK shipped neither — so libbare-signals.4.2.0.so and
10// libbare-fs.4.7.2.so could not dlopen. The handset crash-looped on its OWN bundle with
11//
12// ADDON_NOT_FOUND: Cannot find addon '.' imported from …/hyper-proto-peer/node_modules/bare-signals/binding.js
13// Candidates:
14// - linked:libbare-signals.4.2.0.so
15//
16// naming a library that was present, because bare reports a failed dlopen as a failed resolution. There
17// is no adb on a user's phone and no UI left when the node cannot boot.
18//
19// ⚠ AND IT WAS INTERMITTENT, which is the real argument for the artifact-level gate in CI. Whether the
20// leak fires depends on the four staging dirs DISAGREEING, and they only disagree when the unshipped
21// ABIs hold stale leftovers — restaging them made all four agree and the same source built a working
22// APK. A build that passes is not evidence the packaging is right.
23//
24// ⚠ SOURCE-LEVEL, for the reason announce.test.js and boundary.test.js give: the defect lives in Groovy
25// that only runs inside a gradle build, and a test that has to assemble a 160 MB APK is not a test
26// anyone runs. scripts/verify-apk-addons.mjs is the behavioural check and it runs in CI on the real
27// artifact; this one pins the shape so the regression cannot return quietly.
28
29import test from 'brittle'
30import fs from 'bare-fs'
31import { join } from 'bare-path'
32
33const ROOT = new URL('..', import.meta.url).pathname
34
35test('jniLibs exclusions are qualified by ABI', (t) => {
36 const src = fs.readFileSync(join(ROOT, 'android/app/build.gradle'), 'utf8')
37
38 t.absent(/excludes \+= undemandedAddons\.collect \{ "\*\*\/" \+ it \}/.test(src),
39 'the exclude list is not re-prefixed with a bare `**/` — that is what crossed ABIs')
40 t.ok(/excludes \+= undemandedAddons\b/.test(src),
41 'it takes the patterns as built')
42
43 // Both branches that can populate it — the keep-list one and the header-only fallback — must name the
44 // ABI, or the shape reaching `excludes` depends on which ran.
45 const qualified = src.match(/excess << \("\*\*\/" \+ abi\.name \+ "\/" \+ so\.name\)/g) || []
46 t.is(qualified.length, 2,
47 'both the keep-list branch and the fallback build `**/<abi>/<name>` patterns')
48 t.absent(/excess << so\.name/.test(src),
49 'and neither collects a bare file name, which is shared across every ABI')
50})
51
52test('the artifact gate exists and refuses to pass when it cannot check', (t) => {
53 const p = join(ROOT, 'scripts/verify-apk-addons.mjs')
54 t.ok(fs.existsSync(p), 'scripts/verify-apk-addons.mjs is present')
55
56 const src = fs.readFileSync(p, 'utf8')
57 // ⛔ THE OPPOSITE DIRECTION FROM stage-addons.mjs. That script keeps everything when readelf is
58 // missing, because over-shipping is safe. A verifier that cannot read the artifact has proven
59 // nothing, and passing would be a false green on the last step before a phone.
60 t.ok(/readelf unavailable[\s\S]{0,200}?process\.exit\(1\)/.test(src),
61 'a missing readelf fails the build rather than passing unverified')
62 t.ok(/NEEDED\\\)/.test(src), 'it reads ELF NEEDED entries')
63
64 const ci = fs.readFileSync(join(ROOT, '.pear-git/ci.yaml'), 'utf8')
65 t.ok(/node scripts\/verify-apk-addons\.mjs favela-surface-android\.apk/.test(ci),
66 'and CI runs it on the built APK, which is the only place it can catch this')
67})