OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / 9ae28b6

0.0.24: the surface port survives a restart
⛔ THE REAL CAUSE OF "restart to apply does nothing". serveSurface bound port 0 — a fresh ephemeral port on every boot — so the moment the node came back the WebView was pointing at a dead server and kept rendering the page it had last loaded. logcat proved the node HAD restarted and WAS serving the staged bundle: [worklet] booting STAGED bundle a947b9e5605c… (staged bundle available) [bootstrap] preloaded 0 staged addon(s) node ready → swarm joined → signed in but the first boot served :33093 and the next :43749. Nobody could see any of it. That one defect produced both symptoms — the restart button appearing inert and the banner appearing frozen at the top of the page. The port is now remembered in <storage>/surface-port and re-bound, falling back to ephemeral if something else holds it: refusing to boot over a port number would be a far worse failure than a changed URL. Covered by test/port-stability.test.js against real bare-http1 servers — remembered port re-bound, a taken port falls back to a different one, and 0 still means any. Carries favela-surface 0.0.63, which suppresses a refusal naming a bundle older than the one running. Both fixes are BUNDLE-side and reach a device over OTA; the equivalent shell-side rule lives in boot-plan.js, which is React Native and needs an APK, so the page must not depend on it. Suite 102/102, 325/325. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9ae28b6b6ba30641767bbb85b49009a380a25c79
by plankatron <fleeky@gmail.com> · 2026-09-11T02:05:12.000Z

Changed files (4) +87 -8

