Serial Studio 导出与回放保真度修复实践:从 636 列仅 4 列非空的采集事故到全链路回归防线
【免费下载链接】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/0064-export-replay-fidelity/spec.md(及同目录
plan.md、tasks.md),结合 core/Pipeline/DataModel/FrameBuilder.cpp、core/Pipeline/DataModel/RepublishGate.h、core/Storage/CSV/Export.cpp 等源码实现,完整还原一次"数据保真度(Export and Replay Fidelity)"专项修复:问题现场、根因定位、修复设计与落地、三层回归测试防线,以及遗留边界。读完你可以理解:脚本/表格驱动的虚拟数据集为什么会被录音链路静默丢弃、CSV 导出时间戳为何会出现负值并导致自家文件无法回放、会话回放为何会把 48 kHz 音频抽稀成 12 Hz,以及 Serial Studio 如何用一套可复现的测试体系把这类问题锁死。
一、事故现场:一场"昂贵却近乎空白"的采集
1.1 真实项目背景
Spec 0064 开篇给出的是一个真实生产环境(field project)的采集案例。该项目规模为:
- 109 个分组(groups)、635 个数据集(datasets);
- 三个数据源:一个 CAN/UDP 源(631 个数据集为脚本驱动 + 数据表驱动的虚拟数据集),外加两个48 kHz 音频源(共 4 个数据集)。
所谓"虚拟数据集",是指其数值不是直接从帧(frame)解析出来的,而是由项目的控制脚本(control script)请求、或从数据表中读取后经过变换生成的。这类数据集唯一的发布路径是"合成刷新(synthetic refresh)"——即每收到一帧,控制脚本就请求一次刷新,让这些数据集既能渲染到仪表盘、也能写入录音。
1.2 三种录音 sink 同时失效
在三种录音目标(CSV、MDF4、会话数据库)上,对文件本身的实测结果完全一致——635 个数据集中只有 4 个(音频数据集)到达了任何录音 sink:
- 导出的CSV 有 636 列,其中仅 4 列非空;
- 导出的MDF4 有 109 个通道组,其中 107 个包含 0 个 cycle;
- 会话数据库只记录了 4 个唯一 dataset id 的 blocks;
readings表为空,但捕获的原始字节与表快照(table snapshots)证明源端一直在正常解析和活动。
更值得注意的对比是:一个月前(2026-07-18)同一项目的 CSV 导出是正常的——572 列中有 571 列有数据。行为变化发生在 2026-08-18 落地的"池化块通道统一(pooled block-lane unification)"提交f4e26ef04之后,该提交重构了合成刷新(script-requested refresh)的发布方式。而合成刷新恰恰是表驱动虚拟数据集唯一能进入渲染与录音的发布路径。
1.3 回放的三重故障
在导出为空的基础上,回放还有三个独立问题:
- CSV 回放被自家文件拒绝:Serial Studio 导出的 CSV 无法被自身打开回放。播放器拒绝该文件自带的 elapsed 时间列,转而弹窗要求用户手动指定时间列或固定采样间隔。根因是导出器写入了负的首个 elapsed 值——它从"自己碰到的第一个 block"开始计时,而不是从录音中最早上采样的样本开始;双源场景下,看到的第一个 block 比最早样本晚了 32 ms。
- MDF4 与会话回放能加载能运行,但仪表盘空白:因为录音本身除 4 个密集流数据集外全是空的。
- 生成的会话报告"看起来完整实则空洞":报告列出了录音声明的全部 635 个数据集,却只绘制了 4 个密集流数据集的图。像引擎组这样的分组在报告里出现却没有背后的绘图数据——报告是同一批从未被写入的样本的下游读者。对读者而言,这比"明显被截断的报告"更糟:它诱导读者得出"仪器当时是静默的"的错误结论。
用户可见的最终结果是:一次耗时昂贵的测试采集变得不可恢复——磁盘上数 GB 的文件、里面几乎没有数据、仅存的一点数据也无法回放、报告还歪曲了这次运行。每一个症状都是数据保真度(fidelity)失败,因此本 spec 将它们作为一次整体修复处理。
二、修复目标与明确划出的边界
2.1 十项需求(R1–R10)
Spec 将修复目标拆解为可验证的需求,这里完整列出:
| 编号 | 需求内容 |
|---|---|
| R1 | 现场仪表盘上渲染的每一个数据集都必须被每个已启用的录音 sink 记录,无论其数值来自脚本、数据表还是直接从帧解析——脚本/表格数据集与帧解析数据集"一模一样"地记录 |
| R2 | 只要某个源被录音捕获,就必须捕获该源的全部数据集;源存活期间,录音**绝不能出现"有结构、无样本"**的空壳 |
| R3 | 打开 Serial Studio 生成的 CSV 进行回放时,永不弹窗要求用户指定时间列或提供固定间隔;对导出器可能写出的任何 elapsed 值(含已在磁盘上的零值和负值)都成立 |
| R4 | 新导出 CSV 的首行 elapsed 值 ≥ 0,且以录音中最早样本为计时原点;elapsed 单调不递减,多源独立时钟并存时同样成立 |
| R5 | 回放 MDF4 录音时,仪表盘应填充文件包含的全部数据集,并正确映射到对应数据集(单源与多源均成立) |
| R6 | 回放会话录音时,按当前捕获格式填充录音包含的全部数据集 |
| R7 | 供给录音 sink 的合成刷新必须如实上报"已发布",使文档规定的"仅在值变化或首次发布时重发布"抑制逻辑按规范生效,而不是无条件重发布 |
| R8 | 回放任何录音都绝不重新录音:回放只填充仪表盘和只读观察者,不产生新文件、新会话、新发布消息 |
| R9 | 本次改动之前写的录音仍然能打开、能回放;任何已发布构建产出的录音都不变得不可读 |
| R10 | 生成的会话报告绘制它列出的每一个数据集:报告枚举的每个通道都有绘图数据与汇总统计支撑(脚本驱动与表驱动数据集与密集流数据集一致);绝不允许"填充的通道清单 + 只来自部分源的图表" |
2.2 非目标(Non-Goals)
Spec 明确划出边界,防止修复扩散:
- 不缩减录音文件大小或行率。该项目的 636 列宽稀疏行约消耗60 MB/s,两个独立的 48 kHz 源产生约93k 行/s(而非 48k)——这是真实问题,但属于另一个 spec 的范围。本次只做正确性,而正确性会让文件变大而非变小。
- 不改变 MDF4 通道布局:每个数据集的 raw-value 伴生通道、每组的时间通道保持原样,raw 值从所有录音中都可恢复。
- 不引入新的磁盘导出格式(窄表/长表 CSV、按源分文件等),不要求既有录音或第三方工具适配。
- 不提供用户界面来选择录制哪些数据集(值得做,但不是本次)。
- 不改变会话数据库 schema 或捕获格式版本:既有会话文件必须继续打开。
2.3 约束与不变量
修复还必须遵守一组硬约束(详见 spec.md 的 "Constraints & Invariants" 节):
- 单一发布载荷、单一摄取路径:不得重新引入第二种发布载荷类型或第二个 sink 生产者;
- 发布路径零分配、管线与 GUI 之间无逐帧排队拷贝——用"每帧拷贝"换保真度不可接受;
- 禁止通过限速或按视图降采样来"缩小数字":为了修数据丢失而去丢数据不是修复;过载仍然整块丢弃并计数;
- 256 kHz hotpath 门禁是硬性 CI 门禁,不得回退;
- 回放必须与录音 sink 屏蔽隔离:恢复保真度不得打开"回放即重录"的路径;
- 两种操作模式都要成立:ProjectFile 模式必须成立,QuickPlot 回放(当前正确、作为对照实验)不得被破坏;
- CSV/MDF4/会话数据库无磁盘格式变更、无捕获格式版本号提升;
- 时间戳的所有权在导出器(exporter),而非读取器:时间戳在源边界打点,修复负 elapsed 是修正"录音从哪个样本开始计时",而非在导出 worker 中重新打点;
- 测试必须能在 CI 环境运行:C++ 层在 ctest 下无头运行(无设备、无 GUI);pytest 集成层可能需要运行中的 app,必须打标以便在无 app 时跳过。
三、根因定位:两条重发布通道共享了一个"已发布"标记
3.1 两条"合成刷新"通道
Spec 0064 的后续文档(tasks.md 的 Task 0)记录了在运行中的 app 上对现场项目的逐项排查。排查顺序与结论如下:
- T0.1三源复现:943 MB CSV、145 万行、636 列中仅 4 列有数据;
- T0.2排除块池耗尽(
notePoolExhausted()从未触发); - T0.3排除 sink 队列饱和与稀疏合并器问题(interval 模式绕过合并器、从每个摄入块前向填充,CAN 列仍为零);
- T0.4排除流源屏蔽(
stream.getSources报告 {1,2},config.sourceId = deviceId正确,源 0 从未进入m_streamSourceIds); - T0.5排除 GUI 线程被代码编辑器饿死(UI 降到 5 Hz 无变化);
- T0.6建立边界:仅 CAN 的项目副本能记录 632 列中的 631 列;
- T0.7关键证据:音频以 177 万样本/10 s 流式传输时,仪表盘的 CAN 值实时更新,而 CSV 始终只有 4 列——只有
feedExports == false的发布能做到这一点,说明所有重发布都走的是"屏蔽 sink 的通道",而导出通道被跳过了; - T0.8无硬件的独立复现:解析器不产生数据集的项目 +
dashboard.reprocess模拟流通道的屏蔽刷新,导出通道发布 0 次、CSV 从未创建。
结论落在数据流设计上:dashboardTick()驱动的合成刷新是表驱动虚拟数据集唯一的发布路径,而 spec 0055 给了它一套与"解析帧"分离的 staging/flush 序列。这套分离序列正是数据丢失之处。机制是:两条重发布通道(dashboard 通道与 export 通道)共享同一个"已重发布"标记(m_republishedSourceIds)。只要有流源在跑,UI 每次 tick 都会驱动屏蔽通道(masked lane),消费掉 change-driven 变换时钟,于是导出通道再观察时看到changed == false,其发布被抑制——仪表盘正常更新,而录音 sink 永远欠一次发布。
当前源码中的调用链印证了这一点。在 core/Pipeline/DataModel/FrameBuilder.cpp 中:
- dashboardTick()(L1316)——GUI 侧的合成刷新入口,最终调用
republishFrames(true)(fed,即喂给导出); - reprocessFrames()(L1294)——
dashboard.reprocess、看门狗渲染等走republishFrames(false)(masked,仅喂仪表盘); - republishFrames(bool feedExports)(L1238)——对每个源帧执行"变换 + 条件发布";
- republishOneFrame()(L1276)——
changed时noteChanged(key),然后按needed(key, changed, feedExports)决定是否发布; - emitRepublishedFrame()(L1209)——真正的 stage/flush 与发布,末尾
m_republishGate.notePublished(key, feedExports)。
关于当前代码的时序注释(L1204-L1207)也明确写着:"一个已打开的 block 会先被 flush 且不屏蔽(它装着真实捕获的样本);只有完全表驱动的源才到达 sink;含解析通道的源以屏蔽方式渲染(已记录过)"。这正体现了修复后"两类数据源各归其位"的语义。
3.2 修复核心:DataModel::RepublishGate
修复方案不是"修补被丢的那一步",而是把两条通道的"已发布"记账彻底分离。新引入的头文件 core/Pipeline/DataModel/RepublishGate.h 用注释直接点明了设计动机:
"两条合成刷新通道(spec 0064):dashboard 通道与 export 通道不能共享同一个'已重发布'标记,否则屏蔽刷新消费掉 change-driven 时钟后,会让每次录音都欠一次发布。"
其公开接口(RepublishGate.h):
clear()——丢弃所有标记,新会话重新欠两条通道各一次首发布;noteChanged(key)——标记key的值比录音 sink 持有的更新;每条通道都会调用,屏蔽通道也不例外——因为屏蔽通道恰恰是让 sink 变陈旧的原因;notePublishedTemplate(key)——模板单独发出后抑制首次合成发布;needed(key, changed, feedExports)——该通道是否仍欠key一次发布。export 通道问的是"sink 是否落后"(sinkDirty),dashboard 通道保持更廉价的"变了或从未发布"规则;sinkDirty(key)——sink 是否陈旧。
与之配套,emitRepublishedFrame的"是否真的发布"判定也从探测m_openBlocks改为比较 stage/flush 前后的 block 编号(m_stager.blockNumber(sourceId)前后对比),因为旧逻辑在 flush 已经擦除条目后才去探测m_openBlocks,导致探测恒为 false、m_republishedSourceIds永不填充、R7 的抑制逻辑永不生效。
3.3 回归锁:tst_republish_lanes
最核心的回归测试是 app/tests/tst_republish_lanes.cpp,它是一个QtCore-only的 ctest 套件(不链接 FrameBuilder),直接对RepublishGate进行单元验证。文件头注释(L26-L30)把这个 bug 讲得很透彻:
"两条合成刷新通道(spec 0064)。关键性质是不对称性:仅仪表盘的刷新会消费 change-driven 变换时钟,所以如果它也冲销了导出通道的债务,那么只要有流源持续驱动屏蔽通道,每个录音 sink 都会比屏幕落后一次发布。这正是 635 数据集项目只录到 4 个通道、而仪表盘却正常更新的原因。"
七个用例(L41-L49)覆盖了完整的不变量:
bothLanesOweAFirstPublish——两条通道对未发布过的源都欠首次发布;dashboardLaneSuppressesUnchangedSource——未变化的源不重绘;exportLaneSuppressesUnchangedSourceOnceSinksAreCurrent——导出已携带当前值后,未变化的导出 pass 被抑制;maskedRefreshDoesNotDischargeTheExportLane(L93,回归锁)——屏蔽刷新看到变化并发布到仪表盘后,导出通道仍必须欠一次发布,尽管它自己的 pass 观察到了changed == false,因为没有任何 sink 见过那些值;repeatedMaskedRefreshesNeverStarveTheExportLane——流源持续驱动屏蔽通道时,无论多少次屏蔽刷新都不能让导出通道"不欠发布";templatePublishSuppressesTheDashboardLaneOnly——模板发布只抑制 dashboard 通道;clearRestoresTheFirstPublishObligation——clear()后两条通道恢复首发布义务。
四、CSV 时间戳契约:负 elapsed 从何而来、如何根治
4.1 导出端:计时原点修正 + 单调钳制
旧的导出逻辑以"本批次看到的第一个 block"(items.front()->t0)为参考时间戳。多源场景下,第一个看到的 block 比录音中最早的样本晚(现场是 32 ms),于是最早的样本被写成负的 elapsed。
修复后的 core/Storage/CSV/Export.cpp 的processItems()(L243-L286)改为:
- 参考时间戳 = 整个第一批次的最小
t0(L255-L258),并在文件打开后锁存(latch),不再随后续批次移动:m_referenceTimestamp = items.front()->t0; for (const auto& block : items) if (block && block->samples > 0 && block->t0 < m_referenceTimestamp) m_referenceTimestamp = block->t0; resetMonotonicClock(); - 迟到更早样本钳制到单调下限(
bufferBlock,L307):即使锁存后出现比参考时间更早的样本,也不写负值,而是钳到 0:times.push_back(std::max<qint64>(0, offset));注释(L288-L293)同时说明:每个样本保留其源自己打点的时间戳,不规则 block 只做 per-source 的 tie-break,保证同一粗粒度时钟纳秒上落地的两帧仍是不同行,而不会把一个源的样本重写到另一个源后面(B1 不变量)。
这一点与 spec 的约束完全一致:时间戳的所有权在导出器,修复修正的是"录音从哪个样本开始计时",而不是"样本何时被打点";导出 worker 不重新打点,monotonicFrameNs(...)保持其"同纳秒碰撞安全网"的定位,不成为时间真相来源。
4.2 播放端:接受任意有限数值的首个单元格
旧的 CSV 播放器在快速预检(quick pass)时拒绝负的 elapsed 值,弹窗要求用户手动描述时间列。修复后的播放器逻辑(core/Storage/CSV/Player.cpp 的runQuickPass()注释)为:
"任何有限的数值型首单元格都被当作 elapsed 列;负值不再被拒绝为'无可用的时间'。"
也就是说,播放端现在容忍导出器可能写出的任何有限数值——包括零值和负值(R3),这正是那些已在磁盘上的旧录音能重新打开回放的原因。注意 spec 刻意把 R3(播放端容忍)与 R4(导出端新文件不再产生负值)拆成两个独立需求:只修导出端会让所有存量录音无法回放,只修播放端会让新文件的时间原点毫无意义。
4.3 集成层的契约验证
集成测试 tests/integration/test_export_replay_fidelity.py 的TestCsvTimestampContract(L233)用真实 app + API 验证了这两个契约:
test_elapsed_column_is_non_negative_and_monotonic(L234)——断言首列以 "elapsed" 开头、首值 ≥ 0、整列单调不递减。注释点明:"把批次第一个 block 当参考会把多源录音的原点放到自己的最早样本之后,播放器随后拒绝读自家文件";test_serial_studio_csv_replays_without_a_prompt(L251)——打开录制得到的 CSV 并断言播放器进入打开状态。注释特别说明:"播放器在检测失败时会模态阻塞,所以失败在这里表现为挂起而不是断言——超时本身就是信号"。
五、回放保真:从空白仪表盘到全量、实时、不重录
5.1 排查中发现的连锁缺陷(Tasks 6–7)
回放"仪表盘空白"并非单一原因,tasks.md 的 Task 6/7 记录了按症状测量的发现:
- T6.1播放器打开时从不给仪表盘播种(seed):CSV 打开时 0 widget / 0 dataset,按播放后才到 315/631;
- T6.2回放按"每行一个池化块"发布,而非按
kFrameBlockSampleCap批量;改为把 sink 屏蔽移到DataBlock::masked上,使显示 tick 稍后 flush 的块仍绕过录音 sink(R8),openBlockFor拒绝复用屏蔽状态不同的槽位,杜绝"一个块混装两种样本"; - T6.3
Dashboard::resetData()在管线线程上阻塞 GUI(一次约 4.4 s 的调用吃掉了主线程 8850 个采样中的 4425 个),action-template 获取改为异步; - T6.4
stream.getSources报告的是构建期通道数而非观测值,改为从最后一个 block 报告观测通道数、首块落地前回退到配置值; - T7.1MDF4 seek 压力下的崩溃:
replayChannelsTyped阻塞式 marshal 会泵 GUI 事件循环,排队的close()在循环运行中清掉了m_sourceChannelsByIndex/m_text。修复为重入保护 + 迭代副本 + 注入期间延迟closeFile(),三个播放器统一应用; - T7.2seed 在
Q_EMIT openChanged()之前运行,而后者排队Dashboard::resetData——seed 发布进了即将被清空的状态;改为 emit 之后再排队 seed; - T7.3/T7.4
resetData清空仪表盘时 FrameBuilder 仍持有 per-source "structure published" 标记;且会话/MDF4 seed 必须强制映射每个源(混合速率录音的首个瞬间只有密集源有数据)。
5.2 回归与重新设计(Tasks 8–10)
2026-08-19 晚间,累积的工作树导致一次现场仪表盘回归(值停在 0、结构快照反复重建),维护者下令重置到 HEAD,仅重新应用经过验证的子集,其余工作(含 player seed-on-open、replay batching 的初版、resetData异步化等)先压入 scratchpad 补丁,各自重新设计。这个过程本身就是一次很好的工程实践:先回退到已知良好状态,再按证据逐个重新合入。
随后三个关键修复被重新设计并合入:
- T9.2 结构失效的正确时机:"播放器无/部分仪表盘"的根因是 FrameBuilder 的 per-source 结构已发布标记在
Dashboard::resetData(true)后仍存活,回放为仪表盘已不持有的布局 staging 块,structureIsCurrent()拒绝重发。修复为FrameBuilder::forgetPublishedStructures()仅在notify == true时从resetData调用——重配置路径会重入resetData(false),在那里遗忘就是 8 月 19 日回归的根源。这个 notify 门就是"修复"与"回归"的分界线(FrameBuilder.cpp 附近); - T10.1 回放吞吐:时间探针把 9 ms 的注入拆开——builder 发布 4 µs,GUI 往返 8105 µs。三条回放通道切换到
Qt::BlockingQueuedConnection(dataflow 文档中已记载的"唯一例外"),注入降到 10 µs,回放达到约67k 行/s、完整实时节奏; - T10.2 回放批量:67k 个单样本块/s 灌进 32 槽仪表盘环形缓冲,drain 只有 ~1.9k/s,约 97% 随机丢弃。修复为回放不再逐行 flush,而是按与实时通道相同的
kFrameBlockSampleCap/epoch 规则批量(约 1.1k 块/s),sink 屏蔽由DataBlock::masked携带,保证显示 tick 稍后 flush 的块永远不会漏进录音 sink(R8)。
5.3 回放永不重录(R8)
R8 的集成验证在 tests/integration/test_export_replay_fidelity.py 的TestReplayNeverReRecords(L273):
test_replaying_a_csv_creates_no_new_recording(L274)——录制一个 CSV、启用 CSV 导出、回放该 CSV 4 秒、关闭,断言没有产生任何新 CSV。测试注释解释了为何屏蔽必须挂在块上而非 FrameBuilder 成员上:"回放会批量行,而显示 tick 可能在 staging 它的调用之外 flush 一个待处理块——未标记的块会被重录进一个全新文件"。
在 FrameBuilder.cpp 的replayBlock附近可以看到实际的屏蔽实现:发布前m_maskSinks = true、发布后恢复,保证回放只到达仪表盘与只读观察者。
六、专项修正:密集流通道的会话回放抽稀(Amendment 2026-08-20,R11)
6.1 问题:48 kHz 波形回放成了 12 Hz
spec 追加修正记录了一个新报告:音频 Quick Plot 会话回放时波形明显失真、FFT 死亡,而会话文件里的数据是完整的,等价采集的 CSV/MDF4 回放正常。
对照 spec-0055 的存储布局,机制被读代码证明:
- 录制器把密集均匀块存入
blocks表,dt_ns != 0、带完整样本 blob(48 kHz 下每行最多 4096 个样本)——磁盘数据是正确的; - 播放器的时间戳索引刻意只保留每个密集块的
t0(48 kHz 采集否则会物化约 2900 万个时间戳),于是播放按块速率步进(约12 Hz); - 每一步播放器用精确时间戳匹配读帧值——对密集块恰好匹配一个样本(
t0处那一个),每块其余约 4095 个样本从未被回放。波形被混叠到块速率,FFT 窗口永远填不满; - spec-0054 为全块回放造的机制(
stream_blocks→ decode → sink-masked block publish)仍然存在,但只读旧stream_blocks表,而 spec-0055 的录音让该表保持为空——没有人把密集回放移植到统一的blocks表上。
6.2 需求 R11 与修复
R11:回放会话录音时,密集流通道数据必须以录制时的采样率重现,而非以播放步进速率重现;每个密集块的每一个样本都必须通过 sink-masked 的回放发布到达仪表盘的绘图与 FFT 路径(R8 不变)。旧
stream_blocks录音与逐样本(readings/ 不规则blocks)录音按原样回放(R9 不变)。
实现位于 core/Storage/Sessions/PlayerLoaderWorker.cpp:
- 密集行查询(L186-L187):
SELECT block_id, source_id, unique_id, t0_ns, dt_ns, frames FROM blocks WHERE session_id = ? AND dt_ns != 0 ORDER BY t0_ns ASC, block_id ASC; - 每条密集行同时:继续只向时间戳索引贡献
t0(不变),并新增一条带fromBlocks = true标记的PlayerStreamBlockIndex条目(L213-L221)——block_id作为行 id,携带source_id、unique_id、t0_ns、dt_ns、frames;frames <= 0或超过kMaxBlockFrames的行跳过; - 旧
stream_blocks表仍按原样加载、fromBlocks = false(L314-L337); - 两次加载后,合并向量按
(t0Ns, sourceId, rowId)稳定排序(L445-L452)——分组遍历injectStreamBlocksAt依赖同源在相同t0下连续,而旧表各自的ORDER BY从未为多源时刻保证这一点; fetchStreamSamples按条目标记从blocks.values_blob或stream_blocks.samples取 blob,两者共用同一unpackStreamSamples编解码器(写入端对两张表都用packStreamSamples);- 逐样本通道(
frameValuesFromBlocks)的游标查询加上AND dt_ns = 0,避免密集块的t0样本通过帧通道被二次注入,同时每步不再解码整个 4096 样本 blob 去取一个值; replayBlock在首次发布前确保源的结构已发布(ProjectFile 模式、从m_frame取源帧)——T12.7b 修复了"密集-only 回放从不宣布结构,仪表盘建零 widget、available永不翻转"的问题,重复成本只是一次structureIsCurrent探测。
6.3 验收(AC11–AC13)
- AC11维护者实测:音频 Quick Plot 会话回放波形无失真、FFT 显示实时频谱,scrub 与 settle 不空白音频图;
- AC12混合项目(帧通道 + 密集通道)的会话两条通道都回放,回放不产生新录音(R8 复查);
- AC13旧 spec-0054 的
stream_blocks录音(fixture 或归档文件)仍然回放。
七、会话报告:列出的每个数据集都必须有图(R10)
报告的空洞本质上是 R1/R2 的下游:报告是"被记录样本"的忠实读者,在录音本身为空时,它自然会"列出 635、只画 4"。因此 spec 明确选择不在报告读取器里打补丁(那会掩盖真正的漏洞),而是让报告缺口随 R1/R2 一起消失,并用测试锁死(见 plan.md 的 Tradeoffs 表)。
R10 在源码层的体现见 core/Storage/Sessions/ReportData.cpp:报告为每个数据集维护汇总统计(L164-L176 附近说明 per-block 汇总不带平方和,因此块支撑的会话报告宁可不报告标准差,也不报告错误值;另有专门的"每个数据集非数值样本计数"逻辑),绘图数据则有固定预算的降采样机制(L532-L594 附近:"按预算追加等距样本"、"按时间顺序拷贝选中样本"、"写原始样本或固定预算降采样序列")。AC8 要求:从同时含脚本/表格驱动与密集流数据集的录音生成报告,断言报告列出的每个数据集都携带绘图数据与汇总统计——列出却无样本的报告直接判失败。
八、三层回归防线与验收全景
Spec 的验收标准 AC1–AC10 覆盖四个层级,构成"未来任何发布路径改动都无法再静默清空录音"的保证:
| 层级 | 载体 | 覆盖的 AC / 需求 |
|---|---|---|
| C++ ctest(无头、无设备、QtCore-only) | app/tests/tst_republish_lanes.cpp | AC2、AC3 的时间戳/通道序部分;车道不对称性是确定性锁(集成层无法强制管线线程交错) |
| 维护者构建的 C++ 套件 | tst_csv_sparse_writer扩展(多源乱序批仍产出非负、非递减 elapsed) | AC2 |
pytest 集成(app + API,localhost:7777) | tests/integration/test_export_replay_fidelity.py:表驱动覆盖、交错屏蔽刷新、两个 CSV 时间戳契约、回放不重录 | AC5、AC8 交叉验证、R8 |
| hotpath 门禁 | --benchmark-hotpath(PGO 优化二进制,256 kHz 默认速率全层级通过) | AC10 |
| 维护者实测(真实现场项目) | 短采集 → CSV/MDF4/会话三种回放 + 会话报告 | AC9:无时间列弹窗、仪表盘填充、报告绘制引擎与 CAN 组 |
集成测试文件的结构印证了分层意图:TestRepublishLaneFidelity(L202)中,test_export_lane_survives_interleaved_masked_refreshes(L212)被明确标注为"coverage,not a lock"——"无法强制流通道在管线线程上造成的交错,此处通过不代表车道规则成立,tst_republish_lanes才是确定性锁"。这是测试纪律的示范:能确定性的地方用单元层锁死,无法确定性的地方如实标注为覆盖而非锁。
静态校验同样是流程的一部分:scripts/code-verify.py 对每个改动文件--check、qt-cpp-review覆盖 hotpath 与线程敏感模块、提交前跑 scripts/sanitize-commit.py。
九、遗留问题与边界(坦率记录)
spec 与 tasks 文件对未完成项做了显式、不回避的记录,这本身就是可信度的体现:
- 构建与基准未跑:
.claude/settings.json的 deny 列表禁止了cmake/ninja/make与编译器,所有 C++ 改动 lint 干净、人工复查过但未编译,AC10(--benchmark-hotpath)未运行; - MDF4/Sessions 回放通道映射(tree-order vs uniqueId-order)被放弃:字段项目在 635 个中的第 583 个索引处分叉,但从未被证明会在真实原因找到后错配数据——值得单独开 spec,而不是在这里做投机性重写;
--verify-export-replayCLI 往返模式被放弃:没有构建就无法编写和验证;- 录音文件大小是明确的非目标,却仍是最大的实际问题:约 93k 行/s × 636 列 ≈ 60 MB/s,
CSVExportInterval是现有可用杠杆,需要单独 spec; - App 在持续 API churn 下会卡死:多次加载项目与打开播放器后,
io.disconnect超时 95 s,resetData卡顿(T6.3)是贡献者之一,但未必是唯一原因; - 存量录音不可修复:磁盘上已有的现场 CSV/MDF4/会话文件只含 4 个数据集,本次改动无法修复它们;spec 建议对"打开含无样本源结构的稀疏录音"的警告属于 size/robustness spec 的范围。
维护者 2026-08-19 的最终确认状态是:现场仪表盘稳定;CSV、MDF4、会话回放都以实时节奏填充每个 widget 且数值平滑;回放从不重录;录音携带全部 635 个数据集;会话 PDF 报告生成正常。
十、经验总结:数据保真度问题的通用解法
从 spec 0064 中可提炼出几条可迁移到任何数据采集/回放系统的工程原则:
- 录音保真度必须按"渲染 = 记录"断言:任何渲染在仪表盘上的数据集,都必须到达每个已启用的 sink。把这条作为验收标准,而不是等用户去数 CSV 列数;
- 两条发布路径共享状态是最隐蔽的丢数据来源:masked 刷新消费 change-driven 时钟导致 export 通道"以为"无需发布——分离记账(
RepublishGate)比修补单个丢失步骤更根本; - 时间戳原点由"最早样本"而非"第一个 block"决定,且导出端的修复与播放端的容忍必须同时做(R3+R4 是故意拆开的两个需求),否则要么存量文件不可回放、要么新文件时间无意义;
- 回放必须被屏蔽在录音 sink 之外,且屏蔽标记要挂在数据块(
DataBlock::masked)上而非某个成员状态上,否则批量回放 + 延迟 flush 会把回放内容重录成新文件; - 确定性锁优先于"能跑"的集成测试:无法强制线程交错的场景,如实标注为 coverage,把不变量下沉到 QtCore-only 的单元层;
- 测试纪律体现在"先回退到已知良好状态,再按证据重放":Task 8 的重置与重新合入,比在失控的工作树上继续叠加更安全。
对 Serial Studio 而言,这次修复沉淀下来的 RepublishGate.h、CSV 时间戳契约、回放批量与屏蔽机制,以及 tst_republish_lanes.cpp 与 test_export_replay_fidelity.py 三层防线,将长期保护"635 个数据集只有 4 个被记录"这类事故不再复发。后续读者若想深入了解,可从 dataflow.md 的 "republish lanes" 一节、FrameBuilder.cpp 与 Export.cpp 的对应实现继续跟进。
【免费下载链接】Serial-StudioOpen-source telemetry dashboard. Supports UART, BLE, MQTT, Modbus, CAN Bus and more.项目地址: https://gitcode.com/GitHub_Trending/se/Serial-Studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考