黑屏不是一个 CSS 问题:Open Nua 如何修复会话详情页的状态快照风暴
Open Nua Engineering
会话详情页的性能问题,最初不是以一张漂亮的火焰图出现的。
它表现为黑屏。
在开发版反复验证 Vibe Trading 核心研究任务时,Desktop 主窗口一遍又一遍失去响应,只剩一块黑色画面。Electron Main、GPU、utility 和 Python bridge 还在,Agent run 有时也仍然显示 busy,但用户已经无法看到进度、取消任务或恢复会话。
更麻烦的是,它并不总能复现。一次对 2513.HK 的相同研究路径没有再次黑屏。我们当时只有 render-process-gone、exit code 5 和一个压力很大的长会话,不能因为现象像内存耗尽,就直接把根因写成 OOM。
这篇文章讲的不是“给 React 列表加虚拟化”。它讲的是我们如何把一次反复出现、又时隐时现的黑屏,逐步收敛成可机器判定的 Renderer crash;如何发现 2.2 MiB 的会话快照在几秒内制造了数 GiB 分配压力;以及为什么第一轮背压已经限制了在途字节,黑屏却仍然会回来。
第一层优化已经存在,为什么还会黑屏
Open Nua 此前已经优化过 token 级流式热路径。Canonical history 只在消息身份、顺序、tool 结构或运行终态变化时更新;纯 content delta 留在 per-thread live overlay,不再每个 token 重排完整历史,也不再通知所有 ThreadContext consumer。
这解决了“同一条消息增长,却反复重算整个会话”的问题,但长 Agent 任务还有另一种流量:完整状态快照。
一条真实执行跨越 Python Agent Bridge、Electron Main、IPC、Renderer transport、LangGraph SDK external store、ThreadContext 和 Chat view。文本 delta 是增量的;values event 却可能携带当前完整 messages state。复杂研究图在 graph node 和 middleware branch 间快速推进时,即使消息没有变化,也可能连续产生多个完整快照。
最初的稳定性修复在 Electron Main 建立了 per-run credit relay:按 UTF-8 字节记录已经发送、尚未被 Renderer 消费确认的 event;高水位为 1 MiB,低水位为 512 KiB。达到高水位后,Main 停止拉取 Python async iterator,背压沿 stdout reader 一直传回 producer。
这个设计修复了真正的无界队列,也删除了 Renderer 过载时取消 run、清空队列并丢弃 protocol event 的错误策略。工具生命周期、HITL、错误、取消和 terminal event 不再为了“保护 UI”而消失。
但 0.8 完成后,Vibe Trading 长会话又一次黑屏。
这是整次诊断最重要的转折:
限制同时有多少数据在路上,不等于限制每一条数据到达后会被放大多少次。
先把“黑屏”变成机器事实
我们没有从修改代码开始,而是先建立一个三秒内完成的临时 CDP harness。它连接 Desktop Renderer target,执行最小 DOM expression,并把用户看到的结果压成一个稳定信号:
BLACK_SCREEN_REPRO: RED {"ok":false,"reason":"renderer CDP unresponsive"}
这个信号比截图更有用。它说明不是某个深色蒙层盖住了页面,也不是 CSS 把内容画成了黑色。进一步检查发现 Electron Helper --type=renderer 已经不存在,而 Main、GPU、utility 与 Python bridge 仍在。
于是诊断从“页面看起来黑了”推进到“Renderer 已退出或无法执行”。接下来才有条件逐个证伪:
- 如果是 JavaScript busy loop,Renderer process 应仍存在并保持高 CPU;
- 如果是 GPU 黑帧,DOM 和 CDP 应继续响应;
- 如果是 Vibe Trading runtime 或 API 失败,Renderer 应显示受控错误,而不是进程消失;
- 如果是 Renderer 原生 fatal crash 或内存压力,应留下 crash journal、minidump 与异常内存时间线。
进程状态和 CDP 很快排除了前三类解释。
Renderer 的确崩了,但不能编造 native 根因
Crashpad 留下了一份约 922 KiB 的 pending minidump。本地 crash journal 记录 crashed、exit code 5,并保存了 60 个每两秒一次的安全内存样本。
在两分钟窗口里,Renderer working set 从约 5.75 GiB 上升到峰值 8.64 GiB,最后一个样本仍约 7.63 GiB。前一天的同类黑屏同样以 exit code 5 结束,峰值曾超过 11 GiB。黑屏因此不再是偶发现象,而是一个可重复的 Renderer 内存失控模式。
LLDB 只读打开 minidump 后,异常为 EXC_BREAKPOINT,位置落在 stripped Electron Framework。它足以证明这是 Renderer 内的原生 fatal trap / CHECK,而不是 React error boundary 漏掉的普通 JavaScript exception;但没有 Electron / Chromium symbols,就不能诚实地声称它具体崩在 V8、Mojo 或某个 Chromium 函数。
我们确认了 crash,也保留了未知。
业务结果只有几 MiB,数 GiB 从哪里来
下一步是把内存时间线与真实 execution 对齐。这里也必须先确认环境:本地开发 Desktop 使用 PostgreSQL,不能拿 prod-sim 的 SQLite 路径去找不存在的数据。
通过 active thread、interaction 和 execution journal 定位到现场后,数据规模是:
| 对象 | 大小或数量 |
|---|---|
持久化 thread_values | 约 114 KiB |
| 133 条唯一消息的内容 | 约 1.67 MiB |
| 一份完整序列化消息快照 | 约 2.2 MiB |
| 当前 turn 新增内容 | 不足 100 KiB |
这排除了“研究结果本身已经涨到几 GiB”。数据库累计 checkpoint 和 message writes 也只是数十 MiB。
真正异常的是时间关系。Graph steps 25–32 在约 0.19 秒内完成,多数 middleware branch checkpoint 没有写入新消息;Renderer 却在相邻四秒内又增长约 500 MiB。内存增长更接近 checkpoint 次数,而不是新增内容字节。
我们随后离线重放 34 个约 2.2 MiB 的 values snapshot,主动反证“normalizer 永久保留每份数组”的猜测。强制 GC 后,heap 只增加约 2.28 MiB,RSS 增加约 18.8 MiB;已有 50,000 content delta 压测也继续通过。
所以它不是一个简单的常驻数组泄漏,也不是此前已经优化过的 token delta 路径。真正的故障需要完整 Electron 链路共同参与。
根因不是一份大快照,而是同一份快照被层层放大
最终链路是这样的:
- LangGraph v3 在 graph 与 middleware checkpoint 产生
valuesevent;即使只改变 branch channel,也可能附带完整messagesstate。 - Python bridge 原样转发每个
values.messages,同一份约 2.2 MiB 的历史在极短时间内反复经过 Python、Main 和 Electron structured clone。 - Renderer normalizer 对每份新对象重新扫描、转换并按字节约束消息;内容相同,但 structured clone 后引用已经不同。
- LangGraph SDK
useStream没有 throttle,每个values都同步通知 React subscriber。 ThreadContext再做完整 reconciliation;LiveStreamMessageReconciler因对象是新反序列化结果,重新执行稳定序列化、结构签名和值签名。- 会话详情页虽然默认只展示最近 50 条消息,却仍会重建消息分组、tool result 索引和组件树。
单看其中任何一层,都很难得到 8 GiB。问题是 structured clone、临时字符串、全量扫描、签名、external-store notification 和 React render 在一次 checkpoint burst 中叠加,分配速度超过 GC 回收速度。
这也解释了 credit relay 为什么没有失败,却没有解决黑屏。它限制的是未确认的在途字节;而现场的核心是一条已被允许通过的完整 event,在 Renderer 内部触发的处理放大。
在 source 和 sink 两端去重
只在 React 末端加 memo 不够。2.2 MiB 快照已经完成 Python 序列化、IPC 传输和 structured clone,最昂贵的一半工作早已发生。
最终修复建立了两道防线。
第一道在 Python source。_ProtocolEventEmitter 完成 turn metadata stamping 后,对 values.messages 的受控 JSON 表示计算 SHA-256 revision:
- revision 改变时,wire payload 携带完整
messages和open_neo_messages_revision; - revision 未改变时,只从 wire payload 删除
messages,branch、todo 等其他 state 字段仍然传输; - Python 内部仍保留完整原始 state,token usage、checkpoint buffer 和其他记账逻辑不受影响。
第二道在 Renderer sink。Normalizer 优先读取 source revision,在消息转换、subagent 扫描和 snapshot bounding 之前短路重复快照。对旧 bridge、journal replay 或缺少 revision 的 event,则用消息数、序列化字符数和双滚动 hash 生成 fallback signature。
最后,useStream 增加 16 ms throttle,把突发 subscriber notification 合并到一帧。Stream manager 仍按序消费 event,Renderer ack 和 terminal 语义不变;只是 React 不再被迫观察同一帧内的每一个中间快照。
新的主路径因此变成:第一份或真正改变的完整快照校准消息状态;之后没有消息变化的 checkpoint 只传播控制字段;文本与工具内容继续走增量 message event。
黑屏恢复不能只把窗口重新载入
现场还有第二个用户症状:Renderer 已经退出,原会话却永久显示 busy。
原因是崩溃的 WebContents 不一定立刻表现为 isDestroyed()。Relay 仍然持有未确认 credit,Renderer 已不可能 ack,Main invocation loop 和 Python producer 因此一起等待。
新的 crash recovery 按固定顺序处理:
- 只选择绑定到崩溃
WebContents.id的 active run; - detach relay,释放等待者,并移除崩溃窗口;
- 将 Main-owned lifecycle 标记为
system-interrupted,再 abort Python execution; - 持久化 thread 为
interrupted、is_running=false; - finalizer 保留
renderer-crash语义,不把它覆盖成用户取消或 HITL waiting; - 仅对
crashed与oom自动 reload,避免正常退出或 integrity failure 形成重载循环。
这条路径没有假装任务成功,也没有盲目重放可能已有副作用的执行。它保留 checkpoint 与历史,把机器终态明确落为可恢复的系统中断。
先写红测,再回到那块黑屏
修复前先建立了三类会失败的测试:Python emitter 对相同 values.messages 仍重复发送;Renderer 对内容相同、引用不同的旧协议快照仍重复产生完整 values;Main 缺少“detach → system-interrupted → abort → persist”的 crash recovery 协调。
这些测试转绿后,我们没有止步于 helper。最终 Electron 压测向真实 Renderer 连续注入 100 次完整工具历史,每次约 1.9 MiB,总输入约 187.5 MiB。
结果是:Renderer 只交付第一份 values 和 terminal done,可见保留约 1.24 MiB;强制 GC 后 heap 增长约 10.09 MiB;CDP DOM expression 继续即时响应。重启现场 Desktop 后,Renderer readyState=complete,原 Vibe Trading thread 从永久 busy 变为 interrupted、is_running=false。
交付时的完整回归为 Python 971 passed, 3 skipped、Node 413 passed、Renderer/Web 543 passed,同时通过 typecheck、Ruff 和既有 stream-memory pressure。
这些数字不是为了证明“永远不会再 crash”。它们证明的是这次已观察到的放大路径被固定成回归约束,而原始两个症状——黑屏与 run 卡死——都回到了机器可验证的绿色结果。
我们从无数次黑屏中学到的六件事
1. 用户症状要先变成机器判定。 “黑屏”只有被转成 CDP、进程和 crash artifact 的稳定信号,才不会在 CSS、GPU、runtime 和 OOM 之间盲猜。
2. Backpressure 与 amplification 是两类问题。 Credit window 限制在途数据;revision dedupe 限制重复处理。只做其中一层,另一层仍可能把 Renderer 推垮。
3. 内容相同,不代表对象相同。 Structured clone 会破坏引用相等;跨进程流需要 source revision,也需要旧协议 fallback。
4. 去重要尽量靠近 source。 在 React 末端 memo,只能省下渲染,省不掉序列化、IPC、clone 和前置扫描。
5. Crash recovery 是性能修复的一部分。 如果 Renderer 崩溃后 run 永久 busy,系统就把一次性能故障升级成了状态一致性故障。
6. 不要用没有 symbols 的 minidump 讲一个过度精确的故事。 我们能证明 fatal trap、内存失控和处理放大;不能证明的 native function 仍然应该保持未知。
最后修掉的不是一张黑色页面,而是一条跨进程状态链里的错误假设:只要队列有界,Renderer 就安全。
真正可靠的会话详情页不仅要限制“有多少数据正在到达”,还要识别“这些数据是不是我们刚刚已经处理过”。