lib/node.js +39 -5
……@@ -115,6 +115,30 @@ const ATPROTO_OPS = {
115115 * ⚠ Binds 127.0.0.1 explicitly. bare-http1's `listen(port)` defaults to all interfaces on some
116116 * platforms, and a favela node answering on a phone's Wi-Fi address is not what anyone asked for.
117117 */
118/**
119 * Bind `port` if we can, otherwise any free one.
120 *
121 * ⛔ A STABLE PORT, BECAUSE THE WEBVIEW HOLDS THE URL. Every restart used to bind a fresh ephemeral
122 * port, so the page the user was looking at pointed at a dead server the moment the node came back —
123 * which is why "restart to apply" looked like it did nothing and the banner appeared frozen. The node
124 * HAD restarted and was serving the new bundle; nobody could see it.
125 *
126 * ⚠ Falls back rather than failing. Another app can hold the remembered port, and refusing to boot over
127 * a port number would be a far worse failure than a changed URL.
128 */
129export function listenPreferring (server, port, host = '127.0.0.1') {
130 return new Promise((resolve, reject) => {
131 const onError = (err) => {
132 if (!port) return reject(err)
133 server.removeListener('error', onError)
134 server.once('error', reject)
135 server.listen(0, host, resolve)
136 }
137 server.once('error', onError)
138 server.listen(port, host, () => { server.removeListener('error', onError); resolve() })
139 })
140}
141
118142export async function serveSurface (node, { port = 0, sessions = new Sessions(), appUpdate = null } = {}) {
119143 const client = inProcessClient(node)
120144 const server = http.createServer((req, res) => {
……@@ -126,10 +150,14 @@ export async function serveSurface (node, { port = 0, sessions = new Sessions(),
126150 res.end(String((err && err.message) || err))
127151 })
128152 })
129 await new Promise((resolve, reject) => {
130 server.once('error', reject)
131 server.listen(port, '127.0.0.1', resolve)
132 })
153 // ⛔ A STABLE PORT, BECAUSE THE WEBVIEW HOLDS THE URL. Every restart used to bind a fresh ephemeral
154 // port, so the page the user was looking at pointed at a dead server the moment the node came back —
155 // which is why "restart to apply" looked like it did nothing and the banner appeared frozen. The node
156 // HAD restarted and was serving the new bundle; nobody could see it.
157 //
158 // ⚠ Falls back to ephemeral if the remembered port is taken. Another app can hold it, and refusing to
159 // boot over a port number would be a far worse failure than a changed URL.
160 await listenPreferring(server, port)
133161 const bound = server.address().port
134162 return { server, sessions, client, port: bound, url: 'http://127.0.0.1:' + bound }
135163}
……@@ -141,6 +169,8 @@ export async function serveSurface (node, { port = 0, sessions = new Sessions(),
141169// is why attesting one would hand a WebView the power to mint devices. The app authenticates; the
142170// daemon writes.
143171const BROWSER_KEY = 'browser.key'
172// Where the last bound surface port is remembered, so a restart serves the same URL the WebView holds.
173const PORT_FILE = 'surface-port'
144174
145175/**
146176 * Load or mint this app's anproto keypair.
……@@ -246,7 +276,11 @@ export async function boot (storage) {
246276 const appUpdate = () => {
247277 try { return JSON.parse(fs.readFileSync(join(storage, 'app-update.json'), 'utf8')) } catch { return null }
248278 }
249 surface = await serveSurface(node, { port: 0, appUpdate })
279 // Reuse the port this app bound last time so the WebView's URL survives a restart. See serveSurface.
280 let wantedPort = 0
281 try { wantedPort = Number(String(fs.readFileSync(join(storage, PORT_FILE), 'utf8')).trim()) || 0 } catch {}
282 surface = await serveSurface(node, { port: wantedPort, appUpdate })
283 try { fs.writeFileSync(join(storage, PORT_FILE), String(surface.port)) } catch { /* best effort */ }
250284 state.ready = true
251285 state.feed = node.key.toString('hex')
252286 state.url = surface.url
lib/version.js +2 -2
……@@ -1,4 +1,4 @@
11// GENERATED by scripts/gen-version.mjs — do not edit.
2export const VERSION = "0.0.23"
3export const BUILT_AT = 1789091628615
2export const VERSION = "0.0.24"
3export const BUILT_AT = 1789092276908
44export default { VERSION, BUILT_AT }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface-android",
3 "version": "0.0.23",
3 "version": "0.0.24",
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/port-stability.test.js +45 -0
……@@ -0,0 +1,45 @@
1// The surface port must survive a restart.
2//
3// ⛔ THE BUG THIS FIXES, SEEN ON THE HANDSET: "restart to apply" appeared to do nothing and the update
4// banner looked frozen at the top of the page. The node HAD restarted and WAS serving the new bundle —
5// logcat showed it booting the staged tree and reaching ready — but every boot bound a fresh ephemeral
6// port, so the WebView was still pointing at the previous one. A dead URL renders the page it last
7// loaded, which is indistinguishable from nothing having happened.
8import test from 'brittle'
9import http from 'bare-http1'
10import { listenPreferring } from '../lib/node.js'
11
12const close = (s) => new Promise((r) => s.close(r))
13
14test('a remembered port is bound again', async (t) => {
15 const a = http.createServer(() => {})
16 await listenPreferring(a, 0)
17 const port = a.address().port
18 await close(a)
19
20 const b = http.createServer(() => {})
21 await listenPreferring(b, port)
22 t.is(b.address().port, port, 'the same URL keeps working across a restart')
23 await close(b)
24})
25
26test('a port someone else holds falls back instead of failing to boot', async (t) => {
27 // Refusing to start over a port number would be a far worse failure than a changed URL.
28 const squatter = http.createServer(() => {})
29 await listenPreferring(squatter, 0)
30 const taken = squatter.address().port
31
32 const mine = http.createServer(() => {})
33 await listenPreferring(mine, taken)
34 t.ok(mine.address().port > 0, 'still bound')
35 t.not(mine.address().port, taken, 'on a different port')
36
37 await close(mine); await close(squatter)
38})
39
40test('port 0 still means any free port', async (t) => {
41 const s = http.createServer(() => {})
42 await listenPreferring(s, 0)
43 t.ok(s.address().port > 0)
44 await close(s)
45})