| … | … | @@ -45,7 +45,26 @@ export default function App () { |
| 45 | 45 | |
| 46 | 46 | useEffect(() => { boot().catch((e) => setError(e.message)) }, [boot]) |
| 47 | 47 | |
| 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 | } | |
| 49 | 68 | if (!client || !status) return <Center><ActivityIndicator /><Text style={s.dim}>{booting}</Text></Center> |
| 50 | 69 | if (!status.url) { |
| 51 | 70 | return ( |
| … | … | @@ -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 | */ | |
| 28 | export 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 | ||
| 68 | export default { safeStringify } |
| … | … | @@ -34,6 +34,7 @@ import Hyperswarm from 'hyperswarm' |
| 34 | 34 | import { FavelaNode } from 'favela-peer/lib/node.js' |
| 35 | 35 | import { SwarmTransport } from 'favela-peer/lib/transport.js' |
| 36 | 36 | import { createUtf8Decoder } from './host/utf8.js' |
| 37 | import { safeStringify } from './host/safe-json.js' | |
| 37 | 38 | // Stamped at build time by scripts/gen-version.mjs — the bundle carries no readable package.json. |
| 38 | 39 | import { VERSION, BUILT_AT } from './version.js' |
| 39 | 40 | |
| … | … | @@ -46,8 +47,23 @@ const IPC = typeof BareKit !== 'undefined' ? BareKit.IPC : null |
| 46 | 47 | // learned that the expensive way: sending only over IPC meant the reason a boot failed existed for |
| 47 | 48 | // the two seconds before anyone could ask for it. |
| 48 | 49 | function 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) | |
| 50 | 61 | 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 | } | |
| 51 | 67 | if (!IPC) return |
| 52 | 68 | try { IPC.write(Buffer.from(line + '\n')) } catch { /* UI gone; the node keeps running */ } |
| 53 | 69 | } |
| … | … | @@ -1,4 +1,4 @@ |
| 1 | 1 | // GENERATED by scripts/gen-version.mjs — do not edit. |
| 2 | export const VERSION = "0.0.39" | |
| 3 | export const BUILT_AT = 1789496889840 | |
| 2 | export const VERSION = "0.0.40" | |
| 3 | export const BUILT_AT = 1789502687385 | |
| 4 | 4 | export default { VERSION, BUILT_AT } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface-android", |
| 3 | "version": "0.0.39", | |
| 3 | "version": "0.0.40", | |
| 4 | 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.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "private": true, |
| … | … | @@ -0,0 +1,46 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { safeStringify } from '../lib/host/safe-json.js' | |
| 3 | ||
| 4 | test('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 | ||
| 11 | test('★★★ 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 | ||
| 25 | test('⛔ 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 | ||
| 34 | test('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 | ||
| 41 | test('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 | }) |