OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / 84dab5f

0.0.22: the shell reports its own verdict on a staged bundle
⛔ THE HANDSET BUG. "An update is ready — restart to apply", for ever, restarting nothing. The page had re-derived a decision the shell owns and disagreed with it: favela-surface's updateWaiting() took `staged !== running` as proof an update was pending, with no build-time comparison on that branch and no sight of failed.json, which lives here and which a page cannot read. chooseBundle refuses a condemned bundle and refuses one the APK supersedes — so the notice appeared on precisely the bundles that can never be applied. The shell already RAN chooseBundle. It now reports the answer: `refused` carries the staged hash it declined and the reason, through the shell handoff, into shellState, to the page. Report state, never leave a verdict to be guessed twice. Covered: a bundle that will boot reports no refusal; one the APK supersedes is named with its reason; a CONDEMNED one is too — the case no page could ever have got right, because failed.json is not visible from there; and nothing staged means nothing refused. Carries favela-surface 0.0.62, which renders the verdict rather than deriving it, and shows "this update cannot be applied — <reason>" with no restart button instead of going silent. Suite 93/93 here, 199/199 in favela-surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84dab5fa03fff14f7bc81c109d84a42af44d5545
by plankatron <fleeky@gmail.com> · 2026-09-11T01:31:53.000Z

Changed files (5) +95 -8

