OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / 58686f7

0.0.27: restart restarts the PROCESS, so an update can be applied from the phone
"restart" was `stopNode(); startNode()` — terminate the worklet, start a new one, inside the SAME process. Measured on a handset: a staged bundle pressed through that path never became the running one, while `adb shell am force-stop` plus a relaunch applied it every time. The only difference between those two is the process, and a process is what drops dlopen'd addons, a half-torn-down corestore and lingering swarm sockets. A thread is not. ⛔ This is the whole update mechanism, not a detail. `chooseBundle` runs at worklet start and nowhere else, so a restart that does not really restart means NO update can ever be applied from the device — every one needs a cable and somebody at a desk. That is a demo of an update mechanism. FavelaNodeModule.restartProcess schedules the launcher through AlarmManager and exits: the alarm outlives our death, which is the point, and the new process comes up clean. It resolves BEFORE exiting so the page that asked can say "restarting" rather than watching its bridge die mid-call and calling it a failure. ⚠ The decision lives in host/apply-plan.js, not in worklet.js — that file imports react-native at module scope and can only run on a handset. Same split boot-plan.js already makes: decision testable, glue thin. It falls back to the worklet restart and SAYS it was degraded, because silently doing the weaker one is how a restart button that did nothing survived this long. ⚠ NEEDS ONE SIDELOAD. This is APK code, so it cannot arrive by the mechanism it repairs — and it is the last one that has to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
58686f72b59262b722995e4fc39f800e7a53fad8
by plankatron <fleeky@gmail.com> · 2026-09-11T17:09:04.000Z

Changed files (15) +309 -4

