perf(reader): M32 书架索引落盘 — 冷启动只 stat 434 个目录 mtime,跳过 readdir(3.0s→~0.15s)

M29 只解决了「回书架不重扫」;首次进书架仍要全量扫 434 目录(真机 3.0–3.4s,
其中 opendir+readdir 占 96%)。本单把扫描结果 + 每个扫描目录的 mtime 存到
MeowData/os.meow.reader/index/shelf.idx,冷启动逐目录 stat 比对 mtime,命中即
完全跳过 readdir;任何异常一律 fail-open 全量重扫。

先量再选(任务 1):宿主 434 目录/595 本/51 rar 虚拟库(贴真机量级)
- 434 次目录 stat = 0.48ms/pass(1.1µs/次,不含 readdir);
- 读 72KB 索引 = 0.009ms;全量扫描 3.54ms;索引命中 1.29ms(ts_fn=NULL)。
真机按 M28 实测 1079 次 stat=160–170ms(≈150µs/次)外推:434 次 ≈ 65ms,
远低于「可能不可行」的 1s 阈值 → 选「校验全部扫描目录 mtime」(非只 level-1)。
strace 断言:indexonly 载入 0 次 getdents64、恰好 434 次 stat(真的没 readdir)。
生产路径 ≈ 65ms(stat) + <20ms(索引读) + 40–50ms(rdprog×595) + <20ms(build_rows)
≈ 0.13–0.15s(对比 3.0s)。

索引格式 v1(手工小端序列化,不依赖结构体布局):
header(magic/version/header_size/root_len) + root + dir 记录×(rel,mtime_s,
mtime_ns,books_here) + book 记录×(rel,size,fmt) + trailer(ndirs,nbooks,flags,
depth_skipped,crc32,magic)。truncated/depth_skipped 存在 flags/字段里,载入时
如实重建提示条;**不存阅读进度 ts**,载入后每本由 rdprog_load 现读再排序。

失效语义(存每个扫描过的目录,不只 level-1):
- level-2 新增 / level-3 深处新增 / 删书 → 该目录 mtime 变 → 重扫;
- 一级目录改名 → 父目录 mtime 变 + 旧目录 stat ENOENT → 重扫;
- 超深(>6 层)未扫描子树不在索引内,其内部变化不自动失效(本来就不显示),
  点「刷新」更新 depth_skipped 计数;mtime 精度受限(exFAT 10ms/FAT32 2s)的
  同刻度变更也可能漏判,点「刷新」必可纠正。
M29 语义不变:首次进书架=索引或全扫;回书架=不扫;刷新=删索引+全扫(之后原子
写回新索引)。

安全/健壮(宁可慢不可错):
- 写:shelf.idx.tmp 全量写完(尽力 fsync)后 rename;中途被杀只剩 tmp,正式索引
  要么旧要么完整新;rename 覆盖失败先删旧再改名;fflush/写错即放弃,绝不影响书架。
- 读:O_NOFOLLOW|O_NONBLOCK + S_ISREG(FIFO/符号链接直接拒绝不阻塞);CRC32 覆盖
  全部记录;rel 做绝对路径/“..”/内嵌 NUL/长度校验;版本/根路径/计数/逐目录 mtime
  任一不符即重扫;载入崩溃路径全部 goto 清理,不改书架可用性。
- writer_open 失败(卡满/写保护/无 MeowData)→ 扫描不采集目录、照常出书;
  commit 失败(含只读目录 rename 失败)不留正式索引。

测试:test/shelf_index_smoke 154 pass(含四种变更场景失效、截断/字节翻转/垃圾/
缺失/FIFO/符号链接/版本不符/根路径不符、ts 现读、truncated/depth_skipped 重建、
只读目录写失败、空 Books、root 缺失、.tmp 残渣),ASan+UBSan 干净;
test/shelf_index_bench 模拟 434 目录/595 本/51 rar:scan/load 均 595 本、
truncated=0/depth_skipped=0、索引 72,156B。build_reader.sh 全绿 0 warning
(core 6846 / seg 19 / chapter 71 / shelf_group 63 / index 154 / font 14 /
loaders PASS)。shelf_bench_ondev 增加 index_load/scan_save 计时与
MEOW_BENCH_IDX=miss(真机首开命中 vs 未命中对比,待有卡复核)。

交付:app.json 0.1.4/code5 → 0.1.5/code6;reader md5 df4cadcf
1,711,924B;mpk md5 fec7b1bb 814,535B;未签名不推卡。
This commit is contained in:
AskaEth committed 2026-11-10 18:14:52 +08:00
1 parent 3f09f7c137
commit e0dfab95c3
11 files changed
+1388 -29

No files matched your search