lib/host/boot-plan.js +16 -2
……@@ -45,6 +45,13 @@ export async function planBoot ({ storage, io }) {
4545 let bundleHash = null
4646 let addonsDir = null
4747 let reason = 'apk bundle'
48 // ⛔ WHAT WAS STAGED AND NOT RUN, AND WHY. Observed on a handset: "an update is ready, restart to
49 // apply", for ever, while restarting changed nothing. The page had re-implemented the decision —
50 // `if (running) return staged !== running` — with no build-time comparison and no sight of
51 // failed.json, so it said YES on precisely the bundles chooseBundle refuses. Two rules for one
52 // question, and the visible one was wrong. The shell already RAN chooseBundle; it reports the answer
53 // rather than leaving the page to guess it.
54 let refused = null
4855
4956 try {
5057 const failed = (await io.readJson(bdir + '/failed.json')) || []
……@@ -73,6 +80,13 @@ export async function planBoot ({ storage, io }) {
7380 io.log('[worklet] REFUSED the staged bundle: ' + choice.reason)
7481 }
7582
83 if (choice.use === 'asset' && choice.hash === null) {
84 // Something IS staged but was not used: name it and say why, so the page can stop offering a
85 // restart that cannot help and show the reason instead.
86 const cur = await io.readJson(bdir + '/current.json')
87 if (cur && cur.treeHash) refused = { hash: cur.treeHash, reason: choice.reason }
88 }
89
7690 if (choice.use === 'staged' && await io.exists(bdir + '/' + choice.file)) {
7791 // The watchdog arms itself here, before the risky boot and never after it.
7892 await io.writeText(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash }))
……@@ -115,11 +129,11 @@ export async function planBoot ({ storage, io }) {
115129 const dns = await io.dnsServers()
116130 try {
117131 await io.writeText(storage + '/' + SHELL_FILE, encodeShell({
118 ota: true, pair: true, bundle: bundleHash, dns, apkAddons: apkAddons || []
132 ota: true, pair: true, bundle: bundleHash, dns, apkAddons: apkAddons || [], refused
119133 }))
120134 } catch (e) {
121135 io.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message)
122136 }
123137
124 return { entryPath, startArgs, bundleHash, addonsDir, reason }
138 return { entryPath, startArgs, bundleHash, addonsDir, reason, refused }
125139}
lib/host/shell-handoff.js +14 -3
……@@ -68,18 +68,27 @@ export function parseShell (text) {
6868 // The sonames THIS APK ships, so the OTA store can skip downloading addons that are already on the
6969 // device. Only the shell can answer it — jniLibs are mapped out of the APK, never unpacked — and an
7070 // older shell sends nothing, which the store reads as "skip nothing".
71 apkAddons: Array.isArray(raw.apkAddons) ? raw.apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : []
71 apkAddons: Array.isArray(raw.apkAddons) ? raw.apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : [],
72 // ⛔ THE SHELL'S OWN VERDICT on a staged bundle it declined to run, and the reason. The page must
73 // never re-derive this: it did, disagreed with chooseBundle, and produced a restart button that
74 // could not work and would not go away.
75 refused: raw.refused && isHash(raw.refused.hash)
76 ? { hash: String(raw.refused.hash), reason: String(raw.refused.reason || 'refused') }
77 : null
7278 }
7379}
7480
7581/** The shell's side of the same shape. Written before every worklet start, never appended to. */
76export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [], apkAddons = [] } = {}) {
82export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [], apkAddons = [], refused = null } = {}) {
7783 return JSON.stringify({
7884 ota: ota === true,
7985 pair: pair === true,
8086 bundle: isHash(bundle) ? String(bundle) : null,
8187 dns: Array.isArray(dns) ? dns.filter(isAddr).map(String) : [],
82 apkAddons: Array.isArray(apkAddons) ? apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : []
88 apkAddons: Array.isArray(apkAddons) ? apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : [],
89 refused: refused && isHash(refused.hash)
90 ? { hash: String(refused.hash), reason: String(refused.reason || 'refused') }
91 : null
8392 })
8493}
8594
……@@ -99,6 +108,8 @@ export function shellState ({ shell = null, staged = null, checkedAt = null } =
99108 pair: s.pair === true,
100109 running: isHash(s.bundle) ? String(s.bundle) : null,
101110 staged: isHash(staged) ? String(staged) : null,
111 // Passed straight through from the shell. The page renders THIS instead of deciding for itself.
112 refused: s.refused || null,
102113 checkedAt: typeof checkedAt === 'string' && checkedAt ? checkedAt : null
103114 }
104115}
lib/version.js +2 -2
……@@ -1,4 +1,4 @@
11// GENERATED by scripts/gen-version.mjs — do not edit.
2export const VERSION = "0.0.21"
3export const BUILT_AT = 1789089403887
2export const VERSION = "0.0.22"
3export const BUILT_AT = 1789090294391
44export default { VERSION, BUILT_AT }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface-android",
3 "version": "0.0.21",
3 "version": "0.0.22",
44 "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.",
55 "type": "module",
66 "private": true,
test/boot-plan.test.js +62 -0
……@@ -139,3 +139,65 @@ test('an APK with no addon list still boots, and claims nothing', async (t) => {
139139 await planBoot({ storage: STORAGE, io: d.io })
140140 t.alike(JSON.parse(d.written[STORAGE + '/shell.json']).apkAddons, [], 'empty, which the store reads as skip-nothing')
141141})
142
143// ── the shell reports its own verdict ─────────────────────────────────────────────────────────────
144//
145// ⛔ THE BUG THIS FIXES, OBSERVED ON A HANDSET: "an update is ready, restart to apply" shown for ever
146// while restarting changed nothing. favela-surface's updateWaiting() re-implemented the decision —
147// `if (u.running) return u.staged !== u.running` — with no build-time comparison and no knowledge of
148// failed.json, so it said YES on exactly the bundles chooseBundle refuses. Two rules for one question,
149// and the one the user could see was the wrong one.
150//
151// The shell already RUNS chooseBundle at boot. It knows the verdict and the reason, so it reports them
152// rather than leaving the page to guess. Report state, never a derived verdict.
153
154test('a staged bundle that WILL boot is reported as applicable', async (t) => {
155 const staged = { treeHash: 'a'.repeat(64), file: 'x.bundle', builtAt: 9000 }
156 const d = device({
157 assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 },
158 files: { [BDIR + '/current.json']: JSON.stringify(staged), [BDIR + '/x.bundle']: 'S' }
159 })
160 const plan = await planBoot({ storage: STORAGE, io: d.io })
161 const shell = JSON.parse(d.written[STORAGE + '/shell.json'])
162 t.is(shell.bundle, 'a'.repeat(64), 'it booted')
163 t.is(shell.refused, null, 'nothing refused')
164})
165
166test('a staged bundle the APK supersedes is reported REFUSED, with the reason', async (t) => {
167 // The 0.0.17-staged / 0.0.18-APK case exactly: legitimately un-appliable, for ever.
168 const staged = { treeHash: 'b'.repeat(64), file: 'y.bundle', builtAt: 1000 }
169 const d = device({
170 assets: { ...BASE, 'worklet.bundle.built': '9999' },
171 sizes: { [STORAGE + '/worklet.bundle']: 100 },
172 files: { [BDIR + '/current.json']: JSON.stringify(staged), [BDIR + '/y.bundle']: 'S' }
173 })
174 const plan = await planBoot({ storage: STORAGE, io: d.io })
175 const shell = JSON.parse(d.written[STORAGE + '/shell.json'])
176 t.is(shell.bundle, null, 'the APK bundle booted')
177 t.is(shell.refused.hash, 'b'.repeat(64), 'and the staged one is named as refused')
178 t.ok(shell.refused.reason.includes('more recently'), 'with the reason: ' + shell.refused.reason)
179})
180
181test('a CONDEMNED staged bundle is reported refused too', async (t) => {
182 // Nothing the page could ever have known: failed.json lives on the shell side.
183 const staged = { treeHash: 'c'.repeat(64), file: 'z.bundle', builtAt: 9000 }
184 const d = device({
185 assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 },
186 files: {
187 [BDIR + '/current.json']: JSON.stringify(staged),
188 [BDIR + '/failed.json']: JSON.stringify(['c'.repeat(64)]),
189 [BDIR + '/z.bundle']: 'S'
190 }
191 })
192 await planBoot({ storage: STORAGE, io: d.io })
193 const shell = JSON.parse(d.written[STORAGE + '/shell.json'])
194 t.is(shell.bundle, null)
195 t.is(shell.refused.hash, 'c'.repeat(64))
196 t.ok(shell.refused.reason.includes('failed to boot'), shell.refused.reason)
197})
198
199test('nothing staged means nothing refused', async (t) => {
200 const d = device({ assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 } })
201 await planBoot({ storage: STORAGE, io: d.io })
202 t.is(JSON.parse(d.written[STORAGE + '/shell.json']).refused, null)
203})