RFC: 串口核心深化重构 —— 订阅式帧通道替代轮询,面向 Agent 可编程控制
状态:已评审(经三路对抗式审查修正,此为 v2 定稿)
范围:RX 数据通路 + 连接生命周期 + 测试基建;不含 MCP server 本体、物理 workspace 拆分、多端口 GUI
背景与终局目标
把 GUI 的串口能力开放给 AI Agent 编程化调用,终局支持 agent loop 自调试:agent 自主开口、发激励、读响应、反应。本 RFC 是第一步:重构 RX 数据通路与连接生命周期,确立能承载第二个宿主(MCP server / CLI)的核心架构。
问题(架构摩擦)
现状(develop 分支实测行号):
- RX 唯一通路是轮询:前端每 100ms
invoke('get_logs') 全量克隆整个环形缓冲(frontend/src/App.tsx:419-421 → src-tauri/src/serial_manager.rs:543-549)。缓冲默认 1000 条、硬钳 [100,10000],轮询晚了静默丢帧。全后端无任何串口数据事件。
- 读线程死亡无通知:读循环遇错直接
break,is_connected 卡 true(serial_manager.rs:423-425)——拔线后 UI 显示已连接。
- 断连全局卡顿:
disconnect() 在持 AppState 大锁状态下固定 thread::sleep(200ms) 且不 join 读线程(main.rs:53-55 + serial_manager.rs:459),期间包括轮询在内的所有命令阻塞;且读线程持有 dup fd,退出晚于 200ms 时下一次 connect 间歇性 EBUSY(现存竞态)。
- 帧尺寸无上限:超时 flush 只在 read 超时分支检查,连续数据流下
accumulated_data 无界增长(921600 波特连续流 10 分钟 ≈ 单帧 55MB,现存隐患)。
- 巨型函数 + 复制粘贴:
connect() 330 行,帧产出代码在 4 个分支近乎复制 4 次(serial_manager.rs:229-250 / 281-302 / 337-359 / 395-417),每处内联录制写盘 + 预格式化 + 缓冲修剪。
- 格式化占据数据路径:接收时刻按当前显示设置预格式化
display_text 进每条日志(5 处构造点),改显示设置不回溯旧帧(现存缺陷),且 CPU 花在机器消费者不需要的字符串上。
- 无安全网:零 PR 级 CI(
release.yml 只在 Tag 触发且只 build 不 test);测试仅 10 个纯函数测试;rusqlite/uuid/thiserror 死依赖;LogEntry.id 恒 None。
本期五项架构决策(零代码或低成本,但决定接口语义)
- 宿主拓扑:单一拥有者进程。serialport 独占打开(POSIX
TIOCEXCL),独立 MCP 进程与 GUI 物理上不可能同时持口。MCP server 未来的形态是内嵌 GUI 进程的模块或 GUI 关闭时的 headless 模式——同一个核心 crate 的两个宿主,不是两个并行进程。公共 API 全 serde 化、禁止闭包进签名,跨进程化时不破语义。
- Frame 数据模型:
{session, seq, dir, t_mono_ns, t_wall, data}。seq 会话内从 1 严格单调、TX/RX 同一序列(保留 transcript 交织语义);t_mono_ns 用进程级 Instant 基准,为未来多端口跨口对时序预留。事后补这些字段是 transcript 格式破坏,必须本期进。
- SessionEvent serde 前向兼容:内部标签 +
#[serde(other)] Unknown 兜底 variant;客户端契约写明「未知事件类型必须忽略」。未来加 Reconfigured/Job 等 variant 不炸旧客户端。
- 错误即协议:typed error enum + 每 variant 稳定
code 字符串(不再 Result<T, String>);close 幂等;close 返回前保证读线程 join(或全部 fd 关闭),禁止 sleep 启发式。
- 同口二次 connect 语义写死为
Attached:返回既有会话句柄 + SessionInfo{owned_by},废除现行「隐式先杀旧连接」(serial_manager.rs:117-119)——单客户端时代的便利是多客户端时代的毒。
提议的接口(混合方案 v2)
形状:极简 Session 句柄(A 派);机制:Nagle 合批 + dropped_before(C 派);结构:合批/背压收在核心、传输决策归宿主(D 派);迁移:四步+Step 0(B/C 派)。
pub type SessionId = u64; // 每次 connect 分配,单调递增
pub type Seq = u64; // 会话内从 1 严格递增;TX/RX 同一序列
pub struct Frame {
pub session: SessionId,
pub seq: Seq,
pub dir: Direction, // Sent | Received
pub t_mono_ns: u64, // 进程级单调时钟(跨口对时序预留)
pub t_wall: DateTime<Utc>,
pub data: Arc<[u8]>, // 扇出零拷贝;硬上限 max_frame_bytes(默认 64KiB)切块
}
pub struct FrameBatch {
pub session: SessionId,
pub first_seq: Seq,
pub dropped_before: u64, // 自上一批以来本订阅者被丢的帧数(drop-oldest 只产生前缀缺口)
pub frames: Vec<Frame>,
}
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SessionEvent {
Connected { session: SessionId, port: String },
Lost { session: SessionId, reason: String }, // 读线程死亡,修 is_connected 卡 true
Closed { session: SessionId },
#[serde(other)] Unknown,
}
impl Session { // Arc 句柄,Clone
pub fn send(&self, data: &[u8]) -> Result<Seq>;
pub fn subscribe(&self, policy: BatchPolicy) -> Subscription; // 可多次:GUI/录制/agent 各一份
pub fn configure(&self, patch: SessionPatch) -> Result<SessionStatus>;
pub fn frames_since(&self, after: Seq, limit: usize) -> TranscriptPage; // 快照/分页/导出
pub fn tail(&self, n: usize) -> Vec<Frame>;
pub fn events(&self) -> EventStream;
pub fn close(&self) -> Result<()>; // 幂等;shutdown flag + bounded join
}
pub struct SessionPatch { // 全 Option,None = 不动
pub framing: Option<Framing>,
pub recording: Option<RecordingConfig>,
pub port: Option<PortParamsPatch>, // 【预留】就地重配(termios/SetCommState);
// v1 返回 Unsupported——baud hunting 场景的接口形状先锁定
}
pub struct BatchPolicy { max_delay, max_bytes, max_frames, queue_batches }
// 预设:GUI_DEFAULT(16ms/64KiB/512) · LOW_LATENCY(即推) · BULK(250ms/256KiB)
Nagle 冲刷规则(FrameBus 内,每订阅者一条有界批队列):
- 订阅者队列空(消费者闲)→ 单帧即推(距上次 flush ≥4ms 才即推,否则入队等窗口——堵事件风暴)
- 消费者忙 → 合并进 pending 批
- pending 触任一阈值即 flush(16ms / 64KiB / 512 帧)
- flush 时队列满 → 整批 drop-oldest +
dropped_before 累加;读线程不做阻塞 I/O(锁等待 µs 级有界)
使用示例
GUI(Tauri 桥接层):subscribe(GUI_DEFAULT) → pump 线程逐批做显示装饰 → app.emit("serial://frames");前端 useSerialLogs hook:listen 增量 append(rAF 节流 + key=seq + trim 镜像上限),get_logs_snapshot 初始对齐,dropped_before > 0 时插入「丢失 N 帧」占位行;删 100ms 轮询。
Agent(未来 MCP 内嵌宿主):同一份 Rust API——subscribe(LOW_LATENCY) 逐帧反应,或 frames_since(after) 分页拉取;Lost/dropped_before 使「丢帧/断口」对 agent 显式可见,不再空等。
隐藏了什么:帧分割状态机(4 处复制收敛为 1 个 FrameSegmenter 纯函数组件)、合批/背压/扇出、读线程生命周期(spawn/join/Lost 广播)、录制文件管理、macOS 端口净化、POSIX 能力降级(Mark/Space→None 等,经 effective-config 回读暴露而非静默)。
砍掉/降级:LogEntry(id/display_text/timestamp_formatted 消亡);get_logs 全量轮询(由快照 + 订阅取代,export_logs 改走 Transcript);热路径 DTO 不携带原始字节数组({seq, dir, len, display_text, ts_formatted},字节按需 frames_since 取,JSON 体积降 70-80%);显示设置命令下沉为桥接层 per-subscription 装饰;rusqlite/uuid 死依赖删除。
性能契约(对抗审查后的修正口径)
| 约束 |
承诺 |
依据 |
| 低频延迟 |
分隔符帧 <5ms(帧尾字节→前端可见);超时帧端到端 50ms+ε(帧闭合受 50ms 读超时量子化,收益是消除 0100ms 轮询尾段) |
最坏 ~150ms → ~51ms(3×),不虚报 10× |
| 高频有界 |
稳态合批率 ≤62.5/s;事件率上界 = max(62.5, 帧到达率),小帧洪峰由 512 帧阈值 + 4ms 最小间隔兜底 |
审查 M1 修正 |
| 内存有界 |
max_frame_bytes(默认 64KiB)硬切块后公式闭合:Σ订阅者(帧数据+装饰字符串双配额) + Transcript(10k 帧/8MiB 双界) + 在途批;注明 Arc 共享语义与前端项 |
审查 B2/M5 修正 |
| 丢帧可检测 |
seq 前缀缺口 + dropped_before 计数;raw 录制挂分帧前 tap(保真优先,不参与帧流背压) |
审查 M4 修正 |
依赖策略
- 进程内:serde/chrono/thiserror/log 直接用;
FrameSegmenter、Nagle 判定、seq 算术为纯逻辑,直接单测
- 本地可替代:串口硬件经
PortFactory trait 隔离——生产 SystemPortFactory,测试 MockPortFactory(内存管道对),集成用 socat PTY 对;dirs 从核心移除(录制目录注入)
- 端口与适配器:核心只认
Session/Subscription;Tauri 桥接与未来 MCP 内嵌模块是平级宿主;不立 FrameSink trait(应用 YAGNI 判据:真正的端口就是 Subscription 接收端 + Transcript 分页)
- Mock:
MockPortFactory + 内存订阅者即测试接口;帧分割全分支(含跨 read \r\n 拆帧等现状怪癖)用 golden test 钉住
测试策略(替代,而非叠加)
- 新增边界测试:
FrameSegmenter 全分支 golden test;Nagle 冲刷判定;dropped_before 精确对账;慢订阅者 drop-oldest 后 seq gap 与计数一致;连接/断开/拔线生命周期(bounded join ≤ 读超时+ε,Lost 事件广播)
- 删除/取代:无既有浅层测试受冲击(现有 11 个纯函数测试平移保留)
- 测试环境:
MockPortFactory(无硬件 CI)+ socat -d -d pty,raw,echo=0 pty,raw,echo=0(macOS/Linux 集成冒烟);Step 0 先补 PR 级 CI,否则后续每步「可回滚、零回归」无从验证
实施序列(每步独立 PR、可回滚,附验收标准)
| 步骤 |
内容 |
验收标准 |
估算 |
| 0. 止损前置 |
PR 级 CI(cargo test + 前端 build);删 rusqlite/uuid/thiserror 与 LogEntry.id |
CI 绿,既有 11 测试首次在 CI 运行 |
0.5d |
| 1. 钉行为 |
抽 FrameSegmenter 纯状态机;golden test 钉住现状(含怪癖:跨 read \r\n 拆帧、Ok(0)/TimedOut 双分支、timeout clamp);PortFactory 构造注入 |
4 个产出点全部分支有测试;手工冒烟连/发/收/断/录制无变化 |
2–3d |
| 2. 读循环瘦身 |
用 Segmenter 替换 4 处复制;raw 录制保持分帧前 tap;max_frame_bytes 硬切块 |
golden test 全绿;屏幕输出与录制文件与改动前逐字节一致 |
1–1.5d |
| 3. 生命周期修复 |
bounded join 换 200ms sleep 且移出大锁;读线程死亡 → Lost 事件 + is_connected=false;修 close→reopen EBUSY 竞态 |
断连时其他命令不再卡 200ms;拔线 ≤2s UI 显示断开;连断 100 次压力脚本无死锁 |
1–1.5d |
| 4. 事件推送 |
FrameBus + Nagle 合批 + serial://frames 桥接 + 快照命令;前端 useSerialLogs(增量 append / rAF 节流 / key=seq / trim 镜像 / clear 带 epoch 防快照复活);双写存续期 + localStorage 回滚开关 |
seq 校验脚本 10s 狂发无丢无重;clear 后快照不复活;搜索/自动滚底/字节计数与轮询版逐条人工对照一致 |
3–4d |
止损路线合计 9–11 个工作日(全时单人,含 20% 缓冲),拿到原方案约 80% 收益。全量口径(物理拆 crate + Session 化全量 + 录制订阅化)17–24 个工作日,推迟到 MCP 宿主真实开工。
实施建议(持久的架构指导)
- 模块职责:核心拥有串口 I/O、帧分割、定序、合批扇出、生命周期;宿主(Tauri/MCP)只拥有传输与展示。
- 隐藏:读线程、通道拓扑、背压机制、平台差异永不漏出签名;公共类型不出现 serialport/tauri/tokio 类型。
- 暴露契约:seq 单调、丢帧可检测(
dropped_before + 前缀缺口)、错误 typed 且 code 稳定、配置回读永远返回 effective(POSIX 降级不得撒谎)。
- 迁移纪律:每步独立 PR;双写存续到 Step 4 合并且过一个版本;
get_logs 删除前确认 export_logs/clear_logs/上限逻辑已改走 Transcript。
推迟条款(写明触发条件,不是「以后再说」)
- 物理 workspace 拆分(crates/serial-core):推迟到第二个宿主真实开工;届时先适配发版门禁正则(
scripts/ci-release-gate.sh 从 src-tauri/Cargo.toml 抓版本号)与 rust-cache 路径。本期先在模块级立边界(核心模块禁止 tauri:: import)。
- per-subscription framing:触发条件 = 「第二个有不同 framing 需求的客户端真实同时在线」(即 MCP 内嵌、GUI+agent 双在线时)。届时总线改跑带字节偏移的原始块,framing 下沉为每订阅者视图。本期已做三个零成本预留:Segmenter 纯状态机可每订阅者实例化;
SubscribeOptions 保持开放结构(serde 默认值保证加字段非破坏);Transcript 存完整帧(拼接即原始字节流,可重建字节视图)。
- SendProgram(批量/循环/定时发送编排)与 checksum 进核心:另一候选单独立项;本期只声明演进形状「
send 即单步程序的糖」并预留 SessionEvent::Job。注意现存 bug:Text 模式校验和对 UTF-8 字节计算、线上字节却是 GBK 编码,校验和与线上一致性需在送核时一并修。
- 明确不做:async/tokio 化(桥接契约:pump 线程 + 带超时的阻塞 recv,drop receiver 即退订)、订阅过滤器(
dir 字段落地后加过滤是纯增量)、日志持久化 DB(rusqlite 化石已证上次烂尾)、多端口 GUI。
用户可感知的收益(止损路线完成后)
- 断连不再全局卡 200ms
- 拔线 ≤2s 内 UI 即知(不再假显示已连接)
- 日志端到端延迟最坏 ~150ms → ~51ms(分隔符帧 <5ms)
- 连续高速流下不再有内存无界增长隐患
- 重构全程有 PR 级 CI 与 golden test 兜底
RFC: 串口核心深化重构 —— 订阅式帧通道替代轮询,面向 Agent 可编程控制
背景与终局目标
把 GUI 的串口能力开放给 AI Agent 编程化调用,终局支持 agent loop 自调试:agent 自主开口、发激励、读响应、反应。本 RFC 是第一步:重构 RX 数据通路与连接生命周期,确立能承载第二个宿主(MCP server / CLI)的核心架构。
问题(架构摩擦)
现状(develop 分支实测行号):
invoke('get_logs')全量克隆整个环形缓冲(frontend/src/App.tsx:419-421→src-tauri/src/serial_manager.rs:543-549)。缓冲默认 1000 条、硬钳 [100,10000],轮询晚了静默丢帧。全后端无任何串口数据事件。break,is_connected卡true(serial_manager.rs:423-425)——拔线后 UI 显示已连接。disconnect()在持AppState大锁状态下固定thread::sleep(200ms)且不 join 读线程(main.rs:53-55+serial_manager.rs:459),期间包括轮询在内的所有命令阻塞;且读线程持有 dup fd,退出晚于 200ms 时下一次connect间歇性 EBUSY(现存竞态)。accumulated_data无界增长(921600 波特连续流 10 分钟 ≈ 单帧 55MB,现存隐患)。connect()330 行,帧产出代码在 4 个分支近乎复制 4 次(serial_manager.rs:229-250 / 281-302 / 337-359 / 395-417),每处内联录制写盘 + 预格式化 + 缓冲修剪。display_text进每条日志(5 处构造点),改显示设置不回溯旧帧(现存缺陷),且 CPU 花在机器消费者不需要的字符串上。release.yml只在 Tag 触发且只 build 不 test);测试仅 10 个纯函数测试;rusqlite/uuid/thiserror死依赖;LogEntry.id恒None。本期五项架构决策(零代码或低成本,但决定接口语义)
TIOCEXCL),独立 MCP 进程与 GUI 物理上不可能同时持口。MCP server 未来的形态是内嵌 GUI 进程的模块或 GUI 关闭时的 headless 模式——同一个核心 crate 的两个宿主,不是两个并行进程。公共 API 全 serde 化、禁止闭包进签名,跨进程化时不破语义。{session, seq, dir, t_mono_ns, t_wall, data}。seq会话内从 1 严格单调、TX/RX 同一序列(保留 transcript 交织语义);t_mono_ns用进程级 Instant 基准,为未来多端口跨口对时序预留。事后补这些字段是 transcript 格式破坏,必须本期进。#[serde(other)] Unknown兜底 variant;客户端契约写明「未知事件类型必须忽略」。未来加Reconfigured/Job等 variant 不炸旧客户端。code字符串(不再Result<T, String>);close幂等;close返回前保证读线程 join(或全部 fd 关闭),禁止 sleep 启发式。Attached:返回既有会话句柄 +SessionInfo{owned_by},废除现行「隐式先杀旧连接」(serial_manager.rs:117-119)——单客户端时代的便利是多客户端时代的毒。提议的接口(混合方案 v2)
形状:极简 Session 句柄(A 派);机制:Nagle 合批 +
dropped_before(C 派);结构:合批/背压收在核心、传输决策归宿主(D 派);迁移:四步+Step 0(B/C 派)。Nagle 冲刷规则(FrameBus 内,每订阅者一条有界批队列):
dropped_before累加;读线程不做阻塞 I/O(锁等待 µs 级有界)使用示例
GUI(Tauri 桥接层):
subscribe(GUI_DEFAULT)→ pump 线程逐批做显示装饰 →app.emit("serial://frames");前端useSerialLogshook:listen 增量 append(rAF 节流 +key=seq+ trim 镜像上限),get_logs_snapshot初始对齐,dropped_before > 0时插入「丢失 N 帧」占位行;删 100ms 轮询。Agent(未来 MCP 内嵌宿主):同一份 Rust API——
subscribe(LOW_LATENCY)逐帧反应,或frames_since(after)分页拉取;Lost/dropped_before使「丢帧/断口」对 agent 显式可见,不再空等。隐藏了什么:帧分割状态机(4 处复制收敛为 1 个
FrameSegmenter纯函数组件)、合批/背压/扇出、读线程生命周期(spawn/join/Lost 广播)、录制文件管理、macOS 端口净化、POSIX 能力降级(Mark/Space→None 等,经 effective-config 回读暴露而非静默)。砍掉/降级:
LogEntry(id/display_text/timestamp_formatted消亡);get_logs全量轮询(由快照 + 订阅取代,export_logs改走 Transcript);热路径 DTO 不携带原始字节数组({seq, dir, len, display_text, ts_formatted},字节按需frames_since取,JSON 体积降 70-80%);显示设置命令下沉为桥接层 per-subscription 装饰;rusqlite/uuid死依赖删除。性能契约(对抗审查后的修正口径)
50ms+ε(帧闭合受 50ms 读超时量子化,收益是消除 0100ms 轮询尾段)max_frame_bytes(默认 64KiB)硬切块后公式闭合:Σ订阅者(帧数据+装饰字符串双配额) + Transcript(10k 帧/8MiB 双界) + 在途批;注明 Arc 共享语义与前端项dropped_before计数;raw 录制挂分帧前 tap(保真优先,不参与帧流背压)依赖策略
FrameSegmenter、Nagle 判定、seq 算术为纯逻辑,直接单测PortFactorytrait 隔离——生产SystemPortFactory,测试MockPortFactory(内存管道对),集成用socatPTY 对;dirs从核心移除(录制目录注入)Session/Subscription;Tauri 桥接与未来 MCP 内嵌模块是平级宿主;不立FrameSinktrait(应用 YAGNI 判据:真正的端口就是 Subscription 接收端 + Transcript 分页)MockPortFactory+ 内存订阅者即测试接口;帧分割全分支(含跨 read\r\n拆帧等现状怪癖)用 golden test 钉住测试策略(替代,而非叠加)
FrameSegmenter全分支 golden test;Nagle 冲刷判定;dropped_before精确对账;慢订阅者 drop-oldest 后 seq gap 与计数一致;连接/断开/拔线生命周期(bounded join ≤ 读超时+ε,Lost 事件广播)MockPortFactory(无硬件 CI)+socat -d -d pty,raw,echo=0 pty,raw,echo=0(macOS/Linux 集成冒烟);Step 0 先补 PR 级 CI,否则后续每步「可回滚、零回归」无从验证实施序列(每步独立 PR、可回滚,附验收标准)
LogEntry.idFrameSegmenter纯状态机;golden test 钉住现状(含怪癖:跨 read\r\n拆帧、Ok(0)/TimedOut双分支、timeout clamp);PortFactory构造注入max_frame_bytes硬切块is_connected=false;修 close→reopen EBUSY 竞态serial://frames桥接 + 快照命令;前端useSerialLogs(增量 append / rAF 节流 /key=seq/ trim 镜像 / clear 带 epoch 防快照复活);双写存续期 + localStorage 回滚开关止损路线合计 9–11 个工作日(全时单人,含 20% 缓冲),拿到原方案约 80% 收益。全量口径(物理拆 crate + Session 化全量 + 录制订阅化)17–24 个工作日,推迟到 MCP 宿主真实开工。
实施建议(持久的架构指导)
dropped_before+ 前缀缺口)、错误 typed 且 code 稳定、配置回读永远返回 effective(POSIX 降级不得撒谎)。get_logs删除前确认export_logs/clear_logs/上限逻辑已改走 Transcript。推迟条款(写明触发条件,不是「以后再说」)
scripts/ci-release-gate.sh从src-tauri/Cargo.toml抓版本号)与 rust-cache 路径。本期先在模块级立边界(核心模块禁止tauri::import)。SubscribeOptions保持开放结构(serde 默认值保证加字段非破坏);Transcript 存完整帧(拼接即原始字节流,可重建字节视图)。send即单步程序的糖」并预留SessionEvent::Job。注意现存 bug:Text 模式校验和对 UTF-8 字节计算、线上字节却是 GBK 编码,校验和与线上一致性需在送核时一并修。dir字段落地后加过滤是纯增量)、日志持久化 DB(rusqlite 化石已证上次烂尾)、多端口 GUI。用户可感知的收益(止损路线完成后)