perf(reader): M32 r2 书架索引改 level-1 校验 — 真机冷缓存首开 3.47s→0.13–0.16s(r1 全目录校验被真机证伪)

r1 用宿主 1.1µs/stat + M28 的**热** stat 150µs 外推「434 次≈65ms」,真机 drop_caches 后
实测是 3.15s(7.26ms/次),与 readdir 的 3.0s 同量级:全目录校验的索引命中冷启动仍要
3.06s,只比全扫快 0.2s。「3.0s→0.15s」在冷启动不成立。按任务 1 自己定的判据
(stat 接近 1s 就退回 level-1)改为只校验 Books 根 + 每个一级目录。

真机冷缓存实测(exFAT /usr/data/mnt/sd_0,434 目录/646 文件/595 本;sync + drop_caches;
探针走 /proc/uptime,UI 走 meow_ui_time_ms):
- 索引命中首开:**127 / 138 / 157ms**(复测 147ms),热 dentry 107–120ms;
- 全量扫描首开:**3428 / 3477ms**(复测 3711ms),热 dentry 不变(readdir 不吃 page cache);
- 探针冷分项:索引载入 20–30ms;载入+rdprog×595 70–120ms;4 个目录 stat 0–30ms;
- 从阅读页返回书架 29–31ms(M29 语义未变)。
→ 冷启动 22–26 倍提升,方案值得做;索引实测 **63,579B**(v2;r1 全目录 97,490B)。

实现(格式 v2,magic MEOWIDX2 / version 2):
- writer 回调过滤 rel 含 '/' 的目录:只落 root + 一级目录的 mtime;书/大小/格式记录仍全量;
  level-2+ 不落盘、载入不 stat。载入仍然 0 次 opendir/readdir(strace:0 getdents64,
  真实库 4 次 stat;宿主模拟库 11 次)。
- 校验记录里出现 level-2+ rel 视为坏档;sum(books_here) 改为「≤ nbooks」合理性上界。
- fail-open/原子写/CRC/安全加固/ts 不缓存全部保留(r1 已过评审的部分未动)。

失效矩阵(报文逐条写清):
自动(根 mtime):Books 根下直接增/删/改名书;一级目录新增/删除/改名。
自动(一级目录 mtime):一级目录里直接增/删/改名书、新建子目录。
必须刷新:level-2 及更深处的任何变化(只改深层目录 mtime)。刷新=删索引+全扫。
真机 exFAT mtime 实测 **1 秒**(tm_nsec=0;同秒连续增删 mtime 不变)。我复测 10 轮
「目录刚改过就扫描、同秒再改」→ 9/10 仍命中(陈旧),与评审 29/30 一致;sleep 跨秒后
全部正常失效。头注释已把「10ms」改为「1 秒」,并写明「不在同一秒刻度的变更靠索引失效,
同秒二次变更需刷新」。

bench 修复(P3-3):MEOW_BENCH_IDX=only —— **不预先 load/scan**,直接进书架量生产首开;
miss —— 先删索引再进书架。两者配合 drop_caches 即冷启动口径。旧行为(先 bench_index/
bench_scan 焐热)保留为默认模式。新增 test/shelf_index_probe_mipsel(write/scan/load/
loadts/statpaths/mtime/size)用于真机分项与 mtime 粒度验证,不进发行包。

测试:shelf_index_smoke 170 pass(新增:level-3/level-2 内变更「索引仍命中且先看到旧
列表 → 刷新后可见」、新建 level-2 目录自动失效、一级目录直接删书自动失效、v2 ndirs=
根+一级数);ASan+UBSan 干净;build_reader.sh 0 warning,core 6846 / seg 19 / chapter 71 /
shelf_group 63 / index 170 / font 14 / loaders PASS。真机 6 场景逐项验证:一级直接增/
新建子目录/一级改名/根下删书/一级直接删书 = 自动失效;level-3 深处新增 = 仍命中(需刷新)。

交付:app.json 0.1.5/code6 → **0.1.6/code7**;reader md5 5c848c99… 1,711,988B;
mpk md5 10d676f5… 814,574B;未签名不 push。设备验证后已清理 /tmp 与 m32probe,
真实 MeowData 的 shelf.idx 已删(app 自己的章节 .idx 未动),Books 434/646 不变,
已装 0.1.4 md5 4036b889… 未变。
This commit is contained in:
AskaEth committed 2026-11-10 18:49:48 +08:00
1 parent e0dfab95c3
commit 58f2d9bff4
10 files changed
+309 -57

No files matched your search

