18
0

给 Electron Agent 面板接 MessagePort 时,我会先定义消息信封

最近给一个 Electron 桌面端 Agent 面板补进度通道时,我一开始想让 renderer 直接订阅 worker 的事件。Demo 很快跑通,真实任务一多就暴露问题:流式 token、步骤进度、取消请求、错误、心跳和回执都挤在同一条线上,UI 偶尔会把旧任务的事件渲染到新任务卡片里,排查时也很难判断是端口断了,还是业务任务结束了。

Electron Agent 从 renderer preload main 到 worker 的 MessagePort 消息信封流程图

原创示意图:把 renderer、preload、main process 和 worker 之间的进度、取消、回执与心跳统一收束到消息信封协议。 来源:Codex image generation

问题背景

Electron 的 MessagePorts 文档说明,renderer 里的 MessagePort 行为接近 Web 标准,主进程侧则通过 MessagePortMain 和 MessageChannelMain 处理端口;普通的 send 和 invoke 不能传递 MessagePort,需要使用 postMessage。MessagePortMain 文档还提醒主进程侧使用 Node.js 的 EventEmitter 风格监听消息,也就是 port.on('message', ...)。MDN 对 Channel Messaging 的解释更像一个边界提示:两个上下文通过一对 port 通信,port 被转移后原上下文不再持有它。

这对桌面端 Agent 很关键。Agent 任务常常横跨 renderer、preload、main process 和 worker。Electron 的 Context Isolation 文档也建议通过 contextBridge 暴露窄 API,安全文档强调不要把强大的 IPC 能力直接交给不受信任的内容。换句话说,MessagePort 可以负责高频通信,但业务含义要由我们自己定义清楚。

踩坑和关键难点

第一个坑是事件形状漂移。progress 一开始只有 step,后来加了 tokens、toolName、cost 和 partialError,UI 判断越来越散。第二个坑是 run 生命周期和 port 生命周期混在一起。一个 run 可能重连端口,一个 port 也可能在页面刷新后关闭,二者不能互相代表。第三个坑是取消语义太轻。用户点取消后,如果只发一个字符串,worker 很难区分用户取消、超时取消和上层策略熔断。第四个坑是没有回执。renderer 以为事件发出就成功,main 侧队列已经拥堵时,面板仍继续显示乐观状态。

解决思路

我把 MessagePort 降到传输层,把消息信封升到契约层。每条消息都长成同一个 envelope:version、kind、runId、seq、sentAt、payload、ackFor 和 capability。kind 只允许少量联合类型,例如 hello、progress、chunk、cancel、ack、heartbeat、error 和 close。renderer 看不到原始 ipcRenderer,只调用 preload 暴露的 connectAgentRun(runId)、sendAgentCommand(envelope) 和 closeAgentRun(runId)。

main process 只做三件事:校验 envelope 版本和 runId,按 runId 路由到对应 worker port,维护 seq、ack 和 close 事件。worker 只产出业务事件,心跳固定节奏发送,取消请求必须带 reason、requestId 和是否允许清理临时文件。这样 UI 状态来自业务事件,连接健康来自心跳,端到端确认来自 ack。

我还会把 payload 分成轻量摘要和延迟详情两层。进度通道只传步骤名、百分比、工具名、错误分类和资源提示,完整日志、长文本片段和文件列表走按需查询。这样既能减少 structured clone 的压力,也能让主进程更容易做限流:同一个 run 的 chunk 可以合并,error 和 cancel 必须插队,heartbeat 不参与业务队列。

关键步骤

第一步,用 TypeScript 写一个共享协议包,renderer、preload、main 和 worker 都引用同一份类型。缺省字段全部拒绝,未知 kind 进入 protocol_error。第二步,建立端口后先握手:renderer 发 hello,main 回 ack 和协议版本,worker 进入 attached 后才开始推送进度。第三步,高频 token chunk 做合并。UI 每帧只消费最新批次,日志侧保留原始 seq,避免渲染层被流式事件拖慢。第四步,取消走命令信封。main 记录 requestId,worker 回 ack 后进入 cancelling,超时才关闭端口或杀掉任务。第五步,把 messageerror、close 和心跳超时都写成可见状态,便于判断是协议错误、连接关闭还是 worker 无响应。

可复用经验

这套做法的价值不在 MessagePort 本身,而在把跨进程通信的语义稳定下来。以后换成 WebSocket、utilityProcess、Web Worker 或本地服务,envelope 仍然能复用。我的固定检查清单是:消息有没有版本,事件有没有 runId 和 seq,取消有没有 requestId,端口关闭有没有被解释,UI 是否只依赖可确认的状态。对 Electron 里的 RAG 导入、自动化脚本执行、截图分析和长任务 Agent 面板来说,这几个约束比多写几条 IPC 通道更可靠。

这个检查也适合放进 PR 模板,避免新通道绕过协议。

主要来源

Electron Docs: MessagePorts in Electron

Electron Docs: MessagePortMain

Electron Docs: Context Isolation

MDN: Channel Messaging API

MDN: MessagePort postMessage

评论