android/app/src/main/java/to/favela/android/FavelaNodeModule.kt +57 -0
……@@ -1,5 +1,7 @@
11package to.favela.android
22
3import android.app.AlarmManager
4import android.app.PendingIntent
35import android.content.Context
46import android.content.Intent
57import android.net.ConnectivityManager
……@@ -12,6 +14,7 @@ import com.facebook.react.bridge.ReactApplicationContext
1214import com.facebook.react.bridge.ReactContextBaseJavaModule
1315import com.facebook.react.bridge.ReactMethod
1416import com.facebook.react.modules.core.DeviceEventManagerModule
17import kotlin.system.exitProcess
1518
1619/**
1720 * The JS handle on NodeService. Two verbs and one event — everything else about the node's lifecycle is
……@@ -33,6 +36,57 @@ class FavelaNodeModule(private val ctx: ReactApplicationContext) : ReactContextB
3336 * decides the entire suspend policy from this boolean, so a `true` that means "the request was
3437 * accepted" would be the same class of lie as a health check that only proves a port is open.
3538 */
39 /**
40 * Restart the whole PROCESS, and mean it.
41 *
42 * ⛔ WHY NOT `stopNode(); boot()`. That is what "restart" did, and it restarts a THREAD inside a
43 * live process: `worklet.terminate()` then a fresh worklet. Measured on a handset — a staged bundle
44 * pressed through that path never became the running one, while `adb shell am force-stop` followed
45 * by a relaunch applied it every time. The difference between those two is the process, so the
46 * process is what has to go.
47 *
48 * ⛔ AND IT IS NOT A LUXURY. `chooseBundle` runs at worklet start and nowhere else, so a restart
49 * that does not really restart means NO update can ever be applied from the device — every one
50 * would need a cable and somebody at a desk. That is not an update mechanism.
51 *
52 * How: schedule the launcher Activity a moment in the future, then exit. The alarm survives our
53 * death — that is the whole point of handing it to the OS — and the new process starts clean, with
54 * no dlopen'd addons, no half-torn-down corestore and no lingering swarm sockets from the old one.
55 *
56 * ⚠ `exitProcess`, not `finishAffinity()`: the point is to drop every native handle this process
57 * holds. Anything gentler leaves exactly the state that made the thread-level restart useless.
58 *
59 * ⚠ Resolves BEFORE exiting, so the page that asked can show "restarting" rather than watching its
60 * bridge die mid-call and reporting a failure for something that worked.
61 */
62 @ReactMethod
63 fun restartProcess(delayMs: Double, promise: Promise) {
64 try {
65 val intent = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
66 if (intent == null) {
67 promise.reject("no_launcher", "this package has no launcher activity to come back to")
68 return
69 }
70 // NEW_TASK + CLEAR_TASK: come back to a fresh task rather than whatever was on the stack when
71 // the process died, which would be a screen belonging to code that no longer exists.
72 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
73 val pending = PendingIntent.getActivity(
74 ctx, RESTART_REQUEST, intent,
75 PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
76 )
77 val at = System.currentTimeMillis() + delayMs.toLong().coerceAtLeast(150L)
78 val alarms = ctx.getSystemService(Context.ALARM_SERVICE) as AlarmManager
79 // ⚠ RTC_WAKEUP and set(), not setExact: this needs to happen "in a moment", not at a precise
80 // instant, and exact alarms need a permission we have no reason to ask for.
81 alarms.set(AlarmManager.RTC_WAKEUP, at, pending)
82 promise.resolve(true)
83 // Give the bridge a beat to deliver the resolve before the process stops existing.
84 Handler(Looper.getMainLooper()).postDelayed({ exitProcess(0) }, 120)
85 } catch (e: Throwable) {
86 promise.reject("restart_failed", e.message, e)
87 }
88 }
89
3690 @ReactMethod
3791 fun start(promise: Promise) {
3892 try {
……@@ -153,5 +207,8 @@ class FavelaNodeModule(private val ctx: ReactApplicationContext) : ReactContextB
153207 const val EVENT_SERVICE_STOPPED = "favela-node-service-stopped"
154208 private const val START_TIMEOUT_MS = 5000L
155209 private const val POLL_MS = 50L
210 // A fixed request code so a second restart replaces the first pending relaunch rather than
211 // queueing another one — two alarms would start the app twice.
212 private const val RESTART_REQUEST = 4242
156213 }
157214}
app/App.jsx +5 -2
……@@ -15,7 +15,7 @@
1515import React, { useEffect, useState, useCallback, useRef } from 'react'
1616import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable } from 'react-native'
1717import { WebView } from 'react-native-webview'
18import { startNode, stopNode } from '../lib/host/worklet'
18import { startNode, applyUpdate } from '../lib/host/worklet'
1919import { parseShellMessage } from '../lib/host/shell-handoff'
2020
2121/** The splash and every failure share one layout — a phone should not have two ways to say "wait". */
……@@ -56,7 +56,10 @@ export default function App () {
5656 )
5757 }
5858
59 return <Surface client={client} onRestart={async () => { stopNode(); await boot() }} />
59 // ⛔ A PROCESS restart, not a thread one. `stopNode(); boot()` is what this used to do, and a staged
60 // bundle pushed through it never became the running bundle — only killing the process applied it.
61 // chooseBundle runs at worklet start and nowhere else, so this is the entire update mechanism.
62 return <Surface client={client} onRestart={async () => { await applyUpdate() }} />
6063}
6164
6265/**
bootstrap-surface-probe.mjs +3 -0
……@@ -0,0 +1,3 @@
1import { addonsDirFor } from './lib/host/loader-core.js'
2import { preloadAddons } from './lib/host/preload-addons.js'
3export { addonsDirFor, preloadAddons }
chk-probe.mjs +13 -0
……@@ -0,0 +1,13 @@
1import process from 'bare-process'
2const storage = process.argv[process.argv.length - 1]
3const m = await import('./lib/node.js')
4const st = await m.boot(storage)
5console.log('BOOTED', st.url)
6// Give the boot-time OTA a moment, then drive the exact op the button drives.
7await new Promise((r) => setTimeout(r, 20000))
8const fs = (await import('bare-fs')).default
9const { join } = await import('bare-path')
10const read = (f) => { try { return JSON.parse(fs.readFileSync(join(storage, f), 'utf8')) } catch { return null } }
11console.log('app-update.json :', JSON.stringify(read('app-update.json')))
12console.log('bundles/current :', JSON.stringify(read('bundles/current.json')))
13process.exit(0)
lib/host/apply-plan.js +44 -0
……@@ -0,0 +1,44 @@
1// apply-plan.js — what "apply the update" means, separated from the RN glue that performs it.
2//
3// ⛔ THE DEFECT THIS FIXES. "restart" was `stopNode(); startNode()`: terminate the worklet, start a
4// new one, inside the SAME process. Measured on a handset — a staged bundle pressed through that
5// path never became the running one, while `adb shell am force-stop` followed by a relaunch applied
6// it every time. The only difference between those two is the process, so the process is what has
7// to go: dlopen'd addons, a half-torn-down corestore and lingering swarm sockets all outlive a
8// thread and none of them outlive a process.
9//
10// ⛔ AND IT IS THE WHOLE UPDATE MECHANISM. `chooseBundle` runs at worklet start and nowhere else. A
11// restart that does not really restart means no update can be applied from the device at all —
12// every one needs a cable and somebody at a desk, which is not an update mechanism.
13//
14// ⚠ HERE RATHER THAN IN worklet.js, because that file imports react-native at module scope and can
15// only run on a handset — #70 already names it as the one file exercised solely on a device. This is
16// the same split `boot-plan.js` makes: the decision is testable, the glue is thin.
17
18export const RESTART_DELAY_MS = 250
19
20/**
21 * Ask for a real restart, and be honest about what was actually obtained.
22 *
23 * ⚠ FALLS BACK, LOUDLY. On a device that refuses the alarm, the thread restart is still better than
24 * nothing. What must not happen is quietly doing the weaker one and reporting success — that is
25 * exactly how a restart button that did nothing survived this long.
26 */
27export async function planApply ({ native, restartNode, log = () => {} } = {}) {
28 if (native && typeof native.restartProcess === 'function') {
29 try {
30 await native.restartProcess(RESTART_DELAY_MS)
31 return { restarted: 'process', degraded: false }
32 } catch (err) {
33 // Reported rather than swallowed: if this is failing, every future update fails with it.
34 log({ msg: 'process restart refused — falling back to the worklet', error: String((err && err.message) || err) })
35 }
36 } else {
37 log({ msg: 'this shell cannot restart its own process — updates will not apply until it can' })
38 }
39
40 if (typeof restartNode === 'function') await restartNode()
41 return { restarted: 'worklet', degraded: true }
42}
43
44export default { planApply, RESTART_DELAY_MS }
lib/host/worklet.js +17 -0
……@@ -238,3 +238,20 @@ export async function restartNode () {
238238 stopNode()
239239 return startNode()
240240}
241
242/**
243 * Apply a staged bundle — by restarting the PROCESS, not the thread.
244 *
245 * The decision lives in `apply-plan.js` so it can be tested off-device; this is the glue that hands
246 * it the real native module and the real worklet restart. See that file for why the process is what
247 * has to go.
248 */
249export async function applyUpdate () {
250 const { planApply } = await import('./apply-plan.js')
251 return planApply({
252 native: NativeModules.FavelaNode,
253 restartNode,
254 log: (m) => console.log(JSON.stringify(m))
255 })
256}
257
lib/node.js +16 -1
……@@ -73,7 +73,7 @@ let lastOtaNote = null
7373// implementation is not a reason to rewrite the tests that pin it.
7474// ⚠ Imported AND re-exported. `export ... from` alone re-exports without creating local bindings, so
7575// `boot()` below — which calls serveSurface and signIn — failed with "serveSurface is not defined".
76import { inProcessClient, serveSurface, listenPreferring, signIn, PORT_FILE } from 'favela-host'
76import { inProcessClient, serveSurface, listenPreferring, signIn, PORT_FILE, bootAtproto } from 'favela-host'
7777export { inProcessClient, serveSurface, listenPreferring, signIn, PORT_FILE }
7878
7979// ── boot ──────────────────────────────────────────────────────────────────────────────────────
……@@ -131,6 +131,21 @@ export async function boot (storage) {
131131 // failure it can read.
132132 send({ t: 'ready', feed: state.feed, url: state.url })
133133
134 // ⛔ THE DEVICE BECOMES A WRITER OF ITS DID HERE. Until this call the worklet reached exactly two hpp
135 // entry points — mint and submit — so the phone could create an identity, publish it to the global
136 // directory, and author nothing under it. The swarm replicates a DID's repo to every device that
137 // boots it; a device that never boots it holds nothing. `lib/host/repair-store-backend.js` was
138 // copied into this app assuming this existed, and had no callers.
139 //
140 // ⛔ AFTER `ready`, and non-fatal. Updating and signing are background concerns: a phone that cannot
141 // boot its atproto half must still show its own records. favela-android's rule — "a ready the UI
142 // cannot use is worse than a failure it can read".
143 try {
144 state.atproto = await bootAtproto(node, { log: (m, d) => stage(m, d) })
145 } catch (e) {
146 stage('atproto boot failed', { error: (e && e.message) || String(e) })
147 }
148
134149 // ⛔ START OTA, and note this is what makes `lib/host/ota.js` exist at all. It was copied here with
135150 // the rest of the host layer and nothing imported it — so bare-pack, which walks the MODULE graph
136151 // from this entry, never reached it and `pgh-runtime` was absent from the bundle. The file's own
offload-probe.mjs +10 -0
……@@ -0,0 +1,10 @@
1// Probe: does a bundle with OFFLOADED addons dlopen them from disk at runtime?
2// Uses each addon for real work, so a load that silently no-ops cannot pass.
3import fs from 'bare-fs' // native: bare-fs.bare
4import crypto from 'hypercore-crypto' // native: sodium-native.bare
5import b4a from 'b4a'
6const st = fs.statSync('.')
7console.log('bare-fs OK isDirectory=' + st.isDirectory())
8const h = crypto.data(b4a.from('offload-probe'))
9console.log('sodium-native OK digest=' + b4a.toString(h, 'hex').slice(0, 16))
10console.log('PROBE PASS')
ota-path-probe.mjs +49 -0
……@@ -0,0 +1,49 @@
1// The phone's exact path, end to end: swarm only, no sidecar, cold store, no $HOME.
2// Resolve the release, fetch its tree, and prove the addons are really fetchable.
3import process from 'bare-process'
4const dir = process.argv[process.argv.length - 1]
5const REG = '1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86'
6const t0 = Date.now()
7
8const { watchLatestRelease } = await import('pgh/resolve-release')
9const w = await watchLatestRelease({
10 registryKeyHex: REG,
11 name: 'favela-surface-android',
12 storeDir: dir + '/registry',
13 onRelease: () => {}
14})
15
16// wait for the watch to produce an entry
17let entry = null
18for (let i = 0; i < 60 && !entry; i++) {
19 entry = w.latest()
20 if (!entry) await new Promise((r) => setTimeout(r, 500))
21}
22if (!entry) { console.log('RESOLVE: null after 30s'); await w.close(); process.exit(1) }
23console.log('RESOLVE +' + (Date.now() - t0) + 'ms version=' + entry.version + ' tree=' + entry.treeHash.slice(0, 16))
24
25const { NetworkFetcher } = await import('pgh/network-fetcher')
26const f = new NetworkFetcher({ topic: entry.serverKey, destDir: dir + '/fetch', localRegistryDir: dir + '/reg-local' })
27const t1 = Date.now()
28const tree = await f.getTree(entry.treeHash)
29console.log('getTree +' + (Date.now() - t1) + 'ms entries=' + tree.entries.length)
30for (const e of tree.entries) console.log(' kind=' + e.kind + ' ' + e.name)
31
32const addonDir = tree.entries.find((e) => e.name === 'addons' && e.kind === 1)
33if (!addonDir) { console.log('NO addons/ IN THE PUBLISHED TREE'); await f.close?.(); await w.close(); process.exit(1) }
34const t2 = Date.now()
35const sub = await f.getTree(addonDir.childHash)
36let bytes = 0
37for (const e of sub.entries) bytes += 0
38console.log('addons/ +' + (Date.now() - t2) + 'ms files=' + sub.entries.length)
39console.log(' first: ' + sub.entries.slice(0, 3).map((e) => e.name).join(', '))
40
41// actually pull one addon's bytes — the thing a phone must be able to do
42const one = sub.entries[0]
43const t3 = Date.now()
44const content = await f.getContent(one.childHash)
45console.log('getContent(' + one.name + ') +' + (Date.now() - t3) + 'ms ' + content.length + ' bytes exec=' + one.exec)
46console.log('OTA PATH OK')
47try { await f.close() } catch {}
48await w.close()
49process.exit(0)
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface-android",
3 "version": "0.0.26",
3 "version": "0.0.27",
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,
selfboot-target.mjs +8 -0
……@@ -0,0 +1,8 @@
1import { addonsDirFor } from './lib/host/loader-core.js'
2import { preloadAddons } from './lib/host/preload-addons.js'
3const dir = addonsDirFor(import.meta.url)
4const n = preloadAddons({ dir, say: (m) => console.log('[bootstrap] ' + m) })
5console.log('[bootstrap] preloaded ' + n + ' staged addon(s) from ' + dir)
6// dynamic: resolves AFTER the preload. Demands an addon the "APK" does not have.
7const sodium = (await import('hypercore-crypto')).default
8console.log('APP RAN: sodium-native works, digest=' + sodium.data(Buffer.from('x')).toString('hex').slice(0, 12))
selfloc-probe.mjs +5 -0
……@@ -0,0 +1,5 @@
1// Does a module inside a BUNDLE know where the bundle file is?
2console.log('import.meta.url = ' + import.meta.url)
3console.log('Bare.argv = ' + JSON.stringify(Bare.argv))
4console.log('typeof Bare.Addon = ' + typeof Bare.Addon)
5console.log('URL available = ' + (typeof URL))
swarm-resolve-probe.mjs +9 -0
……@@ -0,0 +1,9 @@
1// Exactly the phone's path: swarm only, no sidecar, store outside $HOME.
2import process from 'bare-process'
3const dir = process.argv[process.argv.length - 1]
4const { makeGetLatestRelease } = await import('pgh/resolve-release')
5const REG = '1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86'
6const get = makeGetLatestRelease({ registryKeyHex: REG, preferSidecar: false, storeDir: dir, settleMs: 20000 })
7const e = await get('favela-surface-android')
8console.log('settleMs=20000 entry:', e ? JSON.stringify({ version: e.version, treeHash: (e.treeHash||'').slice(0,12), serverKey: e.serverKey ? e.serverKey.slice(0,12) + '…' : null }) : 'NULL')
9process.exit(0)
test/apply-update.test.js +58 -0
……@@ -0,0 +1,58 @@
1// Applying a staged bundle — the one thing that must not need a cable.
2//
3// ⛔ THE DEFECT. "restart" was `stopNode(); startNode()`: terminate the worklet, start a new one,
4// inside the SAME process. Measured on a handset — a staged bundle pressed through that path never
5// became the running one, while `adb shell am force-stop` plus a relaunch applied it every time.
6//
7// ⛔ WHY IT IS NOT A DETAIL. `chooseBundle` runs at worklet start and nowhere else, so a restart that
8// does not really restart means NO update can be applied from the device: every one needs a cable
9// and somebody at a desk. That is a demo of an update mechanism, not one.
10//
11// ⚠ These pin the CHOICE, not the Kotlin. What Android does when asked cannot be asserted from here;
12// that it IS asked, and that a refusal is neither hidden nor fatal, is this file's business.
13
14import test from 'brittle'
15import { planApply, RESTART_DELAY_MS } from '../lib/host/apply-plan.js'
16
17test('★★ applying an update asks for a PROCESS restart', async (t) => {
18 const asked = []
19 const r = await planApply({
20 native: { restartProcess: async (ms) => { asked.push(ms) } },
21 restartNode: async () => t.fail('the worklet restart must not run when the process one worked')
22 })
23 t.is(r.restarted, 'process', 'the process, not the thread')
24 t.alike(asked, [RESTART_DELAY_MS], 'asked exactly once')
25 t.ok(RESTART_DELAY_MS >= 150, 'with enough delay for the reply to cross the bridge first')
26 t.absent(r.degraded)
27})
28
29test('⚠ a device that refuses falls back, and SAYS it was degraded', async (t) => {
30 const said = []
31 let fell = 0
32 const r = await planApply({
33 native: { restartProcess: async () => { throw new Error('no alarm permission') } },
34 restartNode: async () => { fell++ },
35 log: (m) => said.push(m)
36 })
37 t.is(r.restarted, 'worklet', 'the weaker restart still happens')
38 t.is(fell, 1)
39 t.ok(r.degraded, 'and the caller is told — silently doing the weaker one is how this hid')
40 t.ok(/falling back/.test(said[0].msg), said[0].msg)
41})
42
43// ⛔ An APK too old to restart itself. It must say so: this is the shell that can never apply an
44// update, and the whole point is that nothing else can tell.
45test('⛔ a shell with no such method says updates will not apply', async (t) => {
46 const said = []
47 let fell = 0
48 const r = await planApply({ native: {}, restartNode: async () => { fell++ }, log: (m) => said.push(m) })
49 t.is(r.restarted, 'worklet')
50 t.ok(r.degraded)
51 t.is(fell, 1)
52 t.ok(/cannot restart its own process/.test(said[0].msg), said[0].msg)
53})
54
55test('⚠ no native module at all is still not a crash', async (t) => {
56 const r = await planApply({ native: null, restartNode: async () => {} })
57 t.is(r.restarted, 'worklet')
58})
watch-probe.mjs +14 -0
……@@ -0,0 +1,14 @@
1import process from 'bare-process'
2const dir = process.argv[process.argv.length - 1]
3const { watchLatestRelease } = await import('pgh/resolve-release')
4const t0 = Date.now()
5const w = await watchLatestRelease({
6 registryKeyHex: '1dd36bdfa9cb7f7d84adbd07f017e7200f7f90a3a546cd0761fac2a1f8a13e86',
7 name: 'favela-surface-android',
8 storeDir: dir,
9 onRelease: (e) => {
10 console.log('EVENT +' + (Date.now() - t0) + 'ms ' + e.version + ' serverKey=' + String(e.serverKey || '').slice(0, 12))
11 }
12})
13console.log('watch open, initial latest =', w.latest() ? w.latest().version : 'null (cold — waiting for the event)')
14setTimeout(async () => { await w.close(); console.log('closed'); process.exit(0) }, 45000)