fix(cfg): 热重载读侧三点一致性校验——掐断打回出厂与保存吞段(#73)
CI / 静态门禁 (pre-commit) (push) Failing after 8m2s
CI / 主机侧单测 (MSVC) (push) Successful in 40s
CI / 静态门禁 (pre-commit) (pull_request) Failing after 5m10s
CI / 主机侧单测 (MSVC) (pull_request) Successful in 37s

真机 #73:运行中改配置触发热重载,卷层(dhara)偶发返回旧/短
视图时,引擎把该视图当成文件全文:逐段按缺省解析后整盘打回
出厂默认且不自愈;此后任何一次保存又按缩小的视图做手术式
重写,文件越写越小(台架 1066B→986B,[sntp] 段整体丢失),
即真机对时静默 10.5 小时的元凶。

处置(引擎侧根因无关防御;卷层回滚归 #79/#81 已合并的 FTL
修复域):
- poll:读完加三点校验(读长==stat.size、读前后 re-stat 的
  size/mtime 一致),判为旧/短视图则 RAM 不动、基线不刷新、
  每 2 拍自动重试自愈;首次拒绝留 1 条 DBG_ERROR+syslog,
  之后每 30 拍一条 WARN 提示重启收敛。
- save:同型三点校验,视图与 stat 不符返回 -1 且文件不动,
  调用方保留 dirty 下拍重试,吞段链在第一次保存即被掐断。
- 边界:「一致但陈旧」的整卷回滚引擎层无法区分,靠重启
  收敛,已写入设计说明风险表与文档口径。

测试:新增 TC-CONFIG-058/059/060(旧视图保持 RAM、保存短
视图拒写、读中改写拒绝+日志限速)。cfg_file 239/239;
iec104 179、run_par 67、adc_cal 117、sntp 98、
cfg_file_store_mcu 34、net_cfg 48、ftp_cfg 16、prot_set 90、
prot_dim 38 全绿;/keil-build 0 Error 0 Warning。

文档:配置文件设计说明 §(3)/§5.3/风险表/变更记录 v1.17;
单元测试说明 058–060 行;网络配置说明与产品需求 FR-CFG-3
同步口径;台账随新鲜 .map 由 ram_baseline --update 回填。
This commit is contained in:
2026-11-05 12:35:46 +08:00
parent 0d5cd1c8d1
commit d9303e05a4
9 changed files with 258 additions and 17 deletions
@@ -349,7 +349,7 @@ flowchart LR
**FR-CFG-3 变更检测与立即应用**
- 需求:FTP 上传配置后无需重启即生效;网络段变化触发网络重建。
- 实现:`defaultTask` 每 2 s `f_stat` 比对 (size, mtime),连续 2 次稳定才重载(沉降去抖);`[network]` 段变化 → `SYS_EVT_NET_RECONFIG` → 网络重建(代次 +1,服务自愈)。
- 实现:`defaultTask` 每 2 s `f_stat` 比对 (size, mtime),连续 2 次稳定才重载(沉降去抖);重载/保存前过视图一致性守卫(2026-11-05)——读到的视图与 stat 不符(卷层旧/短视图)时拒绝应用(RAM 保持当前配置、2s 重试)并拒绝按坏视图落盘,杜绝坏视图打回出厂默认与吞段(issue #73);`[network]` 段变化 → `SYS_EVT_NET_RECONFIG` → 网络重建(代次 +1,服务自愈)。
- 判据:FTP 改 IP 后 2~5 s 生效(网络重建含等链路 ≤3 s)。
**FR-CFG-4 旧档迁移**(已取消 · 2026-10-10)
+1 -1
View File
@@ -81,7 +81,7 @@ mac=00:00:00:00:00:00
- **加载校验(逐键容错)**:单个键值非法(越界/格式错)不影响其他键——非法键回工厂默认并留痕(`parse: [network] <键> value invalid`);文件缺失 → 自动生成本模板;文件 > 4KB = 损坏 → 工厂默认 + 重建模板(坏配置不带崩网络)。
- **保存(外科手术式)**:注释 / 未知段 / 未知键逐行保留,受管键按 RAM 副本原位改写、缺失键补写;写盘 = 临时文件 `/config.TMP` + `f_rename` 原子替换(掉电不损坏目标文件)。
- **外部变更自动重载**(P4 阶段接入):2s 轮询检测 (size, mtime) 变化、4s 沉降(连续 2 次轮询稳定)后重载 `[network]` 段并重建网络——FTP 上传编辑后的文件无需重启即生效。
- **外部变更自动重载**(P4 阶段接入):2s 轮询检测 (size, mtime) 变化、4s 沉降(连续 2 次轮询稳定)后重载 `[network]` 段并重建网络——FTP 上传编辑后的文件无需重启即生效。重载/保存均过**视图一致性守卫**(2026-11-05):卷层给回与 stat 不符的旧/短视图时拒绝应用/拒绝落盘(RAM 配置不会被坏视图打回出厂默认,文件也不会被短视图重写吞段),2s 重试自愈;若串口持续出现 `[CFG] poll ... HELD` / `WARN ... held` 告警,重启一次即收敛。机制详见 `docs/子系统设计/配置文件设计说明.md` §5.3。
完整设计(引擎 / 双端 / 并发模型 D15)见 `docs/子系统设计/配置文件设计说明.md`。
@@ -215,7 +215,7 @@ flowchart TD
- **超长守卫(v1.12)**:外科手术输出超 `CFG_FILE_MAX_LEN`(4096B)→ **拒绝保存返回 -1**(原文件不动 + 自检上报 `cfg save overflow`);`s_write_doc`(模板/外科同闸的唯一落盘出口)硬性拒写 >4096B —— "引擎永不产出超长 config.ini"是结构性质。超长文件一律按损坏处置(load/poll 同判,见 §5.3)
**(3) 变更检测** —— `cfg_file_poll()`(defaultTask / 模拟器主循环,2s 节拍):`f_stat` 取 (size, mtime-epoch) 与基线比对 → 沉降后**先按真实 `st.size` 查超长**(v1.12):>4096B = 损坏 → 各段复位工厂默认 + 重建模板 + 自检留痕(`cfg poll oversized`),**不触发段回调**(保留 FTP/在线修复通道,下次保存或重启收敛);正常路径重载 → 段变化则触发该段回调 → 刷新基线。
**(3) 变更检测** —— `cfg_file_poll()`(defaultTask / 模拟器主循环,2s 节拍):`f_stat` 取 (size, mtime-epoch) 与基线比对 → 沉降后**先按真实 `st.size` 查超长**(v1.12):>4096B = 损坏 → 各段复位工厂默认 + 重建模板 + 自检留痕(`cfg poll oversized`),**不触发段回调**(保留 FTP/在线修复通道,下次保存或重启收敛);正常路径重载先过**视图一致性守卫**(v1.17,§5.3):stat → read → 再 stat 三点必须一致——读长 ≠ `st.size` 或读期间 (size, mtime) 变化 = 卷层给了旧/短视图或读中被改写 → 拒绝应用(RAM 不动、基线不刷新,2s 重试自愈);一致后重载 → 段变化则触发该段回调 → 刷新基线。
**(4) 段变更回调** —— 未来模块的接入点:
@@ -300,6 +300,7 @@ sequenceDiagram
- 检测粒度 2s(轮询周期);网络重建含等链路 ≤3s,总生效时间约 2~5s
- 下载天然支持(RETR 即文件读)
- **沉降去抖(D15-②)**:(size, mtime) 相对基线变化且**连续 2 次轮询(4s)稳定**才重载——防 FTP STOR 进行中的中间态触发多次重载(STOR 先写 `.TMP`,目标文件 (size, mtime) 在 rename 前不变,rename 单次原子更新 → 至多一次重载)
- **视图一致性守卫(D15-②延伸,v1.17,issue #73 台架实证)**:沉降触发重载后校验 stat → read → 再 stat 三点一致(读长 == `st.size` 且读期间 (size, mtime) 不变)。不一致视图若照单应用 = 全段静默打回工厂默认(真机 8/8 复现、10.5h 对时沉默元凶),若照短视图外科保存 = 重写整档吞段(实测 1066B→986B)。拒绝语义:poll **不动 RAM、不刷基线**(2s 重试,视图收敛即自愈;持续到重启则 boot 全新挂载必然收敛);save **返回 -1**(调用方保留 dirty 标志重试)原文件一字节不动。首次拒绝 ERROR + 自检留痕(`cfg poll view stale` / `cfg save view stale`),poll 此后每 30 趟(≈60s)一条 WARN 计数行(限速)。**边界**:内容一致但陈旧的整卷视图((size,mtime) 恰好相同)引擎层无法检出——属卷层(dhara/FTL)领域,重启收敛是最后防线;运行期"改过配置后重启一次"在卷层根治前始终是最稳口径
- **单写者(D15-③)**:`/config.ini` 的直接写者收敛为 defaultTask(UI 保存 = 请求投递至 defaultTask,由其落盘(tmp+rename)+ 应用(触发网络重建),复用既有 `SYS_EVT_NET_RECONFIG` 路径);唯一例外 = FTP STOR 路径(lftpd,tmp+rename,与 defaultTask 写路径经卷锁互斥)
### 5.4 线程安全(FatFS 可重入)
@@ -554,6 +555,7 @@ void adc_cfg_init(void)
| FTP STOR 半截写(掉电 / 断链) | 中 | 逐键容错 + 工厂回退;0 字节 STOR 自动删文件(lftpd 现状)→ 下次上电重新生成模板;下次保存自愈 |
| 墙钟无效(开机 RTC 读取失败且默认校时失败) | 低 | mtime 退化为固定值(检测退化为 size);既有开机告警链 + 默认校时(RTC_DEFAULT_INIT)兜底,实际极罕见 |
| mtime 秒边界碰撞(同秒两次写) | 低 | (size, mtime) 双比对;2s 轮询通常跨秒;误报 = 多一次重载(无害) |
| 卷层给回旧/短视图(dhara FTL 缓存 / 整卷回滚,台架 issue #73 实证) | 高 | 引擎三点视图一致性守卫(§4-(3) / §5.3):stat 与 read 不符 → poll 拒绝应用 / save 拒绝落盘,坏视图既不碰 RAM 也不重写文件;"一致但陈旧"的整卷视图引擎不可检出,属卷层课题,重启收敛兜底 |
| save 堆峰值与 LVGL / lftpd 争用 | 低 | 事件触发(罕见);FR_TIMEOUT 兜底;实施后 map 复核堆空闲 |
| 8.3 文件名约束 | 无 | `config.ini` 合规;段名 / 键名属文件内容,不受限 |
| 卷重新格式化丢配置 | 中(现状一致) | 与现状相同(f_mkfs 清空卷);文档提示 |
@@ -591,3 +593,4 @@ void adc_cfg_init(void)
| 2026-10-10 | v1.14:**删除旧档 NCFG 迁移(D10 撤销)**——`Drivers/BSP/NETCFG/net_cfg_legacy.{c,h}` 整体移除、`cfg_store_legacy_path()` 撤销(MCU / 模拟器后端与主机单测 `test/host/cfg_file/cfg_file_test.c` 同步删除),`net_cfg_load` 不再调用 `net_cfg_legacy_migrate()`;旧档 `/cfg/netcfg.bin` 若存在则被**忽略**(不读、不删、不迁移),配置回落工厂默认。理由:该一次性路径把整条配置写链挂进启动期调用图,defaultTask 静态最坏栈链 **+600 B**(该链整体 5312 → 2776 B,另三个杠杆见 `docs/内存管理/RAM使用分析与优化方案.md`),而本仓库不存在携带旧档的设备。连带影响:`App/util` 的 CRC-16/MODBUS 失去唯一调用者(现为零调用者);模拟器 `--cfgtest` 不再覆盖迁移场景;§2 D10 / §3.5 / §4.2 / §5.1 / §6.2 / §6.4 / §7 / §9 / §11.1 同步 |
| 2026-10-26 | v1.15:**新增 `[comm]` 段(道G task-8,「UI 补齐」波次里的非 UI 前置项)**——`App/protocol/iec104/iec104_comm.h/.c` 配对模块(104 公共地址 / 监听端口 2 键,getter 合法带自愈,adc_cal 先例);内核前 `iec104_comm_init()` 注册([adc_cal] / [run_par] 同「先注册后 load」契约);生效 = **改后重启**(不挂回调、不新增事件位,区别于 [network] 免重启重建);段占位 5→6 / 8,键表与主站地址不做理由 → `docs/子系统设计/网络配置说明.md` §3.6;机器钉 = `test/host/cfg_file` TC-CONFIG-056/057(段/键上限)+ `test/host/iec104` TC-IEC104-031~033(模板/重载/带外/ca 端到端)
| 2026-11-04 | v1.16:**新增 `[sntp]` 段(板端 SNTP 客户端接入,#57 调研结论落地)**——`Drivers/BSP/NETCFG/sntp_cfg.h/.c`(enable / server1 / server2 / poll_min 4 键;出厂默认**关闭 + 服务器留空 + 60 分钟** = "未配置零网络足迹"口径;`poll_min` 带外自愈 1..1440 → 60 不写回 RAM);数据本体 = `Drivers/BSP/NETCFG/sntp_cfg.h/.c`(不依赖引擎头,分层门禁 layer-deps 口径),段描述符由 `net_cfg.c` 的 `s_sntp_register()` 组装,注册编排 = `net_cfg_load()`(与 [ftp] 同编排,先注册后 load);消费方 = `App/sync/time_sntp.c`(挂 timeSyncTask 既有循环,vendor `Lib/coreSNTP` serializer 做 48B 报文编解码/防欺骗校验);保存立即生效 = 每轮直读 RAM 副本 + 网络代次重建弃 UDP socket 重散布,**无段回调、不新增事件位**;段占位 6→7 / 8;键表与生效细则 → `docs/子系统设计/网络配置说明.md` §3.7;机器钉 = `test/host/net_cfg` TC-NETCFG-005/006b..d/007(段注册契约)+ `test/host/sntp`(RFC 4330 §10 节奏 / §5.3 防欺骗 / §5.5 步进策略 / poll 钳位)
| 2026-11-05 | v1.17:**热重载/保存路径视图一致性守卫(issue #73 台架实证根治)**——真机 8/8 复现"运行中改配置触发热重载打回出厂默认"且此后任何保存吞段(1066B→986B):卷层在 stat→read 窗口给回旧/短视图,引擎照单应用(全段 memcpy 默认、零解析错误行)或照短视图外科重写文件。① `cfg_file_poll` 沉降读取后三点校验(读长 == `st.size` + 读后复 stat (size, mtime) 不变):不符 → **拒绝应用**(RAM 原样 / 基线不刷新 / 2s 重试自愈),首次拒绝 `cfg poll view stale` 自检 + ERROR 一行,此后每 30 趟(≈60s)一条 WARN 计数;② `cfg_file_save` 同族守卫:视图与 stat 不符 → **拒存返回 -1**(调用方保留 dirty 重试)原文件一字节不动 + `cfg save view stale` 自检——"短视图 → 外科重写 → 非默认段被删"链就此斩断;③ 边界如实披露:一致但陈旧的整卷视图引擎层无法检出(dhara/FTL 属卷层),重启必然收敛,卷层根治前"改过配置后重启一次"仍是最稳口径。机器钉 = TC-CONFIG-058(poll HELD + 自愈)/ 059(save 拒存文件不动)/ 060(读中改写检出 + 日志限速);`cfg_file.c` v1.6 / `cfg_file.h` v1.5 契约同步
@@ -162,8 +162,11 @@ test/host/cfg_file/cfg_file_test.c
| **TC-CONFIG-054** | poll 检出超长 → 工厂默认 + 重建模板 + 留痕;**不触发回调** | 在线修复通道保留 |
| **TC-CONFIG-056** | `[comm]` 段容量钉:段数上限 8、键数上限 16 写进断言(改上限必须同批改本行与用例) | 段/键上限是配置引擎的公开契约,不能靠"跑起来看看" |
| **TC-CONFIG-057** | 段/键写满后再新增 → 拒绝保存且原文件不动(满则拒,不是静默截断) | 溢出语义与 TC-CONFIG-043 同源,覆盖 `[comm]` 新增段 |
| **TC-CONFIG-058** | 卷层旧视图(读长 ≠ stat 长)→ poll 拒绝应用:RAM 保旧值、基线保留重试、视图追平即自愈应用 + 回调;拒绝期间零回调、全程 1 ERROR + 1 留痕 | 2026-11-05 视图一致性守卫(issue #73 热重载打回出厂默认根治) |
| **TC-CONFIG-059** | save 侧同族守卫:视图 ≠ stat → 拒存 -1、原文件一字节不动 + 自检留痕;视图追平后可再存 | 斩断"短视图 → 外科重写 → 非默认段被吞"链(实测 1066B→986B 形态) |
| **TC-CONFIG-060** | 读中改写(读后复 stat 令牌变)→ HELD 后重新沉降自愈;持续不一致:全程仅 1 ERROR + 1 留痕,WARN 每 30 趟一条 | 拒绝分支日志限速(防 2s 节拍日志风暴) |
**当前结果**:139/139 PASSED(`failed=0`)。
**当前结果**:239/239 PASSED(`failed=0`)。
---
@@ -175,6 +178,8 @@ test/host/cfg_file/cfg_file_test.c
需"内容变但令牌不变"的边界用 `vfs_put_notoken(text)`;
4. 用 `vfs_get()` 取当前文件文本做检索断言(注释/未知段是否保留等);
5. 用故障注入开关 `s_fail_read / s_fail_write / s_fail_alloc / s_fail_stat` 构造故障路径;
视图不一致用 `vfs_mark_stale()`(read 冻结在旧快照、stat 报真实盘)/
`vfs_clear_stale()`(视图追平 = 自愈),读中改写用一次性开关 `s_touch_on_read`;
6. `CHECK(cond, "描述")` 断言;在 `main()` 里注册。
> ⚠ **`vfs_put` 与 `vfs_put_notoken` 的区别很关键**:poll 依 `(size, mtime)` 判变更,