280 歌单链路三修:开A→开B 竞态防串(5194 串数据) + 92/200 批边界 Load Failed + 歌单列表盘缓存 + 孤儿 .part 回收

ncm_app(REPORT §26, ncm 158ec367 未部署留主会话):
- 打开时快照 plid+代次:appstate_playlist_begin/open_gen/open_idx/reset +
  appstate_load_playlist_songs_ctx(快照 pl 不读 g_st.playlists[],detail 结果落
  staging 锁内原子发布,代次/目标不匹配判 NCM_ERR_STALE 不写全局);playlist_tick
  过期丢弃 + appstate_playlist_reset 清窗口防串。修 5194「我喜欢的音乐」3 首/串数据。
- appstate_song_ensure_range fill-and-continue:bn<m 部分返回/单批网络失败非致命
  (记 NCM_ERR_NOTFOUND/r2),已物化保留+占位行;worker 首屏 track_ids_count>0
  降级非致命(snack)。修 92/200 首歌单点开整页 Load Failed。
- 歌单列表盘缓存 <data_dir>/playlists.bin(magic+ver+ts+数组):home_reload 先缓存
  秒渲 + 后台静默刷新,刷新失败有数据不置错态。修返回首页卡 LOADING(275 遗留)。
- 孤儿 .part/.tmp:evict/total_bytes LRU 扫描顺续删 mtime>7 天(in-flight mtime 新鲜安全)。
验证:新 bench/playlist_race_sim(竞态防串/批边界/非致命/盘缓存/reset)+cache_evict_sim
case9(7 天回收)+window/pagination/search/stream_sim 零回归+test_ncm_core host+mipsel
PASS;build_ncm.sh -Wall -Wextra 0 warning(text 978354/bss 1496484);0 真实请求。
遗留:appstate_logout 未联动删 playlists.bin;g_st.playlists[] 快照拷贝仍有极小交错窗。
This commit is contained in:
MeowOS committed 2026-09-01 21:06:57 +08:00
1 parent d8eeef5c57
commit f0b73e119d
11 files changed
+744 -48

No files matched your search

