OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / 78fd89c

0.0.40: a logger that cannot kill the node, and an error screen with a way out
⛔ MEASURED ON A HANDSET 2026-09-15. The app showed "Converting circular structure to JSON — Scheduler → _timeouts → Heap → _data → Array[0] → Timeout → _scheduler" and sat on it, while the worklet behind it had reached "atproto up" and went on logging OTA lines for minutes. Two independent defects, either of which alone would have been survivable. 1. `send()` stringified OUTSIDE the try. The IPC WRITE was guarded and the ENCODE was not, so one payload holding a Bare timer threw into whoever called send — on the boot path. A logger must not be able to kill what it is logging, whatever the payload was, which is why the fix is a serializer rather than a patch to one caller. `safeStringify` also NAMES what it could not encode, so the next occurrence says which field carried the timer instead of leaving it to be guessed at. ⚠ I have NOT identified the payload. frame() returns a string, the fatal path sends e.stack, and request() sends {id,op,params:{}} — all cleared. The instrument ships so the phone can answer it. 2. The error screen had no way out. `error` was set once and nothing ever cleared it, so a single failure anywhere in boot() showed it for ever and the only recovery was a force-stop — which a person holding the phone has no reason to try. It now offers `try again`, and `startNode()` memoises so a retry re-uses the running node rather than starting a second. ⚠ The live log line under the error is what makes this safe to offer rather than a guess: it keeps updating from the stream, so a reader can SEE the node is alive beneath the error. On the handset it moved from "starting the node…" to "ota: watching favela-surface-android…" between two screenshots taken three minutes apart. 119/119, 376/376. App.jsx compiles under the project's babel preset; the worklet packs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
78fd89c34b5f573399520d236707721025d4e7ed
by plankatron <fleeky@gmail.com> · 2026-09-15T20:04:53.000Z

Changed files (6) +154 -5

