问题(P2-2):离线判定用 `ncm_apicache_hits()` 前后差值判「本次是否缓存命中」—— ①其它 worker 的命中会污染差值 → 误判「已回在线」;②盘缓存分支的 g_hits++ 在 g_mu 锁外, 跨线程自增/64 位读存在竞争与撕裂可能。 修法(出参方案): - `api_post_cached(..., int *from_cache)`:命中置 1(未走网络),miss/失败为 0; 四个调用点显式传参。 - 新增 `ncm_api_user_playlist_ex(..., int *out_from_cache)`;out_n 与原接口一致**必填** (NULL→NCM_ERR_ARG,修入口容忍 NULL、成功路径却无条件解引用的契约不一致,评审 P2-1); out_from_cache 可 NULL。旧 `ncm_api_user_playlist` 为薄包装(调用方零改动)。 - `appstate_load_playlists_ex` 透传该标志;home_worker 用本次请求的 from_cache 判定是否 回在线;歌单页只在失败时同步离线态(成功可能是缓存命中,不据此清离线)。 - 删除 `ncm_apicache_hits()`/g_hits(无使用者,同时消除数据竞争);盘缓存命中分支两行 合并为一行(行为不变,重构)。 - 测试:session_gate_sim 新增 D2 五例(miss→0 且真网络 +1 次 / 命中→1 且 0 请求 / 失败→0 / 兼容入口不崩 / out_n 必填契约);playlist_race_sim、plpage_sim 补 `_ex` 桩。 验证:session_gate_sim 88 PASS/0 FAIL;test_ncm_core_host 139 PASS/0 FAIL; http_truncate_sim 13 PASS/0 FAIL;核心 13 TU(-Wall -Wextra)0 warning。
284 lines
14 KiB
C
284 lines
14 KiB
C
/* bench/plpage_sim.c — 歌单页「懒加载分页 + 加载后列表刷新」离线回归(0 真实 NCM 请求)。
|
||
*
|
||
* 驱动真实 app/ui/playlist.c(页面打开 / 滚动触发 / worker / tick 消费全链路)+ 真实
|
||
* appstate.c(滑动窗口)+ 真实 songmeta.c(/tmp 盘缓存)+ 宿主 SDK(meow_listview/draw 全真,
|
||
* 与 ncm_host 同一批 out/host/sdk_*.o)。网络端点(ncm_api_playlist_detail /
|
||
* ncm_api_song_detail)为本地合成 stub(计调用次数、可注入单批失败)。
|
||
*
|
||
* Bug1(大歌单懒加载分页)验证点:
|
||
* ① 打开 400 首歌单:首屏 = 1 detail + 恰 1 批 song_detail(物化 [0,100),第 100 首未物化)
|
||
* ——不再首屏连拉多批补满窗口(旧行为 ≤3 批连发,5k+ 歌单阻塞 + 风控);
|
||
* ② 顶部小幅滚动(视口底距已分页底部 >20 首):不发任何新请求;
|
||
* ③ 滑到已分页底部(余量 ≤20 首):恰拉 1 页;单批失败时 g_paged_hi 不推进(占位保留),
|
||
* 再滑到底部重试同一页成功(一次一页,不连拉);
|
||
* ④ 逐页到底(400 = 4 页);全部拉完后滑到尾部:不再发请求(停止);
|
||
* ⑤ 回滚到顶部:失效槽(窗口挤出)由盘缓存补物化,song_detail 调用数 0 增长(不占网络额度);
|
||
* Bug2(加载完列表刷新)验证点:
|
||
* ⑥ 首屏/每页 worker 完成经 playlist_tick 消费后,整帧重画计数(--wrap=meow_ui_paint_full)
|
||
* 递增,且新物化行经 appstate_song_copy 取到真实歌名/歌手(= pl_src2 将绑定真实数据,
|
||
* 不再是「... 加载中」占位)。
|
||
* 风控纪律:0 次真实 NCM 请求(离线 stub + /tmp 盘缓存)。
|
||
*
|
||
* 编译/运行见 build_ncm_sims.sh「plpage_sim」段(宿主,ncm_app 目录下)。
|
||
*/
|
||
#include "../app/ncm_app_internal.h"
|
||
#include <stdio.h>
|
||
#include <string.h>
|
||
#include <stdlib.h>
|
||
#include <unistd.h>
|
||
#include <time.h>
|
||
|
||
ncm_app_state_t g_st;
|
||
ncm_play_run_t g_play = { .mu = PTHREAD_MUTEX_INITIALIZER, .state = NCM_PC_STOPPED, .cur_song = -1 };
|
||
volatile int g_app_quit = 0;
|
||
volatile int g_net_ready = 0;
|
||
int g_host_mock = 0; /* 真实分页链路(不走 mock 跳网分支) */
|
||
|
||
/* ---- core 符号最小实现(同 playlist_race_sim 口径) ---- */
|
||
size_t ncm_strlcpy(char *dst, const char *src, size_t sz)
|
||
{
|
||
size_t n = 0;
|
||
if (sz) {
|
||
while (src[n] && n < sz - 1) { dst[n] = src[n]; n++; }
|
||
dst[n] = 0;
|
||
}
|
||
while (src[n]) n++;
|
||
return n;
|
||
}
|
||
int64_t ncm_now_ms(void)
|
||
{
|
||
struct timespec ts;
|
||
clock_gettime(CLOCK_REALTIME, &ts);
|
||
return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||
}
|
||
|
||
/* ---- cover / 播放 stub(本 sim 不测封面与播放;playlist.c 引用,链接需要) ---- */
|
||
void cover_request(const char *url, const char *song_id) { (void)url; (void)song_id; }
|
||
meow_img *cover_get(void) { return NULL; }
|
||
void cover_put(void) {}
|
||
int cover_changed(void) { return 0; }
|
||
void cover_ack(void) {}
|
||
void cover_cache_reset(void) {}
|
||
void player_play_song(int idx) { (void)idx; }
|
||
|
||
/* ---- core 符号(appstate.c 引用;运行时不到达,仅链接需要) ---- */
|
||
int ncm_login_status_check(const char *device_id, const char *cookie, char *uid, size_t uid_sz,
|
||
char *nick, size_t nick_sz)
|
||
{ (void)device_id;(void)cookie;(void)uid;(void)uid_sz;(void)nick;(void)nick_sz; return NCM_ERR_AUTH; }
|
||
int ncm_ctx_clear_cookie(ncm_ctx_t *ctx) { (void)ctx; return NCM_OK; }
|
||
int ncm_api_user_playlist(const char *device_id, const char *cookie, const char *uid,
|
||
int limit, int offset, ncm_playlist_t *out, int max, int *out_n)
|
||
{ (void)device_id;(void)cookie;(void)uid;(void)limit;(void)offset;(void)out;(void)max;(void)out_n; return NCM_ERR_NET; }
|
||
/* M9 出参变体(appstate_load_playlists_ex 引用;本 sim 不到达) */
|
||
int ncm_api_user_playlist_ex(const char *device_id, const char *cookie, const char *uid,
|
||
int limit, int offset, ncm_playlist_t *out, int max, int *out_n,
|
||
int *out_from_cache)
|
||
{ if (out_from_cache) *out_from_cache = 0;
|
||
return ncm_api_user_playlist(device_id, cookie, uid, limit, offset, out, max, out_n); }
|
||
|
||
/* ---- 网络端点 stub(离线,合成数据;计调用次数 + 可注入单批失败) ---- */
|
||
#define SIM_TOTAL 400 /* 4 页:100 × 4(> NCM_SONG_WINDOW 256,回滚必滑窗) */
|
||
static int g_detail_calls;
|
||
static int g_song_calls; /* song_detail 成功批次数 */
|
||
static int g_song_attempts; /* song_detail 发起次数(含失败批) */
|
||
static int g_song_fail_n; /* 接下来 N 批 song_detail 网络失败模拟 */
|
||
|
||
int ncm_api_playlist_detail(const char *device_id, const char *cookie, const char *playlist_id,
|
||
char *name, size_t name_sz, char *cover, size_t cover_sz,
|
||
int64_t *ids, int id_cap, int *out_id_n)
|
||
{
|
||
(void)device_id; (void)cookie; (void)playlist_id; (void)cover_sz;
|
||
g_detail_calls++;
|
||
if (name) snprintf(name, name_sz, "Sim Playlist");
|
||
if (cover) cover[0] = 0;
|
||
int n = (SIM_TOTAL < id_cap) ? SIM_TOTAL : id_cap;
|
||
for (int i = 0; i < n; i++) ids[i] = 1000000LL + i;
|
||
*out_id_n = n;
|
||
return NCM_OK;
|
||
}
|
||
|
||
int ncm_api_song_detail(const char *device_id, const char *cookie, const int64_t *ids, int n,
|
||
ncm_song_t *out, int max, int *out_n)
|
||
{
|
||
(void)device_id; (void)cookie;
|
||
g_song_attempts++;
|
||
if (g_song_fail_n > 0) { /* 单批网络失败模拟(失败不推进分页高水位 → 同页重试) */
|
||
g_song_fail_n--;
|
||
if (out_n) *out_n = 0;
|
||
return NCM_ERR_NET;
|
||
}
|
||
g_song_calls++;
|
||
int m = (n < max) ? n : max;
|
||
for (int i = 0; i < m; i++) {
|
||
snprintf(out[i].id, sizeof out[i].id, "%lld", (long long)ids[i]);
|
||
snprintf(out[i].name, sizeof out[i].name, "Song%lld", (long long)ids[i]);
|
||
snprintf(out[i].art_name, sizeof out[i].art_name, "Artist%lld", (long long)ids[i]);
|
||
snprintf(out[i].album_name, sizeof out[i].album_name, "Album%lld", (long long)ids[i]);
|
||
out[i].duration_ms = 200000;
|
||
out[i].fee = 0;
|
||
}
|
||
if (out_n) *out_n = m;
|
||
return NCM_OK;
|
||
}
|
||
|
||
/* ---- Bug2 证据:整帧重画计数(链接时 --wrap=meow_ui_paint_full 重定向到此) ---- */
|
||
extern void __real_meow_ui_paint_full(void);
|
||
static int g_paint_full_calls;
|
||
void __wrap_meow_ui_paint_full(void)
|
||
{
|
||
g_paint_full_calls++;
|
||
__real_meow_ui_paint_full();
|
||
}
|
||
|
||
/* ---- 驱动辅助:模拟 app_on_tick 周期性消费 worker 完成 ---- */
|
||
static int pump_until(int (*cond)(void), int timeout_ms)
|
||
{
|
||
for (int t = 0; t < timeout_ms; t += 5) {
|
||
playlist_tick(0);
|
||
if (cond()) {
|
||
/* 再喂若干 tick:确保完成态被消费(g_plthr_on 清零),后续滚动可再触发 */
|
||
for (int k = 0; k < 30; k++) { playlist_tick(0); usleep(2000); }
|
||
return 1;
|
||
}
|
||
usleep(5000);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
static int g_fail = 0;
|
||
#define CHECK(cond, fmt, ...) do { \
|
||
if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \
|
||
else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); g_fail = 1; } \
|
||
} while (0)
|
||
|
||
/* 条件(C 无闭包,用全局目标值) */
|
||
static int g_want_calls;
|
||
static int cond_calls(void) { return g_song_calls >= g_want_calls; }
|
||
static int g_want_attempts;
|
||
static int cond_attempts(void) { return g_song_attempts >= g_want_attempts; }
|
||
static int cond_first_screen(void)
|
||
{
|
||
return g_st.track_ids_count == SIM_TOTAL && g_song_calls >= 1;
|
||
}
|
||
|
||
/* 滚动到指定首行(行高 APP_ROW_H px;swipe 距离按当前行差换算) */
|
||
static void scroll_to_row(int row)
|
||
{
|
||
static int cur;
|
||
int d = row - cur;
|
||
cur = row;
|
||
playlist_swipe(d >= 0 ? MEOW_SWIPE_UP : MEOW_SWIPE_DOWN,
|
||
(d >= 0 ? d : -d) * APP_ROW_H);
|
||
}
|
||
|
||
int main(void)
|
||
{
|
||
printf("== plpage_sim: 懒加载分页 + 加载后列表刷新(%d 首,窗口 %d,批 %d,触底余量 20)==\n",
|
||
SIM_TOTAL, NCM_SONG_WINDOW, NCM_SONG_BATCH);
|
||
|
||
/* 宿主 SDK 离屏初始化(与 ncm_host 同口径) */
|
||
setenv("MEOW_UI_DUMP", "/tmp/plpage_sim.ppm", 0);
|
||
meow_ui_opts o = { APP_W, APP_H };
|
||
if (meow_ui_init(&o) != 0) { fprintf(stderr, "init failed\n"); return 1; }
|
||
meow_theme_set_mode(MEOW_THEME_DARK);
|
||
ncm_on_theme(MEOW_THEME_DARK);
|
||
|
||
/* 歌单 mock:1 张 SIM_TOTAL 首歌单;songmeta 盘缓存用临时目录 */
|
||
char cdt[] = "/tmp/plpage_cache_XXXXXX";
|
||
if (!mkdtemp(cdt)) { fprintf(stderr, "mkdtemp failed\n"); return 1; }
|
||
snprintf(g_st.cache_dir, sizeof g_st.cache_dir, "%s", cdt);
|
||
g_st.playlist_count = 1;
|
||
snprintf(g_st.playlists[0].id, sizeof g_st.playlists[0].id, "pl0");
|
||
snprintf(g_st.playlists[0].name, sizeof g_st.playlists[0].name, "SimPlaylist");
|
||
g_st.playlists[0].track_count = SIM_TOTAL;
|
||
|
||
ncm_song_t sq;
|
||
int paint0;
|
||
|
||
/* ---- ① Bug1:打开歌单 → 首屏恰 1 detail + 1 批 song_detail(物化 [0,100)) ---- */
|
||
printf("== case 1: 首屏只拉一页 ==\n");
|
||
paint0 = g_paint_full_calls;
|
||
playlist_view_create();
|
||
playlist_open(0);
|
||
CHECK(pump_until(cond_first_screen, 8000), "first-screen loaded (track_ids=%d calls=%d)",
|
||
g_st.track_ids_count, g_song_calls);
|
||
CHECK(g_detail_calls == 1, "detail calls=%d (expect 1)", g_detail_calls);
|
||
CHECK(g_song_calls == 1, "song_detail calls=%d (expect 1, 不再首屏连拉多批)", g_song_calls);
|
||
CHECK(g_st.track_ids_count == SIM_TOTAL, "track_ids_count=%d (expect %d)",
|
||
g_st.track_ids_count, SIM_TOTAL);
|
||
CHECK(appstate_song_copy(99, &sq) == 0 && strcmp(sq.name, "Song1000099") == 0,
|
||
"row 99 materialized name='%s'", sq.name);
|
||
CHECK(appstate_song_copy(100, &sq) != 0, "row 100 NOT materialized yet (placeholder)");
|
||
/* Bug2:首屏完成 → 重画计数递增 + 行绑定真实歌名/歌手(非占位) */
|
||
CHECK(g_paint_full_calls > paint0, "paint_full after first-screen %d -> %d (list repainted)",
|
||
paint0, g_paint_full_calls);
|
||
CHECK(appstate_song_copy(5, &sq) == 0 && strcmp(sq.art_name, "Artist1000005") == 0,
|
||
"row 5 binds real artist='%s' (src2 填真实数据,非「... 加载中」)", sq.art_name);
|
||
|
||
/* ---- ② 顶部小幅滚动:不触发任何新请求(距已分页底部 >20 首) ---- */
|
||
printf("== case 2: 顶部滚动不拉页 ==\n");
|
||
scroll_to_row(10);
|
||
int a0 = g_song_attempts;
|
||
for (int k = 0; k < 40; k++) { playlist_tick(0); usleep(3000); }
|
||
CHECK(g_song_attempts == a0, "song_detail attempts=%d (unchanged, 顶部滚动不拉页)", g_song_attempts);
|
||
|
||
/* ---- ③ 滑到已分页底部(余量 ≤20 首):恰拉 1 页;单批失败不推进,再滑重试同页成功 ---- */
|
||
printf("== case 3: 触底拉下一页(一次一页;失败重试同页) ==\n");
|
||
g_song_fail_n = 1; /* 注入:下一次 song_detail 网络失败 */
|
||
int att_before = g_song_attempts;
|
||
scroll_to_row(90); /* 视口底 ≈97 行,余量 ≤20 → 触发第 2 页(失败) */
|
||
g_want_attempts = att_before + 1;
|
||
CHECK(pump_until(cond_attempts, 8000), "page-2 first attempt fired (attempts=%d)", g_song_attempts);
|
||
CHECK(g_song_calls == 1, "page-2 failed -> calls=%d (g_paged_hi 不推进,占位保留)", g_song_calls);
|
||
CHECK(appstate_song_copy(199, &sq) != 0, "row 199 still placeholder after failed page");
|
||
scroll_to_row(91); /* 再滑(滚动事件)→ 重试同一页 */
|
||
g_want_calls = 2;
|
||
CHECK(pump_until(cond_calls, 8000), "page 2 retried & loaded (calls=%d attempts=%d)",
|
||
g_song_calls, g_song_attempts);
|
||
CHECK(g_song_attempts == att_before + 2,
|
||
"attempts +%d (1 失败 + 1 重试同页 = 一次一页,未连拉)", g_song_attempts - att_before);
|
||
CHECK(appstate_song_copy(199, &sq) == 0 && strcmp(sq.name, "Song1000199") == 0,
|
||
"row 199 materialized name='%s'", sq.name);
|
||
CHECK(g_paint_full_calls > paint0, "paint_full after page-2 consumed (=%d)", g_paint_full_calls);
|
||
|
||
/* ---- ④ 逐页到底;全部拉完后滑到尾部:不再发请求 ---- */
|
||
printf("== case 4: 逐页到底 + 拉完即止 ==\n");
|
||
att_before = g_song_attempts;
|
||
scroll_to_row(190); /* 余量 ≤20 → 触发第 3 页 */
|
||
g_want_calls = 3;
|
||
CHECK(pump_until(cond_calls, 8000), "page 3 loaded (calls=%d)", g_song_calls);
|
||
scroll_to_row(290); /* 余量 ≤20 → 触发第 4 页(末页) */
|
||
g_want_calls = 4;
|
||
CHECK(pump_until(cond_calls, 8000), "page 4 (last) loaded (calls=%d)", g_song_calls);
|
||
CHECK(g_song_attempts == att_before + 2, "attempts +%d (第 3、4 页恰各 1 批)",
|
||
g_song_attempts - att_before);
|
||
CHECK(appstate_song_copy(399, &sq) == 0 && strcmp(sq.name, "Song1000399") == 0,
|
||
"tail row 399 materialized name='%s'", sq.name);
|
||
att_before = g_song_attempts;
|
||
scroll_to_row(390); /* 已在尾部:全部拉完 → 不再发请求 */
|
||
for (int k = 0; k < 40; k++) { playlist_tick(0); usleep(3000); }
|
||
CHECK(g_song_attempts == att_before, "all loaded -> scroll at tail: attempts=%d (unchanged)",
|
||
g_song_attempts);
|
||
|
||
/* ---- ⑤ 回滚到顶部:失效槽(窗口挤出)盘缓存补物化,song_detail 0 增长 ---- */
|
||
printf("== case 5: 回滚补物化走盘缓存(0 网络) ==\n");
|
||
att_before = g_song_attempts;
|
||
scroll_to_row(0); /* 回顶部:窗口已滑走,行 0-35 失效 → 盘缓存补 */
|
||
for (int k = 0; k < 400 && !appstate_range_loaded(0, 30); k++) { playlist_tick(0); usleep(5000); }
|
||
CHECK(appstate_range_loaded(0, 30), "rows 0-29 re-materialized after scroll-back");
|
||
CHECK(g_song_attempts == att_before, "scroll-back refill: song_detail attempts=%d "
|
||
"(unchanged, 盘缓存命中不占网络)", g_song_attempts);
|
||
CHECK(appstate_song_copy(0, &sq) == 0 && strcmp(sq.name, "Song1000000") == 0,
|
||
"row 0 re-materialized name='%s'", sq.name);
|
||
|
||
printf("%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS");
|
||
/* 清理临时盘缓存(≤400 个小文件) */
|
||
{
|
||
char cmd[600];
|
||
snprintf(cmd, sizeof cmd, "rm -rf %s", cdt);
|
||
if (system(cmd) != 0) fprintf(stderr, "cleanup failed: %s\n", cdt);
|
||
}
|
||
meow_ui_shutdown();
|
||
return g_fail ? 1 : 0;
|
||
}
|