Serial Studio 连接判定统一化(Spec 0050):一次连接尝试只产生一个可观察结果,无定时器、无实时编辑抖动
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
Serial Studio 的开源遥测仪表盘支持 UART、BLE、MQTT、Modbus、CAN Bus、网络 TCP 等多种总线,而连接管理层的复杂度曾让"连接结果没有单一所有者"成为根因级缺陷。本文基于仓库 spec.md(及同目录 plan.md、tasks.md),完整讲解"一次尝试、一个判定(one verdict)、无定时器、无实时编辑抖动"的设计目标、七项需求、八项验收标准,并结合 HAL_Driver.h、ConnectionManager.cpp、test_connection_verdicts.py 与 code-verify.py 等源码,深入剖析 openFinished 信号 + 闩锁机制、轮询扫描的退役与 setter 守卫 lint 规则的实际落地。读完本文,你将理解 Serial Studio 如何保证每一次连接尝试都以恰好一个用户可见结果收尾、实时连接如何免疫配置抖动,以及如何用可验证的测试与静态检查守住这一不变量。
一、问题背景:连接处理的组合式退化
2026-08-10 的连接事后复盘(基于 v3.2.7 与工作树调用图对比)发现,连接处理是"组合式退化"的:重拨(dial retries)、看门狗(watchdogs)、DNS 门控、配置编辑自动重开(auto-reopen on config edits)、判定扫描(verdict sweeps)这些机制单独看都合理,但它们各自订阅了彼此发出的通知,形成反馈循环,任何单次修改都无法让评审者看清全貌。实测后果包括:
- TCP telnet 会话每隔几秒重启一次,直到远程服务将该用户 IP 封禁 30 天;
- 健康的链路被一次过期的对端有效性读取(stale peer-validity read)误拆;
- 重复的辅助进程争抢同一端口;
- 演示项目需要点击两次连接,因为拨号与辅助进程的 bind 存在竞态。
更深层的缺陷在于:一次连接尝试的结果没有单一所有者。同步驱动器通过 open 调用的返回值报告结果;异步驱动器通过轮询扫描报告成功,而失败——在五个驱动器中——"报告给没有人"。一次失败的蓝牙或 Modbus 拨号可能让应用永远停留在"connecting"状态。与此同时,在链路存活期间编辑连接设置会喂给一个"重新应用(re-apply)织体",当月已两次导致健康连接被弹掉。
该修复必须在 2026-08-11 的演示(演示将覆盖 UART、CAN、BLE 硬件、脚本启动辅助进程的 Network TCP、带模拟器的 Modbus TCP 每一条连接路径)上达到"不能在台上丢脸"的质量。
二、设计目标与非目标
2.1 目标(Goals)
- 每次连接尝试都以恰好一个可观察结果结束——connected,或一条清晰的失败通知——无论尝试以何种方式失败、在何种总线上。
- 后台没有任何东西可以打开、关闭或重拨连接。只有用户、真实的链路错误,或显式的应用级重建(项目结构 / 许可证变更)可以。
- 存活连接对配置抖动免疫:重新应用、持久化或回显相同设置,永远不会触碰它。
- 连接状态下不能编辑连接设置(BLE 连接后的 service/characteristic 选择器除外),从而从入口上杜绝不稳定的编辑路径。
- 脚本启动的演示项目在第一次点击时即可连接成功,且恰好只产生一个辅助进程。
2.2 非目标(Non-Goals)
- 不把 BLE、MQTT 或 Modbus 握手改为同步(平台 API 无法阻塞,其握手确实需要数秒)。
- 不改变 CAN 在总线错误时"报告但保持连接"的策略。
- 不删除 UART 的 opt-in 自动重连复选框(2026-08-10 明确保留)。
- 不改变 MQTT 发布器子系统及其重连策略。
- 不重新设计外部 API 命令面。
- 2026-08-11 演示之前不落地任何实现(见约束)。
三、七项需求详解(R1–R7)
R1 — 每次尝试只有一个结果
连接到一个不可达或拒绝连接的端点时,任何总线都应在有界且已声明的时间内,产生恰好一条用户可见的失败通知;此后应用完全处于断开状态(按钮、状态 API、诊断全部一致)。不得出现第二个弹窗,通知之后也不得继续存在后台尝试。
从源码看,这一需求由ConnectionManager::onDriverOpenFinished(bool ok, const QString& reason)(ConnectionManager.cpp)兑现:它通过sender()反向解析设备 id,从 pending 集合中移除该 id,转发给既有的onDeviceOpenFinished(id, ok, reason)(诊断 + 收尾 + 通知),失败时静默关闭设备且不发sessionClosed。其语义与临时补丁路径一致,但去掉了对"仅仅拨号失败"场景的"device dropped"误导性日志。
R2 — 不允许卡死的 "connecting"
所有异步拨号总线(Bluetooth LE、Modbus TCP、MQTT、Process)都必须报告失败尝试:connecting 指示器始终能解析为 connected 或 disconnected,在尝试实际死亡后永远不能无限期持续。
R3 — 连接期间锁定编辑
当任一设备已连接时,每条总线的所有连接配置控件都被禁用,唯有蓝牙 LE 的 service/characteristic 选择器在连接后仍可使用。断开连接会重新启用它们。由于编辑不可能发生,也就不存在"对存活链路自动重新应用编辑"。
R4 — 存活链路对抖动免疫
连接建立后(包括 hostname 寻址的 TCP 链路),后台活动——设置持久化、项目自动保存、相同值重新应用、DNS 解析完成——永不使其断开、重开或重启。telnet 风格会话必须无打扰地保持 30 分钟以上。对应验收 AC4 的 soak 测试:hostname 寻址的本地服务器保持 30 分钟且项目自动保存开启,帧计数器在窗口内严格单调递增,日志中零重连。
R5 — 幂等的设置应用
重新应用与当前值完全相同的值必须是完全无操作:不触发 DNS 查找、不产生 undo 历史条目、不自动保存、不产生通知级联。对应验收 AC5:通过 API 十次重新应用相同连接设置,断言无新 undo 条目、无自动保存写入、无重连,且(hostname 场景)无额外 DNS 查找可观察。
R6 — 首次点击即可连接的演示
每个内置的脚本启动示例(Dual Drone Telemetry、Modbus PLC Simulator 等)在冷启动后的第一次连接点击即成功连接,且无论用户重连多少次,每个项目恰好运行一个辅助进程。
R7 — 反复循环不留残留
任何总线上连续 20 次连接/断开循环都不留下卡死状态:无残留 "connecting"、无重复辅助进程、无孤儿拨号机制,第 21 次连接表现得像第 1 次。
四、验收标准(AC1–AC8)
spec 以验收标准把需求钉死,仓库中绝大多数已被勾选为完成([x]):
| 标准 | 对应需求 | 验证方式与断言 |
|---|---|---|
| AC1 | R1 | pytest 集成:对本地关闭端口发起 TCP 连接,状态 API 在有界时间内落到 disconnected,且恰好浮出一条错误;Modbus TCP 对关闭端口重复验证 |
| AC2 | R2 | pytest 集成:对死端点发起 Modbus TCP 与 Network 拨号,断言 connecting 标志在有界时间内回落;阶段硬件手动检查:BLE 拨号已断电外设解析为 disconnected 且一条通知 |
| AC3 | R3 | 手动清单、所有总线:设备连接时每个连接设置控件被禁用;BLE service/characteristic 选择器保持可用;断开后全部恢复 |
| AC4 | R4 | soak:hostname 寻址本地服务器保持 30 分钟且自动保存开启;帧计数器严格单调;日志零重连 |
| AC5 | R5 | pytest:通过 API 十次重新应用相同连接设置;断言无新 undo、无自动保存、无重连、无额外 DNS 查找 |
| AC6 | R6 | 脚本化:冷启动后逐个打开脚本启动示例并连接一次;第一次点击即有数据;三次重连后进程表显示每项目恰好一个辅助进程 |
| AC7 | R7 | pytest:每个可脚本化总线(Network TCP、Modbus TCP、Process)20 次连接/断开循环;按状态 API 最终状态与新鲜状态一致 |
| AC8 | 结构性 | 仓库 lint 器强制每个驱动器配置 setter 具备同值守卫,且检查全树通过 |
五、实现方案:openFinished 信号 + 闩锁,轮询扫描退役
5.1 核心思路:给尝试结果一个单一的推式所有者
计划的总体方案一句话概括:给连接尝试的结果一个单一的推式(push-based)所有者——新的HAL_Driver::openFinished(bool ok, QString reason)信号,通过受保护的闩锁辅助函数每次打开尝试恰好发射一次,取代settlePendingDialVerdicts()轮询扫描。
- 同步驱动器从不发射它——它们的
open()返回值就是判定,不变; - 四个异步驱动器(BluetoothLE、Modbus、MQTT、Process)与半异步的 CANBus 通过同一边沿报告成功与失败;
ConnectionManager在一个槽中消费它:结算 pending 尝试、发布状态、在拨号失败时静默关闭设备(退役各驱动器临时补丁中的disconnectDevice(this)调用——已建立链路的掉落路径仍保留这些调用)。
5.2 基类闩锁:emit-exactly-once 的强制机制
方案对比时曾考虑过"每驱动器自行自律"与"基类闩锁"两种 emit-once 方案,最终选择在 HAL_Driver 基类中实现闩锁——一个机制、不可作弊;"每驱动器自律"恰恰是之前判定被搁浅的原因。
core/Core/IO/HAL_Driver.h 中的实际实现:
- 信号:
signals: void openFinished(bool ok, const QString& reason); - 闩锁状态:
bool m_openReportArmed(普通 bool,无定时器); - 公开接口:
armOpenReport()/disarmOpenReport()/openReportArmed(); - 受保护的发射函数:
void reportOpenFinished(bool ok, const QString& reason = QString()) { if (!m_openReportArmed) return; m_openReportArmed = false; Q_EMIT openFinished(ok, reason); }闩锁语义:连接管理器在open()之前armOpenReport();驱动器在成功和失败两个结果上调用reportOpenFinished();首次报告即解除闩锁(emit-exactly-once),之后的已建立链路掉落事件不可能伪装成拨号判定;同步结算与用户取消时也会解除闩锁,因此同步驱动器永远不可能发射迟到的判定。时序上,connectDevice(int)先armOpenReport()再调用DeviceManager::open();同步驱动器返回后管理器在!isConnecting()时立即清除闩锁。
onDriverOpenFinished中的守卫SS_ASSERT_LOG(!halDriver->openReportArmed())(ConnectionManager.cpp)进一步断言:到达该槽时闩锁必然已解除,从构造上杜绝双发射。
5.3 ConnectionManager:结算、静默拆除与扫掠删除
- ConnectionManager.h 新增私有槽
void onDriverOpenFinished(bool ok, const QString& reason); - 删除
settlePendingDialVerdicts()及其在notifyConnectedStateChanged()内的调用(tasks T2 验证手段:grep 证明零残留引用); - 失败拨号的拆除集中在管理器:静默关闭、绝不发
sessionClosed(辅助进程在失败拨号后存活); - 关键不变量:
rebuildDevices()必须在销毁前断开"注定消亡"驱动器的信号,保证迟到的判定无法到达(既有模式,实现清单中保留该顺序); refreshConnectedState()(queued)保留:它发布连接状态转换,只是不再兼任判定扫描。
判定路径的完整数据流(主线程,仅主线程):connectDevice(int)→armOpenReport()→open()→(同步:返回值即判定;异步:状态机槽调用reportOpenFinished())→ 闩锁解除并发射openFinished→onDriverOpenFinished解析 id、takePendingDial(deviceId)、失败时disconnectDevice(deviceId)、转发onDeviceOpenFinished→ 诊断 + 收尾 + 通知。
5.4 各驱动器的报告路径
tasks 文档(tasks.md)将实现拆为 12 个可独立评审的小任务,其中 T3–T7 逐一改造异步驱动器。仓库源码中已可确认的实际报告点:
| 驱动器 | 成功报告 | 失败报告 |
|---|---|---|
| BluetoothLE(BluetoothLE.cpp) | announceGattReady()→reportOpenFinished(true)(L546) | onControllerError()→reportOpenFinished(false, reason)(L377);pre-readydisconnected→reportOpenFinished(false, ...)(L339);onStateChanged中的 BLE service error(L935) |
| Modbus(Modbus.cpp) | onStateChanged(Connected)→reportOpenFinished(true)(L1191) | failDial()→reportOpenFinished(false, error)(L484) |
| MQTT(MQTT.cpp) | onStateChanged(Connected)→reportOpenFinished(true)(L1135) | 拨号窗口onErrorChanged()→reportOpenFinished(false, reason)(L146);broker 在尝试期间关闭连接(L1140);onStateChanged错误消息(L1231) |
| Process(Process.cpp) | 启动模式QProcess::started→reportOpenFinished(true)(L273);管道模式 peer-attach 编组槽 →reportOpenFinished(true)(L667) | FailedToStart/启动期早退 →reportOpenFinished(false, ...)(L632/L637);未连接时的管道错误(L619/L654/L676) |
| CANBus(CANBus.cpp) | 插件处于 ConnectingState 时onStateChanged(Connected)且闩锁 armed →reportOpenFinished(true)(L960) | onStateChanged(Unconnected)且 armed →reportOpenFinished(false, errorString)(L964) |
两个重要的实现约束:Process 的管道线程只负责编组对端事件到主线程,reportOpenFinished只能从这些编组后的槽调用,绝不能在管道线程上发射;gs_usb 路径同步、从不报告(闩锁在 open 返回结算时已被管理器清除)。
架构文档 doc/claude/architecture/io.md 总结了最终所有权模型:每次尝试的判定有且只有一个所有者——同步驱动器的open()返回值、异步驱动器的openFinished信号(每次尝试恰好发射一次)。该文档明确写入禁令:绝不重新添加"稍后检查 isOpen()"式的结算路径;"异步拨号且不报告两个结果的驱动器会卡死连接按钮——这正是本设计要消灭的 bug 类别"。
5.5 UART 同值守卫与 setter-guard lint(AC8)
UART 的setPortIndex是当时唯一的活违规项,在同一变更中修复(tasks T8)。UART.cpp 中可见:
const quint8 clamped = (portIndex < portList().count()) ? portIndex : 0; if (portIndex == m_portIndex && clamped == m_portIndex) return; // 同值早退:重新应用不能触发任何发射该守卫保留自动重连的setPortIndex + connectDevice()流程(显式连接不受跳过的发射影响)。
AC8 落地为 scripts/code-verify.py 中的driver-setter-guard规则,错误级、限定作用于app/src/IO/Drivers/*.cpp(当前仓库路径为 core/Devices/IO/Drivers/):具体驱动器中带标量/QString 参数的set*方法,其函数体必须包含同值早退(m_\w+ [=!]= ...比较)或isOpen()门控,否则报错——"settings sync fabric replays identical values, and an unguarded setter turns that echo into lookups/emits (spec 0050; the telehack reconnect loop)"。规则豁免setDriverProperty分发重写(它们扇出到具体的、已被守卫的 setter),因为缺少守卫正是"telehack 重连循环"的引擎,advisory 级会让下一个驱动器带着基线债务上线。
5.6 实时文本应用:撤销过时的防御装甲
2026-08-10 维护者追加(plan.md QML/UI 节):驱动器文本框此前只在editingFinished(回车或焦点丢失)时提交——这是"重开时代"的刻意防御,因为当时每次击键都会重启 DNS 并重拨链路。如今重开机制已删除、连接时设置已锁定,该防御过时:驱动设置面板的文本框改为在onTextEdited时应用(该信号只响应用户输入——onTextChanged会与每个面板Connections处理程序执行的程序化写回形成循环)。涉及表面:SetupPanes/Drivers/Network.qml的地址字段,以及按同一editingFinished提交模式 grep 到的其他驱动面板(Modbus host、MQTT hostname/topic、Process 可执行文件/参数、UART 自定义设备)。逐击键的 hostname DNS 查找现在已无害(查找下游没有任何东西能触碰连接),无需防抖。
六、约束与不变量
- 演示冻结:2026-08-11 演示结束前不落地任何实现;当夜仅对当前工作树按演示的精确流程做验证。
- 不得回归 256 kHz 热路径门:连接层改动不得触碰帧路径。
- 组合根构造顺序与单例普查不变。
- 会话语义保留:辅助进程仅在用户(或 API 客户端 / 播放器接管)结束会话时被回收——绝不在驱动器掉落或重建抖动时回收。这对应架构文档 io.md 中
sessionClosed语义:它只在显式无参disconnectDevice()路径且会话确实存在时发射;驱动器发起的掉落、取消的拨号、rebuildDevices抖动、失败的拨号从不发射它(dual-drone 示例曾因 source-0 掉落回收辅助进程、而 source-1 仍在拨号而死亡)。 - 任何定时器不得作用于连接状态:唯一允许的周期性活动仍是设备枚举与数据轮询(UART 的 opt-in 自动重连是唯一得到批准的例外,保持不变)。
- 异步完成事件(DNS 结果、握手进度)可以更新数据并通知 UI,但绝不允许打开、关闭或重配置连接。
- 三种运行模式一致:QuickPlot、ConsoleOnly、ProjectFile,单源与多源。
- 无新依赖;商业总线保持在既有构建门之后。
七、开放问题与演进
spec 记录了两个需维护者裁决的开放问题,其中第一个(有界失败时间的实现机制)已由最终设计给出答案:
- 无看门狗的有界失败时间(R1/R2 对无定时器不变量):对于平台 API 既不完成也不报错的 BLE 或 Modbus 拨号,什么来界定判定?最终选择"闩锁使缺口可见"——用户取消时
disconnectDevice(id)/close()像今天一样清除 pending 尝试,新的 pytest 循环(AC7)断言每总线 20 次循环后无 pending 残留;BLE 的错误枚举是最风险点(多条QLowEnergyController错误路径),缓解措施是从onControllerError与 pre-readydisconnected路径报告,这两条路径今天已汇聚所有失败。 - 编辑锁的作用域(R3):总线类型选择器本身在连接时是否锁定(推荐:是),拨号进行中是否也锁定(推荐:是)。
- Modbus TCP 首次点击覆盖(R6):平台客户端不能阻塞;bind 竞态由端点预探或其它机制关闭由 plan 决定,但 spec 要求首次点击成功且无上限后台重试。
八、测试与验证体系
8.1 集成测试(pytest)
新文件 tests/integration/test_connection_verdicts.py 将当晚的手工测试规范化(要求应用已启动且启用 API 服务器):
- AC1:
test_network_dead_port_settles_with_one_verdict、test_modbus_dead_port_settles_with_one_verdict—— 对本地关闭端口(REFUSED_PORT = 9)发起 Network TCP 与 Modbus TCP 拨号,_wait_settled在 6 秒预算(VERDICT_BUDGET_S = 6.0)内轮询直到linkState离开connecting,断言isConnected is False且linkState == "idle";窗口内linkState永不停留在connecting。 - AC2:同一文件断言 Modbus/Process 死端点的 connecting 标志回落。
- AC7:
test_network_cycle_20x_leaves_no_residue—— 20 次连接/断开循环后按状态 API 断言最终状态与新鲜状态一致、linkState == "idle";无辅助进程重复(system.runningProcesses或 pgrep)。 - AC5:
test_identical_settings_reapply_is_noop—— 对 hostname(localhost)寻址的本地服务建立连接后 10 次重新应用相同配置,断言连接未被打断。 - 额外保障:
test_connect_then_write_reaches_the_peer验证io.connect()+writeData()序列——拨号不再阻塞在open()内,飞行中写入的字节由驱动器暂存、套接字连接瞬间刷出(spec 0075 异步拨号后 spec 0050 承诺的保持)。
测试基座还包含平台感知守卫:refusal_is_observable()探测本机对关闭回环端口是否回 RST(Windows Firewall stealth 模式会丢弃 SYN、把"拒绝"变成 OS 连接超时,此时判定预算无法成立,测试跳过)。
8.2 手动与静态验证
- 手动(维护者、阶段硬件):AC2-BLE(拨号已断电外设,判定解析为一条通知);AC3 各总线编辑锁的视觉检查。
- 静态:
python scripts/code-verify.py --check全树干净(含新规则);.code-report重新生成;提交前sanitize-commit.py;交接前对 diff 跑qt-cpp-review。 - 热路径:不涉及,无需基准运行(维护者仍可按常规推送门运行
--benchmark-hotpath)。
九、风险与缓解
- 从不报告的驱动器路径(旧的卡死 "connecting" 类别):闩锁使缺口可见;用户取消路径与 AC7 循环测试兜底。
- 双重发射(如 Modbus Connected 后尝试中途传输掉落):构造上不可能——闩锁首次报告即解除。
- 重建驱动器上的
sender()查找:rebuildDevices()在销毁前断开旧驱动器的信号(既有模式),注定消亡实例的迟到openFinished无法到达。 - 静默破坏类别:报告不携带 UI(无模态入错误栈);全部同线程(无 queued-vs-direct 问题);文件清单即变更边界。
- Lint 误报:规则在可用处基于 tree-sitter 函数体文本运行,回退正则已在规则中记录;落地前对全部十个驱动器验证(Audio 的
isOpen()门控通过;UART 已修复)。
十、总结
Spec 0050 以"一次尝试、一个判定"为中心,用三层机制把连接管理从组合式退化的泥潭中拉出:结构上,HAL_Driver::openFinished信号 + 基类闩锁让每个异步拨号尝试拥有单一、恰好一次的推式结果所有者,轮询扫描settlePendingDialVerdicts()彻底退役;行为上,连接期间编辑锁定(R3)、幂等设置应用(R5)与无定时器操作连接状态(Constraints)共同消除了抖动与反馈循环的来源;质量上,driver-setter-guard错误级 lint 规则(AC8)与 test_connection_verdicts.py(AC1/AC2/AC5/AC7)把"不允许连接按钮卡死""不允许循环残留"固化为可持续验证的工程约束。对于希望贡献 Serial Studio 连接层、或借鉴其连接状态机设计的开发者,spec.md 定义目标,plan.md 解释取舍,tasks.md 给出可独立评审的落地顺序,而本文引用的源码与测试则是这三个文档在仓库中的最终形态。
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考