Serial Studio 连接判定统一架构(Spec 0050):openFinished 信号、轮询退役与 setter 守卫 lint 实战解析
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
本文基于 Serial Studio 仓库中
doc/claude/specs/0050-connection-verdict-unification/下的tasks.md(配合spec.md、plan.md与doc/claude/architecture/io.md)展开。该规格解决的是遥测仪表盘中一个真实而棘手的工程问题:一次连接尝试(open attempt)的结局必须只有一个唯一的所有者(owner)。本文会完整讲解驱动层HAL_Driver::openFinished(ok, reason)信号与 emit-once 闩锁(latch)的机制、ConnectionManager如何消费判定并退役旧的轮询清扫(sweep),以及驱动 setter 同值守卫 lint 规则与集成测试的落地方式。读者读完后,可以掌握在 Qt/QML 多总线架构(UART、BLE、MQTT、Modbus、Process、CANBus 等)中设计“一次尝试、恰好一个判定、绝不卡死 connecting”的工程范式。
背景:为什么“连接判定”需要统一
问题来源:连接处理的组合式退化
Spec 0050 的动机源自 2026-08-10 的一次连接后验(post-mortem):连接处理是“组合式退化”的——拨号重试、看门狗、DNS 门控、配置编辑自动重开、判定轮询等机制,各自订阅了其他机制发出的通知,形成了反馈环,没有任何单一变更能向评审者展示全貌。由此带来的可测量后果包括:
- TCP telnet 会话每隔几秒被重启,直到远程服务把用户 IP 封禁 30 天;
- 健康的链路被过期的 peer-validity 读取撕掉;
- 重复的 helper 进程争夺同一个端口;
- 演示项目需要点击两次连接,因为拨号与 helper 的 bind 在竞态。
核心缺陷:尝试的结局没有单一所有者
更深的缺陷是:**一次连接尝试的结局没有单一所有者。**同步驱动通过 open 调用的返回值报告;异步驱动则通过轮询清扫(polling sweep)报告成功,而失败——在五个驱动里——报告给了“没有人”。一次失败的蓝牙或 Modbus 拨号,可能让应用永远停留在 “connecting”。
这正是本规格要消灭的 bug 类别:“检查 isOpen() 的延迟结算路径”导致 connect 按钮被卡死。而 R1/R2 需求对应的现象是:任何总线上连接不可达或拒绝端点,都应该在有限、可陈述的时间内,产生恰好一个用户可见的失败通知,之后应用完全进入断开状态;connecting指示器绝不能无限期持续。
架构核心:openFinished 信号 + emit-once 闩锁
HAL_Driver 的判定原语(T1)
T1 的任务是在app/src/IO/HAL_Driver.h(当前仓库实际路径为core/Core/IO/HAL_Driver.h)中引入判定原语。当前源码中这组 API 已经落地,与任务描述完全一致:
- 新增信号
void openFinished(bool ok, const QString& reason); - 公开的闩锁操作:
armOpenReport()、disarmOpenReport()、openReportArmed() - 受保护的报告入口
reportOpenFinished(bool ok, const QString& reason = QString())
reportOpenFinished的核心逻辑(core/Core/IO/HAL_Driver.h第 292-299 行)体现了“恰好一次”的强制力:
void reportOpenFinished(bool ok, const QString& reason = QString()) { if (!m_openReportArmed) return; m_openReportArmed = false; Q_EMIT openFinished(ok, reason); }这是一个纯 bool 闩锁,没有任何定时器(符合规格中 “No timer may act on connection state” 的不变量)。它保证:
- 只有闩锁被
armOpenReport()武装后才允许发射; - 第一次发射后自动解除武装,因此后续已建立链路的掉线事件永远不会伪装成拨号判定(emit-exactly-once);
- 由基类强制,而不是依赖各驱动的自律——plan.md 明确说明:“per-driver discipline is exactly what stranded verdicts before”(正是各驱动的自律导致此前判定被搁浅)。
ConnectionManager 的消费路径(T2)
core/Devices/IO/ConnectionManager.cpp中,connectDevice(int)在调用DeviceManager::open()之前先调用halDriver->armOpenReport()(第 831 行),并在同步结算或用户取消时disarmOpenReport()(第 846、865 行)。随后onDriverOpenFinished(bool ok, const QString& reason)(第 616 行)作为唯一消费槽:
- 通过既有 sender 反向查找解析出是哪个驱动报告的(
QObject::sender()); - 将该设备 id 从 pending 集合中移除;
- 转发给既有的
onDeviceOpenFinished(id, ok, reason)(诊断、conclude、notify 一条龙); - 当
!ok时静默关闭设备,且绝不发射sessionClosed。
openFinished到onDriverOpenFinished的接线发生在setBusType()(第 1031-1033 行)和buildDeviceForSource()(第 1235-1237 行),均为同线程直接连接(默认 Auto 连接在同线程退化为直接调用),符合 plan 中 “No new cross-thread signal/slot” 的约定。
同时,任务明确要求删除settlePendingDialVerdicts()及其在notifyConnectedStateChanged()内的调用。全仓库搜索确认:当前代码中settlePendingDialVerdicts仅存在于规格文档中,实现树中已彻底移除——这正是 “sweep retirement”(轮询清扫退役)的最终证据。
为什么淘汰轮询清扫
在旧设计中,notifyConnectedStateChanged()既发布连接状态,又兼职“判定清扫”(periodically check isOpen() 来结算未决的拨号)。这带来两个问题:
- 判定产生于轮询而非推送,结算延迟不可预测;
- 清扫只覆盖了“成功”的观察,失败路径在五个驱动中各自为政,导致
connecting卡死。
新设计中,refreshConnectedState()(queued)仍然负责发布连接状态转换,但不再兼任判定清扫。plan.md 原文:“it publishes connected-state transitions; it just no longer doubles as the verdict sweep.”
各异步驱动的报告改造(T3-T7)
T3 — BluetoothLE:双向报告
core/Devices/IO/Drivers/BluetoothLE.cpp:
announceGattReady()→reportOpenFinished(true)(第 546 行);onControllerError()→reportOpenFinished(false, reason)(第 377 行);- 删除临时补救
ConnectionManager::disconnectDevice(this)——manager 的统一 teardown 现在处理失败的拨号。
源码中还有两个额外的失败报告点(第 339 行“connected 完成前设备断开”、第 935 行“BLE service error during connect”),说明所有 controller 失败路径都会汇入onControllerError或 pre-readydisconnected → close路径。这正是 T3 验证步骤“read-back confirms every controller failure path funnels through onControllerError”的实现证据。
T4 — Modbus:状态机双出口
core/Devices/IO/Drivers/Modbus.cpp:onStateChanged(ConnectedState)→reportOpenFinished(true)(第 1191 行);failDial()→reportOpenFinished(false, error)(第 484 行)。Queued-box 规则保留(failDial 的 box 已经排队)。
T5 — MQTT:拨号窗口内失败收敛
core/Devices/IO/Drivers/MQTT.cpp:onStateChanged(Connected)→reportOpenFinished(true)(第 1135 行);拨号窗口内的onErrorChanged()→reportOpenFinished(false, reason)(第 146 行),替换原临时补救的 disconnect 块,同时保留m_userWantsOpen/m_reconnectPending的清理。第 1140、1231 行还覆盖了“broker 在尝试期间关闭连接”等路径。
T6 — Process:两种模式都报告
core/Devices/IO/Drivers/Process.cpp:
- Launch 模式:
QProcess::started→ report(true);FailedToStart或仍在 Starting 时退出 → report(false, reason); - Pipe 模式:marshal 后的 peer-attach 槽 → report(true);
!m_pipeConnected时的管道错误 → report(false, reason)。
线程不变量:报告只从主线程槽触发(管道事件已经 marshal),绝不在管道线程上调用 report。
T7 — CANBus:插件异步拨号时报告
core/Devices/IO/Drivers/CANBus.cpp第 960-964 行:onStateChanged中,闩锁武装时 Connected →reportOpenFinished(true);武装时 Unconnected →reportOpenFinished(false, errorString)。gs_usb 路径是同步的,从不报告(open 返回结算后闩锁已被 ConnectionManager 清除)。
注:除上述任务列出的驱动外,当前源码中
Iec104.cpp、Network.cpp、OpcUa.cpp同样接入了reportOpenFinished。它们属于后续规格(如 0066/0067/0075)对同一判定范式的扩展——doc/claude/architecture/io.md中 “The verdict has ONE owner per attempt (spec 0050)” 一节将此描述为设计被持续复用的证据。
同值守卫:UART setPortIndex 与 driver-setter-guard lint(T8-T9)
背景:为什么 setter 需要同值守卫
AC5(R5,幂等设置应用)要求:重新应用与当前值相同的值必须是完整 no-op——无 DNS 查找、无 undo 历史条目、无 autosave、无通知级联。因为applyConnectionSettings()(HAL_Driver.h第 268-279 行)会重放项目持久化的每个 key,若 setter 在值未变化时仍然发射configurationChanged,就会引发重连/autosave 的涟漪。
T8 — UART 的修复
core/Devices/IO/Drivers/UART.cpp第 581-596 行的setPortIndex(const quint8 portIndex):当 clamp 后的索引等于m_portIndex且持久化名称已经是最新时提前返回,使重复应用无法再发射。关键设计是:自动重连的setPortIndex + connectDevice()流程不受影响——显式 connect 无论 emit 是否被跳过都会继续执行。
if (portIndex == m_portIndex && clamped == m_portIndex) // early return:同值重应用不发射 configurationChanged return; m_portIndex = clamped;T9 — driver-setter-guard lint 规则
AC8 要求仓库 linter 强制所有驱动配置 setter 具备同值守卫,且全树检查通过。规则要点:
- 错误级别(error severity),作用域限定
app/src/IO/Drivers/*.cpp; - 具体
set*(scalar|QString)方法必须包含同值提前返回或isOpen()门; setDriverProperty分发 override 豁免(它扇出到具体 setter,具体 setter 才是被守卫的表面);- 实现位于
scripts/code_verify_rules.py(C++ 规则宿主scripts/code-verify.py),并重新生成.code-report; - 验证方式:
python scripts/code-verify.py --check全树干净;故意破坏一个守卫以观察规则触发,然后恢复。
plan.md 解释了为何是错误级别而非 advisory:“the missing guard was the telehack loop's engine”——缺失守卫正是之前 IP 被封循环的引擎,advisory 级别的基线债务会让下一个驱动带着同样的 bug 上船。
Live Text Apply:驱动面板文本实时生效(T10)
设计动机
规格中的 “edit-lock”(R3:连接期间锁定连接配置控件)与“reopen 机制已删除”(2026-08-10)使旧有的防御失效:驱动文本字段此前只在editingFinished(Enter 或失焦)时提交,这是 reopen 时代的“护甲”——那时每次击键都会重启 DNS 并重拨链路。如今:
- reopen 机制已删除;
- 连接期间设置被 UI 锁定;
- 每次击键的 hostname DNS 查找现在是惰性的(lookup 下游没有任何东西能触碰连接),无需防抖。
实现要点
文本字段改为在onTextEdited上提交(仅用户输入触发;onTextChanged会与每个面板Connectionshandler 的程序化 write-back 形成循环——这个不对称性就是绑定不变量)。保留editingFinished作为空字段默认地址场景的回退。涉及面板(grep 确认):app/qml/MainWindow/Panes/SetupPanes/Drivers/Network.qml(地址),以及 Modbus host、MQTT hostname/topic、Process executable/arguments、UART custom device 等对等面板。
判定集成测试(T11)
任务要求新建tests/integration/test_connection_verdicts.py,当前仓库中该文件已存在(tests/integration/test_connection_verdicts.py),覆盖:
- AC1 死端口判定:Network TCP + Modbus TCP 拨号关闭的本地端口 → 状态在 ≤6 s 内结算为 disconnected,
linkState绝不卡在connecting(测试在第 47-55 行有一个专门的poll until linkState leaves 'connecting'辅助函数,超出预算即pytest.fail); - AC2 connecting 标志回落:Modbus/Process 死端点拨号,连接中标志在界内回落;
- AC7 20 次循环:每个可脚本化总线(Network、Modbus、Process)20 次 connect/disconnect 循环,最终状态与全新状态相等,且单 helper 断言(无重复 helper 进程);
- AC5 回归钉:10 次相同设置重应用 → 无重连、undo 深度不增长(通过
project.getStatus/ undo depth 验证)。
测试基架api_client、clean_state与feed_server提供了运行中的应用上下文。运行方式:应用启动后执行
pytest tests/integration/test_connection_verdicts.py -v(维护者负责拉起应用实例。)
Definition of Done 与验证闭环
任务的完成定义(全部已勾选)构成完整的质量闭环:
spec.md中每个验收标准都达成并在该文件勾选(AC3/AC2-BLE 保留维护者在舞台硬件上的手工验证);python scripts/code-verify.py --check在所有改动文件上干净,包括新的driver-setter-guard规则全树通过;qt-cpp-review对 C++ diff 运行,发现项已处理或记录;- 热路径未触碰(plan 中明确 “Touches the hotpath? No”,无
FrameReader/CircularBuffer/FrameBuilder/Dashboard-draw 代码被读写),无基准回归预期; pytest tests/integration/test_connection_verdicts.py列入维护者清单;python scripts/sanitize-commit.py运行,工作树无 lint 债务;- diff 是“被要求的内容且仅此而已”——无范围蔓延、无无关文件;
spec.md状态置为done。
架构落点与扩展:从 io.md 看统一判定范式的全貌
doc/claude/architecture/io.md的 “The verdict has ONE owner per attempt (spec 0050)” 一节给出了该设计的精确定义,可作为权威总结:
同步驱动:
open()返回值,由connectDevice(int)传给onDeviceOpenFinished(deviceId, ok, reason)。异步驱动(BLE、Modbus、MQTT、Process、异步 CAN 插件):HAL_Driver::openFinished(ok, reason)信号,每次尝试恰好发射一次——通过基类闩锁(manager 在open()前armOpenReport();驱动在两种结局上都reportOpenFinished();在首次报告、同步结算、用户取消时解除武装)。ConnectionManager::onDriverOpenFinished结算 pending id,失败拨号时静默关闭设备(绝不sessionClosed),并转发给onDeviceOpenFinished——因此 spec-0035 的诊断自动触发现在也能看到异步失败。不存在轮询清扫:settlePendingDialVerdicts()已删除;切勿重新引入 “later check isOpen()” 的结算路径。
文档同时给出了这个设计与其他 IO 不变量的协同关系:
sessionClosed语义:只有用户(或 API 客户端/播放器接管)结束一个真实存在的会话时才发射,仅来自显式无参disconnectDevice()路径。驱动发起的掉线、被取消的拨号、rebuildDeviceschurn、失败的拨号都不发射它——因为API::ProcessLauncher依赖该信号收割脚本启动的 helper,helper 往往正服务于正在掉线/重试的链路。rebuildDevices()顺序:必须在销毁注定失败的驱动前断开其信号(现有模式),这样迟到的openFinished不可能到达——这是 T2 明确列出的绑定不变量。isConnecting():十一个类 override(Network、Iec104、Modbus、MQTT、OpcUa、S7、EthernetIp、CANBus、BluetoothLE、Process 及测试桩Test::FakeDriver)。toggleConnection()在任一设备报告进行中的拨号时中止;ConnectionManager::isConnecting驱动工具栏 “Connecting…” 标签。- 失败仍需到达
disconnectDevice(this):已建立链路的掉线路径保留各驱动的disconnectDevice(this)调用(BLEonControllerError、ModbusfailDial、MQTT 拨号窗口onErrorChanged),使 pending 判定结算、connecting总能回落——但这是掉线路径,与拨号失败路径(由onDriverOpenFinished统一处理)职责分离。
工程启示:从任务清单反推可迁移的设计原则
tasks.md的约定部分本身即是工程方法论:一个任务 = 一个聚焦、可评审的变更(触碰 >3 文件或需一整段描述就拆分);每个任务有独立的Verify(通常python scripts/code-verify.py --check <files>加一个测试或回读)和Deps(前置依赖);顺序保证每步之后概念上可编译。T1→T9 的依赖链(T2 依赖 T1,T3-T7 依赖 T2,T9 依赖 T8)展示了如何在共享基类上先立原语、再逐驱动迁移、最后用 lint 守住边界。
对任何 Qt 多总线应用,这套方案可迁移的关键决策是:
- 判定推送化:用基类闩锁信号取代轮询清扫,从机制上消除“判定搁浅”;
- 单一消费者:所有驱动只报告,teardown 由 manager 集中执行,删除 N 份重复的 teardown 排序代码与误导性的 “device dropped” 日志;
- 幂等 setter + lint 强制:同值 no-op 通过自动化规则固化为架构约束,而不是靠 code review 提醒;
- 测试钉死回归:AC1/AC2/AC5/AC7 以 pytest 形式常驻,任何驱动改动若重新引入卡死
connecting或重复 helper,都会在 CI 中立即暴露。
相关阅读与验证入口
- 规格三件套:spec.md(WHAT/WHY)、plan.md(HOW)、tasks.md(有序清单)
- 架构权威描述:io.md 的 Verdict/Opening a Link 章节
- 基类实现:HAL_Driver.h(第 206-212 行信号、第 292-299 行
reportOpenFinished) - 管理器实现:ConnectionManager.cpp(
connectDevice/onDriverOpenFinished/接线点) - 驱动示例:BluetoothLE.cpp、Modbus.cpp、MQTT.cpp、Process.cpp、CANBus.cpp、UART.cpp
- 集成测试:test_connection_verdicts.py
- 静态验证:
python scripts/code-verify.py --check与规则宿主scripts/code_verify_rules.py
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考