+45
View File
@@ -28,6 +28,7 @@
#include <dirent.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <time.h>
/* ---- ncm_cache.c 依赖的符号(核心库其他 TU 提供;此处最小内联/桩,避免链 mbedtls) ---- */
size_t ncm_strlcpy(char *dst, const char *src, size_t sz)
@@ -59,6 +60,12 @@ int ncm_store_mkdir_p(const char *path)
return 0;
}
int ncm_store_read_cfg_int(const char *dir, const char *key, int def) { (void)dir; (void)key; return def; }
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;
}
/* ---- 工具 ---- */
static char g_dir[512];
@@ -285,6 +292,44 @@ int main(void)
CHECK(stat(p, &st2) != 0, "part_remove deleted .part");
}
/* ⑨ §26 孤儿半成品回收:LRU 扫描顺手续删超过 7 天的 .part/.tmp(用户不再点该曲、不续传),
* 新鲜的在途/刚中断残体保留(写端 mtime 持续更新,不会被误删)。 */
printf("== case 9: orphan .part/.tmp older than 7d swept by LRU scan (stage3 §26.4) ==\n");
cleanup();
{
char op[600], ot[600], lp[600];
int64_t now = ncm_now_ms() / 1000;
int64_t old = now - 8 * 86400; /* 8 天前 */
struct stat st;
struct timespec ts[2];
ts[0].tv_sec = old; ts[0].tv_nsec = 0; ts[1].tv_sec = old; ts[1].tv_nsec = 0;
/* 8 天前的 .part:应被 evict 扫描顺手删 */
ncm_cache_path(&g_c, "orph", ".mp3", op, sizeof op);
snprintf(op + strlen(op), sizeof op - strlen(op), ".part");
int fd = open(op, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) { (void)!write(fd, "old", 3); close(fd); }
utimensat(AT_FDCWD, op, ts, 0);
/* 8 天前的 .tmp:也应被删 */
ncm_cache_path(&g_c, "orcht", ".mp3", ot, sizeof ot);
snprintf(ot + strlen(ot), sizeof ot - strlen(ot), ".tmp");
fd = open(ot, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) { (void)!write(fd, "tm", 2); close(fd); }
utimensat(AT_FDCWD, ot, ts, 0);
/* 新鲜的 .part(现在):应保留(可能正在进行/刚中断,mtime 新) */
ncm_cache_path(&g_c, "live", ".mp3", lp, sizeof lp);
snprintf(lp + strlen(lp), sizeof lp - strlen(lp), ".part");
fd = open(lp, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) { (void)!write(fd, "live", 4); close(fd); }
ncm_cache_evict(&g_c);
CHECK(stat(op, &st) != 0, "8-day .part swept by evict scan");
CHECK(stat(ot, &st) != 0, "8-day .tmp swept by evict scan");
CHECK(stat(lp, &st) == 0, "fresh .part kept (inflight/resumable)");
}
cleanup();
printf("%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS");
return g_fail ? 1 : 0;
+293
View File
@@ -0,0 +1,293 @@
/* bench/playlist_race_sim.c — 歌单链路三问题 离线验证(0 真实 NCM 请求,全本地 stub)。
*
* 驱动真实 appstate.c(歌单详情 + 滑动窗口 + 打开上下文 + 歌单列表盘缓存),网络端点为本地
* 可配置 stub,验证 REPORT §26 的三类修复:
* A. 批边界:72 / 92 / 200 首歌单首屏 + 二批 ensure(≤100/批 网格)正常物化;
* B. 部分响应/单批失败不再整页 Load Failed:歌单索引(全量 trackIds)成功即保留 + 已物化槽
* + snack,缺槽留占位(修【92/200 首真机点开即「加载失败」】);
* C. 异步竞态防串:开 A(未载完)→开 B,A 的迟到结果判 NCM_ERR_STALE 丢弃,不把 A 的索引/
* 窗口写进全局(修【5194 首 3 首歌/串数据/加载失败】);打开时快照 plid 免疫 home_worker
* 对 g_st.playlists[] 的并发 memset 污染;
* D. 歌单列表磁盘缓存:appstate_playlists_cache_save/load 回环(断网离线浏览秒开);
* E. 打开上下文 reset 清窗口(UI 丢弃过期结果时不留旧数据)。
* 风控纪律:0 次真实 NCM 请求(离线 stub;songmeta 全 miss/不落盘)。
*
* 编译(宿主,ncm_app 目录下):
* GCC -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd -Icore -Ivendor/jsmn \
* -c bench/playlist_race_sim.c -o /tmp/pr.o
* GCC -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd -Icore -Ivendor/jsmn \
* -c app/logic/appstate.c -o /tmp/pr_app.o
* GCC -O2 -Wl,--gc-sections -o playlist_race_sim /tmp/pr.o /tmp/pr_app.o -lpthread
* 运行:./playlist_race_sim (0 网络;PASS/FAIL 退出码)
*/
#include "../app/ncm_app_internal.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.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;
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;
}
void meow_log(meow_log_level lvl, const char *fmt, ...) { (void)lvl; (void)fmt; }
int meow_config_get(const char *key, char *out, int outsz) { (void)key; (void)out; (void)outsz; return MEOW_CFG_ERR_NOENT; }
int meow_config_set(const char *key, const char *val) { (void)key; (void)val; return MEOW_CFG_OK; }
void meow_config_remove(const char *key) { (void)key; }
int meow_config_int(const char *key, int def) { (void)key; return def; }
/* songmeta 全 miss / 不落盘(本 sim 不测盘缓存歌曲元数据,只测歌单列表上下文)。 */
int songmeta_load(const char *cd, const char *id, ncm_song_t *out) { (void)cd;(void)id;(void)out; return NCM_ERR_IO; }
int songmeta_save(const char *cd, const char *id, const ncm_song_t *s){ (void)cd;(void)id;(void)s; return NCM_ERR_IO; }
/* ---- 可配置网络 stub ---- */
static int g_pl_total = 92; /* 当前歌单返回的 trackIds 总数 */
static int64_t g_id_base = 1000000;
static int g_detail_calls, g_song_calls;
static int g_song_drop; /* song_detail 每次少返回条数(部分响应模拟;0=全量) */
static int g_song_fail_n; /* 前 N 次 song_detail 网络失败模拟 */
static char g_last_plid[64]; /* detail 收到的 playlist_id(验证快照不被 memset 污染) */
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)cover_sz;
g_detail_calls++;
if (playlist_id) { snprintf(g_last_plid, sizeof g_last_plid, "%s", playlist_id); }
if (name) snprintf(name, name_sz, "Sim Playlist");
if (cover) cover[0] = 0;
int n = (g_pl_total < id_cap) ? g_pl_total : id_cap;
for (int i = 0; i < n; i++) ids[i] = g_id_base + 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;
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;
int want = m - g_song_drop; if (want < 0) want = 0;
for (int i = 0; i < want; 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]);
snprintf(out[i].al_pic, sizeof out[i].al_pic, "http://p.music.126.net/cov%lld.jpg", (long long)ids[i]);
out[i].duration_ms = 200000;
out[i].fee = 0;
}
if (out_n) *out_n = want;
return NCM_OK;
}
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)
static void set_pl(int idx, const char *id, const char *name, int total)
{
ncm_strlcpy(g_st.playlists[idx].id, id, sizeof g_st.playlists[idx].id);
ncm_strlcpy(g_st.playlists[idx].name, name, sizeof g_st.playlists[idx].name);
snprintf(g_st.playlists[idx].cover, sizeof g_st.playlists[idx].cover, "http://p1.music.126.net/%s.jpg", id);
g_st.playlists[idx].track_count = total;
}
static void reset_ns(void)
{
g_detail_calls = 0; g_song_calls = 0; g_song_drop = 0; g_song_fail_n = 0;
g_last_plid[0] = 0;
}
/* 模拟 playlist.c 首屏 worker 的非致命降级逻辑(§26):索引成功但曲目批部分失败 → snack,不置 load_err。 */
static int worker_first_downgrade(int rc)
{
if (rc != NCM_OK && rc != NCM_ERR_AUTH && rc != NCM_ERR_STALE && g_st.track_ids_count > 0)
return 0; /* 非致命:等价 g_load_err=0(页面保留已物化 + snack) */
return (rc != NCM_OK); /* 致命:g_load_err=rc(整页空态) */
}
int main(void)
{
if (system("rm -rf /tmp/ncm_plrace_data /tmp/ncm_plrace_cache") != 0) {
printf("FAIL cannot clean /tmp data dir\n");
return 1;
}
memset(&g_st, 0, sizeof g_st);
snprintf(g_st.data_dir, sizeof g_st.data_dir, "/tmp/ncm_plrace_data");
snprintf(g_st.cache_dir, sizeof g_st.cache_dir, "/tmp/ncm_plrace_cache");
if (system("mkdir -p /tmp/ncm_plrace_data /tmp/ncm_plrace_cache") != 0) {
printf("FAIL cannot mkdir /tmp dirs\n");
return 1;
}
g_st.logged_in = 1;
ncm_strlcpy(g_st.uid, "408996274", sizeof g_st.uid);
g_st.playlist_count = 2;
set_pl(0, "plFav", "我喜欢的音乐", 92); /* A:92 首 */
set_pl(1, "plDoc", "通勤歌单", 200); /* B:200 首 */
ncm_strlcpy(g_st.ctx.device_id, "dev", sizeof g_st.ctx.device_id);
ncm_strlcpy(g_st.ctx.cookie, "ck", sizeof g_st.ctx.cookie);
/* ---- A. 批边界 ---- */
printf("== A. batch boundary: 72 / 92 / 200 ==\n");
int totals[3] = {72, 92, 200};
for (int k = 0; k < 3; k++) {
reset_ns();
g_pl_total = totals[k];
g_st.playlists[0].track_count = totals[k];
ncm_playlist_t snap = g_st.playlists[0];
int rc = appstate_load_playlist_songs_ctx(&snap, 0, -1, 0, 0, NCM_SONG_BATCH);
CHECK(rc == NCM_OK, "[%d] first load rc=%d", totals[k], rc);
CHECK(g_st.track_ids_count == totals[k], "[%d] track_ids_count=%d (expect %d)",
totals[k], g_st.track_ids_count, totals[k]);
int first = (totals[k] < NCM_SONG_BATCH) ? totals[k] : NCM_SONG_BATCH;
CHECK(g_st.song_count == first, "[%d] first batch valid=%d (expect %d)",
totals[k], g_st.song_count, first);
CHECK(g_detail_calls == 1 && g_song_calls == 1,
"[%d] detail=%d song_detail=%d (expect 1+1)", totals[k], g_detail_calls, g_song_calls);
ncm_song_t s;
CHECK(appstate_song_copy(first - 1, &s) == 0, "[%d] first-batch last song copy rc=0", totals[k]);
if (totals[k] > NCM_SONG_BATCH) { /* 二批 ensure:200 首 → [100,200) */
int rc2 = appstate_song_ensure_range(NCM_SONG_BATCH, totals[k]);
CHECK(rc2 == NCM_OK && g_st.song_count == totals[k],
"[%d] second batch ensure rc=%d valid=%d (expect %d)",
totals[k], rc2, g_st.song_count, totals[k]);
CHECK(g_song_calls == 2, "[%d] song_detail calls=%d (expect 2 batches)", totals[k], g_song_calls);
CHECK(appstate_song_copy(totals[k] - 1, &s) == 0,
"[%d] tail song copy rc=0 after 2nd batch", totals[k]);
}
appstate_playlist_reset(); /* 下一次干净 */
}
/* ---- B. 部分响应/单批失败非致命(92/200 点开不再整页 Load Failed) ---- */
printf("== B. partial response / single-batch failure non-fatal ==\n");
reset_ns();
g_pl_total = 92; g_song_drop = 1; /* 首批少返回 1 首(服务端缺歌) */
g_st.playlists[0].track_count = 92;
{
ncm_playlist_t snap = g_st.playlists[0];
int rc = appstate_load_playlist_songs_ctx(&snap, 0, -1, 0, 0, NCM_SONG_BATCH);
CHECK(rc == NCM_ERR_NOTFOUND || rc == NCM_OK, "partial batch rc=%d (NOTFOUND=非致命)", rc);
CHECK(g_st.track_ids_count == 92, "partial keeps index: track_ids_count=%d (expect 92)", g_st.track_ids_count);
CHECK(g_st.song_count == 91, "partial keeps 91 materialized: valid=%d", g_st.song_count);
int page_err = worker_first_downgrade(rc);
CHECK(page_err == 0, "worker downgrades partial to non-fatal (page NOT wiped)");
}
reset_ns();
g_pl_total = 200; g_song_fail_n = 1; /* 首批网络失败一次(index 已建,曲目批失败) */
g_st.playlists[0].track_count = 200;
{
ncm_playlist_t snap = g_st.playlists[0];
int rc = appstate_load_playlist_songs_ctx(&snap, 0, -1, 0, 0, NCM_SONG_BATCH);
CHECK(g_st.track_ids_count == 200, "network-fail keeps index: track_ids_count=%d (expect 200)",
g_st.track_ids_count);
int page_err = worker_first_downgrade(rc);
CHECK(page_err == 0, "single-batch network fail non-fatal (page not wiped; placeholders remain)");
}
/* ---- C. 异步竞态防串 + 快照免疫 memset ---- */
printf("== C. async race: open A unfinished -> open B (A late result discarded) ==\n");
reset_ns();
g_pl_total = 92;
g_st.playlists[0].track_count = 92;
{ /* 打开 A:begin(A) 记代次/目标 → A 正常加载 */
int genA = appstate_playlist_begin(0);
ncm_playlist_t snapA = g_st.playlists[0];
int rc = appstate_load_playlist_songs_ctx(&snapA, 0, genA, 0, 0, NCM_SONG_BATCH);
CHECK(rc == NCM_OK && g_st.track_ids_count == 92, "open A loads (rc=%d count=%d)", rc, g_st.track_ids_count);
}
{ /* 开 A 未完 → 开 B:begin(B) 记 B 代次/目标;A 的迟到结果(genA)被判过期丢弃 */
g_pl_total = 200;
g_st.playlists[1].track_count = 200;
int genB = appstate_playlist_begin(1);
/* A 的快照 + A 的旧代次 = 过期 → 立即 STALE,不写任何全局 */
int genA_stale = genB - 1;
ncm_playlist_t snapA = g_st.playlists[0];
int rA = appstate_load_playlist_songs_ctx(&snapA, 0, genA_stale, 0, 0, NCM_SONG_BATCH);
CHECK(rA == NCM_ERR_STALE, "A late load marked STALE (rc=%d)", rA);
CHECK(appstate_playlist_open_gen() == genB && appstate_playlist_open_idx() == 1,
"open context now B (gen=%d idx=%d)", appstate_playlist_open_gen(), appstate_playlist_open_idx());
/* B 以 B 的当前代次加载 → 采纳,全局内容为 B */
ncm_playlist_t snapB = g_st.playlists[1];
int rB = appstate_load_playlist_songs_ctx(&snapB, 0, genB, 1, 0, NCM_SONG_BATCH);
CHECK(rB == NCM_OK && g_st.track_ids_count == 200,
"B loads (rc=%d track_ids_count=%d expect 200)", rB, g_st.track_ids_count);
CHECK(g_detail_calls == 2 && g_last_plid[0] != 0, "two detail calls total=%d", g_detail_calls);
}
/* 快照免疫 home_worker memset:打开时快照 plid,之后清空 g_st.playlists[] 也不影响加载 */
printf("== C2. snapshot plid immune to home_worker memset(g_st.playlists[]) ==\n");
reset_ns();
g_pl_total = 92; g_st.playlists[0].track_count = 92;
{
ncm_playlist_t snap = g_st.playlists[0]; /* playlist_refresh 在打开时拷的快照 */
memset(g_st.playlists, 0, sizeof g_st.playlists);/* home_worker appstate_load_playlists 的 memset */
g_st.playlist_count = 0;
int rc = appstate_load_playlist_songs_ctx(&snap, 0, -1, 0, 0, NCM_SONG_BATCH);
CHECK(rc == NCM_OK && g_st.track_ids_count == 92,
"snapshot load survives memset (rc=%d count=%d)", rc, g_st.track_ids_count);
CHECK(strcmp(g_last_plid, "plFav") == 0, "detail received snapshot plid='%s' (expect plFav)", g_last_plid);
}
/* ---- D. 歌单列表磁盘缓存 ---- */
printf("== D. playlists disk cache (save -> load roundtrip) ==\n");
{
g_st.logged_in = 1;
g_st.playlist_count = 2;
set_pl(0, "plFav", "我喜欢的音乐", 92);
set_pl(1, "plDoc", "通勤歌单", 200);
int rc = appstate_playlists_cache_save();
CHECK(rc == NCM_OK, "cache_save rc=%d", rc);
memset(g_st.playlists, 0, sizeof g_st.playlists);
g_st.playlist_count = 0;
int64_t ts = 0;
rc = appstate_playlists_cache_load(&ts);
CHECK(rc == NCM_OK, "cache_load rc=%d", rc);
CHECK(g_st.playlist_count == 2, "cache_load count=%d (expect 2)", g_st.playlist_count);
CHECK(ts > 0, "cache timestamp=%lld (expect >0)", (long long)ts);
CHECK(strcmp(g_st.playlists[1].id, "plDoc") == 0 &&
strcmp(g_st.playlists[1].name, "通勤歌单") == 0,
"roundtrip keeps content (id/name)");
}
/* ---- E. 打开上下文 reset 清窗口 ---- */
printf("== E. appstate_playlist_reset clears window/index ==\n");
{
g_pl_total = 92;
ncm_playlist_t snap = g_st.playlists[0];
appstate_load_playlist_songs_ctx(&snap, 0, -1, 0, 0, NCM_SONG_BATCH);
CHECK(g_st.track_ids_count == 92 && g_st.song_count > 0, "prior: count=%d valid=%d",
g_st.track_ids_count, g_st.song_count);
appstate_playlist_reset();
CHECK(g_st.track_ids_count == 0 && g_st.song_count == 0 && g_st.window_base == 0,
"after reset: count=%d valid=%d base=%d", g_st.track_ids_count, g_st.song_count, g_st.window_base);
}
printf("%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS");
return g_fail ? 1 : 0;
}