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
+71
View File
@@ -29,6 +29,13 @@
* len 1/2/4 小端补码)——s_decode_int() 用 strtol + ERANGE +
* 宽度上下界校验,s_encode() 带负号输出;负零点偏移从此可
* 干净落盘(动因见 docs/任务规划/数据可信.md §4.4 与 §7 决策 3)
* v1.6 2026-11-05 DTU 项目组 热重载/保存视图一致性守卫(issue #73 SNTP 补验揪出)——
* poll stat→read→复 stat 三点不一致(空读/截断旧视图或读中改写)
* 拒绝应用:RAM 与基线不动、2s 重试自愈、首次自检留痕 + 每 30 趟
* WARN 限速;save 读视图与 stat 基准不符拒写(断"短视图→整档
* 外科重写→非默认段被删"吞段链,台架实测 1066B→986B)。
* 台架根因为卷层 dhara 旧视图回滚(≥8/8 复现),引擎侧无法
* 分辨"一致但陈旧"的整卷回滚——该形态重启必收敛,见设计说明
* 函数说明:引擎状态非重入——调用方须收敛同一任务(defaultTask:poll/save;
* 启动期 load 先于任务竞争);FS 卷并发由 FatFS REENTRANT 卷锁保证
* (见《配置文件设计说明》D15 A+ 方案)
@@ -102,6 +109,8 @@ static cfg_file_stat_t s_last; /* 上次 poll 的 stat */
static uint8_t s_stable; /* 连续相同轮询计数(沉降去抖) */
static uint8_t s_base_valid; /* 基线是否已建立 */
static uint8_t s_snap[CFG_SNAP_MAX];
static uint16_t s_reject_streak; /* 连续被拒重载计数(视图不一致;0 = 无进行中
拒绝;饱和不回绕,仅用于告警限速节奏) */
static int s_sec_hdr[CFG_FILE_MAX_SECTIONS]; /* 段头行号(0xFFFF = 未见) */
static uint16_t s_key_line[CFG_FILE_MAX_SECTIONS][CFG_FILE_MAX_KEYS]; /* 受管键行号(0xFFFF = 未见) */
@@ -1240,6 +1249,26 @@ int cfg_file_save(void) {
return -1;
}
/* 2026-11-05 视图一致性守卫(真机台架实证,issue #73 SNTP 补验揪出):
* stat 与 read 之间卷层可给出旧视图(dhara 缓存回滚/FTL 视图滞后)。旧实现
* 直接按"读到的视图"外科重写整档,视图偏短时非默认段随重写一并被静默删掉
* (实测 1066B → 986B,[sntp] 段整段蒸发)。读长与 stat 报长不符、或读期间
* 文件被改写 → 拒存:原文件不动、返回 -1;属主任务脏位保留 = 下一趟自然
* 重试(defaultTask 50ms 循环 / iec104 下轮 poll 投递,均自带重试语义) */
{
cfg_file_stat_t st2;
if (in_len != (size_t)st.size ||
cfg_store_stat(cfg_store_config_path(), &st2) != 0 ||
st2.size != st.size || st2.mtime != st.mtime) {
cfg_store_free(p_in);
DBG_ERROR("save: view %luB != stat %luB, aborted (file untouched)",
(unsigned long)in_len, (unsigned long)st.size);
syslog_post(SYSLOG_SELFTEST, "cfg save view stale");
return -1;
}
}
/* Pass 1 在独立副本上切分(避免破坏原文的行结束符);
* 副本在 p_out 分配前释放,堆峰值维持 p_in + p_out(≤ CFG_HEAP_BUDGET,
* 编译期已校验,见文件常量区) */
@@ -1371,6 +1400,46 @@ void cfg_file_poll(void) {
if (s_load_file(&p_buf, &len) != 0) {
return; /* 读失败:保留,下次 poll 重试 */
}
/* 2026-11-05 视图一致性守卫(与 save 侧同族,真机台架实证 issue #73):
* stat → read → 再 stat 三点必须一致——读长 ≠ st.size(空读/截断 =
* 卷层旧视图;台架 8/8 复现的"热重载打回出厂默认"即空读形态:全段
* 静默 memcpy 默认、零解析错误行)或读期间文件被改写 = 读到的内容不
* 对应变更令牌。不一致 → 拒绝应用:RAM 副本原样保留、基线不刷新,
* 下一趟(2s)重试,视图恢复一致即正常应用(自愈);若持续到重启,
* boot 路径全新挂载必然收敛。首次拒绝自检留痕 + ERROR 一行,此后每
* 30 趟(约 60s)一条 WARN 计数行(限速,防日志风暴) */
{
cfg_file_stat_t st2;
int stale;
if (len != (size_t)st.size) {
stale = 1; /* 空读/截断/旧视图 */
}
else if (cfg_store_stat(cfg_store_config_path(), &st2) != 0 ||
st2.size != st.size || st2.mtime != st.mtime) {
stale = 1; /* 读期间文件消失或被改写 */
}
else {
stale = 0;
}
if (stale != 0) {
cfg_store_free(p_buf);
if (s_reject_streak < 0xFFFFu) {
s_reject_streak++;
}
if (s_reject_streak == 1u) {
DBG_ERROR("poll: view %luB != stat %luB, HELD (RAM kept)",
(unsigned long)len, (unsigned long)st.size);
syslog_post(SYSLOG_SELFTEST, "cfg poll view stale");
}
else if ((s_reject_streak % 30u) == 0u) {
DBG_INFO("WARN: cfg reload held %u rounds, reboot to converge",
(unsigned)s_reject_streak);
}
return; /* 基线不刷新 = 同一变更下趟仍触发重试;RAM 不动 */
}
}
for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) {
const cfg_sec_t *p_sec = &s_sections[i];
@@ -1395,6 +1464,8 @@ void cfg_file_poll(void) {
DBG_INFO("poll: config file reloaded");
}
s_reject_streak = 0u; /* 应用成功(含超长重建)= 拒绝期结束 */
/* 重载后刷新基线(回调触发的写盘不引发二次重载) */
{
cfg_file_stat_t st2;
+14 -4
View File
@@ -18,6 +18,10 @@
* v1.4 2026-10-25 DTU 项目组 「数据可信」有符号零点类型:新增 CFG_VAL_INT 有符号十进制值类型
* (len = 1/2/4,小端补码)——标定零点偏移等负值需有符号
* 落盘,动因与取舍见 docs/任务规划/数据可信.md §4.4
* v1.5 2026-11-05 DTU 项目组 契约同步视图一致性守卫(issue #73 SNTP 补验揪出):
* poll 读视图与 stat 令牌不符 = 拒绝应用(RAM/基线不动、2s
* 重试自愈);save 读视图不符 = 拒存返回 -1(原文件不动)。
* 细节见 cfg_file.c v1.6 与《配置文件设计说明》变更节
*/
#ifndef CFG_FILE_H
@@ -116,7 +120,10 @@ int cfg_file_load(void);
* 值原位改写,缺失段/键补写;文件缺失/损坏 → 按 RAM 副本重建
* 完整文档(含文件头注释);写盘 = 临时文件 + 原子改名;输出超
* CFG_FILE_MAX_LEN → 拒绝保存返回 -1(原文件不动 + 自检上报,
* 引擎写盘闸口同拒)
* 引擎写盘闸口同拒);读视图与 stat 基准不符(空读/截断/读中
* 被改写)同样拒存返回 -1——防按短视图外科重写把文件写小、
* 非默认段被静默删除(2026-11-05 视图一致性守卫,调用方脏位
* 保留 = 下一趟自然重试)
*/
int cfg_file_save(void);
@@ -129,9 +136,12 @@ int cfg_file_save(void);
* 函数说明:(size, mtime 变更令牌) 相对基线变化且连续 CFG_FILE_SETTLE_CNT
* 次轮询稳定 → 先查 st.size 超 CFG_FILE_MAX_LEN = 损坏 → 工厂默认
* + 重建模板 + 自检留痕(不触发段回调:保留在线修复通道,重启
* 收敛);否则重载全部段(各段复位工厂默认后解析);挂回调
* 段有差异 → 调用其回调(如触发网络重建);重载后刷新基线
* (回调触发的写盘不引发二次重载)
* 收敛);否则过视图一致性守卫(读长 = st.size 且读前后 (size,
* mtime) 不变;不符 = 卷层旧视图/读中被改写 → 拒绝应用:RAM 与
* 基线不动,2s 重试自愈,持续到重启则由 load 全新挂载收敛;首次
* 拒绝自检留痕,其后每 30 趟一条 WARN)后重载全部段(各段复位
* 工厂默认后解析);挂回调段有差异 → 调用其回调(如触发网络重建);
* 重载后刷新基线(回调触发的写盘不引发二次重载)
*/
void cfg_file_poll(void);