+2
View File
@@ -11,6 +11,8 @@
/test/font_resolve_smoke_host
/test/font_resolve_bench_host
/test/shelf_scan_bench_host
/test/shelf_index_smoke_host
/test/shelf_index_bench_host
/test/shelf_scan_bench_mipsel
/test/rdft_smoke_mipsel
/test/book_smoke_mipsel
+1 -1
View File
@@ -1 +1 @@
{"name":"Reader","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.reader/reader","package":"os.meow.reader","version":"0.1.4","version_code":5,"framework":1,"fonts":"common","author":"MeowOS","icon":"icon.png","volume_page":true}
{"name":"Reader","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.reader/reader","package":"os.meow.reader","version":"0.1.5","version_code":6,"framework":1,"fonts":"common","author":"MeowOS","icon":"icon.png","volume_page":true}
+58 -18
View File
@@ -22,11 +22,17 @@
* M30:M29 的「刚读完不置顶」缺口补上(只刷一本书的 ts + 内存重排,不重扫全盘)。
* M31:置顶时一并重算这本的副题(shelf_fmt_sub),「· 读」立即出现,不等下一次全量刷新;
* 顶栏「刷新」同时丢弃字体路径缓存(新拷入的字库此时生效)。
* M32:书架索引落盘(app/shelf_index.c)。冷启动先试 `MeowData/os.meow.reader/index/shelf.idx`:
* 版本/CRC/root 路径/全部目录 mtime 校验通过 → 直接出书,**完全跳过 readdir**
* (真机全量扫描 3.0s 的 96% 是 434 个目录的 opendir+readdir;校验只做 434 次 stat);
* 任何异常 → 照常全量重扫(fail-open)。扫描完成后原子写回索引(写失败只丢缓存,不影响书架)。
* M29 语义不变:首次进书架=索引或全扫;从阅读页返回=不扫;顶栏「刷新」=清索引+全扫。
*/
#include "../reader_internal.h"
#include "font_resolve.h"
#include "progress.h"
#include "reader_conf.h"
#include "shelf_index.h"
#include "shelf_model.h"
#include "meow_ui_internal.h" /* meow_gfx_fill_round(宫格卡圆角填充) */
#include <stdio.h>
@@ -77,40 +83,69 @@ static void notice_set(const char *line1, const char *line2)
g_notice_until = meow_ui_time_ms() + 6000; /* 6s 后由 shelf_on_tick 清掉,不常驻压列表 */
}
/* 扫描 Books/ 并写入 g_books/g_nbooks(同时更新提示条)。首次进入书架与顶栏「刷新」才调。 */
static void scan_books(void)
/* 扫描/索引载入共用的报告 → 提示条文案(M29 语义不变)。
* 索引里存了 truncated/depth_skipped,载入时如实重建,避免「用索引打开」后提示失真。 */
static void notice_from_report(const shelf_scan_report *rep)
{
const char *dir = READER_SD_ROOT "/Books";
g_notice[0] = 0;
g_notice2[0] = 0;
g_notice_ms = 0;
shelf_scan_report rep;
g_nbooks = shelf_scan_books(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep);
g_scanned = 1;
if (rep.root_missing) {
if (rep->root_missing) {
char line[160];
snprintf(line, sizeof line, "未找到 %s 目录,请放入书籍(TXT)", dir);
notice_set(line, NULL);
} else if (rep.truncated && rep.depth_skipped > 0) {
} else if (rep->truncated && rep->depth_skipped > 0) {
/* 长句拆两行(480px 单行会被切掉) */
char l1[160], l2[160];
snprintf(l1, sizeof l1, "书籍超过 %d 本,已只显示前 %d 本", SHELF_MAX_BOOKS, SHELF_MAX_BOOKS);
snprintf(l2, sizeof l2, "另有 %d 个文件目录超过 %d 层未显示",
rep.depth_skipped, SHELF_SCAN_MAX_DEPTH);
rep->depth_skipped, SHELF_SCAN_MAX_DEPTH);
notice_set(l1, l2);
} else if (rep.truncated) {
} else if (rep->truncated) {
char line[160];
snprintf(line, sizeof line, "书籍超过 %d 本,已只显示前 %d 本",
SHELF_MAX_BOOKS, SHELF_MAX_BOOKS);
notice_set(line, NULL);
} else if (rep.depth_skipped > 0) {
} else if (rep->depth_skipped > 0) {
char line[160];
snprintf(line, sizeof line, "有 %d 个文件因目录层数超过 %d 层未显示",
rep.depth_skipped, SHELF_SCAN_MAX_DEPTH);
rep->depth_skipped, SHELF_SCAN_MAX_DEPTH);
notice_set(line, NULL);
}
}
/* 全量扫描 Books/ 并写回索引(首次无索引 / 刷新 / 索引校验失败时走这里)。
* 索引写盘是纯附加:open 失败(卡满/写保护/无 MeowData)→ 传 NULL 闭包,扫描照常;
* commit 失败只丢缓存。**索引问题绝不影响书架能否打开**。 */
static void scan_books_full(void)
{
const char *dir = READER_SD_ROOT "/Books";
shelf_scan_report rep;
/* 扫描时把每个目录的 mtime 直接流进索引临时文件,不占额外内存、也不二次 readdir。 */
shelf_idx_writer *w = shelf_index_writer_open(dir);
g_nbooks = shelf_scan_books_ex(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep,
w ? shelf_index_writer_dir : NULL, w);
if (w) shelf_index_writer_commit(w, g_books, g_nbooks, &rep); /* 成功/失败都接管 w */
if (rep.root_missing) shelf_index_remove(dir); /* 卡/目录没了:旧索引作废,别留陈旧缓存 */
g_scanned = 1;
notice_from_report(&rep);
}
/* 首次进入书架:先试索引(命中=只 stat 目录 + 读进度,不 readdir);任何异常退回全扫。 */
static void scan_books_first(void)
{
const char *dir = READER_SD_ROOT "/Books";
shelf_scan_report rep;
if (shelf_index_load(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep)) {
g_nbooks = rep.nbooks;
g_scanned = 1;
notice_from_report(&rep);
return;
}
scan_books_full();
}
/* 排队行:folder 模式按目录分段,否则按格式(默认)。 */
static void build_rows(void)
{
@@ -304,12 +339,13 @@ static void shelf_on_enter(meow_view *v, void *ctx)
const rdconf *c = rdconf_get();
g_layout = (c && c->shelf[0] && strcmp(c->shelf, "grid") == 0) ? 1 : 0;
g_group_folder = (c && strcmp(c->shelf_group, "folder") == 0) ? 1 : 0;
/* 书架缓存:本进程只在首次进入时扫一次 Books/(从阅读页返回不再重扫)。
* 扫描在真机是 3.1s(exFAT 434 目录 opendir/readdir),是「回书架冻结」的唯一大头。
* 代价:新放入的书不会自动出现,需点顶栏「刷新」(强制重扫)。
/* 书架缓存:本进程只在首次进入时取一次 Books/(从阅读页返回不再重扫)。
* M32:首次先试索引(命中=只 stat 目录,跳过 readdir);索引不可用才全量扫描。
* 扫描/校验在真机是 3.1s / ~0.1s 的差别(exFAT 434 目录 opendir/readdir 是扫描的唯一大头)。
* 代价:进程内不再自动发现新书,需点顶栏「刷新」;跨进程重启则由索引 mtime 校验兜底。
* M30:刚读完那本例外——promote_just_read() 只读它一个进度文件并重排,低成本置顶。
* 提示条(超上限截断 / 超深目录)语义不变:首次扫描时算好并保留在 g_notice*。 */
if (!g_scanned) scan_books();
* 提示条(超上限截断 / 超深目录)语义不变:索引里存了这两个值,载入时如实重建。 */
if (!g_scanned) scan_books_first();
else promote_just_read();
build_rows();
const meow_theme *th = meow_theme_get();
@@ -437,10 +473,14 @@ int shelf_on_tap(int x, int y)
} else if (bi == 1) { /* 分组切换:format ↔ folder */
shelf_set_group(g_group_folder ? 0 : 1);
return 1;
} else if (bi == 2) { /* 刷新:强制重扫(缓存失效 + 重扫;新书/刚读完的书在此出现) */
} else if (bi == 2) { /* 刷新:清索引 + 全量重扫(新书/改名/删书在此出现) */
g_scanned = 0;
font_cache_invalidate(); /* 字体路径记忆化也一并失效(新拷入的字库在此生效) */
scan_books(); build_rows();
/* 先删索引再全量扫描:即使旧索引 mtime 校验碰巧通过(如文件系统时间戳精度不足),
* 刷新也一定重扫;扫描成功后写回新索引(写失败只丢缓存)。 */
shelf_index_remove(READER_SD_ROOT "/Books");
scan_books_full();
build_rows();
shelf_build_content();
shelf_redraw();
return 1;
+498
View File
@@ -0,0 +1,498 @@
/* app/shelf_index.c — 书架索引落盘/校验(实现见 app/shelf_index.h)。
*
* 落盘格式 v1(全部小端、手工序列化,不依赖结构体布局):
* [header 24B] "MEOWIDX1" | 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
* [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 校验,杜绝索引坏档造成越界读。
*
* 写入顺序:临时文件 `shelf.idx.tmp` 全量写完(flash 尽力 fsync)后 rename 发布。
* 扫描中途被杀只会留下 tmp(下次覆盖),正式索引要么是旧的、要么是完整新的。
*/
#include "shelf_index.h"
#include "meow_ui.h" /* meow_config_dir() */
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#define IDX_HDR_SIZE 24
#define IDX_TRL_SIZE 24
#define IDX_TRL_MAGIC 0x314D5749u /* "IWM1" */
#define IDX_REL_MAX 1000 /* 单条 rel 上限(真机 <100B,留足余量) */
#define IDX_DIR_REC_FIX 24 /* dir 记录定长部分 */
#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 void put32(unsigned char *p, uint32_t v)
{
p[0] = (unsigned char)(v & 0xFF); p[1] = (unsigned char)((v >> 8) & 0xFF);
p[2] = (unsigned char)((v >> 16) & 0xFF); p[3] = (unsigned char)((v >> 24) & 0xFF);
}
static uint32_t get32(const unsigned char *p)
{
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static void put64(unsigned char *p, uint64_t v)
{
put32(p, (uint32_t)(v & 0xFFFFFFFFu));
put32(p + 4, (uint32_t)((v >> 32) & 0xFFFFFFFFu));
}
static uint64_t get64(const unsigned char *p)
{
return (uint64_t)get32(p) | ((uint64_t)get32(p + 4) << 32);
}
/* ---- CRC32(IEEE 802.3,查表;表首次使用时生成 1KB) ---- */
static uint32_t g_crc_tbl[256];
static int g_crc_ready;
static void crc_table_init(void)
{
for (uint32_t i = 0; i < 256; i++) {
uint32_t c = i;
for (int k = 0; k < 8; k++)
c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
g_crc_tbl[i] = c;
}
g_crc_ready = 1;
}
static uint32_t crc_step(uint32_t c, const void *buf, size_t n)
{
if (!g_crc_ready) crc_table_init();
const unsigned char *p = (const unsigned char *)buf;
while (n--) c = g_crc_tbl[(c ^ *p++) & 0xFF] ^ (c >> 8);
return c;
}
/* ---- 路径推导:<MeowData>/<pkg>/data → <MeowData>/<pkg>/index/shelf.idx ---- */
static void strip_data_suffix(const char *d, char *out, int outsz)
{
if (!d || !d[0]) { strncpy(out, "/tmp", (size_t)outsz - 1); out[outsz - 1] = 0; return; }
size_t n = strlen(d);
const char *suffix = "/data";
size_t sl = strlen(suffix);
size_t keep = (n >= sl && strcmp(d + n - sl, suffix) == 0) ? n - sl : n;
if (keep >= (size_t)outsz) keep = (size_t)outsz - 1;
memcpy(out, d, keep);
out[keep] = 0;
}
/* mkdir -p(逐分量,EEXIST 视为成功)。 */
static int mkdir_p(const char *path)
{
char tmp[520];
if (!path || !path[0] || strlen(path) >= sizeof tmp) return -1;
memcpy(tmp, path, strlen(path) + 1);
for (char *p = tmp + 1; *p; p++) {
if (*p != '/') continue;
*p = 0; mkdir(tmp, 0755); *p = '/';
}
mkdir(tmp, 0755);
return 0;
}
/* 索引目录 <MeowData>/<pkg>/index(只算路径;mkdir 只在写索引时做)。 */
static int index_dir(char *out, int outsz)
{
const char *data = meow_config_dir();
if (!data) return -2;
char base[400];
strip_data_suffix(data, base, sizeof base);
if (snprintf(out, (size_t)outsz, "%s/index", base) >= outsz) return -3;
return 0;
}
int shelf_index_path(const char *root, char *out, int outsz)
{
(void)root; /* 索引跟 App 数据目录走,不跟 Books 路径走 */
if (!out || outsz <= 0) return -1;
out[0] = 0;
char dir[440];
if (index_dir(dir, sizeof dir) != 0) return -2;
if (snprintf(out, (size_t)outsz, "%s/shelf.idx", dir) >= outsz) return -5;
return 0;
}
/* ---- rel 校验:相对路径、无 ".." 分量、无内嵌 NUL、长度受控 ---- */
static int rel_valid(const char *rel, int len, int allow_empty)
{
if (len < 0 || len > IDX_REL_MAX) return 0;
if (len == 0) return allow_empty;
if (rel[0] == '/') return 0;
if (memchr(rel, 0, (size_t)len) != NULL) return 0;
int comp = 0;
for (int i = 0; i <= len; i++) {
char ch = (i == len) ? '/' : rel[i];
if (ch == '/') {
if (i - comp == 2 && rel[comp] == '.' && rel[comp + 1] == '.') return 0;
comp = i + 1;
}
}
return 1;
}
/* 目录 rel(""/a/b)拼成绝对路径;返回 0=成功。 */
static int join_path(const char *root, const char *rel, int rel_len, char *out, int outsz)
{
if (rel_len == 0) {
if ((int)strlen(root) >= outsz) return -1;
memcpy(out, root, strlen(root) + 1);
return 0;
}
int n = snprintf(out, (size_t)outsz, "%s/%s", root, rel);
return (n < 0 || n >= outsz) ? -1 : 0;
}
/* book rel → title/group/top。与 shelf_scan_books 的写法保持逐字一致
* (path 必须是同一个绝对路径,进度键 rdprog_hash(path) 才能对上)。 */
static void fill_from_rel(shelf_book *b, const char *rel, int rel_len)
{
const char *base = rel;
for (int i = 0; i < rel_len; i++) if (rel[i] == '/') base = rel + i + 1;
char name[256];
int nl = rel_len - (int)(base - rel);
if (nl >= (int)sizeof name) nl = (int)sizeof name - 1;
memcpy(name, base, (size_t)nl);
name[nl] = 0;
shelf_copy_utf8(b->title, (int)sizeof b->title, name);
char *dot = strrchr(b->title, '.');
if (dot) *dot = 0;
/* group = rel 去掉最后一段;top = 第一段。*/
int glen = (int)(base - rel);
if (glen > 0) glen--; /* 去掉结尾 '/' */
char g[IDX_REL_MAX + 1];
if (glen < 0) glen = 0;
memcpy(g, rel, (size_t)glen);
g[glen] = 0;
shelf_copy_utf8(b->group, (int)sizeof b->group, g);
const char *sl = strchr(g, '/');
int tlen = sl ? (int)(sl - g) : glen;
char t[IDX_REL_MAX + 1];
memcpy(t, g, (size_t)tlen);
t[tlen] = 0;
shelf_copy_utf8(b->top, (int)sizeof b->top, t);
}
/* ================= 载入 ================= */
int shelf_index_load(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep)
{
if (rep) { rep->nbooks = 0; rep->truncated = 0; rep->depth_skipped = 0; rep->root_missing = 0; }
if (!root || !books || cap <= 0) return 0;
char ipath[500];
if (shelf_index_path(root, ipath, sizeof ipath) != 0) return 0;
/* O_NOFOLLOW/O_NONBLOCK + S_ISREG:索引是 App 自己的普通文件;卡上若有同名 FIFO/符号链接,
* 直接拒绝重扫,避免 fopen 阻塞或读到卡外内容。 */
int fd = open(ipath, O_RDONLY | O_NONBLOCK | O_NOFOLLOW);
if (fd < 0) return 0;
struct stat fst;
if (fstat(fd, &fst) != 0 || !S_ISREG(fst.st_mode) || fst.st_size <= 0) {
close(fd);
return 0;
}
FILE *f = fdopen(fd, "rb");
if (!f) { close(fd); return 0; }
int ok = 0;
long fsz = (long)fst.st_size;
if (fsz < IDX_HDR_SIZE + IDX_TRL_SIZE || fsz > IDX_MAX_SIZE) goto done;
/* trailer 先读:拿 ndirs/nbooks/crc,再从头顺序校验。 */
unsigned char tr[IDX_TRL_SIZE];
if (fseek(f, fsz - IDX_TRL_SIZE, SEEK_SET) != 0) goto done;
if (fread(tr, 1, sizeof tr, f) != sizeof tr) goto done;
if (get32(tr + 20) != IDX_TRL_MAGIC) goto done;
uint32_t ndirs = get32(tr + 0);
uint32_t nbooks = get32(tr + 4);
uint32_t flags = get32(tr + 8);
uint32_t depth_skipped = get32(tr + 12);
uint32_t crc_expect = get32(tr + 16);
if (ndirs < 1 || ndirs > 65535) goto done;
if (nbooks > (uint32_t)cap || nbooks > (uint32_t)SHELF_MAX_BOOKS) goto done;
if (depth_skipped > 0x7FFFFFFFu) goto done;
long trailer_off = fsz - IDX_TRL_SIZE;
if (fseek(f, 0, SEEK_SET) != 0) goto done;
uint32_t crc = 0xFFFFFFFFu;
/* header + root */
unsigned char h[IDX_HDR_SIZE];
if (fread(h, 1, sizeof h, f) != sizeof h) goto done;
crc = crc_step(crc, h, sizeof h);
if (memcmp(h, IDX_MAGIC, 8) != 0) goto done;
if (get32(h + 8) != SHELF_INDEX_VERSION) goto done;
if (get32(h + 12) != IDX_HDR_SIZE) goto done;
uint32_t root_len = get32(h + 16);
if (root_len > IDX_REL_MAX || (int)root_len != (int)strlen(root)) goto done;
char rootbuf[IDX_REL_MAX + 1];
if (root_len > 0 && fread(rootbuf, 1, root_len, f) != root_len) goto done;
if (root_len > 0) crc = crc_step(crc, rootbuf, root_len);
rootbuf[root_len] = 0;
if (strcmp(rootbuf, root) != 0) goto done; /* 换了卡/挂载点 → 重扫 */
/* 目录记录:逐个 stat 比对 mtime(唯一允许的校验 IO,不做 readdir)。 */
uint32_t sum_here = 0;
for (uint32_t i = 0; i < ndirs; i++) {
unsigned char rh[IDX_DIR_REC_FIX];
if (fread(rh, 1, sizeof rh, f) != sizeof rh) goto done;
crc = crc_step(crc, rh, sizeof rh);
uint32_t rel_len = get32(rh + 0);
uint32_t books_here = get32(rh + 4);
uint64_t mt_sec = get64(rh + 8);
uint32_t mt_nsec = get32(rh + 16);
if (rel_len > IDX_REL_MAX || books_here > (uint32_t)cap) goto done;
char rel[IDX_REL_MAX + 1];
if (rel_len > 0 && fread(rel, 1, rel_len, f) != rel_len) goto done;
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;
char path[400];
if (join_path(root, rel, (int)rel_len, path, sizeof path) != 0) goto done;
struct stat st;
if (stat(path, &st) != 0) goto done;
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;
}
/* 书记录 */
for (uint32_t i = 0; i < nbooks; i++) {
unsigned char bh[IDX_BOOK_REC_FIX];
if (fread(bh, 1, sizeof bh, f) != sizeof bh) goto done;
crc = crc_step(crc, bh, sizeof bh);
uint32_t rel_len = get32(bh + 0);
uint32_t size = get32(bh + 4);
uint32_t fmt = get32(bh + 8);
if (rel_len < 1 || rel_len > IDX_REL_MAX || fmt > 4) goto done;
char rel[IDX_REL_MAX + 1];
if (fread(rel, 1, rel_len, f) != rel_len) goto done;
crc = crc_step(crc, rel, rel_len);
rel[rel_len] = 0;
if (!rel_valid(rel, (int)rel_len, 0)) goto done;
shelf_book *b = &books[i];
memset(b, 0, sizeof *b);
if (join_path(root, rel, (int)rel_len, b->path, sizeof b->path) != 0) goto done;
if ((int)strlen(b->path) != (int)(strlen(root) + 1 + rel_len)) goto done;
fill_from_rel(b, rel, (int)rel_len);
b->fmt = (int)fmt;
b->size = (long)size;
b->ts = 0; /* 进度不落盘:下面用 ts_fn 现读 */
}
/* 必须正好读到 trailer 起点(多/少字节都视为坏档) */
if (ftell(f) != trailer_off) goto done;
if ((crc ^ 0xFFFFFFFFu) != crc_expect) goto done;
if (sum_here != nbooks) goto done;
/* 校验全过:现读进度 ts(与全量扫描同一口径),重建副题并按最近阅读排序。 */
for (uint32_t i = 0; i < nbooks; i++) {
books[i].ts = ts_fn ? ts_fn(books[i].path) : 0;
shelf_fmt_sub(&books[i]);
}
shelf_sort_recent(books, (int)nbooks);
if (rep) {
rep->nbooks = (int)nbooks;
rep->truncated = (flags & 1u) ? 1 : 0;
rep->depth_skipped = (int)depth_skipped;
rep->root_missing = 0;
}
ok = 1;
done:
fclose(f);
return ok;
}
/* ================= 写入 ================= */
struct shelf_idx_writer {
FILE *f;
char root[400];
char dst[500];
char tmp[520];
uint32_t crc;
int ndirs;
int ok;
};
static int wraw(shelf_idx_writer *w, const void *buf, size_t n)
{
if (!w->ok) return -1;
if (fwrite(buf, 1, n, w->f) != n) { w->ok = 0; return -1; }
w->crc = crc_step(w->crc, buf, n);
return 0;
}
shelf_idx_writer *shelf_index_writer_open(const char *root)
{
if (!root) return NULL;
size_t rl = strlen(root);
if (rl == 0 || rl >= sizeof(((shelf_idx_writer *)0)->root)) return NULL;
shelf_idx_writer *w = (shelf_idx_writer *)calloc(1, sizeof *w);
if (!w) return NULL;
memcpy(w->root, root, rl + 1);
if (shelf_index_path(root, w->dst, sizeof w->dst) != 0) { free(w); return NULL; }
char idir[500];
snprintf(idir, sizeof idir, "%s", w->dst);
char *slash = strrchr(idir, '/');
if (!slash) { free(w); return NULL; }
*slash = 0;
mkdir_p(idir); /* 失败也让 fopen 去失败(只读卡 → 不写索引,书架照常) */
if (snprintf(w->tmp, sizeof w->tmp, "%s.tmp", w->dst) >= (int)sizeof w->tmp) { free(w); return NULL; }
w->f = NULL;
int fd = open(w->tmp, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, 0644);
if (fd < 0) { free(w); return NULL; }
w->f = fdopen(fd, "wb");
if (!w->f) { close(fd); unlink(w->tmp); free(w); return NULL; }
w->ok = 1;
w->crc = 0xFFFFFFFFu;
unsigned char h[IDX_HDR_SIZE];
memset(h, 0, sizeof h);
memcpy(h, IDX_MAGIC, 8);
put32(h + 8, SHELF_INDEX_VERSION);
put32(h + 12, IDX_HDR_SIZE);
put32(h + 16, (uint32_t)rl);
if (wraw(w, h, sizeof h) != 0 || wraw(w, root, rl) != 0) {
shelf_index_writer_abort(w);
return NULL;
}
return w;
}
void shelf_index_writer_dir(void *ctx, const char *rel, int64_t mtime_sec,
long mtime_nsec, int books_here)
{
shelf_idx_writer *w = (shelf_idx_writer *)ctx;
if (!w || !w->ok || !rel || books_here < 0) return;
size_t rl = strlen(rel);
if (rl > IDX_REL_MAX) { w->ok = 0; return; }
unsigned char rh[IDX_DIR_REC_FIX];
put32(rh + 0, (uint32_t)rl);
put32(rh + 4, (uint32_t)books_here);
put64(rh + 8, (uint64_t)mtime_sec);
put32(rh + 16, (uint32_t)mtime_nsec);
put32(rh + 20, 0);
if (wraw(w, rh, sizeof rh) != 0) return;
if (rl > 0 && wraw(w, rel, rl) != 0) return;
w->ndirs++;
}
int shelf_index_writer_commit(shelf_idx_writer *w, const shelf_book *books,
int nbooks, const shelf_scan_report *rep)
{
if (!w) return 0;
if (!w->ok || nbooks < 0 || (nbooks > 0 && !books) ||
w->ndirs <= 0 || w->ndirs > 65535) {
shelf_index_writer_abort(w);
return 0;
}
size_t root_len = strlen(w->root);
for (int i = 0; i < nbooks; i++) {
const shelf_book *b = &books[i];
size_t pl = strlen(b->path);
if (pl <= root_len + 1 || strncmp(b->path, w->root, root_len) != 0 ||
b->path[root_len] != '/') {
/* 路径不是扫描 root 下的相对路径(被截断/异常)→ 宁可不写索引 */
shelf_index_writer_abort(w);
return 0;
}
const char *rel = b->path + root_len + 1;
size_t rl = strlen(rel);
if (rl < 1 || rl > IDX_REL_MAX || b->fmt < 0 || b->fmt > 4 ||
b->size < 0 || (uint64_t)b->size > 0xFFFFFFFFu) {
shelf_index_writer_abort(w);
return 0;
}
unsigned char bh[IDX_BOOK_REC_FIX];
put32(bh + 0, (uint32_t)rl);
put32(bh + 4, (uint32_t)b->size);
put32(bh + 8, (uint32_t)b->fmt);
put32(bh + 12, 0);
if (wraw(w, bh, sizeof bh) != 0 || wraw(w, rel, rl) != 0) {
shelf_index_writer_abort(w);
return 0;
}
}
uint32_t flags = 0;
int depth = 0;
if (rep) {
if (rep->truncated) flags |= 1u;
if (rep->depth_skipped > 0) { flags |= 2u; depth = rep->depth_skipped; }
}
unsigned char tr[IDX_TRL_SIZE];
put32(tr + 0, (uint32_t)w->ndirs);
put32(tr + 4, (uint32_t)nbooks);
put32(tr + 8, flags);
put32(tr + 12, (uint32_t)depth);
put32(tr + 16, w->crc ^ 0xFFFFFFFFu);
put32(tr + 20, IDX_TRL_MAGIC);
if (wraw(w, tr, sizeof tr) != 0) {
shelf_index_writer_abort(w);
return 0;
}
/* 刷盘后再 rename:断电最多丢 tmp,正式索引保持旧版本(仍受 mtime 校验保护)。
* fsync 失败不算致命(FUSE/部分卡返回 EINVAL):rename 后若内容不完整,CRC 会拒绝。 */
if (fflush(w->f) != 0) {
shelf_index_writer_abort(w);
return 0;
}
(void)fsync(fileno(w->f));
if (fclose(w->f) != 0) {
w->f = NULL;
unlink(w->tmp);
free(w);
return 0;
}
w->f = NULL;
if (rename(w->tmp, w->dst) != 0) {
/* exFAT 某些实现不支持「rename 覆盖已存在文件」:先删旧再改名。 */
unlink(w->dst);
if (rename(w->tmp, w->dst) != 0) {
unlink(w->tmp);
free(w);
return 0;
}
}
free(w);
return 1;
}
void shelf_index_writer_abort(shelf_idx_writer *w)
{
if (!w) return;
if (w->f) fclose(w->f);
if (w->tmp[0]) unlink(w->tmp);
free(w);
}
void shelf_index_remove(const char *root)
{
char ipath[500];
if (shelf_index_path(root, ipath, sizeof ipath) == 0) unlink(ipath);
}
+55
View File
@@ -0,0 +1,55 @@
/* app/shelf_index.h — 书架索引落盘(M32):把一次全量扫描的结果 + 每个目录的 mtime
* 存到 `MeowData/os.meow.reader/index/shelf.idx`,冷启动时用「逐个 stat 目录 mtime」校验,
* 命中就完全跳过 readdir(真机全量扫描 3.0s 的大头是 434 次 opendir+readdir)。
*
* 安全优先(fail-open):版本不符 / 文件缺失 / 截断 / CRC 错 / 任一目录 mtime 变化 /
* 任何 IO 错误 → shelf_index_load() 返回 0,调用方照常全量重扫。**绝不**用陈旧索引
* 冒充真实书库,也绝不因为索引写失败而让书架打不开。
*
* 索引里**不存阅读进度 ts**:ts 每次由调用方的 ts_fn(生产=rdprog_load)现读,
* 否则「刚读完回书架」会显示旧的置顶顺序。索引只存与进度无关的扫描事实。
*
* 目录 mtime 失效语义(存「扫描过的每个目录」的 mtime,非只存 level-1):
* - 任意目录里新增/删除/改名文件 → 该目录 mtime 变 → 校验失败 → 重扫(含 level-3 深处);
* - 一级目录改名 → 父目录 mtime 变 + 旧目录 stat 失败 → 重扫;
* - 超深(> SHELF_SCAN_MAX_DEPTH)未扫描的子树不在索引里,其内部变化不会自动失效
* (那些书本来就不显示),需点「刷新」更新 depth_skipped 计数。
* mtime 精度受文件系统限制(exFAT 约 10ms / FAT32 2s):与上次扫描落在同一时间刻度的
* 变更可能漏判,点一次「刷新」必可纠正(刷新=清索引+全量重扫)。
*/
#ifndef SHELF_INDEX_H
#define SHELF_INDEX_H
#include "shelf_model.h"
/* 落盘格式版本:**扫描语义变了必须 +1**(新增格式扩展名/改深度/改分组成因等),
* 版本不符时旧索引直接作废重扫。 */
#define SHELF_INDEX_VERSION 1
/* 载入并校验索引。成功返回 1(books/rep 填好、已按 ts 排序,等价于一次全量扫描);
* 任何异常返回 0(fail-open,books 内容不可用,调用方必须全量重扫)。
* 成功路径**不做 opendir/readdir**:只 open/read 索引 + 逐目录 stat + 每本书 ts_fn。 */
int shelf_index_load(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep);
/* 增量写索引(扫描时逐目录上报 → 扫描结束提交)。
* open 失败返回 NULL(卡满/写保护/无 MeowData):调用方传 NULL 即可,扫描照常。
* dir 回调签名与 shelf_dir_fn 一致,可直接传给 shelf_scan_books_ex。
* commit 成功返回 1 并原子发布(临时文件 + rename);失败返回 0 且清理临时文件。
* commit 总是接管 writer(成功/失败都会释放),之后不得再用。 */
typedef struct shelf_idx_writer shelf_idx_writer;
shelf_idx_writer *shelf_index_writer_open(const char *root);
void shelf_index_writer_dir(void *ctx, const char *rel, int64_t mtime_sec,
long mtime_nsec, int books_here);
int shelf_index_writer_commit(shelf_idx_writer *w, const shelf_book *books,
int nbooks, const shelf_scan_report *rep);
void shelf_index_writer_abort(shelf_idx_writer *w);
/* 删除索引(顶栏「刷新」= 清索引 + 全量重扫;root 只用于定位,不存在也静默)。 */
void shelf_index_remove(const char *root);
/* 索引文件路径(测试/诊断用)。0=成功;负值=无数据目录/路径超长。 */
int shelf_index_path(const char *root, char *out, int outsz);
#endif /* SHELF_INDEX_H */
+31 -7
View File
@@ -100,6 +100,8 @@ typedef struct scan_ctx {
int truncated;
int depth_skipped;
shelf_ts_fn ts_fn;
shelf_dir_fn dir_fn; /* M32:目录 mtime 采集(NULL=不采集) */
void *dir_ctx;
} scan_ctx;
/* 统计 dir 子树里还能识别的书籍文件数(只数不收集)。自身留 8 层硬上限,
@@ -125,13 +127,19 @@ static int count_books_under(const char *dir, int depth)
}
/* 递归收集。rel = 相对 root 的完整目录路径(root 自身为 ""),
* top = 第一级目录名(root 自身/根下文件为 "")。 */
static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel, const char *top)
* top = 第一级目录名(root 自身/根下文件为 "")。
* dst = 本目录的 stat 结果(调用方已 stat;NULL=没有 mtime 可上报)。
* M32:扫描完一个目录后用 dir_fn 上报 (rel, mtime, 本目录收录书数),供索引做失效校验。
* books_here 只数**实际写进 books[] 的**书(被 cap 丢掉的不计),保证索引里
* sum(books_here) == nbooks,载入时可用它做一致性校验。 */
static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel,
const char *top, const struct stat *dst)
{
if (depth > SHELF_SCAN_MAX_DEPTH) return; /* 防深嵌套/循环 */
if (c->n >= c->cap) return; /* 已满:不再进新目录 */
DIR *dp = opendir(dir);
if (!dp) return;
int books_here = 0;
struct dirent *de;
while ((de = readdir(dp))) {
if (de->d_name[0] == '.') continue;
@@ -154,7 +162,7 @@ static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel, c
char subrel[SHELF_GROUP_MAX * 16];
snprintf(subrel, sizeof subrel, "%s%s%s", rel, rel[0] ? "/" : "", de->d_name);
/* 第一级目录名 = top;更深层沿用。de->d_name 在递归返回前有效。 */
scan_dir(c, path, depth + 1, subrel, depth == 0 ? de->d_name : top);
scan_dir(c, path, depth + 1, subrel, depth == 0 ? de->d_name : top, &st);
continue;
}
int f = shelf_fmt_from_ext(de->d_name);
@@ -172,12 +180,17 @@ static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel, c
b->ts = c->ts_fn ? c->ts_fn(b->path) : 0;
shelf_fmt_sub(b); /* 副题:格式 · 大小 [· 读](+ folder 子路径) */
c->n++;
books_here++;
}
closedir(dp);
if (c->dir_fn && dst)
c->dir_fn(c->dir_ctx, rel, (int64_t)dst->st_mtime,
(long)dst->st_mtim.tv_nsec, books_here);
}
int shelf_scan_books(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep)
int shelf_scan_books_ex(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep,
shelf_dir_fn dir_fn, void *dir_ctx)
{
if (rep) { rep->nbooks = 0; rep->truncated = 0; rep->depth_skipped = 0; rep->root_missing = 0; }
if (!root || !books || cap <= 0) {
@@ -191,9 +204,14 @@ int shelf_scan_books(const char *root, shelf_book *books, int cap,
}
closedir(dp);
/* root 自身的 mtime 也要上报(新增/删除 Books/ 下的一级目录会改它)。 */
struct stat rst;
int have_rst = (stat(root, &rst) == 0);
scan_ctx c;
c.books = books; c.cap = cap; c.n = 0; c.truncated = 0; c.depth_skipped = 0; c.ts_fn = ts_fn;
scan_dir(&c, root, 0, "", "");
c.books = books; c.cap = cap; c.n = 0; c.truncated = 0; c.depth_skipped = 0;
c.ts_fn = ts_fn; c.dir_fn = dir_fn; c.dir_ctx = dir_ctx;
scan_dir(&c, root, 0, "", "", have_rst ? &rst : NULL);
shelf_sort_recent(books, c.n);
if (rep) {
rep->nbooks = c.n;
@@ -203,6 +221,12 @@ int shelf_scan_books(const char *root, shelf_book *books, int cap,
return c.n;
}
int shelf_scan_books(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep)
{
return shelf_scan_books_ex(root, books, cap, ts_fn, rep, NULL, NULL);
}
/* ---- 行序列 ---- */
/* 分组键 = top(Books 第一级目录名)。长度截到第一个 '/',排序时忽略大小写。 */
+14 -1
View File
@@ -51,6 +51,13 @@ typedef struct shelf_row {
/* 进度时间戳查询(生产=rdprog_load;测试/基线=NULL,全部 ts=0)。 */
typedef uint64_t (*shelf_ts_fn)(const char *path);
/* M32:扫描时逐目录回调(书架索引的失效校验数据源)。
* rel = 相对 root 的目录路径(root 自身为 ""),books_here = 该目录里**实际收录**的
* 书籍文件数,mtime = 该目录的 stat 结果(秒 + 纳秒)。
* 回调**不能中止扫描**(返回 void):索引写盘失败只应放弃索引,书架必须照常出书。 */
typedef void (*shelf_dir_fn)(void *ctx, const char *rel, int64_t mtime_sec,
long mtime_nsec, int books_here);
/* 最近阅读排序:ts 降序,同 ts 按书名(不区分大小写)。扫描后与「刚读完置顶」共用。 */
void shelf_sort_recent(shelf_book *books, int nbooks);
@@ -63,7 +70,13 @@ typedef struct shelf_scan_report {
/* 递归扫描 root(目录深度 ≤ SHELF_SCAN_MAX_DEPTH,超限文件计数但不出书),
* 按 ts 降序/书名升序排序后写 books(容量 cap)。
* 返回写入本数。空目录不产生任何记录(段头由 build_rows 按书生成,天然无空段头)。 */
* 返回写入本数。空目录不产生任何记录(段头由 build_rows 按书生成,天然无空段头)。
* dir_fn 非 NULL 时,每进入并成功扫描一个目录回调一次(见 shelf_dir_fn)。 */
int shelf_scan_books_ex(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep,
shelf_dir_fn dir_fn, void *dir_ctx);
/* 同 shelf_scan_books_ex(不采集目录 mtime),供旧调用方/基准复用。 */
int shelf_scan_books(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep);
+9 -2
View File
@@ -7,6 +7,8 @@
# reader mipsel 静态 ELF(链 meow_ui.a + freetype + miniz + libmobi + zlib)
# test/test_reader_core_host 宿主 core 单测(enc/layout/txt)
# test/shelf_group_smoke_host 宿主书架分组单测(M27/M31 副题)
# test/shelf_index_smoke_host 宿主书架索引单测(M32:命中/失效/fail-open/ts 不缓存)
# test/shelf_index_bench_host 宿主索引耗时基准(M32,只编不跑;bench/indexonly)
# test/font_resolve_smoke_host 宿主字体路径记忆化单测(M31)
# test/font_resolve_bench_host 宿主字体 walk/命中耗时对比(M31,只编不跑)
# test/loaders_smoke_host 宿主四格式 loader 单测
@@ -119,6 +121,11 @@ echo "==> host shelf group test (M27)"
$HOST_CC -O2 -Wall -Wextra -Iapp -I../sdk test/shelf_group_smoke.c app/shelf_model.c app/progress.c app/reader_conf.c -o test/shelf_group_smoke_host
./test/shelf_group_smoke_host
echo "==> host shelf index test (M32)"
$HOST_CC -O2 -Wall -Wextra -Iapp -I../sdk test/shelf_index_smoke.c app/shelf_model.c app/shelf_index.c app/progress.c -o test/shelf_index_smoke_host
./test/shelf_index_smoke_host
$HOST_CC -O2 -Wall -Wextra -Iapp -I../sdk test/shelf_index_bench.c app/shelf_model.c app/shelf_index.c -o test/shelf_index_bench_host
echo "==> host font resolve test (M31)"
$HOST_CC -O2 -Wall -Wextra -Iapp test/font_resolve_smoke.c app/font_resolve.c -o test/font_resolve_smoke_host
./test/font_resolve_smoke_host
@@ -169,7 +176,7 @@ run_qemu 150 "$QEMU_BIN" ./test/formats_smoke_mipsel "${FONT:-$ROOT/font/src/MiS
echo "==> reader binary"
CORE_L="core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdchapter.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c"
ENGINE_L="engine/rdft.c"
APP_L="app.c app/shelf.c app/shelf_model.c app/font_resolve.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c"
APP_L="app.c app/shelf.c app/shelf_model.c app/shelf_index.c app/font_resolve.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c"
$CC -Os -static -Wall -Wextra -I../sdk -I../meow_launcherd -I. -Icore -Iapp -Iengine $FT_INCS $COREINC \
$CORE_L $APP_L $ENGINE_L "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o \
../sdk/meow_ui.a "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -lpthread -o reader
@@ -181,7 +188,7 @@ echo " 运行(qemu): qemu-mipsel ./reader"
# ---------- 8) 真机耗时基准(不进发行包:与 reader 同源,只把 app.c 的 main 换成 bench main) ----------
# 用法(设备):MEOW_FONT_* 等 env 与 App 一致时执行 ./shelf_bench_ondev_mipsel [书...]
echo "==> ondev shelf bench (not shipped)"
APP_BENCH_L="app/shelf.c app/shelf_model.c app/font_resolve.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c"
APP_BENCH_L="app/shelf.c app/shelf_model.c app/shelf_index.c app/font_resolve.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c"
$CC -Os -static -Wall -Wextra -I../sdk -I../meow_launcherd -I. -Icore -Iapp -Iengine $FT_INCS $COREINC \
test/shelf_bench_ondev.c $CORE_L $APP_BENCH_L $ENGINE_L "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o \
../sdk/meow_ui.a "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -lpthread -o test/shelf_bench_ondev_mipsel
@@ -15,13 +15,44 @@
*/
#include "reader_internal.h"
#include "app/shelf_model.h"
#include "app/shelf_index.h"
#include "meow_ui_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
static uint64_t now_ms(void) { return meow_ui_time_ms(); }
/* M32:索引命中/未命中的耗时(不含 UI)。先在本次运行开头测:
* - index_load:读到上次运行写下的索引(首次运行 miss);
* - scan_save :全量扫描 + 落盘(生产「无索引首次进入」)。
* 之后 shelf_on_enter 会走生产路径:有索引=命中(默认),MEOW_BENCH_IDX=miss 时先删索引。 */
static void bench_index(void)
{
static shelf_book books[SHELF_MAX_BOOKS];
shelf_scan_report rep;
uint64_t t0 = now_ms();
int hit = shelf_index_load(READER_SD_ROOT "/Books", books, SHELF_MAX_BOOKS, NULL, &rep);
uint64_t t1 = now_ms();
printf("BENCH index_load_ms %llu (hit=%d n=%d trunc=%d skip=%d)\n",
(unsigned long long)(t1 - t0), hit, rep.nbooks, rep.truncated, rep.depth_skipped);
t0 = now_ms();
shelf_idx_writer *w = shelf_index_writer_open(READER_SD_ROOT "/Books");
int n = shelf_scan_books_ex(READER_SD_ROOT "/Books", books, SHELF_MAX_BOOKS, NULL, &rep,
w ? shelf_index_writer_dir : NULL, w);
int ok = w ? shelf_index_writer_commit(w, books, n, &rep) : 0;
t1 = now_ms();
char ip[500];
struct stat st;
long long isz = (shelf_index_path(READER_SD_ROOT "/Books", ip, sizeof ip) == 0 &&
stat(ip, &st) == 0) ? (long long)st.st_size : -1;
printf("BENCH scan_save_ms %llu (n=%d commit=%d index_bytes=%lld)\n",
(unsigned long long)(t1 - t0), n, ok, isz);
fflush(stdout);
}
static void bench_scan(void)
{
static shelf_book books[SHELF_MAX_BOOKS];
@@ -40,8 +71,17 @@ int main(int argc, char **argv)
if (meow_ui_init(&o) != 0) { printf("BENCH init_fail\n"); return 1; }
meow_theme_add_listener(NULL);
bench_index(); /* M32:索引命中/未命中 + 扫描落盘(不含 UI) */
bench_scan(); /* 纯扫描基线(不建 UI) */
/* 首次进书架:默认走索引命中;MEOW_BENCH_IDX=miss 先删索引 → 走全量扫描(对比用)。 */
const char *eidx = getenv("MEOW_BENCH_IDX");
if (eidx && strcmp(eidx, "miss") == 0) {
shelf_index_remove(READER_SD_ROOT "/Books");
printf("BENCH index_removed (miss path)\n");
fflush(stdout);
}
shelf_view_create();
reader_view_create();
settings_view_create();
@@ -0,0 +1,254 @@
/* test/shelf_index_bench.c — M32 索引方案可行性计时(宿主,纯 C + clock_gettime)。
*
* 用法:
* ./shelf_index_bench_host [bench|indexonly] [root]
* bench —— 建虚拟书库(默认 452 目录 / 688 本,贴合真机 434/595 量级)并计时
* indexonly —— 只载入已有索引一次(供 `strace -e getdents64` 断言「载入不 readdir」)
*
* 为什么宿主数字不能直接当真机用:宿主是 ext4/f2fs 热缓存(每次 stat ~1µs),真机 TF 卡是
* exFAT(M28 实测 1079 次 stat = 160–170ms,约 150µs/次)。本工具用来量**调用次数与代码路径**
* 的相对成本;绝对毫秒按 M28 的每次 stat/条目成本外推(见报文)。
*
* 计时口径:
* - stat_all :先 opendir 走一遍把目录列表收好(不计时),再单独循环 stat(计时的只有 stat);
* - full_scan :shelf_scan_books_ex(生产全量扫描形态,ts_fn=NULL);
* - scan+save :全量扫描 + 索引落盘(生产「无索引首次进入」路径);
* - index_load :shelf_index_load 命中(生产「有索引首次进入」路径,ts_fn=NULL 以隔离进度读);
* - index_read :单次 open+fstat+read 整个索引文件(校验之前的纯 IO 成本)。
*/
#include "shelf_model.h"
#include "shelf_index.h"
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
static char g_cfgdir[500];
const char *meow_config_dir(void) { return g_cfgdir; }
static double now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1e6;
}
static void mkdirs(const char *path)
{
char t[600];
snprintf(t, sizeof t, "%s", path);
for (char *p = t + 1; *p; p++) {
if (*p == '/') { *p = 0; mkdir(t, 0755); *p = '/'; }
}
mkdir(t, 0755);
}
static void put_file(const char *path, long size)
{
FILE *f = fopen(path, "wb");
if (!f) return;
fseek(f, size > 0 ? size - 1 : 0, SEEK_SET);
fputc('x', f);
fclose(f);
}
/* 虚拟书库:精确复刻真机量级 —— 434 个目录 / 595 本书 / 51 个 .rar(不支持格式)。
* 根 1 + 一级 10 + 「400+本高质量完本合集」下 40 分类 + 382 作品目录 + 轻小说/子目录 1 = 434;
* 书 = 382 作品 × 1 + 其它 9 个一级目录 × 20 + 根 3 + 子目录 30 = 595。 */
static int make_library(const char *root)
{
char p[600];
DIR *d = opendir(root);
if (d) { closedir(d); return 0; } /* 已存在:复用 */
mkdirs(root);
const char *l1[] = { "400+本高质量完本合集", "轻小说", "玄幻", "都市", "科幻",
"历史", "武侠", "游戏", "悬疑", "同人科幻" };
for (size_t i = 0; i < sizeof l1 / sizeof l1[0]; i++) {
snprintf(p, sizeof p, "%s/%s", root, l1[i]); mkdirs(p);
}
int works = 0, rar = 0;
/* 40 个分类目录,382 个作品目录(前 22 个分类各 10 个,其余各 9 个):
* 1 根 + 10 一级 + 40 分类 + 382 作品 + 1 子目录 = 434 个目录。 */
for (int c = 1; c <= 40; c++) {
snprintf(p, sizeof p, "%s/400+本高质量完本合集/%02d、分类%d", root, c, c); mkdirs(p);
int nw = (c <= 22) ? 10 : 9;
for (int w = 1; w <= nw; w++, works++) {
char wd[600];
snprintf(wd, sizeof wd, "%s/400+本高质量完本合集/%02d、分类%d/%02d、作品%d",
root, c, c, w, w);
mkdirs(wd);
snprintf(p, sizeof p, "%s/第1卷.txt", wd);
put_file(p, 1024 + (long)((works * 7919) % 200000));
if ((works % 7) == 0 && rar < 51) { snprintf(p, sizeof p, "%s/合集.rar", wd); put_file(p, 1000); rar++; }
}
}
for (size_t i = 1; i < sizeof l1 / sizeof l1[0]; i++) {
for (int b = 1; b <= 20; b++) {
snprintf(p, sizeof p, "%s/%s/书%d.txt", root, l1[i], b);
put_file(p, 4096);
}
}
for (int b = 1; b <= 3; b++) {
snprintf(p, sizeof p, "%s/根下%d.txt", root, b);
put_file(p, 2048);
}
snprintf(p, sizeof p, "%s/轻小说/子目录", root); mkdirs(p);
for (int b = 1; b <= 30; b++) {
snprintf(p, sizeof p, "%s/轻小说/子目录/深%d.txt", root, b);
put_file(p, 4096);
}
/* 补齐 51 个 rar(若作品目录里不够,放到一级目录) */
while (rar < 51) {
snprintf(p, sizeof p, "%s/玄幻/压缩%d.rar", root, rar);
put_file(p, 100);
rar++;
}
return 1;
}
/* 收集目录列表(不计时;用于单独量 stat 成本)。 */
static int collect_dirs(const char *dir, char paths[][512], int max, int n, int depth)
{
if (depth > 8 || n >= max) return n;
DIR *d = opendir(dir);
if (!d) return n;
struct dirent *e;
while ((e = readdir(d))) {
if (e->d_name[0] == '.') continue;
char path[512];
if (snprintf(path, sizeof path, "%s/%s", dir, e->d_name) >= (int)sizeof path) continue;
struct stat st;
if (stat(path, &st) != 0) continue;
if (S_ISDIR(st.st_mode) && n < max) {
snprintf(paths[n], 512, "%s", path);
n = collect_dirs(path, paths, max, n + 1, depth + 1);
}
}
closedir(d);
return n;
}
static int count_files(const char *dir, int depth)
{
if (depth > 8) return 0;
DIR *d = opendir(dir);
if (!d) return 0;
int n = 0;
struct dirent *e;
while ((e = readdir(d))) {
if (e->d_name[0] == '.') continue;
char path[512];
snprintf(path, sizeof path, "%s/%s", dir, e->d_name);
struct stat st;
if (stat(path, &st) != 0) continue;
if (S_ISDIR(st.st_mode)) n += count_files(path, depth + 1);
else n++;
}
closedir(d);
return n;
}
int main(int argc, char **argv)
{
const char *mode = (argc > 1) ? argv[1] : "bench";
const char *tmp = getenv("TMPDIR");
if (!tmp || !tmp[0]) tmp = "/tmp";
char base[500], root[520];
if (argc > 2) snprintf(base, sizeof base, "%s", argv[2]);
else snprintf(base, sizeof base, "%s/m32bench", tmp);
snprintf(root, sizeof root, "%s/Books", base);
snprintf(g_cfgdir, sizeof g_cfgdir, "%s/data", base);
mkdirs(g_cfgdir);
static shelf_book books[SHELF_MAX_BOOKS];
shelf_scan_report rep;
if (strcmp(mode, "indexonly") == 0) {
int hit = shelf_index_load(root, books, SHELF_MAX_BOOKS, NULL, &rep);
printf("indexonly hit=%d nbooks=%d\n", hit, rep.nbooks);
return hit ? 0 : 1;
}
int created = make_library(root);
static char dirs[4096][512];
snprintf(dirs[0], 512, "%s", root);
int nd = 1 + collect_dirs(root, dirs, 4096, 0, 0);
int nfiles = count_files(root, 0);
printf("library: %s (%s) dirs=%d files=%d\n", root, created ? "created" : "reused", nd, nfiles);
/* ① 目录逐个 stat(校验索引的唯一 IO) */
struct stat st;
int reps = 20;
double t0 = now_ms();
for (int r = 0; r < reps; r++)
for (int i = 0; i < nd; i++) stat(dirs[i], &st);
double t1 = now_ms();
printf("stat_all_dirs: %d x%d = %.2f ms total, %.3f ms/pass, %.2f us/stat\n",
nd, reps, t1 - t0, (t1 - t0) / reps, (t1 - t0) * 1000.0 / (reps * nd));
/* ② 全量扫描(无 ts) */
double best_scan = 1e9, best_save = 1e9;
int n = 0;
for (int r = 0; r < 3; r++) {
double a = now_ms();
n = shelf_scan_books_ex(root, books, SHELF_MAX_BOOKS, NULL, &rep, NULL, NULL);
double b = now_ms();
if (b - a < best_scan) best_scan = b - a;
}
printf("full_scan: books=%d truncated=%d depth_skipped=%d best=%.2f ms\n",
n, rep.truncated, rep.depth_skipped, best_scan);
int scan_trunc = rep.truncated, scan_skip = rep.depth_skipped;
/* ③ 全量扫描 + 写索引 */
for (int r = 0; r < 3; r++) {
double a = now_ms();
shelf_idx_writer *w = shelf_index_writer_open(root);
int n2 = shelf_scan_books_ex(root, books, SHELF_MAX_BOOKS, NULL, &rep,
w ? shelf_index_writer_dir : NULL, w);
int ok = w ? shelf_index_writer_commit(w, books, n2, &rep) : 0;
double b = now_ms();
if (b - a < best_save) best_save = b - a;
if (r == 0) printf("scan+save: scan_books=%d index_commit=%d\n", n2, ok);
}
printf("scan+save: best=%.2f ms\n", best_save);
/* ④ 索引命中(不含 ts 读取) */
double best_load = 1e9;
int hit = 0, ln = 0;
for (int r = 0; r < 5; r++) {
double a = now_ms();
hit = shelf_index_load(root, books, SHELF_MAX_BOOKS, NULL, &rep);
double b = now_ms();
ln = rep.nbooks;
if (b - a < best_load) best_load = b - a;
}
printf("index_load: hit=%d nbooks=%d best=%.2f ms\n", hit, ln, best_load);
/* ⑤ 索引文件大小 + 单次顺序读成本 */
char ip[600];
if (shelf_index_path(root, ip, sizeof ip) == 0 && stat(ip, &st) == 0) {
char *buf = (char *)malloc((size_t)st.st_size);
double a = now_ms();
for (int r = 0; r < 20 && buf; r++) {
FILE *f = fopen(ip, "rb");
if (f) { if (fread(buf, 1, (size_t)st.st_size, f) != (size_t)st.st_size) { /* 短读 */ } fclose(f); }
}
double b = now_ms();
printf("index_file: %lld bytes, open+read %.3f ms/pass\n",
(long long)st.st_size, buf ? (b - a) / 20 : 0.0);
free(buf);
}
/* ⑥ 生产路径估算:索引命中 + 595 次 rdprog_load(M28 实测 40–50ms,未在此重复测) */
printf("NOTE 生产路径 = index_load + rdprog_load×nbooks(M28: 40–50ms)+ build_rows(<20ms)\n");
/* ⑦ 模拟真机书库的正确性断言:595 本全可见、无截断、无超深跳过,索引与扫描同数。 */
int pass = (n == 595 && ln == 595 && hit == 1 && scan_trunc == 0 && scan_skip == 0);
printf("CHECK library_sim scan_books=%d load_books=%d hit=%d truncated=%d depth_skipped=%d -> %s\n",
n, ln, hit, scan_trunc, scan_skip, pass ? "PASS" : "FAIL");
return pass ? 0 : 1;
}
@@ -0,0 +1,426 @@
/* test/shelf_index_smoke.c — M32 书架索引宿主单测(纯 C,无设备/SDK 依赖)。
*
* 覆盖:
* I1 全量扫描 → 写索引 → 载入:书数/书名/分组/path/fmt/size/ts 全部与扫描一致
* I2 载入不缓存阅读进度:同一索引换 ts_fn 再载入,ts 立刻反映新值(含真实 rdprog 文件)
* I3 四种书库变更都必须失效(fail-open 重扫):
* level-2 新增 / level-3 深处新增 / level-1 目录改名 / 删书
* I4 坏档 fail-open:截断 / 中间字节翻转 / 垃圾内容 / 文件缺失 / 索引路径是目录 /
* 版本不符 / Books 根路径不符 —— 一律返回 0,不崩
* I5 truncated / depth_skipped 如实重建(索引里存了这两个值)
* I6 写盘失败不影响书架:索引目录不可写时 writer_open 返回 NULL、扫描照常出书;
* commit 失败不留下半截索引
* I7 空 Books / Books 不存在:空索引可载入;root_missing 不写索引
* I8 载入路径不做 readdir(由 build 验证步骤用 strace 另行断言)
*/
#include "shelf_model.h"
#include "shelf_index.h"
#include "progress.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
static int g_pass = 0, g_fail = 0;
#define CHECK(cond, msg) do { \
if (cond) g_pass++; \
else { g_fail++; printf("FAIL %s:%d %s\n", __FILE__, __LINE__, msg); } \
} while (0)
static char g_cfgdir[400];
const char *meow_config_dir(void) { return g_cfgdir; }
static void mkdirs(const char *path)
{
char t[400];
snprintf(t, sizeof t, "%s", path);
for (char *p = t + 1; *p; p++) {
if (*p == '/') { *p = 0; mkdir(t, 0755); *p = '/'; }
}
mkdir(t, 0755);
}
static void write_file(const char *path, const char *body)
{
FILE *f = fopen(path, "wb");
if (!f) { printf("无法创建 %s\n", path); exit(2); }
fputs(body, f);
fclose(f);
}
/* 进度按文件名给(I2:换 ts_fn 必须立即生效,证明索引没缓存 ts)。 */
static uint64_t g_ts_boost;
static uint64_t ts_of(const char *path)
{
const char *b = strrchr(path, '/');
b = b ? b + 1 : path;
if (strcmp(b, "1.txt") == 0) return 100 + g_ts_boost;
if (strcmp(b, "2.epub") == 0) return 50;
return 0;
}
/* 真实进度文件口径(I2 第二段)。 */
static uint64_t prog_ts_of(const char *path)
{
rdprog p;
return (rdprog_load(path, &p) == 1) ? p.ts : 0;
}
static const shelf_book *find_path(const shelf_book *books, int n, const char *path)
{
for (int i = 0; i < n; i++) if (strcmp(books[i].path, path) == 0) return &books[i];
return NULL;
}
static const shelf_book *find_title(const shelf_book *books, int n, const char *title)
{
for (int i = 0; i < n; i++) if (strcmp(books[i].title, title) == 0) return &books[i];
return NULL;
}
static int idx_path(const char *root, char *out, int outsz)
{
return shelf_index_path(root, out, outsz);
}
static int scan_and_save(const char *root, shelf_book *books, int cap, shelf_scan_report *rep)
{
shelf_idx_writer *w = shelf_index_writer_open(root);
CHECK(w != NULL, "writer_open 成功");
int n = shelf_scan_books_ex(root, books, cap, ts_of, rep,
w ? shelf_index_writer_dir : NULL, w);
if (w) CHECK(shelf_index_writer_commit(w, books, n, rep) == 1, "writer_commit 成功");
return n;
}
/* 建一棵贴合真机布局的树(含 level-3 深处、超深目录、不支持格式、空目录)。 */
static void make_tree(const char *root)
{
char p[400];
mkdirs(root);
snprintf(p, sizeof p, "%s/轻小说", root); mkdirs(p);
snprintf(p, sizeof p, "%s/玄幻", root); mkdirs(p);
snprintf(p, sizeof p, "%s/B/sub", root); mkdirs(p);
snprintf(p, sizeof p, "%s/D/E/F", root); mkdirs(p);
snprintf(p, sizeof p, "%s/G/H/I/J/K/L/M", root); mkdirs(p);
snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破", root); mkdirs(p);
snprintf(p, sizeof p, "%s/空目录", root); mkdirs(p);
snprintf(p, sizeof p, "%s/轻小说/1.txt", root); write_file(p, "a");
snprintf(p, sizeof p, "%s/轻小说/2.epub", root); write_file(p, "bb");
snprintf(p, sizeof p, "%s/轻小说/notes.md", root); write_file(p, "not a book");
snprintf(p, sizeof p, "%s/玄幻/3.txt", root); write_file(p, "ccc");
snprintf(p, sizeof p, "%s/根下.txt", root); write_file(p, "root book");
snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破/《斗破》.txt", root);
write_file(p, "doupo");
snprintf(p, sizeof p, "%s/B/sub/5.mobi", root); write_file(p, "mobi");
snprintf(p, sizeof p, "%s/D/E/F/6.txt", root); write_file(p, "deep3");
snprintf(p, sizeof p, "%s/G/H/I/J/K/L/M/7.txt", root); write_file(p, "too deep");
}
static void test_roundtrip(const char *root)
{
static shelf_book scanb[SHELF_MAX_BOOKS], loadb[SHELF_MAX_BOOKS];
shelf_scan_report r1, r2;
int n = scan_and_save(root, scanb, SHELF_MAX_BOOKS, &r1);
CHECK(n == 7, "扫描到 7 本(7.txt 超深不计)");
CHECK(r1.depth_skipped == 1 && r1.truncated == 0, "深度超限计数=1");
char ip[500];
CHECK(idx_path(root, ip, sizeof ip) == 0, "索引路径可得");
struct stat st;
CHECK(stat(ip, &st) == 0 && st.st_size > 0, "索引文件已落盘");
printf("index: %s (%lld bytes)\n", ip, (long long)st.st_size);
int n2 = shelf_index_load(root, loadb, SHELF_MAX_BOOKS, ts_of, &r2);
CHECK(n2 == 1, "索引命中");
CHECK(r2.nbooks == n && r2.truncated == r1.truncated &&
r2.depth_skipped == r1.depth_skipped && r2.root_missing == 0, "报告重建一致");
for (int i = 0; i < n; i++) {
const shelf_book *b = find_path(loadb, r2.nbooks, scanb[i].path);
CHECK(b != NULL, "载入含同一 path");
if (!b) continue;
CHECK(strcmp(b->title, scanb[i].title) == 0, "title 一致");
CHECK(strcmp(b->group, scanb[i].group) == 0, "group 一致");
CHECK(strcmp(b->top, scanb[i].top) == 0, "top 一致");
CHECK(strcmp(b->sub, scanb[i].sub) == 0, "sub 一致");
CHECK(strcmp(b->sub_folder, scanb[i].sub_folder) == 0, "sub_folder 一致");
CHECK(b->fmt == scanb[i].fmt && b->size == scanb[i].size, "fmt/size 一致");
CHECK(b->ts == scanb[i].ts, "ts 一致");
}
/* 排序:最近阅读优先 */
CHECK(r2.nbooks >= 2 && strcmp(loadb[0].title, "1") == 0,
"载入后按 ts 排序(1.txt 100 在最前)");
}
/* I2:索引不缓存 ts —— 同一份索引,换 ts_fn 载入必须立即反映新值。 */
static void test_ts_not_cached(const char *root)
{
static shelf_book b[SHELF_MAX_BOOKS];
shelf_scan_report rep;
g_ts_boost = 0;
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) == 7, "重新扫描写索引");
g_ts_boost = 900;
static shelf_book lb[SHELF_MAX_BOOKS];
CHECK(shelf_index_load(root, lb, SHELF_MAX_BOOKS, ts_of, &rep) == 1, "索引命中");
const shelf_book *x = find_title(lb, rep.nbooks, "1");
CHECK(x && x->ts == 1000, "载入后 ts 用当前 ts_fn(100+900),不是索引里的旧值");
CHECK(x && strstr(x->sub, "读") != NULL, "副题随新 ts 重建(· 读)");
g_ts_boost = 0;
/* 真实进度文件:写 rdprog 后同一索引载入,ts/副题立即更新。 */
char path[400];
snprintf(path, sizeof path, "%s/根下.txt", root);
rdprog p;
memset(&p, 0, sizeof p);
p.valid = 1; p.seg = 2; p.ch = 9; p.fs = 20; p.ls = 130; p.enc = -1; p.ts = 777777;
CHECK(rdprog_save(path, &p) == 0, "写入测试进度");
CHECK(shelf_index_load(root, lb, SHELF_MAX_BOOKS, prog_ts_of, &rep) == 1, "索引命中(真实进度)");
const shelf_book *y = find_title(lb, rep.nbooks, "根下");
CHECK(y && y->ts == 777777, "ts 来自 rdprog_load 现读");
/* 删掉进度文件(注意 rdprog_save 的参数是书路径,进度文件另按 hash 落盘)。 */
char pdir[400], phash[24], ppath[440];
if (rdprog_dir(pdir, sizeof pdir) == 0) {
rdprog_hash(path, phash, sizeof phash);
snprintf(ppath, sizeof ppath, "%s/%s", pdir, phash);
unlink(ppath);
}
}
/* I3:四种变更都必须让索引失效。每次变更后:先断言 load=0(不静默用陈旧索引),
* 再全量重扫 + 写新索引,断言变化可见。 */
static void test_invalidation(const char *root)
{
static shelf_book b[SHELF_MAX_BOOKS];
shelf_scan_report rep;
char p[400], q[400];
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "基线扫描写索引");
/* ① level-2 直接新增 */
usleep(20000);
snprintf(p, sizeof p, "%s/轻小说/新书.txt", root); write_file(p, "new");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0,
"level-2 新增 → 索引失效");
int n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep);
CHECK(find_title(b, n, "新书") != NULL, "重扫后新书可见");
/* ② level-3 深处新增(Books/400+.../01、玄幻/01、斗破/) */
usleep(20000);
snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破/新书2.txt", root);
write_file(p, "new2");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0,
"level-3 深处新增 → 索引失效");
n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep);
CHECK(find_title(b, n, "新书2") != NULL, "重扫后深处新书可见");
/* ③ 一级目录改名 */
usleep(20000);
snprintf(p, sizeof p, "%s/玄幻", root);
snprintf(q, sizeof q, "%s/玄幻改", root);
CHECK(rename(p, q) == 0, "改名一级目录");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0,
"一级目录改名 → 索引失效");
n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep);
const shelf_book *m = find_title(b, n, "3");
CHECK(m && strcmp(m->top, "玄幻改") == 0 && strcmp(m->group, "玄幻改") == 0,
"重扫后分组键跟随新目录名");
/* ④ 删书 */
usleep(20000);
snprintf(p, sizeof p, "%s/根下.txt", root);
CHECK(unlink(p) == 0, "删除一本书");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0,
"删书 → 索引失效");
n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep);
CHECK(find_title(b, n, "根下") == NULL, "重扫后删掉的书消失");
}
static void test_bad_index(const char *root)
{
static shelf_book b[SHELF_MAX_BOOKS];
shelf_scan_report rep;
char ip[500];
CHECK(idx_path(root, ip, sizeof ip) == 0, "索引路径可得");
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "基线扫描写索引");
/* 截断:去掉 tail 10 字节(trailer 没了) */
struct stat st;
CHECK(stat(ip, &st) == 0, "索引存在");
CHECK(truncate(ip, st.st_size - 10) == 0, "截断索引");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "截断 → 拒绝");
/* 中间字节翻转:CRC 必须抓到 */
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引");
FILE *f = fopen(ip, "r+b");
CHECK(f != NULL, "打开索引改写");
if (f) {
CHECK(fseek(f, st.st_size / 2, SEEK_SET) == 0, "定位中部");
int c = fgetc(f);
CHECK(c != EOF, "读中部字节");
CHECK(fseek(f, st.st_size / 2, SEEK_SET) == 0, "回定位");
fputc(c ^ 0x01, f);
fclose(f);
}
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "字节翻转 → 拒绝");
/* 垃圾内容 */
write_file(ip, "this is not a shelf index at all, just garbage garbage garbage");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "垃圾内容 → 拒绝");
/* 文件缺失 */
unlink(ip);
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "文件缺失 → 拒绝");
/* FIFO:O_NONBLOCK 必须直接拒绝(不能挂死在 open 上) */
CHECK(mkfifo(ip, 0644) == 0, "建 FIFO 假索引");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "FIFO → 拒绝且不阻塞");
unlink(ip);
/* 符号链接:O_NOFOLLOW 只认普通文件,不跟随 */
CHECK(symlink("/etc/hostname", ip) == 0, "建符号链接假索引");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "符号链接 → 拒绝");
unlink(ip);
/* 索引路径是目录 */
mkdir(ip, 0755);
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "索引是目录 → 拒绝");
rmdir(ip);
/* 扫描中途被杀留下的 .tmp 残渣:绝不能影响正式索引的载入 */
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引");
char tmpip[520];
snprintf(tmpip, sizeof tmpip, "%s.tmp", ip);
write_file(tmpip, "half written index leftover");
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 1, ".tmp 残渣不影响载入");
unlink(tmpip);
/* 版本不符:先把版本字段改成 2 */
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引");
f = fopen(ip, "r+b");
CHECK(f != NULL, "打开索引改版本");
if (f) {
unsigned char v[4] = { 2, 0, 0, 0 };
CHECK(fseek(f, 8, SEEK_SET) == 0, "定位版本字段");
fwrite(v, 1, sizeof v, f);
fclose(f);
}
CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "版本不符 → 拒绝");
/* Books 根路径不符(同长度不同路径) */
CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引");
char root2[400];
snprintf(root2, sizeof root2, "%s", root);
root2[strlen(root2) - 1] = 'z'; /* Books → Bookz */
CHECK(shelf_index_load(root2, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "根路径不符 → 拒绝");
}
/* I5:cap 截断 + 超深计数都要从索引如实重建。 */
static void test_report_rebuild(const char *root)
{
static shelf_book b[SHELF_MAX_BOOKS];
shelf_scan_report r1;
int n = scan_and_save(root, b, 3, &r1);
CHECK(n == 3 && r1.truncated == 1, "cap=3 扫描截断标记");
CHECK(shelf_index_load(root, b, 3, ts_of, &r1) == 1, "截断索引仍可命中");
CHECK(r1.truncated == 1 && r1.nbooks == 3, "截断标记如实重建");
}
/* I6:索引不可写 → 扫描照常;commit 失败无残留。 */
static void test_write_failure(const char *root)
{
static shelf_book b[SHELF_MAX_BOOKS];
shelf_scan_report rep;
char ip[500];
CHECK(idx_path(root, ip, sizeof ip) == 0, "索引路径可得");
char idir[500];
snprintf(idir, sizeof idir, "%s", ip);
char *slash = strrchr(idir, '/');
if (slash) *slash = 0;
mkdirs(idir);
if (geteuid() == 0) { printf("(skip 写失败用例:root 不受权限位限制)\n"); return; }
CHECK(chmod(idir, 0500) == 0, "索引目录改为只读");
shelf_idx_writer *w = shelf_index_writer_open(root);
CHECK(w == NULL, "只读目录 → writer_open 返回 NULL(不崩)");
/* 生产路径:w=NULL → 扫描不采集目录,仍必须出书。 */
int n = shelf_scan_books_ex(root, b, SHELF_MAX_BOOKS, ts_of, &rep, NULL, NULL);
CHECK(n > 0 && rep.root_missing == 0, "索引不可写时书架扫描照常");
CHECK(chmod(idir, 0755) == 0, "恢复目录权限");
/* commit 失败:打开 writer(tmp 已建)后再把目录改只读 → rename 失败 → 返回 0 且无残留。 */
unlink(ip); /* 先清掉前面用例留下的正式索引,才能断言「失败后不存在」 */
w = shelf_index_writer_open(root);
CHECK(w != NULL, "可写时 writer_open 成功");
if (w) {
CHECK(chmod(idir, 0500) == 0, "提交前目录改只读");
int navail = shelf_scan_books_ex(root, b, SHELF_MAX_BOOKS, ts_of, &rep,
shelf_index_writer_dir, w);
int ok = shelf_index_writer_commit(w, b, navail, &rep);
CHECK(ok == 0, "rename 失败 → commit 返回 0");
CHECK(chmod(idir, 0755) == 0, "恢复目录权限");
struct stat st;
CHECK(stat(ip, &st) != 0, "commit 失败不留正式索引");
}
}
/* I7:空 Books 可缓存;root 不存在不写索引。 */
static void test_empty_and_missing(const char *root)
{
static shelf_book b[SHELF_MAX_BOOKS];
shelf_scan_report rep;
char empty[400];
snprintf(empty, sizeof empty, "%s_empty/Books", root);
mkdirs(empty);
int n = scan_and_save(empty, b, SHELF_MAX_BOOKS, &rep);
CHECK(n == 0 && rep.root_missing == 0, "空 Books 扫描 0 本");
CHECK(shelf_index_load(empty, b, SHELF_MAX_BOOKS, ts_of, &rep) == 1, "空索引可命中");
CHECK(rep.nbooks == 0, "空索引 0 本");
char missing[400];
snprintf(missing, sizeof missing, "%s_nope/Books", root);
CHECK(shelf_index_load(missing, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "根不存在 → 拒绝");
/* writer_open 只依赖 App 数据目录(不 stat Books):根不存在时也能打开,
* 但扫描不到任何目录(ndirs=0)→ commit 必须失败且不发布半截索引。 */
shelf_scan_report rm;
shelf_idx_writer *w = shelf_index_writer_open(missing);
int mn = shelf_scan_books_ex(missing, b, SHELF_MAX_BOOKS, ts_of, &rm,
w ? shelf_index_writer_dir : NULL, w);
CHECK(mn == 0 && rm.root_missing == 1, "根不存在 → root_missing");
if (w) CHECK(shelf_index_writer_commit(w, b, mn, &rm) == 0, "根不存在 → commit 拒绝空索引");
}
int main(void)
{
const char *base = getenv("TMPDIR");
if (!base || !base[0]) base = "/tmp";
char tmpl[400];
snprintf(tmpl, sizeof tmpl, "%s/m32_index_XXXXXX", base);
char *tmp = mkdtemp(tmpl);
if (!tmp) { printf("mkdtemp 失败\n"); return 2; }
snprintf(g_cfgdir, sizeof g_cfgdir, "%s/data", tmp); /* 索引落 <tmp>/index */
mkdirs(g_cfgdir);
char root[400];
snprintf(root, sizeof root, "%s/Books", tmp);
make_tree(root);
test_roundtrip(root);
test_ts_not_cached(root);
test_invalidation(root);
test_bad_index(root);
test_report_rebuild(root);
test_write_failure(root);
test_empty_and_missing(root);
printf("shelf_index_smoke: %d pass, %d fail\n", g_pass, g_fail);
char cmd[512];
snprintf(cmd, sizeof cmd, "rm -rf '%s' '%s_empty' '%s_nope'", tmp, root, root);
if (system(cmd) != 0) { /* 忽略清理失败 */ }
return g_fail ? 1 : 0;
}