app/App.jsx +20 -1
……@@ -45,7 +45,26 @@ export default function App () {
4545
4646 useEffect(() => { boot().catch((e) => setError(e.message)) }, [boot])
4747
48 if (error) return <Center><Text style={s.err}>{error}</Text><Text style={s.dim}>{booting}</Text></Center>
48 // ⛔ AN ERROR HERE IS NOT A DEAD END, AND IT USED TO BE. This screen had no way out: `error` was set
49 // once and nothing ever cleared it, so a single failure anywhere in boot() showed this for ever —
50 // while the node behind it was healthy and still reporting. Observed on a handset 2026-09-15: the
51 // worklet reached "atproto up" and went on logging OTA lines for minutes, and the only way back was
52 // a force-stop, which a person holding the phone has no reason to try.
53 //
54 // ⚠ The live line below the error is the tell, and it is why this is safe to offer: `booting`
55 // updates from the log stream, so if the node is alive the reader can SEE it moving under the
56 // error. `startNode()` memoises, so retrying re-uses the running node rather than starting a second.
57 if (error) {
58 return (
59 <Center>
60 <Text style={s.err}>{error}</Text>
61 <Text style={s.dim}>{booting}</Text>
62 <Pressable onPress={() => boot().catch((e) => setError(e.message))}>
63 <Text style={s.retry}>try again</Text>
64 </Pressable>
65 </Center>
66 )
67 }
4968 if (!client || !status) return <Center><ActivityIndicator /><Text style={s.dim}>{booting}</Text></Center>
5069 if (!status.url) {
5170 return (
lib/host/safe-json.js +68 -0
……@@ -0,0 +1,68 @@
1// A serializer that cannot take the node down.
2//
3// ⛔ THE FAILURE THIS ENDS, observed on a handset 2026-09-15. `send()` in lib/node.js was
4//
5// const line = JSON.stringify(obj) // ← outside the try
6// console.log(line)
7// try { IPC.write(...) } catch { } // ← guarded
8//
9// so the WRITE was protected and the SERIALIZATION was not. One payload holding a Bare timer
10// (`Scheduler → _timeouts → Heap → _data → Array[0] → Timeout → _scheduler`) threw a TypeError into
11// whoever called send, on a boot path, and the shell latched it and showed an error screen for ever —
12// over a node that had started cleanly and went on logging for minutes behind it.
13//
14// ⚠ A LOGGER MUST NOT BE ABLE TO KILL WHAT IT IS LOGGING. That is true whatever the payload was, which
15// is why this exists rather than a fix to one caller. The payload is a separate question and this
16// answers it too: what could not be serialized is NAMED in the output, so the next occurrence says
17// which field carried the timer instead of leaving it to be guessed at.
18//
19// ⚠ NOT A PRETTY-PRINTER. A repeated reference to the same object is reported as [circular] even when
20// it is merely shared. For a log line that is the right trade: correctness about STRUCTURE matters
21// less than never throwing, and a shared reference printed twice is not worth a second pass.
22
23/**
24 * `JSON.stringify` that always returns a string.
25 *
26 * @returns {{ line: string, degraded: boolean, dropped: string[] }}
27 */
28export function safeStringify (obj) {
29 // The overwhelmingly common path: nothing special, no allocation, no replacer.
30 try {
31 const line = JSON.stringify(obj)
32 if (typeof line === 'string') return { line, degraded: false, dropped: [] }
33 } catch { /* fall through and find out what it was */ }
34
35 const seen = new WeakSet()
36 const dropped = []
37 const note = (path, what) => { if (dropped.length < 12) dropped.push(path + ':' + what) }
38
39 try {
40 const line = JSON.stringify(obj, function (key, value) {
41 if (typeof value === 'function') { note(key || '(root)', 'function'); return undefined }
42 if (typeof value === 'bigint') { note(key || '(root)', 'bigint'); return String(value) }
43 if (value && typeof value === 'object') {
44 if (seen.has(value)) {
45 note(key || '(root)', 'circular ' + ((value.constructor && value.constructor.name) || 'object'))
46 return '[circular]'
47 }
48 seen.add(value)
49 }
50 return value
51 })
52 if (typeof line === 'string') return { line, degraded: true, dropped }
53 } catch (err) {
54 // ⛔ EVEN THE REPLACER CAN THROW — a getter that raises, a Proxy that refuses. There is no third
55 // attempt: return something valid that says so, because the one outcome this module exists to
56 // prevent is returning nothing.
57 return {
58 line: JSON.stringify({ t: 'log', msg: 'send: payload could not be serialized at all', error: String((err && err.message) || err) }),
59 degraded: true,
60 dropped
61 }
62 }
63
64 // `JSON.stringify` returns undefined for a bare undefined/function/symbol at the root.
65 return { line: JSON.stringify({ t: 'log', msg: 'send: payload was not serializable', type: typeof obj }), degraded: true, dropped }
66}
67
68export default { safeStringify }
lib/node.js +17 -1
……@@ -34,6 +34,7 @@ import Hyperswarm from 'hyperswarm'
3434import { FavelaNode } from 'favela-peer/lib/node.js'
3535import { SwarmTransport } from 'favela-peer/lib/transport.js'
3636import { createUtf8Decoder } from './host/utf8.js'
37import { safeStringify } from './host/safe-json.js'
3738// Stamped at build time by scripts/gen-version.mjs — the bundle carries no readable package.json.
3839import { VERSION, BUILT_AT } from './version.js'
3940
……@@ -46,8 +47,23 @@ const IPC = typeof BareKit !== 'undefined' ? BareKit.IPC : null
4647// learned that the expensive way: sending only over IPC meant the reason a boot failed existed for
4748// the two seconds before anyone could ask for it.
4849function send (obj) {
49 const line = JSON.stringify(obj)
50 // ⛔ THE SERIALIZATION IS THE PART THAT KILLED IT. This used to be a bare `JSON.stringify(obj)`
51 // above the try: the WRITE was guarded and the ENCODE was not. On 2026-09-15 one payload reached
52 // here holding a Bare timer (Scheduler → _timeouts → Heap → _data → Array[0] → Timeout →
53 // _scheduler), the TypeError went up into whoever called send on the boot path, and the shell
54 // latched it — an error screen for ever over a node that had started cleanly and kept logging for
55 // minutes behind it.
56 //
57 // ⚠ A logger must not be able to kill what it is logging, whatever the payload was. `safeStringify`
58 // also NAMES what it could not encode, so the next occurrence says which field carried the timer
59 // rather than leaving it to be guessed at — which is how this one was found.
60 const { line, degraded, dropped } = safeStringify(obj)
5061 console.log(line)
62 if (degraded) {
63 const warn = JSON.stringify({ t: 'log', msg: 'send: payload was not fully serializable', dropped })
64 console.log(warn)
65 if (IPC) { try { IPC.write(Buffer.from(warn + '\n')) } catch { /* UI gone */ } }
66 }
5167 if (!IPC) return
5268 try { IPC.write(Buffer.from(line + '\n')) } catch { /* UI gone; the node keeps running */ }
5369}
lib/version.js +2 -2
……@@ -1,4 +1,4 @@
11// GENERATED by scripts/gen-version.mjs — do not edit.
2export const VERSION = "0.0.39"
3export const BUILT_AT = 1789496889840
2export const VERSION = "0.0.40"
3export const BUILT_AT = 1789502687385
44export default { VERSION, BUILT_AT }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface-android",
3 "version": "0.0.39",
3 "version": "0.0.40",
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/safe-json.test.js +46 -0
……@@ -0,0 +1,46 @@
1import test from 'brittle'
2import { safeStringify } from '../lib/host/safe-json.js'
3
4test('ordinary payloads pass through untouched', (t) => {
5 const r = safeStringify({ t: 'log', stage: 'node ready', n: 1, ok: true, nil: null })
6 t.absent(r.degraded)
7 t.alike(JSON.parse(r.line), { t: 'log', stage: 'node ready', n: 1, ok: true, nil: null })
8 t.is(r.dropped.length, 0)
9})
10
11test('★★★ a circular payload still produces a line, and NAMES what closed the circle', (t) => {
12 // The handset's shape: a Bare timer reachable from a log payload.
13 function Scheduler () { this._timeouts = { _data: [] } }
14 function Timeout (s) { this._scheduler = s }
15 const sched = new Scheduler()
16 sched._timeouts._data.push(new Timeout(sched))
17
18 const r = safeStringify({ t: 'log', msg: 'ota: something', detail: sched })
19 t.ok(r.degraded, 'it had to degrade')
20 const parsed = JSON.parse(r.line)
21 t.is(parsed.msg, 'ota: something', 'the part that was serializable survived')
22 t.ok(r.dropped.some((d) => /circular/.test(d)), 'and the circle is reported rather than guessed at: ' + r.dropped.join(', '))
23})
24
25test('⛔ it never throws — that is the entire point', (t) => {
26 const evil = {}
27 Object.defineProperty(evil, 'boom', { enumerable: true, get () { throw new Error('nope') } })
28 const r = safeStringify({ t: 'log', evil })
29 t.ok(typeof r.line === 'string' && r.line.length > 0, 'a line came back')
30 t.ok(r.degraded)
31 JSON.parse(r.line) // and it is valid JSON
32})
33
34test('functions and bigints are dropped by name, not silently', (t) => {
35 const r = safeStringify({ t: 'log', fn () {}, big: 10n, keep: 'yes' })
36 t.is(JSON.parse(r.line).keep, 'yes')
37 t.ok(r.dropped.some((d) => /fn:function/.test(d)))
38 t.ok(r.dropped.some((d) => /big:bigint/.test(d)))
39})
40
41test('a root that JSON cannot represent still yields valid JSON', (t) => {
42 const r = safeStringify(undefined)
43 const parsed = JSON.parse(r.line)
44 t.ok(r.degraded)
45 t.ok(/not serializable/.test(parsed.msg))
46})