// 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 }