Skip to content

RFC: 串口核心深化重构 —— 订阅式帧通道替代轮询,面向 Agent 可编程控制 #3

Description

@Gyanano

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-421src-tauri/src/serial_manager.rs:543-549)。缓冲默认 1000 条、硬钳 [100,10000],轮询晚了静默丢帧。全后端无任何串口数据事件。
  • 读线程死亡无通知:读循环遇错直接 breakis_connectedtrueserial_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.idNone

本期五项架构决策(零代码或低成本,但决定接口语义)

  1. 宿主拓扑:单一拥有者进程。serialport 独占打开(POSIX TIOCEXCL),独立 MCP 进程与 GUI 物理上不可能同时持口。MCP server 未来的形态是内嵌 GUI 进程的模块GUI 关闭时的 headless 模式——同一个核心 crate 的两个宿主,不是两个并行进程。公共 API 全 serde 化、禁止闭包进签名,跨进程化时不破语义。
  2. Frame 数据模型{session, seq, dir, t_mono_ns, t_wall, data}seq 会话内从 1 严格单调、TX/RX 同一序列(保留 transcript 交织语义);t_mono_ns 用进程级 Instant 基准,为未来多端口跨口对时序预留。事后补这些字段是 transcript 格式破坏,必须本期进。
  3. SessionEvent serde 前向兼容:内部标签 + #[serde(other)] Unknown 兜底 variant;客户端契约写明「未知事件类型必须忽略」。未来加 Reconfigured/Job 等 variant 不炸旧客户端。
  4. 错误即协议:typed error enum + 每 variant 稳定 code 字符串(不再 Result<T, String>);close 幂等close 返回前保证读线程 join(或全部 fd 关闭),禁止 sleep 启发式
  5. 同口二次 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 内,每订阅者一条有界批队列):

  1. 订阅者队列空(消费者闲)→ 单帧即推(距上次 flush ≥4ms 才即推,否则入队等窗口——堵事件风暴)
  2. 消费者忙 → 合并进 pending 批
  3. pending 触任一阈值即 flush(16ms / 64KiB / 512 帧)
  4. 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 回读暴露而非静默)。

砍掉/降级LogEntryid/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 分页)
  • MockMockPortFactory + 内存订阅者即测试接口;帧分割全分支(含跨 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.shsrc-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。

用户可感知的收益(止损路线完成后)

  1. 断连不再全局卡 200ms
  2. 拔线 ≤2s 内 UI 即知(不再假显示已连接)
  3. 日志端到端延迟最坏 ~150ms → ~51ms(分隔符帧 <5ms)
  4. 连续高速流下不再有内存无界增长隐患
  5. 重构全程有 PR 级 CI 与 golden test 兜底

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions