Files
MeowOS-SDK/tuku/test/test_reader_many_pages.c
T
AskaEth 40751c09bd Tuku reader: 真实解码峰值模型 + 3MB 硬阈值 + 解码取消 + 3 槽(M22 止损版)
真机(R3 Pro II 480×720 / MemTotal 56872kB):空闲 RSS 6044kB → 翻页 11400kB →
峰值 15600kB(MemFree 仅 1404kB)→ SIGKILL。M20 审计坐实根因是「单页解码瞬时峰值」,
且旧模型把 stb 当缩解库,对 JPEG 低估 3.5–8 倍(实测 baseline 2000×3000 +10.1MB、
渐进 +23.5MB、1200×1600 PNG +12.9MB)。本次按实测重定预算:

- 真实峰值模型 decode_peak_bytes():JPEG 分量面(+渐进 short 系数面)、PNG 源缓冲 +
  RGBA 转换副本、BMP 直写目标,再叠精修/落框两段一次性重采样;上限常态 3MB
  (MR_DECODE_PEAK_CAP),只有 MemFree 真富余时才放宽到 min(12MB, MemFree-4MB)。
- 超预算时的降级:JPEG 逐步加粗 stbi 缩解档位(meow_img_load_file_step),
  PNG/BMP 无解码期缩放 → 占位;结果精确重采样回显示框(meow_img_resample),
  命中页仍是单次 blit(M17 热路径纪律不破)。
- 占位可重试(g_page_skip 与永久失败 g_page_fail 分离):焦点页冷却 1.5s 自动重试、
  中间区点按立即重试、离开再回来即重试;坏图仍是永久占位。
- 解码前内存门控:MemAvailable < 6MB 不解码(读不到就放行,不猜)。
- 解码请求取消:worker 出队后、mr_book_open 前二次判 gen/窗口;拖动/fling 期间只解落点页,
  预取邻居仅当 MemAvailable ≥ 12MB。
- 先释放再解码:queue_page 按落地字节 slots_reserve() 先淘汰不可见旧图;可见页不淘汰。
- 槽位 4→3、常驻上限 9MB→3MB、结果队列 4→2。
- 可观测性:`reader decode page=N src=WxH peak=XkB memfree=YkB memavail=ZkB ok|skip=… step=S ms=M`
  与进出阅读器 rss/hwm/memfree/memavail。

page_source: 头部探测新增 codec/progressive/channels/planes(mr_probe_info),
只带最小 SOF 的合成头仍能取尺寸(旧契约不退化)。

宿主证据:新增 test_reader_membudget(--wrap malloc 计真实峰值):
PNG 600×960 model 4001kB vs 实测 3943kB;JPEG 1000×1500 step2 model 3749/实测 3711;
连翻 100 页 peak_transient 0kB(先释放再解码让缓冲复用)、驻留 ≤3MB、解码 100 次(取消生效)、
3MB 阈值下 1.5MP JPEG 可解、4MP JPEG 需 MemFree 放行(否则占位)、渐进式模型更高于 baseline。

run_tests.sh 全绿(含 zip 106 / cbz 325)、run_webtoon_tests.sh 全绿、SDK 与 tuku 交叉构建零警告。
2026-11-05 21:28:50 +08:00

320 lines
12 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* test_reader_many_pages.c — 多页(140 张)文件夹打开/翻页/跳页回归 +「从第 N 张开始」API。
*
* 用户反馈:「文件夹里图片过多时(例如 140 个),APP 会直接闪退」;且后续书库要改成文件
* 管理器模式,需要「从选中的那张开始看」。本测试走真实 SDK UI 链路(view/activity/anim/
* 真实解码),断言:
* M1 140 页册可打开,页数/模式正确(翻页模式、第 1 页);
* M2 mr_reader_open_dir_at(dir, 57):从第 57 页(0 基)开始;越界 9999 → 末页;负数 → 首页;
* M3 显式下标优先于续读记录;mr_reader_open_dir 仍走续读记录(旧语义不变);
* M4 连续翻页穿过坏图(解码失败)不崩、不卡死,坏页给占位后继续翻;
* M5 条漫模式:140 页排版/跳页/滚动可用;
* M6 常驻页图有字节上界(<=9MB)——页数增长不会线性吃内存。
*
* 编译:test/run_tests.sh(与 test_open_book_host 同口径;只发布 meow_ui.a 时 SKIP)。
*/
#include "tuku_internal.h"
#include "ui/reader/reader.h"
#include "meow_ui_internal.h"
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#define BOOK_PAGES 140
void mr_nav_report(void) {}
static int g_fail;
#define CHECK(cond, ...) do { \
if (cond) { printf(" PASS\n"); } \
else { g_fail++; printf(" FAIL %s:%d ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); } \
} while (0)
static void put16(unsigned char *p, unsigned v) { p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8); }
static void put32(unsigned char *p, unsigned v)
{
p[0] = (unsigned char)v; p[1] = (unsigned char)(v >> 8);
p[2] = (unsigned char)(v >> 16); p[3] = (unsigned char)(v >> 24);
}
static int write_bmp24(const char *path, int w, int h, unsigned seed)
{
int stride = ((w * 3 + 3) / 4) * 4;
int imgsz = stride * h;
unsigned char fh[14] = { 0 }, ih[40] = { 0 };
fh[0] = 'B'; fh[1] = 'M';
put32(fh + 2, (unsigned)(54 + imgsz));
put32(fh + 10, 54);
put32(ih + 0, 40);
put32(ih + 4, (unsigned)w);
put32(ih + 8, (unsigned)h);
put16(ih + 12, 1);
put16(ih + 14, 24);
put32(ih + 20, (unsigned)imgsz);
FILE *f = fopen(path, "wb");
if (!f) return -1;
int ok = fwrite(fh, 1, sizeof fh, f) == sizeof fh &&
fwrite(ih, 1, sizeof ih, f) == sizeof ih;
unsigned char *row = (unsigned char *)calloc(1, (size_t)stride);
if (!row) ok = 0;
for (int y = 0; ok && y < h; y++) {
for (int x = 0; x < w; x++) {
row[x * 3 + 0] = (unsigned char)((x + seed) & 0xFF);
row[x * 3 + 1] = (unsigned char)((y + seed) & 0xFF);
row[x * 3 + 2] = (unsigned char)(seed & 0xFF);
}
if (fwrite(row, 1, (size_t)stride, f) != (size_t)stride) ok = 0;
}
free(row);
if (fclose(f) != 0) ok = 0;
return ok ? 0 : -1;
}
static int write_garbage(const char *path)
{
static const unsigned char junk[64] = { 'N', 'O', 'T', 'A', 'N', 'I', 'M', 'G' };
FILE *f = fopen(path, "wb");
if (!f) return -1;
int ok = fwrite(junk, 1, sizeof junk, f) == sizeof junk;
if (fclose(f) != 0) ok = 0;
return ok ? 0 : -1;
}
/* 合法 BMP24 头(声称 3000x3000,无像素数据):stbi_info 能读到尺寸,
* 整幅 RGBA 峰值 36MB 必须被 MR_DECODE_PEAK_CAP(12MB) 在解码前拦下
* (旧代码的 raw 上限是 40MB,这种页会被放行 → 真机上瞬时分配即可能被 OOM 杀掉)。 */
static int write_huge_bmp_header(const char *path)
{
unsigned char b[54] = { 0 };
b[0] = 'B'; b[1] = 'M';
put32(b + 2, 54); /* 文件大小(谎报,只为通过头检查) */
put32(b + 10, 54); /* 像素数据偏移 */
put32(b + 14, 40); /* BITMAPINFOHEADER */
put32(b + 18, 3000); /* width */
put32(b + 22, 3000); /* height */
put16(b + 26, 1); /* planes */
put16(b + 28, 24); /* bpp */
FILE *f = fopen(path, "wb");
if (!f) return -1;
int ok = fwrite(b, 1, sizeof b, f) == sizeof b;
if (fclose(f) != 0) ok = 0;
return ok ? 0 : -1;
}
static int mkdir_p(const char *path)
{
char tmp[MR_PATH_MAX];
snprintf(tmp, sizeof tmp, "%s", path);
for (size_t i = 1; i <= strlen(tmp); i++) {
if (tmp[i] == '/' || tmp[i] == '\0') {
char ch = tmp[i];
tmp[i] = '\0';
if (mkdir(tmp, 0755) != 0) {
struct stat st;
if (stat(tmp, &st) != 0 || !S_ISDIR(st.st_mode)) return -1;
}
tmp[i] = ch;
}
}
return 0;
}
static int rm_rf(const char *path)
{
struct stat st;
if (lstat(path, &st) != 0) return -1;
if (!S_ISDIR(st.st_mode)) return unlink(path);
DIR *d = opendir(path);
if (!d) return -1;
struct dirent *de;
while ((de = readdir(d)) != NULL) {
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue;
char full[MR_PATH_MAX];
snprintf(full, sizeof full, "%s/%s", path, de->d_name);
rm_rf(full);
}
closedir(d);
return rmdir(path);
}
static void pump_ms(int ms)
{
for (int i = 0; i < ms / 10; i++) {
usleep(10 * 1000);
meow_anim_tick();
}
}
static int tap(int x, int y)
{
reader_on_touch_down(x, y);
reader_on_tap(x, y);
pump_ms(300);
return 1;
}
int main(void)
{
const char *base = getenv("TMPDIR");
if (!base || !base[0]) base = ".";
char tmpl[512];
snprintf(tmpl, sizeof tmpl, "%s/mr_many_XXXXXX", base);
char *data = mkdtemp(tmpl);
if (!data) { printf("SKIP: mkdtemp failed\n"); return 0; }
setenv("MEOW_DATA_DIR", data, 1);
char root[MR_PATH_MAX], book[MR_PATH_MAX], bad[MR_PATH_MAX];
snprintf(root, sizeof root, "./mr_many_books_%ld", (long)getpid());
snprintf(book, sizeof book, "%s/v140", root);
snprintf(bad, sizeof bad, "%s/vbad", root);
if (mkdir_p(book) != 0 || mkdir_p(bad) != 0) { printf("SKIP: mkdir failed\n"); return 0; }
for (int i = 0; i < BOOK_PAGES; i++) {
char p[MR_PATH_MAX];
snprintf(p, sizeof p, "%s/p%03d.bmp", book, i + 1);
if (write_bmp24(p, 200, 267, (unsigned)i) != 0) { printf("SKIP: bmp write failed\n"); return 0; }
}
/* 坏册:第 2 页是垃圾字节(解码失败 → 占位,不许崩/死循环)。 */
for (int i = 0; i < 4; i++) {
char p[MR_PATH_MAX];
snprintf(p, sizeof p, "%s/p%d.bmp", bad, i + 1);
if (i == 1) { if (write_garbage(p) != 0) { printf("SKIP: write failed\n"); return 0; } }
else if (i == 2) { if (write_huge_bmp_header(p) != 0) { printf("SKIP: write failed\n"); return 0; } }
else if (write_bmp24(p, 200, 267, (unsigned)i) != 0) { printf("SKIP: bmp write failed\n"); return 0; }
}
meow_ui_opts o = { 480, 720 };
if (meow_ui_init(&o) != 0) { printf("SKIP: meow_ui_init failed (no host backend)\n"); return 0; }
static const meow_view_hooks lh = { 0 };
meow_view *lib = meow_view_create(ACT_LIBRARY, MEOW_VIEW_ACTIVITY, &lh, NULL);
meow_view *reader = reader_view_create();
meow_view_page_push(ACT_LIBRARY);
int w = meow_ui_width();
int mode_x = w - 10 - 48 * 3 + 24;
int jump_x = w - 10 - 48 * 2 + 24;
int bar_y = 28;
int slider_y = 56 + 4 + 20;
int sx = 24, sw = w - 48;
/* ---- M1 140 页可打开 ---- */
mr_reader_open_dir_at(book, 0);
CHECK(meow_ui_activity_active() == ACT_READER, "reader active");
CHECK(mr_reader_ui_count() == BOOK_PAGES, "pages=%d (want %d)",
mr_reader_ui_count(), BOOK_PAGES);
CHECK(mr_reader_ui_page() == 0, "start at page 0 (got %d)", mr_reader_ui_page());
pump_ms(600);
int draws0 = mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws();
meow_ui_paint_full();
CHECK(mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws() > draws0,
"draw path executed (paged, 140p book)");
/* ---- M2 从第 N 张开始 / 越界钳制 ---- */
meow_view_page_pop();
mr_reader_open_dir_at(book, 57);
CHECK(mr_reader_ui_page() == 57, "open_dir_at(57) -> page %d", mr_reader_ui_page());
pump_ms(400);
meow_view_page_pop();
mr_reader_open_dir_at(book, 9999);
CHECK(mr_reader_ui_page() == BOOK_PAGES - 1, "open_dir_at(9999) clamps to last (%d)",
mr_reader_ui_page());
pump_ms(400);
meow_view_page_pop();
mr_reader_open_dir_at(book, -5);
CHECK(mr_reader_ui_page() == 0, "open_dir_at(-5) clamps to first (%d)", mr_reader_ui_page());
pump_ms(300);
meow_view_page_pop();
/* ---- M3 显式下标优先于续读;open_dir 保持续读语义 ---- */
mr_reader_open_dir_at(book, 77); /* 离开时会把 77 落盘 */
pump_ms(300);
meow_view_page_pop();
mr_reader_open_dir_at(book, 12); /* 显式下标赢过续读记录 77 */
CHECK(mr_reader_ui_page() == 12, "explicit start beats resume record (%d)",
mr_reader_ui_page());
pump_ms(300);
meow_view_page_pop();
mr_reader_open_dir(book); /* 旧入口 = 续读 */
CHECK(mr_reader_ui_page() == 12, "open_dir resumes saved page (%d)", mr_reader_ui_page());
pump_ms(300);
/* ---- M4 连续翻页(含异步解码)不崩、页码推进、内存有界 ---- */
int ok_move = 1;
for (int i = 0; i < 12; i++) {
int before = mr_reader_ui_page();
tap(40, 300);
pump_ms(260);
meow_ui_paint_full(); /* 绘制路径覆盖(P2-5) */
if (mr_reader_ui_page() == before) ok_move = 0;
}
CHECK(mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws() > draws0,
"draw path executed across flips");
CHECK(ok_move, "12 consecutive flips advanced the page");
CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, "resident bounded on 140p book (%ldKB)",
mr_reader_ui_resident_kb());
/* 坏图册:翻过坏页不崩、可继续翻。 */
meow_view_page_pop();
mr_reader_open_dir_at(bad, 0);
pump_ms(600);
int bad_ok = 1;
for (int i = 0; i < 5; i++) {
int before = mr_reader_ui_page();
tap(40, 300);
pump_ms(300);
if (mr_reader_ui_page() == before && before < 3) bad_ok = 0;
}
CHECK(bad_ok, "flip across corrupt/huge pages keeps working (page=%d)", mr_reader_ui_page());
CHECK(mr_reader_ui_resident_kb() <= 9 * 1024,
"huge page skipped without allocating it (%ldKB)", mr_reader_ui_resident_kb());
/* M22:坏图 = 永久占位(failed);超预算大页 = 预算跳过(skipped,可重试,不是 failed)。 */
CHECK(mr_reader_ui_failed_pages() == 1,
"garbage page is a permanent placeholder (failed=%d)", mr_reader_ui_failed_pages());
CHECK(mr_reader_ui_skipped_pages() == 1,
"oversized page is a retryable budget skip (skipped=%d)", mr_reader_ui_skipped_pages());
CHECK(meow_ui_activity_active() == ACT_READER, "still in reader after corrupt page");
/* ---- M5 条漫:140 页排版 + 跳页落位 ---- */
meow_view_page_pop();
mr_reader_open_dir_at(book, 0);
pump_ms(400);
CHECK(mr_reader_ui_count() == BOOK_PAGES, "back on the 140p book (%d)", mr_reader_ui_count());
CHECK(tap(mode_x, bar_y) == 1, "mode icon consumed");
CHECK(mr_reader_ui_mode() == MR_MODE_WEBTOON, "webtoon mode on 140 pages");
pump_ms(500);
CHECK(tap(jump_x, bar_y) == 1, "jump icon consumed");
CHECK(mr_reader_ui_jump_open() == 1, "slider open");
reader_on_touch_down(sx + sw, slider_y);
reader_on_tap(sx + sw, slider_y);
pump_ms(900);
CHECK(mr_reader_ui_jump_pending() == 0, "jump landed in webtoon");
{
int d0 = mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws();
meow_ui_paint_full();
meow_ui_paint_full(); /* 条漫绘制路径覆盖(P2-5) */
CHECK(mr_reader_ui_blit_draws() + mr_reader_ui_scaled_draws() > d0,
"draw path executed (webtoon)");
}
CHECK(mr_reader_ui_page() >= BOOK_PAGES - 3, "jumped near last page (got %d)",
mr_reader_ui_page());
CHECK(mr_reader_ui_resident_kb() <= 9 * 1024, "resident bounded in webtoon (%ldKB)",
mr_reader_ui_resident_kb());
meow_view_page_pop();
meow_view_free(reader);
meow_view_free(lib);
meow_ui_shutdown();
rm_rf(root);
rm_rf(data);
printf("%s: %s\n", g_fail ? "FAIL" : "PASS",
g_fail ? "see failures above" : "many-pages reader checks passed");
return g_fail ? 1 : 0;
}