From c775f8558ce9f57e9b371db7187390757f95333a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 12:13:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(tuku):=20=E8=AF=84=E5=AE=A1=20P2-1/P2-2?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=A7=A3=E5=8E=8B=E9=A1=B5=E4=B8=8E=E7=BC=A9?= =?UTF-8?q?=E7=95=A5=E5=9B=BE=E7=BC=93=E5=AD=98=E4=BA=92=E5=88=A0=E8=87=AA?= =?UTF-8?q?=E6=84=88=20+=20=E5=A4=A7=E9=A1=B5=E6=BA=A2=E5=87=BA=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(必修):解压页缓存与缩略图 LRU 在 *.bmp 上相互踩踏 - ui/library 的 cache_prune 先按 page_cache 的共用判据 mr_cache_is_page_name 跳过 mrpage_*,不再把 BMP 解压页算进缩略图额度或 unlink(含 Vol10.cbz 这类 BMP 档不再出现「被缩略图 LRU 删掉 → 首页解码失败」); - mr_cache_touch 命中时 stat 校验文件存在与大小,缺失/尺寸不符即移出索引并按 未命中返回,调用方重新解压——外部清理/掉电残留/误删都能自愈,不再返回死路径。 P2-2(文档化一次性溢出): - page_cache.h 与 make_room_locked 写明:单条目本身超过 32MiB 时只保留该条、 其它全部淘汰,占用上限受页源单页 64MiB(MR_ZIP_PAGE_MAX_BYTES)约束; 大页仍可读,不因预算拒绝落缓存。 - 新增 33MiB 页夹具(PNG 头 + 可压零填充,deflate 后仅数十 KB)与断言: 大页可读、cache_bytes 允许超 32MiB、小页被淘汰、再来小页时大页按预算淘汰。 顺手:page_cache.h 注释包名 os.meow.picobrowser → os.meow.picbrowser(测试路径同步)。 测试:test_cbz 267 → 296 项全 PASS;run_tests.sh(zip 106)与 run_webtoon_tests.sh 全绿;交叉构建 sdk/tuku 零警告,产物静态 MIPS32r2 ELF。 --- tuku/logic/reader/page_cache.c | 24 ++++++++-- tuku/logic/reader/page_cache.h | 14 ++++-- tuku/test/make_cbz_samples.py | 11 +++++ tuku/test/test_cbz.c | 85 +++++++++++++++++++++++++++++++--- tuku/ui/library/library.c | 4 ++ 5 files changed, 125 insertions(+), 13 deletions(-) diff --git a/tuku/logic/reader/page_cache.c b/tuku/logic/reader/page_cache.c index 7075398..4cf403b 100644 --- a/tuku/logic/reader/page_cache.c +++ b/tuku/logic/reader/page_cache.c @@ -57,6 +57,11 @@ int mr_cache_dir_from_data(const char *data_dir, char *out, int outsz) return 0; } +int mr_cache_is_page_name(const char *name) +{ + return name && strncmp(name, MR_CACHE_PREFIX, strlen(MR_CACHE_PREFIX)) == 0; +} + const char *mr_cache_dir(void) { return g_ready ? g_dir : ""; @@ -95,6 +100,9 @@ static void idx_evict_locked(void) idx_remove_locked(oldest); } +/* 腾出 len 字节:页数或字节先到上限就淘汰最旧。单条 len 本身超过 + * MR_CACHE_MAX_BYTES 时会清空其它条目、让这一条独占(文档化的一次性溢出, + * 上限由页源单页 64MiB 约束)——大页仍可读,不因预算拒绝落缓存。 */ static void make_room_locked(long long len) { while (g_n > 0 && (g_n >= MR_CACHE_MAX_PAGES || @@ -275,19 +283,27 @@ int mr_cache_touch(const char *path) return 0; } int i = idx_find_locked(path); + /* 命中也要 stat 校验(P2-1 纵深防御):外部清理、掉电残留、或别的 LRU 误删 + * 都会让索引指向不存在的文件;假命中会让页源返回死路径且永不自愈。缺失或 + * 尺寸不符就从索引移除、按未命中处理,让调用方重新解压。 */ + struct stat st; + int exists = stat(path, &st) == 0 && S_ISREG(st.st_mode) && st.st_size > 0; if (i >= 0) { + if (!exists || (long long)st.st_size != g_idx[i].size) { + idx_remove_locked(i); + pthread_mutex_unlock(&g_lock); + return 0; + } g_tick++; g_idx[i].used = g_tick; pthread_mutex_unlock(&g_lock); return 1; } /* 索引里没有(外部写入/索引满):磁盘上有就算命中,能装进索引就装。 */ - struct stat st; - int ok = stat(path, &st) == 0 && S_ISREG(st.st_mode) && st.st_size > 0; - if (ok && g_n < MR_CACHE_MAX_PAGES && strlen(path) < MR_CACHE_FILE_MAX) + if (exists && g_n < MR_CACHE_MAX_PAGES && strlen(path) < MR_CACHE_FILE_MAX) idx_add_locked(path, (long long)st.st_size); pthread_mutex_unlock(&g_lock); - return ok ? 1 : 0; + return exists ? 1 : 0; } int mr_cache_store(const char *path, const void *data, size_t len) diff --git a/tuku/logic/reader/page_cache.h b/tuku/logic/reader/page_cache.h index 51df42e..e0c4bda 100644 --- a/tuku/logic/reader/page_cache.h +++ b/tuku/logic/reader/page_cache.h @@ -5,10 +5,14 @@ * 解码回调按路径解码——两种阅读模式(翻页/条漫)与目录册走完全相同的代码路径。 * * 目录与上限(纪律): - * - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picobrowser/data, + * - 目录 = <数据目录>/cache(数据目录形如 .../MeowData/os.meow.picbrowser/data, * 与缩略图缓存同目录;文件名统一前缀 mrpage_,与缩略图 *.bmp 互不影响); * - 上限 = 64 页 或 32MiB,先到者触发 LRU 淘汰(按最近使用计数); - * - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页); + * - **单条目溢出**:某页解压后本身就超过 32MiB 时允许一次性溢出——只保留该条、 + * 其它条目全部淘汰,占用上限受页源单页上限 64MiB(MR_ZIP_PAGE_MAX_BYTES)约束。 + * 这样超大页仍可阅读,不会因为预算拒绝落缓存而变成永久空白页; + * - mr_cache_init() 首次成功时清掉上一进程遗留的 mrpage_*(孤儿页) + * (注意:缩略图 LRU 也必须在同一目录里跳过 mrpage_*,否则会互删); * - 写入走 ".part" + rename,进程崩溃不会留下半个缓存文件。 * * 键 = 档案路径 + 档案 mtime/size + 条目名 + 页下标:档案被替换(mtime/size 变)或 @@ -34,10 +38,14 @@ extern "C" { #define MR_CACHE_FILE_MAX 256 /* 数据目录 → 缓存目录(纯字符串):末尾的 "data" 段替换为 "cache"。 - * 例:/…/MeowData/os.meow.picobrowser/data → /…/MeowData/os.meow.picobrowser/cache。 + * 例:/…/MeowData/os.meow.picbrowser/data → /…/MeowData/os.meow.picbrowser/cache。 * 成功 0;参数非法/缓冲不足 -1。 */ int mr_cache_dir_from_data(const char *data_dir, char *out, int outsz); +/* 文件名是否属于解压页缓存(mrpage_ 前缀)。缩略图 LRU 与启动清孤儿共用这一判据, + * 保证两套缓存同目录时互不误删(P2-1)。1=解压页缓存。 */ +int mr_cache_is_page_name(const char *name); + /* 建立/切换缓存目录并清孤儿。0=可用;-1=不可用(之后 store/touch 均失败)。 * 同一目录重复调用为无操作(不会删掉正在用的页)。 */ int mr_cache_init(const char *data_dir); diff --git a/tuku/test/make_cbz_samples.py b/tuku/test/make_cbz_samples.py index 10ea21a..90f467e 100755 --- a/tuku/test/make_cbz_samples.py +++ b/tuku/test/make_cbz_samples.py @@ -32,6 +32,7 @@ import zipfile import zlib DATE = (2024, 1, 1, 0, 0, 0) +HUGE_PAGE_LEN = 33 * 1024 * 1024 # > 32MiB 预算:锁定「单条目一次性溢出」行为 # ---------------------------------------------------------------- 图片构造 @@ -190,6 +191,14 @@ def build_features(ro): with open(os.path.join(feat, "truncated.cbz"), "wb") as f: f.write(raw[:len(raw) // 2]) + # 单页 33MiB(PNG 头 + 可压零填充,deflate 后只有几十 KB): + # 页过滤放行(< 64MiB),解压后落缓存时应允许一次性超过 32MiB 预算。 + head = png_bytes(64, 64, 31) + payload = head + b"\x00" * (HUGE_PAGE_LEN - len(head)) + write_zip(os.path.join(feat, "hugepage.cbz"), [ + ("huge.png", payload, zipfile.ZIP_DEFLATED), + ]) + def write_manifest(ro): """每档案每条目:<相对路径>\t<条目名>\t\t(Tab 分隔,UTF-8 原名)。""" @@ -198,6 +207,8 @@ def write_manifest(ro): for name in sorted(files): if not name.lower().endswith((".cbz", ".zip")): continue + if name == "hugepage.cbz": + continue # 33MiB 页只为缓存溢出测试,manifest 不校验它 path = os.path.join(base, name) rel = os.path.relpath(path, ro) try: diff --git a/tuku/test/test_cbz.c b/tuku/test/test_cbz.c index 257d682..4e4b29f 100644 --- a/tuku/test/test_cbz.c +++ b/tuku/test/test_cbz.c @@ -48,13 +48,14 @@ #define PATH_CAP 1024 #define CJK_PAGE "\xe4\xb8\xad\xe6\x96\x87\xe5\x90\x8d.png" /* 中文名.png */ +#define HUGE_PAGE_LEN (33 * 1024 * 1024) /* 与 make_cbz_samples.py 的 HUGE_PAGE_LEN 一致 */ static int g_pass; static int g_fail; static char g_root[PATH_CAP]; /* 夹具根 */ static char g_lib[PATH_CAP]; /* /library */ static char g_feat[PATH_CAP]; /* /features */ -static char g_data[PATH_CAP]; /* /app/os.meow.picobrowser/data */ +static char g_data[PATH_CAP]; /* /app/os.meow.picbrowser/data */ static const char *g_argv0; #define CHECK(cond, ...) do { \ @@ -388,6 +389,13 @@ static void test_meta(void) mr_zip_book_bind(&b, &zb); CHECK(mr_book_meta(&b, 0, &w, &h) == 1 && w == 7 && h == 9, "bmp meta=%dx%d (want 7x9)", w, h); + /* P2-1:BMP 页的缓存文件名也是 *.bmp,但必须能被缩略图 LRU 判据识别为解压页 */ + char vp[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_book_page_path(&zb, 0, vp, (int)sizeof vp) == 0, "bmp page path"); + const char *vbase = strrchr(vp, '/'); + CHECK(vbase && mr_cache_is_page_name(vbase + 1) == 1, + "bmp page cache looks like a thumbnail: %s", vp); + CHECK(vbase && strstr(vbase, ".bmp") != NULL, "bmp page lost its extension: %s", vp); mr_book_destroy(&b); joinp(path, sizeof path, g_lib, "box/box2.cbz"); @@ -427,10 +435,19 @@ static void test_cache(void) char path[PATH_CAP]; char data_dir[PATH_CAP]; char cache_copy[MR_CACHE_DIR_MAX]; + + /* 解压页缓存与缩略图的文件名判据(P2-1):缩略图 LRU 依赖它跳过 mrpage_*。 */ + CHECK(mr_cache_is_page_name("mrpage_0123456789abcdef.bmp") == 1, "page name bmp"); + CHECK(mr_cache_is_page_name("mrpage_0123456789abcdef.png") == 1, "page name png"); + CHECK(mr_cache_is_page_name("0123456789abcdef_1700000000_1234.bmp") == 0, "thumb name"); + CHECK(mr_cache_is_page_name("thumb.bmp") == 0, "plain thumb"); + CHECK(mr_cache_is_page_name("") == 0 && mr_cache_is_page_name(NULL) == 0, + "page name edges"); + joinp(path, sizeof path, g_feat, "big.cbz"); /* ---- (a) 64 页 LRU ---- */ - joinp(data_dir, sizeof data_dir, g_root, "c1/os.meow.picobrowser/data"); + joinp(data_dir, sizeof data_dir, g_root, "c1/os.meow.picbrowser/data"); CHECK(mr_cache_init(data_dir) == 0, "cache init c1"); snprintf(cache_copy, sizeof cache_copy, "%s", mr_cache_dir()); CHECK(cache_copy[0] && strstr(cache_copy, "/cache") != NULL, @@ -470,10 +487,18 @@ static void test_cache(void) CHECK(stat(p79, &s1) == 0, "stat p79"); CHECK(mr_zip_book_page_path(&zb, 79, p, (int)sizeof p) == 0, "page79 re-get"); CHECK(stat(p, &s2) == 0 && s1.st_ino == s2.st_ino, "cache hit rewrote the file"); + + /* 假命中自愈(P2-1):外部删掉缓存文件后再取同页,应重新解压而不是返回死路径 */ + unlink(p79); + CHECK(access(p79, F_OK) != 0, "unlink p79"); + CHECK(mr_zip_book_page_path(&zb, 79, p, (int)sizeof p) == 0, "page79 after unlink"); + CHECK(access(p79, F_OK) == 0, "page79 not re-extracted after external unlink"); + CHECK(mr_cache_count() == MR_CACHE_MAX_PAGES, "count after self-heal=%d", + mr_cache_count()); mr_zip_book_free(&zb); /* ---- (b) 32MiB 字节上限 + 启动清孤儿 ---- */ - joinp(data_dir, sizeof data_dir, g_root, "c2/os.meow.picobrowser/data"); + joinp(data_dir, sizeof data_dir, g_root, "c2/os.meow.picbrowser/data"); mr_cache_shutdown(); CHECK(mr_cache_init(data_dir) == 0, "cache init c2"); snprintf(cache_copy, sizeof cache_copy, "%s", mr_cache_dir()); @@ -514,7 +539,55 @@ static void test_cache(void) } unlink(keep); - /* ---- (c) 缓存不可用时优雅失败(不崩、不写文件) ---- */ + /* ---- (c) 单页 > 32MiB:文档化的一次性溢出(大页可读,其它条目按预算淘汰) ---- */ + joinp(data_dir, sizeof data_dir, g_root, "c3/os.meow.picbrowser/data"); + mr_cache_shutdown(); + CHECK(mr_cache_init(data_dir) == 0, "cache init c3"); + + joinp(arc, sizeof arc, g_lib, "box/box1.cbz"); + mr_zip_book small; + CHECK(mr_zip_book_init(&small, arc, 720, fake_load, fake_unload, NULL) == 2, + "small book init"); + char sp[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_book_page_path(&small, 0, sp, (int)sizeof sp) == 0, "small page"); + CHECK(mr_cache_count() == 1, "small cache count=%d", mr_cache_count()); + mr_zip_book_free(&small); + + joinp(arc, sizeof arc, g_feat, "hugepage.cbz"); + mr_zip_book huge; + CHECK(mr_zip_book_init(&huge, arc, 720, fake_load, fake_unload, NULL) == 1, + "hugepage init"); + char hp[MR_CACHE_FILE_MAX]; + CHECK(mr_zip_book_page_path(&huge, 0, hp, (int)sizeof hp) == 0, "huge page path"); + CHECK(mr_cache_count() == 1, "overflow count=%d", mr_cache_count()); + CHECK(mr_cache_bytes() == (long long)HUGE_PAGE_LEN, + "overflow bytes=%lld (want %d)", mr_cache_bytes(), HUGE_PAGE_LEN); + CHECK(mr_cache_bytes() > MR_CACHE_MAX_BYTES, "huge page should exceed the budget"); + CHECK(access(sp, F_OK) != 0, "huge page should evict the small page"); + CHECK(access(hp, F_OK) == 0, "huge page file missing"); + CHECK(count_cache_files() == 1, "overflow left extra cache files"); + + mr_book hb; + mr_zip_book_bind(&hb, &huge); + int before_load = g_load_n; + meow_img *him = mr_book_open(&hb, 0); + CHECK(him != NULL && g_load_n == before_load + 1, "huge page not readable"); + mr_book_close(&hb, him); + mr_book_destroy(&hb); + + /* 再来一个小页:整体预算仍然生效,大页被淘汰 */ + joinp(arc, sizeof arc, g_lib, "box/box1.cbz"); + mr_zip_book small2; + CHECK(mr_zip_book_init(&small2, arc, 720, fake_load, fake_unload, NULL) == 2, + "small2 init"); + CHECK(mr_zip_book_page_path(&small2, 0, sp, (int)sizeof sp) == 0, "small2 page"); + CHECK(mr_cache_count() == 1 && mr_cache_bytes() < MR_CACHE_MAX_BYTES, + "budget not restored: count=%d bytes=%lld", mr_cache_count(), + mr_cache_bytes()); + CHECK(access(hp, F_OK) != 0, "huge page should be evicted by the budget"); + mr_zip_book_free(&small2); + + /* ---- (d) 缓存不可用时优雅失败(不崩、不写文件) ---- */ mr_cache_shutdown(); CHECK(mr_cache_ready() == 0, "shutdown left cache ready"); joinp(path, sizeof path, g_lib, "vol2.cbz"); @@ -530,7 +603,7 @@ static void test_cache(void) CHECK(g_load_n == before, "decode callback called without cache"); mr_book_destroy(&b); - joinp(data_dir, sizeof data_dir, g_root, "app/os.meow.picobrowser/data"); + joinp(data_dir, sizeof data_dir, g_root, "app/os.meow.picbrowser/data"); CHECK(mr_cache_init(data_dir) == 0, "cache init app"); } @@ -815,7 +888,7 @@ int main(int argc, char **argv) } joinp(g_lib, sizeof g_lib, g_root, "library"); joinp(g_feat, sizeof g_feat, g_root, "features"); - joinp(g_data, sizeof g_data, g_root, "app/os.meow.picobrowser/data"); + joinp(g_data, sizeof g_data, g_root, "app/os.meow.picbrowser/data"); if (!run_generator(gen)) { printf("generator failed: %s\n", gen); diff --git a/tuku/ui/library/library.c b/tuku/ui/library/library.c index a202aed..7d6083a 100644 --- a/tuku/ui/library/library.c +++ b/tuku/ui/library/library.c @@ -137,6 +137,10 @@ static void cache_prune(void) time_t oldest_t = 0; struct dirent *de; while ((de = readdir(d)) != NULL) { + /* 解压页缓存(M11)与缩略图同目录:mrpage_* 归 page_cache 自己的 + * 64 页/32MiB LRU 管,这里绝不能把它们当缩略图统计或删除——否则 + * 档案页会被缩略图 LRU 清掉(P2-1)。判据与 page_cache 共用。 */ + if (mr_cache_is_page_name(de->d_name)) continue; const char *dot = strrchr(de->d_name, '.'); if (!dot || strcmp(dot, ".bmp") != 0) continue; char full[MR_PATH_MAX + 96];