OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / lib / host / safe-json.js

69 lines · javascript ref:
// A serializer that cannot take the node down.
//
// ⛔ THE FAILURE THIS ENDS, observed on a handset 2026-09-15. `send()` in lib/node.js was
//
//     const line = JSON.stringify(obj)   // ← outside the try
//     console.log(line)
//     try { IPC.write(...) } catch { }   // ← guarded
//
// so the WRITE was protected and the SERIALIZATION was not. One payload holding a Bare timer
// (`Scheduler → _timeouts → Heap → _data → Array[0] → Timeout → _scheduler`) threw a TypeError into
// whoever called send, on a boot path, and the shell latched it and showed an error screen for ever —
// over a node that had started cleanly and went on logging for minutes behind it.
//
// ⚠ A LOGGER MUST NOT BE ABLE TO KILL WHAT IT IS LOGGING. That is true whatever the payload was, which
// is why this exists rather than a fix to one caller. The payload is a separate question and this
// answers it too: what could not be serialized is NAMED in the output, so the next occurrence says
// which field carried the timer instead of leaving it to be guessed at.
//
// ⚠ NOT A PRETTY-PRINTER. A repeated reference to the same object is reported as [circular] even when
// it is merely shared. For a log line that is the right trade: correctness about STRUCTURE matters
// less than never throwing, and a shared reference printed twice is not worth a second pass.

/**
 * `JSON.stringify` that always returns a string.
 *
 * @returns {{ line: string, degraded: boolean, dropped: string[] }}
 */
export function safeStringify (obj) {
  // The overwhelmingly common path: nothing special, no allocation, no replacer.
  try {
    const line = JSON.stringify(obj)
    if (typeof line === 'string') return { line, degraded: false, dropped: [] }
  } catch { /* fall through and find out what it was */ }

  const seen = new WeakSet()
  const dropped = []
  const note = (path, what) => { if (dropped.length < 12) dropped.push(path + ':' + what) }

  try {
    const line = JSON.stringify(obj, function (key, value) {
      if (typeof value === 'function') { note(key || '(root)', 'function'); return undefined }
      if (typeof value === 'bigint') { note(key || '(root)', 'bigint'); return String(value) }
      if (value && typeof value === 'object') {
        if (seen.has(value)) {
          note(key || '(root)', 'circular ' + ((value.constructor && value.constructor.name) || 'object'))
          return '[circular]'
        }
        seen.add(value)
      }
      return value
    })
    if (typeof line === 'string') return { line, degraded: true, dropped }
  } catch (err) {
    // ⛔ EVEN THE REPLACER CAN THROW — a getter that raises, a Proxy that refuses. There is no third
    // attempt: return something valid that says so, because the one outcome this module exists to
    // prevent is returning nothing.
    return {
      line: JSON.stringify({ t: 'log', msg: 'send: payload could not be serialized at all', error: String((err && err.message) || err) }),
      degraded: true,
      dropped
    }
  }

  // `JSON.stringify` returns undefined for a bare undefined/function/symbol at the root.
  return { line: JSON.stringify({ t: 'log', msg: 'send: payload was not serializable', type: typeof obj }), degraded: true, dropped }
}

export default { safeStringify }