+16 -6
View File
@@ -1,14 +1,18 @@
/* app/shelf_index.c — 书架索引落盘/校验(实现见 app/shelf_index.h)。
*
* 落盘格式 v1(全部小端、手工序列化,不依赖结构体布局):
* [header 24B] "MEOWIDX1" | u32 version | u32 header_size | u32 root_len | u32 rsv
* 落盘格式 v2(全部小端、手工序列化,不依赖结构体布局):
* [header 24B] "MEOWIDX2" | u32 version | u32 header_size | u32 root_len | u32 rsv
* [root bytes]
* [dir 记录 ×N] u32 rel_len | u32 books_here | u64 mtime_sec | u32 mtime_nsec | u32 rsv | rel
* 只含 Books 根(rel="")与一级目录(rel 不含 '/');level-2+ 不落盘。
* [book 记录 ×M] u32 rel_len | u32 size | u32 fmt | u32 rsv | rel
* [trailer 24B] u32 ndirs | u32 nbooks | u32 flags | u32 depth_skipped | u32 crc32 | u32 magic
* crc32 覆盖 [0, trailer_off)(含 header/root/全部记录),用来发现截断/坏页。
* rel 一律相对 Books 根,路径做 ".."/绝对路径/内嵌 NUL 校验,杜绝索引坏档造成越界读。
*
* 校验范围只有根 + 一级目录(真机冷缓存 434 次 stat=3.15s,4 次≈20ms;见头文件)。
* level-2 及更深处的变更不会自动失效,需点「刷新」——这是本方案明确接受的代价。
*
* 写入顺序:临时文件 `shelf.idx.tmp` 全量写完(flash 尽力 fsync)后 rename 发布。
* 扫描中途被杀只会留下 tmp(下次覆盖),正式索引要么是旧的、要么是完整新的。
*/
@@ -31,7 +35,7 @@
#define IDX_BOOK_REC_FIX 16 /* book 记录定长部分 */
#define IDX_MAX_SIZE (8 * 1024 * 1024)
static const unsigned char IDX_MAGIC[8] = { 'M','E','O','W','I','D','X','1' };
static const unsigned char IDX_MAGIC[8] = { 'M','E','O','W','I','D','X','2' };
/* ---- 小端序列化 ---- */
static void put32(unsigned char *p, uint32_t v)
@@ -244,7 +248,8 @@ int shelf_index_load(const char *root, shelf_book *books, int cap,
rootbuf[root_len] = 0;
if (strcmp(rootbuf, root) != 0) goto done; /* 换了卡/挂载点 → 重扫 */
/* 目录记录:逐个 stat 比对 mtime(唯一允许的校验 IO,不做 readdir)。 */
/* 校验记录:只有 Books 根 + 一级目录(v2)。逐个 stat 比对 mtime(唯一允许的校验 IO,
* 不做 readdir)。level-2+ 的 rel 含 '/',出现在 v2 文件里即视为坏档。 */
uint32_t sum_here = 0;
for (uint32_t i = 0; i < ndirs; i++) {
unsigned char rh[IDX_DIR_REC_FIX];
@@ -260,6 +265,7 @@ int shelf_index_load(const char *root, shelf_book *books, int cap,
if (rel_len > 0) crc = crc_step(crc, rel, rel_len);
rel[rel_len] = 0;
if (!rel_valid(rel, (int)rel_len, 1)) goto done;
if (rel_len > 0 && strchr(rel, '/')) goto done; /* v2 只允许一级 */
char path[400];
if (join_path(root, rel, (int)rel_len, path, sizeof path) != 0) goto done;
@@ -268,7 +274,7 @@ int shelf_index_load(const char *root, shelf_book *books, int cap,
if (!S_ISDIR(st.st_mode)) goto done;
if ((uint64_t)(int64_t)st.st_mtime != mt_sec) goto done;
if ((uint32_t)(long)st.st_mtim.tv_nsec != mt_nsec) goto done;
sum_here += books_here;
sum_here += books_here; /* 只作合理性上界:根+一级里直接放的书写数 ≤ nbooks */
}
/* 书记录 */
@@ -299,7 +305,8 @@ int shelf_index_load(const char *root, shelf_book *books, int cap,
/* 必须正好读到 trailer 起点(多/少字节都视为坏档) */
if (ftell(f) != trailer_off) goto done;
if ((crc ^ 0xFFFFFFFFu) != crc_expect) goto done;
if (sum_here != nbooks) goto done;
/* v2 只校验根+一级:books_here 之和是「根/一级里直接放的书写数」,不可能超过总书数。 */
if (sum_here > nbooks) goto done;
/* 校验全过:现读进度 ts(与全量扫描同一口径),重建副题并按最近阅读排序。 */
for (uint32_t i = 0; i < nbooks; i++) {
@@ -386,6 +393,9 @@ void shelf_index_writer_dir(void *ctx, const char *rel, int64_t mtime_sec,
{
shelf_idx_writer *w = (shelf_idx_writer *)ctx;
if (!w || !w->ok || !rel || books_here < 0) return;
/* v2:只落 Books 根("")与一级目录(rel 不含 '/')——真机冷缓存下 434 次 stat=3.15s,
* 4 次≈20ms;level-2+ 的 mtime 不参与校验(深层变更靠「刷新」,见头文件)。 */
if (rel[0] && strchr(rel, '/')) return;
size_t rl = strlen(rel);
if (rl > IDX_REL_MAX) { w->ok = 0; return; }