diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index b6ed131..7b0b155 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -2392,3 +2392,99 @@ d9bfe3d7a6eaa76b00161a4e97196eb8 bench/player_flow_sim.c # 新 P12:同 idx ``` (`REPORT.md` 追加 §25;`WORK_STATE.md` 同步锚点见 devdocs。) + +--- + +## 26. 歌单链路三修:开A→开B 竞态防串(5194 首 3 首歌/串数据/加载失败)+ 92/200 批边界 Load Failed + 歌单列表盘缓存 + 孤儿 .part 回收(2026-09-01,commit `cd216fa`) + +> 范围:仅 `patch_v2/ncm_app/`。定案见 `devdocs/plan/NCM_CACHE_DESIGN.md §2.2`(歌单列表快照秒开+后台刷新)、 +> `WORK_STATE.md` 254(滑动窗口)/ 273(搜索不覆写歌单上下文)/ 275 遗留(歌单列表无离线缓存)。 +> 真机现象(用户实测):①「我喜欢的音乐」5194 首要么只显示 3 首(怀疑串数据)要么加载失败要么卡久后失败; +> ②92 与 200 首歌单点开「加载失败」、72 能开;③从歌曲列表返回首页必卡几秒 LOADING; +> ④孤儿 `.part` 无回收(275 遗留)。风控纪律:真实 NCM 请求 **0 次**(全离线 stub);**未部署真机**(留主会话)。 + +### 26.1 根因(文件:行) + +- **① 5194 首(3 首/串数据/加载失败)= 歌单上下文并发竞态(三条叠加)**: + 1. **`appstate_load_playlists`(`app/logic/appstate.c:182`)`memset(g_st.playlists, ...)` 清空再回填**。用户「进歌单→快速返回首页→再进另一个歌单」时,home_worker 的网络拉取(`appstate_load_playlists`)与 playlist_worker 并发;旧 `appstate_load_playlist_songs_page` 只在起点取一次 `pl = &g_st.playlists[idx]`,`detail` 内再读 `pl->id`——若此刻数组被 memset,`pl->id` 为空串 → `detail("")` 取回**空/少量** trackIds(3 首)或异常 → 「只显示 3 首」「串数据」。 + 2. **worker 单飞(`g_plthr_on`)只限制并发数量,不校验结果归属**:开 A→未载完→开 B,A 的迟到结果(全量 trackIds + 批物化)平铺进全局 `song_ids/window/track_ids_count`;UI 在 B 标题下读到 A 数据(串数据);旧 `playlist_tick` 仅在 `g_plloaded_idx != active` 时重拉,但重拉前的 `pl_apply` 已把 A 数据画在 B 标题下(瞬时串数据)。 + 3. **首屏把任何曲目批错误映射整页失败**:`appstate_song_ensure_range`(旧 `app/logic/appstate.c:360`)批内 `bn0`」把非致命错误降级为 `g_ens_err`(snack)+ `g_load_err=0`——页面保留已物化 + 占位行,不再整页 Load Failed(索引失败/鉴权失败仍整页空态可点重试)。 +3. **歌单列表盘缓存(修③,NCM_CACHE_DESIGN §2.2 stale-while-revalidate)**:`appstate_playlists_cache_save/load`(`L200/L222`,`/playlists.bin`:magic+ver+时间戳+数组;刻意不进 LRU 音频池,防被「清理缓存」误清);`appstate_load_playlists`(`L188`)网络成功即覆写缓存。`home_reload`(`L223`)先 `appstate_playlists_cache_load` 秒渲(有缓存不显示 LOADING),后台 worker 静默刷新;`home_worker`(`L216`)刷新失败但有数据(`playlist_count>0`)不置错误态(断网可离线浏览);`home_draw_empty`(`L167`)有数据即显示列表。手动刷新按钮(`home_on_tap`↻ → `home_reload`)保留。 +4. **孤儿 `.part` 回收(修④)**:`core/ncm_cache.c` `ncm_cache_evict`(`L176/L187`)与 `ncm_cache_total_bytes`(`L325/L335`)LRU 扫描时顺手续删 `mtime > 7 天`(`NCM_ORPHAN_PART_DAYS=7`,`L25`)的 `.part`/`.tmp`(在播/刚中断的 mtime 新鲜安全);`ncm_cache_clear`(清理缓存)本删全部 `song_*`(含 `.part`/`.tmp`),满足「清理缓存一并清所有 .part」。 + +### 26.3 测试证据(0 真实 NCM 请求) + +| 项 | 方式 | 结果 | +|---|---|---| +| ①异步竞态(开A未完→开B,A 迟到结果丢弃不串 B) | **新 `bench/playlist_race_sim.c`**(真实 appstate + 本地 stub) | **PASS**:open A 载 92 → begin(B)→A 以旧代次 load 返回 `NCM_ERR_STALE`(-16)不写全局 → B 以当代次 load 采纳(count=200);open 上下文正确指向 B | +| 快照 plid 免疫 home_worker memset | `bench/playlist_race_sim.c` | **PASS**:打开时快照后 `memset(g_st.playlists)` → 快照载 92 仍准、detail 收到快照 `plFav`(非空) | +| ②92/200 批边界 | `bench/playlist_race_sim.c` | **PASS**:72/92 首屏 1 detail+1 批全物化;200 首屏 1 批(100)+二批 ensure(200) 共 2 次 song_detail、tail 可达 | +| ②部分返回/单批网络失败非致命 | `bench/playlist_race_sim.c` | **PASS**:`bn0、id/name 一致 | +| ④.part 7 天回收 | `bench/cache_evict_sim.c` 新 case9 | **PASS**:8 天 `.part`/`.tmp` 被 evict 扫描删、新鲜 `.part` 保留 | +| 现有回归 | window_sim / pagination_sim / search_sim / cache_evict_sim(1-8) / stream_sim | 全 **PASS**(6000 首滚动 / 5191 无截断 / 搜索队列 / LRU 续传 / 边下边播全矩阵+resume) | +| core 回归 | `test_ncm_core_host` + qemu `test_ncm_core_mipsel` | 宿主 + mipsel **PASS** | +| mipsel 构建 | `bash build_ncm.sh` 自有代码 `-Wall -Wextra` | **0 warning**;`text 978354 / data 1624 / bss 1496484`(bss +~132KB 来自 `s_build[]` staging) | +| 宿主离屏渲染 | `build_ncm_host.sh` + `NCM_PAGE=home/playlist/search` | rc=0,渲染正常 | + +### 26.4 md5(本批改动文件 + 产物) + +``` +158ec3672aa7708f791dce7d79f7d8b1 ncm # mipsel 静态 App(新;未部署,留主会话) +a3c336a9e863017a84827c3ad3e204de ncm_host # 宿主离屏调试二进制(重链) +7922fc00d19666e64eb353d6685aba0a core/ncm_common.h # +NCM_ERR_STALE(-16) +a6d561a33797da2ac260d46d53d76357 core/ncm_cache.c # §26:7 天孤儿 .part/.tmp 顺手续删(evict/total_bytes) +6c4780a3253b2187224db684a9243fc9 app/ncm_app_internal.h # 打开上下文 API + 快照 loader + 盘缓存声明 +cca1a51d031092e0bdbf184266031e67 app/logic/appstate.c # §26:打开代次/快照 ctx loader + 部分非致命 ensure + 歌单列表盘缓存 +c098a80b69e55f0be4b38ee84a73b5c7 app/ui/playlist.c # §26:快照+代次 worker,tick 丢弃+reset 防串 +01975811d562c231022c607eed766f3a app/ui/home.c # §26:歌单列表缓存秒渲 + 后台静默刷新 +b7a2bfaec3c3d7bd29a151072cd40f32 bench/cache_evict_sim.c # 新 case9:.part/.tmp 7 天回收(+ncm_now_ms 桩) +56673b0e56ebb605ac575ce97c5e27a8 bench/playlist_race_sim.c # 新:竞态防串/批边界/非致命/盘缓存/reset(0 网络) +``` + +### 26.5 出入清单(只改 `patch_v2/ncm_app/`) + +``` +修改: + core/ncm_common.h # +NCM_ERR_STALE(-16) + core/ncm_cache.c # 孤儿 .part/.tmp 7 天顺续删(evict/total_bytes 扫描时) + app/ncm_app_internal.h # 打开上下文(gen/idx/begin/reset) + 快照 loader 声明 + 盘缓存声明 + + # appstate_range_loaded 声明恢复(补回) + app/logic/appstate.c # 打开代次上下文 + 快照式 appstate_load_playlist_songs_ctx/impl + + # 索引用 staging 锁内原子发布 + song_fetch 索引读加锁 + + # ensure_range fill-and-continue(部分/单批非致命)+ 歌单列表盘缓存 + app/ui/playlist.c # 打开快照(g_tgt)+代次;worker 用快照 id 走 ctx loader; + # tick 过期(idx/gen/stale)丢弃 + appstate_playlist_reset 防串; + # 首屏部分失败降级为非致命(snack) + app/ui/home.c # home_reload 缓存秒渲 + 后台静默刷新;home_worker 刷新失败不置错; + # home_draw_empty 有数据即显示列表 + bench/cache_evict_sim.c # 新 case9(7 天回收)+ ncm_now_ms 桩 +新增: + bench/playlist_race_sim.c # 歌单链路三问题离线回归(0 网络) + REPORT.md # 本批记录(§26) +(未动 sdk/、meow_launcherd/、vendor/;未部署真机;未动设备系统/恢复区) +``` + +### 26.6 诚实出入 / 遗留 + +- **真机未部署未验**(留主会话统一)。真机验收点:①「我喜欢的音乐」5194 首正常显示/滚动/回滚无 3 首或串数据;快进回首页再进其它歌单不再串;②92/200 首歌单点开正常(不再整页 Load Failed,个别缺歌只占位+snack);③从歌曲列表返回首页秒出(缓存渲染)再被后台刷新替换;④清理缓存清掉全部 `.part`,LRU 扫描顺手清 7 天前 `.part`。 +- **打开快照残留竞态窗口**:`playlist_refresh` 的 `g_tgt.pl = *pcur`(读 `g_st.playlists[idx]`)与 home_worker 的 `memset` 仍可能瞬时交错(相差若干指令);`pl_cur()` 快照拷贝前加了 `!pcur->id[0]` 防御(空 id 直接报错可重试),不产生 3 首垃圾数据,但理论上仍有一次「取到半零 id」的极小概率——后续可给 `g_st.playlists[]` 加互斥彻底消除(本次未做,保持最小改动)。 +- **`appstate_song_ensure_range` 返回首个非致命错误码**(`NCM_ERR_NOTFOUND`/网络码)供 UI snack;语义从「整段失败」改为「已物化保留+占位自愈」,`appstate_range_loaded` 对未覆盖区间仍返回 0(触发后续 ensure 重试)。 +- **歌单列表缓存无版本化清理**:`playlists.bin` 靠 ver/magic 判 miss;数组改版/账号切换不主动失效(账号掉线重登前 `appstate_logout` 未清 playlists.bin——本次 `logout` 未联动删缓存,**下一账号可能看到上一账号列表直到首次网络成功覆写**;留主会话)。 +- **`.part` 7 天回收只在 LRU 扫描(evict/total_bytes)时触发**:无网/不触发 evict 时不会主动清;属「惰性删除」(任务要求)。`.tmp`(在播写穿)mtime 持续更新,安全。 +- **未动 sdk/、meow_launcherd/、vendor/**;部署链路同 §13.5 留主会话。commit `cd216fa`(git 记录)。 diff --git a/patch_v2/ncm_app/app/logic/appstate.c b/patch_v2/ncm_app/app/logic/appstate.c index 56f442e..8096ee5 100644 --- a/patch_v2/ncm_app/app/logic/appstate.c +++ b/patch_v2/ncm_app/app/logic/appstate.c @@ -13,6 +13,39 @@ static pthread_mutex_t g_song_mu = PTHREAD_MUTEX_INITIALIZER; /* 搜索结果独立数组互斥(§23):g_st.search_songs/search_pls 读写 + queue_src 读。 */ static pthread_mutex_t g_search_mu = PTHREAD_MUTEX_INITIALIZER; +/* 歌单加载上下文(§26 开A→未载完→开B 竞态防串):open_gen 在每次「打开/刷新歌单」时递增并记 + * 目标下标;加载器(appstate_load_playlist_songs_ctx)在 worker 完成时校验代次/目标不匹配即 + * 判过期(NCM_ERR_STALE),不把旧歌单的索引/窗口落进全局——防「上一歌单结果串进新打开的」。 + * init 时 open_idx=-1(无打开目标),open_gen=0(无代次 → ctx_gen<0 兼容路径不校验)。 */ +static pthread_mutex_t g_plopen_mu = PTHREAD_MUTEX_INITIALIZER; +static int g_plopen_gen; /* 打开代次(每次 begin +1) */ +static int g_plopen_idx = -1; /* 当前打开目标下标(-2=搜索哨兵;-1=无) */ + +int appstate_playlist_open_gen(void) { return g_plopen_gen; } +int appstate_playlist_open_idx(void) { return g_plopen_idx; } + +int appstate_playlist_begin(int idx) +{ + pthread_mutex_lock(&g_plopen_mu); + g_plopen_gen++; + g_plopen_idx = idx; + int g = g_plopen_gen; + pthread_mutex_unlock(&g_plopen_mu); + return g; +} + +/* 清窗口/索引(UI 丢弃过期歌单结果时用;播放快照不受影响——播放经 play_song 快照)。 */ +void appstate_playlist_reset(void) +{ + pthread_mutex_lock(&g_song_mu); + memset(g_st.song_valid, 0, sizeof g_st.song_valid); + memset(g_st.song_ids, 0, sizeof g_st.song_ids); + g_st.window_base = 0; + g_st.song_count = 0; + g_st.track_ids_count = 0; + pthread_mutex_unlock(&g_song_mu); +} + static void appstate_srch_hist_init(void); /* §23:读入本地搜索历史(appstate_init 首调) */ int appstate_init(void) @@ -152,9 +185,66 @@ int appstate_load_playlists(void) NCM_PLAYLIST_MAX, 0, g_st.playlists, NCM_PLAYLIST_MAX, &n); g_st.playlist_count = (rc == NCM_OK) ? n : 0; meow_log(MEOW_LOG_INFO, "home: playlists rc=%d count=%d uid=%s", rc, g_st.playlist_count, g_st.uid); + if (rc == NCM_OK) appstate_playlists_cache_save(); /* §26:网络成功 → 覆写盘缓存(stale-while-revalidate) */ return rc; } +/* ---- 歌单列表磁盘缓存(§26,NCM_CACHE_DESIGN §2.2)---- + * 快照 /playlists.bin:magic(4) + ver(2) + 时间戳(8) + count(4) + 数组(ncm_playlist_t)。 + * 进首页先渲染缓存秒开(断网可离线浏览),后台 worker 拉网络成功后覆写;结构体改版靠 ver 判 miss。 + * 刻意不进 ncm_cache LRU 池(那是音频/封面池,被「清理缓存」清空)——歌单列表是浏览数据。 */ +#define PLS_CACHE_MAGIC 0x50534C54u /* 'PLST' */ +#define PLS_CACHE_VER 1 +static int64_t g_pls_cache_ts; /* 上次缓存快照时间(秒) */ + +int appstate_playlists_cache_save(void) +{ + char path[640]; + snprintf(path, sizeof path, "%s/playlists.bin", g_st.data_dir); + FILE *f = fopen(path, "wb"); + if (!f) return NCM_ERR_IO; + int64_t ts = ncm_now_ms() / 1000; + uint32_t magic = PLS_CACHE_MAGIC; + uint16_t ver = PLS_CACHE_VER; + uint32_t count = (uint32_t)g_st.playlist_count; + if (count > NCM_PLAYLIST_MAX) count = NCM_PLAYLIST_MAX; + int ok = (fwrite(&magic, sizeof magic, 1, f) == 1 && + fwrite(&ver, sizeof ver, 1, f) == 1 && + fwrite(&ts, sizeof ts, 1, f) == 1 && + fwrite(&count, sizeof count, 1, f) == 1 && + fwrite(g_st.playlists, sizeof(ncm_playlist_t), (size_t)count, f) == (size_t)count); + fclose(f); + if (ok) { g_pls_cache_ts = ts; return NCM_OK; } + unlink(path); + return NCM_ERR_IO; +} + +int appstate_playlists_cache_load(int64_t *ts_out) +{ + if (ts_out) *ts_out = 0; + if (g_st.logged_in == 0) return NCM_ERR_AUTH; /* 未登录不落缓存 */ + char path[640]; + snprintf(path, sizeof path, "%s/playlists.bin", g_st.data_dir); + FILE *f = fopen(path, "rb"); + if (!f) return NCM_ERR_NOENT; + uint32_t magic; uint16_t ver; int64_t ts; uint32_t count; + int ok = (fread(&magic, sizeof magic, 1, f) == 1 && + fread(&ver, sizeof ver, 1, f) == 1 && + fread(&ts, sizeof ts, 1, f) == 1 && + fread(&count, sizeof count, 1, f) == 1 && + magic == PLS_CACHE_MAGIC && ver == PLS_CACHE_VER && + count <= NCM_PLAYLIST_MAX); + if (!ok) { fclose(f); return NCM_ERR_IO; } + size_t rd = fread(g_st.playlists, sizeof(ncm_playlist_t), (size_t)count, f); + fclose(f); + if (rd != (size_t)count) return NCM_ERR_IO; + g_st.playlist_count = (int)count; + g_pls_cache_ts = ts; + if (ts_out) *ts_out = ts; + meow_log(MEOW_LOG_INFO, "home: playlists cache hit count=%d ts=%lld", (int)count, (long long)ts); + return NCM_OK; +} + /* ============================================================ * 滑动窗口(REPORT §17,三期第三批) * songs[i] ↔ 全局下标 window_base+i;song_valid[] 为槽位有效位图。 @@ -257,7 +347,8 @@ int appstate_song_ensure_range(int lo, int hi) * 走 song_detail;成功写窗 + 落盘。网络/盘 IO 均在锁外,锁内仅写槽。 */ static ncm_song_t s_fetch[NCM_SONG_BATCH]; /* 单写者(playlist worker),免 60KB 栈 */ int rc = NCM_OK; - for (int a = lo; a < hi && rc == NCM_OK; ) { + int first_err = NCM_OK; /* §26:批内错误非致命(缺歌/单批网络失败),记首个供 UI 提示 */ + for (int a = lo; a < hi; ) { pthread_mutex_lock(&g_song_mu); int va = slot_valid_locked(a); pthread_mutex_unlock(&g_song_mu); @@ -280,17 +371,19 @@ int appstate_song_ensure_range(int lo, int hi) if (a0 < g_st.window_base) a0 = g_st.window_base; if (b0 > wend2) b0 = wend2; if (b0 - a0 > NCM_SONG_BATCH) b0 = a0 + NCM_SONG_BATCH; - /* 本段 [a0,b0):盘命中直接写窗,有效槽跳过,miss 记录待批拉 */ + /* 本段 [a0,b0):盘命中直接写窗,有效槽跳过,miss 记录待批拉。 + * 索引读取在锁内(与「索引重建」写 song_ids 的 worker/重建路径防竞态)。 */ int64_t ids[NCM_SONG_BATCH]; int gslot[NCM_SONG_BATCH]; int m = 0; for (int i = a0; i < b0; i++) { pthread_mutex_lock(&g_song_mu); int vv = slot_valid_locked(i); + int64_t sid64 = vv ? 0 : g_st.song_ids[i]; pthread_mutex_unlock(&g_song_mu); if (vv) continue; char sid[24]; - snprintf(sid, sizeof sid, "%lld", (long long)g_st.song_ids[i]); + snprintf(sid, sizeof sid, "%lld", (long long)sid64); ncm_song_t ms; if (songmeta_load(g_st.cache_dir, sid, &ms) == NCM_OK) { pthread_mutex_lock(&g_song_mu); @@ -301,7 +394,7 @@ int appstate_song_ensure_range(int lo, int hi) } pthread_mutex_unlock(&g_song_mu); } else { - ids[m] = g_st.song_ids[i]; + ids[m] = sid64; gslot[m] = i; m++; } @@ -310,36 +403,45 @@ int appstate_song_ensure_range(int lo, int hi) int bn = 0; int r2 = ncm_api_song_detail(g_st.ctx.device_id, g_st.ctx.cookie, ids, m, s_fetch, NCM_SONG_BATCH, &bn); - if (r2 != NCM_OK) { rc = r2; break; } - if (bn > m) bn = m; - for (int k = 0; k < bn; k++) { /* song_detail 结果与请求 ids 同序 */ - pthread_mutex_lock(&g_song_mu); - int s = gslot[k] - g_st.window_base; - if (s >= 0 && s < NCM_SONG_WINDOW) { - g_st.songs[s] = s_fetch[k]; - g_st.song_valid[s] = 1; + if (r2 == NCM_OK) { + if (bn > m) bn = m; + for (int k = 0; k < bn; k++) { /* song_detail 结果与请求 ids 同序 */ + pthread_mutex_lock(&g_song_mu); + int s = gslot[k] - g_st.window_base; + if (s >= 0 && s < NCM_SONG_WINDOW) { + g_st.songs[s] = s_fetch[k]; + g_st.song_valid[s] = 1; + } + pthread_mutex_unlock(&g_song_mu); + char sid[24]; + snprintf(sid, sizeof sid, "%lld", (long long)ids[k]); + songmeta_save(g_st.cache_dir, sid, &s_fetch[k]); } - pthread_mutex_unlock(&g_song_mu); - char sid[24]; - snprintf(sid, sizeof sid, "%lld", (long long)ids[k]); - songmeta_save(g_st.cache_dir, sid, &s_fetch[k]); + /* §26:部分返回(bn a) ? b0 : b; /* 推进到本批网格末尾:失败批不在同一次调用内被重复重发 */ } pthread_mutex_lock(&g_song_mu); win_recount_locked(); pthread_mutex_unlock(&g_song_mu); - return rc; + return (first_err != NCM_OK) ? first_err : rc; } int appstate_song_fetch(int gidx, ncm_song_t *out) { if (!out) return NCM_ERR_ARG; - if (appstate_song_copy(gidx, out) == 0) return NCM_OK; /* 窗内直接命中 */ - if (gidx < 0 || gidx >= g_st.track_ids_count) return NCM_ERR_ARG; + if (gidx < 0) return NCM_ERR_ARG; + pthread_mutex_lock(&g_song_mu); + if (gidx >= g_st.track_ids_count) { pthread_mutex_unlock(&g_song_mu); return NCM_ERR_ARG; } int64_t id = g_st.song_ids[gidx]; + pthread_mutex_unlock(&g_song_mu); if (id == 0) return NCM_ERR_ARG; /* 无索引(如搜索上下文窗外) */ char sid[24]; snprintf(sid, sizeof sid, "%lld", (long long)id); @@ -368,13 +470,14 @@ void appstate_adopt_songs(int n) pthread_mutex_unlock(&g_song_mu); } -int appstate_load_playlist_songs_page(int idx, int offset, int limit) +/* 核心加载(无代次校验):pl 为打开时的快照副本(已拷贝,之后不读 g_st.playlists[]—— + * 防 home_worker 刷新歌单列表 memset 旧数组时把正在加载的歌单 id 污染成空、导致 + * detail("") 取回 3 首歌/串数据/加载失败,§26)。offset==0 重头用 staging 缓冲收编 + * detail 结果、锁内原子发布索引(防 worker 写 song_ids 与播放线程 song_fetch 读竞态)。 */ +static int appstate_load_playlist_songs_impl(const ncm_playlist_t *pl, int is_search, + int offset, int limit) { - /* 搜索歌单详情(§23):idx==NCM_PL_SEARCH 时读 g_st.search_pl_open(复用本链路)。 */ - if (idx != NCM_PL_SEARCH && (idx < 0 || idx >= g_st.playlist_count)) return NCM_ERR_ARG; - /* 不在此处写 g_st.active_playlist:调用方(playlist_open/UI)已在 UI 线程设定; - * 若后台 worker 在此改写,会覆盖用户已切换到的新歌单,造成“打开 B 却显示 A”。 */ - const ncm_playlist_t *pl = (idx == NCM_PL_SEARCH) ? &g_st.search_pl_open : &g_st.playlists[idx]; + (void)is_search; if (limit <= 0 || limit > NCM_SONG_BATCH) limit = NCM_SONG_BATCH; if (offset < 0) offset = 0; @@ -384,7 +487,8 @@ int appstate_load_playlist_songs_page(int idx, int offset, int limit) * 歌曲对象只物化视口附近的 NCM_SONG_WINDOW 首(offset 为「要确保的区间」起点)。 */ if (offset == 0) { - /* 重头:清窗口 + 重建 trackIds 索引(song_count/track_ids_count 无论成败都归零)。 */ + /* 重头:清窗口 + 重建 trackIds 索引(song_count/track_ids_count 无论成败都归零)。 + * 封面兜底值取自快照 pl->cover(不读 g_st.playlists[],防污染)。 */ pthread_mutex_lock(&g_song_mu); memset(g_st.song_valid, 0, sizeof g_st.song_valid); memset(g_st.song_ids, 0, sizeof g_st.song_ids); @@ -392,13 +496,19 @@ int appstate_load_playlist_songs_page(int idx, int offset, int limit) g_st.song_count = 0; g_st.track_ids_count = 0; pthread_mutex_unlock(&g_song_mu); + ncm_strlcpy(g_st.cover_url, pl->cover, sizeof g_st.cover_url); + /* detail 为网络调用(不能持锁);结果落 staging 缓冲,锁内一次性发布索引(原子)。 + * 静态缓冲:本加载函数单写者(playlist worker 单飞),免 128KB 栈。 */ + static int64_t s_build[NCM_TRACKID_MAX]; int idn = 0; int rc = ncm_api_playlist_detail(g_st.ctx.device_id, g_st.ctx.cookie, pl->id, - NULL, 0, NULL, 0, - g_st.song_ids, NCM_TRACKID_MAX, &idn); + NULL, 0, NULL, 0, s_build, NCM_TRACKID_MAX, &idn); if (rc != NCM_OK) return rc; + if (idn > NCM_TRACKID_MAX) idn = NCM_TRACKID_MAX; + pthread_mutex_lock(&g_song_mu); + if (idn > 0) memcpy(g_st.song_ids, s_build, (size_t)idn * sizeof(int64_t)); g_st.track_ids_count = idn; - appstate_apply_playlist_cover(idx); + pthread_mutex_unlock(&g_song_mu); } else if (g_st.track_ids_count == 0) { /* 防御:offset>0 但没有索引(不应发生;主路径先走 offset=0)。 */ return NCM_ERR_ARG; @@ -410,6 +520,38 @@ int appstate_load_playlist_songs_page(int idx, int offset, int limit) return appstate_song_ensure_range(offset, hi); } +int appstate_load_playlist_songs_ctx(const ncm_playlist_t *pl, int is_search, + int ctx_gen, int ctx_idx, int offset, int limit) +{ + if (!pl || !pl->id[0]) return NCM_ERR_ARG; + /* §26 打开上下文校验:代次/目标不匹配 → 判过期(不写全局),防「开 A→开 B 的迟到结果串 B」。 + * ctx_gen<0 = 兼容调用方(既有单测/autoplay)不校验,永远采纳。 */ + if (ctx_gen >= 0 && (ctx_gen != g_plopen_gen || ctx_idx != g_plopen_idx)) + return NCM_ERR_STALE; + int rc = appstate_load_playlist_songs_impl(pl, is_search, offset, limit); + /* 加载跨网络(detail 一次 + song_detail 分批),期间用户可能已切到新歌单(开 B): + * 再验一次代次,过期即弃(清窗口,避免旧歌单数据被画在新歌单标题下)。 */ + if (ctx_gen >= 0 && (ctx_gen != g_plopen_gen || ctx_idx != g_plopen_idx)) { + appstate_playlist_reset(); + return NCM_ERR_STALE; + } + return rc; +} + +int appstate_load_playlist_songs_page(int idx, int offset, int limit) +{ + /* 搜索歌单详情(§23):idx==NCM_PL_SEARCH 时读 g_st.search_pl_open(复用本链路)。 */ + if (idx != NCM_PL_SEARCH && (idx < 0 || idx >= g_st.playlist_count)) return NCM_ERR_ARG; + /* 不在此处写 g_st.active_playlist:调用方(playlist_open/UI)已在 UI 线程设定; + * 若后台 worker 在此改写,会覆盖用户已切换到的新歌单,造成“打开 B 却显示 A”。 */ + ncm_playlist_t snap; + int is_search = (idx == NCM_PL_SEARCH); + if (is_search) snap = g_st.search_pl_open; + else snap = g_st.playlists[idx]; + /* ctx_gen=-1:兼容入口(单测/autoplay)不做代次校验——由调用方协调并发。 */ + return appstate_load_playlist_songs_ctx(&snap, is_search, -1, idx, offset, limit); +} + int appstate_load_playlist_songs(int idx) { /* 兼容入口:载索引 + 物化首个窗口。 */ diff --git a/patch_v2/ncm_app/app/ncm_app_internal.h b/patch_v2/ncm_app/app/ncm_app_internal.h index 9d3933f..4a25a27 100644 --- a/patch_v2/ncm_app/app/ncm_app_internal.h +++ b/patch_v2/ncm_app/app/ncm_app_internal.h @@ -337,6 +337,8 @@ int appstate_song_ensure_range(int lo, int hi); * 窗口未覆盖先滑窗(重叠区保留,出窗槽位失效); * 缺失槽位先读盘缓存(/meta/.bin), * miss 攒批 ≤NCM_SONG_BATCH 走 song_detail,成功落盘。 + * 批内部分返回/单批网络失败不再整段置失败——已物化槽保留, + * 缺槽留 invalid(下次 ensure 再试),返回仍为 NCM_OK(§26)。 * worker 线程调用(含网络);返回 NCM_OK/负错误码。 */ int appstate_range_loaded(int lo, int hi); /* 1=[lo,hi) 全部在窗内且有效(UI 判是否需 ensure) */ int appstate_song_copy(int gidx, ncm_song_t *out); /* 锁内读窗:0=已物化拷出;-1=窗外/未物化 */ @@ -345,6 +347,32 @@ int appstate_song_fetch(int gidx, ncm_song_t *out); /* 任意全局下标物化 void appstate_adopt_songs(int n); /* 收编外部已填的 songs[0..n)(搜索结果)为窗口 base=0, * 同步 song_ids/track_ids_count(播放上下文) */ +/* ---- 歌单加载上下文(§26 开A→未载完→开B 竞态防串)---- + * UI(playlist.c)在每次打开/刷新歌单时调用 appstate_playlist_begin 记录「代次+目标」, + * worker 用同一代次调 appstate_load_playlist_songs_ctx;加载完成时若代次/目标已不匹配, + * 结果判过期丢弃(NCM_ERR_STALE),防「前一次加载的 worker 结果落进新打开的歌单」。 */ +int appstate_playlist_open_gen(void); /* 当前打开代次(0=从未打开) */ +int appstate_playlist_open_idx(void); /* 当前打开目标下标(-2=搜索歌单哨兵;-1=无) */ +int appstate_playlist_begin(int idx); /* 递增代次并记目标;返回新代次(UI 打开/刷新时调) */ +void appstate_playlist_reset(void); /* 清窗口/索引(UI 丢弃过期结果、在播安全)。加锁。 */ +int appstate_load_playlist_songs_ctx(const ncm_playlist_t *pl, int is_search, + int ctx_gen, int ctx_idx, int offset, int limit); + /* 快照式加载(歌单详情/曲目物化): + * pl = 打开时快照(不读 g_st.playlists[],防 + * home_worker 并发 memset 污染 id); + * is_search = 1=搜索歌单(NCM_PL_SEARCH 哨兵); + * ctx_gen/ctx_idx = begin() 返回的代次与目标,加载中/末 + * 验不匹配 → NCM_ERR_STALE(不写全局); + * ctx_gen < 0 = 不校验(兼容既有调用方/测试)。 + * offset=0 重头(detail 全量 trackIds 重建索引+清窗口); + * offset>0 = 物化 [offset,offset+limit)(滑窗+盘缓存+补载)。 */ + +/* ---- 歌单列表磁盘缓存(§26,NCM_CACHE_DESIGN §2.2)---- + * 快照 /playlists.bin(magic/ver/时间戳 + 数组)。进首页先渲染缓存秒开, + * 后台 worker 拉网络成功后覆写缓存(stale-while-revalidate);断网时列表可离线浏览。 */ +int appstate_playlists_cache_load(int64_t *ts_out); /* 0=命中(g_st.playlists/playlist_count 已填) */ +int appstate_playlists_cache_save(void); /* 把当前 g_st.playlists/playlist_count 落盘 */ + /* ---- 播放队列来源(§23)---- */ void appstate_set_queue(int src); /* NCM_QUEUE_PLAYLIST / NCM_QUEUE_SEARCH */ int appstate_get_queue(void); diff --git a/patch_v2/ncm_app/app/ui/home.c b/patch_v2/ncm_app/app/ui/home.c index 601482f..f36fbbc 100644 --- a/patch_v2/ncm_app/app/ui/home.c +++ b/patch_v2/ncm_app/app/ui/home.c @@ -168,7 +168,7 @@ static void home_draw_empty(void *ctx) { (void)ctx; if (meow_ui_activity_active() != ACT_HOME) return; - if (g_load_err == 0 && g_st.playlist_count != 0) return; + if (g_st.playlist_count != 0) return; /* 有数据(缓存/已载)即显示列表,后台刷新失败不置空 */ if (g_home_loading) { ncm_draw_empty(r_list, "LOADING", "正在加载歌单..."); return; } if (g_st.logged_in == 0) ncm_draw_empty(r_list, "NOT SIGNED IN", "请先登录"); else if (g_load_err == NCM_ERR_AUTH) ncm_draw_empty(r_list, "SESSION EXPIRED", "请重新登录"); @@ -201,7 +201,9 @@ static void home_apply(void) meow_ui_paint_full(); } -/* 后台 worker:唯一走网络的地方(ensure_uid + user_playlist)。 */ +/* 后台 worker:唯一走网络的地方(ensure_uid + user_playlist)。 + * §26 stale-while-revalidate:网络成功 → appstate_load_playlists 覆写盘缓存 + 更新内存; + * 失败但有缓存在展示 → 保持缓存列表(g_load_err=0),后台静默不打断(断网可离线浏览)。 */ static void *home_worker(void *arg) { (void)arg; @@ -211,7 +213,7 @@ static void *home_worker(void *arg) if (rc == NCM_OK) account_vip_maybe_refresh(0); /* §20:超 1 天/到期后各补查一次(worker 线程,不卡 UI) */ pthread_mutex_lock(&g_home_mu); - g_load_err = rc; + g_load_err = (g_st.playlist_count > 0) ? 0 : rc; /* 有数据(缓存/已载)即不置错误态 */ g_home_loading = 0; g_home_gen++; pthread_mutex_unlock(&g_home_mu); @@ -222,9 +224,14 @@ void home_reload(void) { if (g_host_mock) { g_load_err = 0; home_apply(); return; } if (g_home_thr_on) return; + /* §26:进首页先用磁盘缓存秒渲染(若有),后台 worker 静默刷新;无缓存才显示 LOADING。 */ + int64_t ts = 0; + int had_cache = (appstate_playlists_cache_load(&ts) == NCM_OK && g_st.playlist_count > 0); g_load_err = 0; - g_st.playlist_count = 0; - g_home_loading = 1; + if (!had_cache) { + g_st.playlist_count = 0; /* 无缓存 → LOADING 空态 */ + g_home_loading = 1; + } g_home_thr_on = 1; if (pthread_create(&g_home_thr, NULL, home_worker, NULL) != 0) { g_home_thr_on = 0; g_home_loading = 0; g_load_err = NCM_ERR_IO; diff --git a/patch_v2/ncm_app/app/ui/playlist.c b/patch_v2/ncm_app/app/ui/playlist.c index 48e3a8d..25eaaa6 100644 --- a/patch_v2/ncm_app/app/ui/playlist.c +++ b/patch_v2/ncm_app/app/ui/playlist.c @@ -35,10 +35,23 @@ static volatile int g_plgen; static int g_placked; static volatile int g_plloading; static volatile int g_plloaded_idx; +static volatile int g_plloaded_gen; /* worker 启动时代次(§26:过期结果丢弃判据) */ +static volatile int g_pl_stale; /* 1=本次结果被判过期(未写全局,需重拉当前目标) */ static volatile int g_ens_lo, g_ens_hi; /* 滑窗补载目标区间(全局下标) */ static volatile int g_ens_first; /* 1=首屏重头(offset=0) */ static volatile int g_ens_err; /* 最近一次滑窗补载错误(0=ok;首屏错误走 g_load_err) */ +/* 打开快照(§26 开A→未载完→开B 防串):refresh 在启动 worker 前把「目标歌单」拷进 g_tgt, + * worker 启动时再拷到本地——detail 加载不再依赖 g_st.playlists[](会被回家时 home_worker 的 + * appstate_load_playlists 并发 memset 污染)。tgen = appstate_playlist_begin 返回的打开代次。 */ +typedef struct { + ncm_playlist_t pl; /* 目标快照(id/name/cover/track_count) */ + int is_search; /* 1=搜索歌单(active_playlist==NCM_PL_SEARCH) */ + int idx; /* 目标下标(-2=搜索哨兵) */ + int gen; /* 打开代次(appstate_playlist_begin 返回) */ +} pl_target_t; +static pl_target_t g_tgt; + static void pl_drag_reset(void) { g_press_row = -1; g_press_delta = 0; g_dragging = 0; g_drag_last_y = 0; @@ -218,19 +231,45 @@ static void pl_apply(int reset_scroll) static void *playlist_worker(void *arg) { (void)arg; - int my_idx = g_st.active_playlist; - int first = g_ens_first; - int lo = g_ens_lo, hi = g_ens_hi; + /* 拷贝打开快照 + 任务参数:单飞(g_plthr_on)保证 worker 运行期间 g_tgt 不被改写。 */ + ncm_playlist_t tpl; + int tsearch, tidx, tgen, first, lo, hi; + pthread_mutex_lock(&g_plmu); + tpl = g_tgt.pl; tsearch = g_tgt.is_search; + tidx = g_tgt.idx; tgen = g_tgt.gen; + first = g_ens_first; lo = g_ens_lo; hi = g_ens_hi; + pthread_mutex_unlock(&g_plmu); + int cur_active = g_st.active_playlist; /* ensure 路径结束校验用(当前目标) */ int rc; if (g_host_mock) rc = 0; - else if (my_idx < 0) rc = NCM_ERR_ARG; - else if (first) rc = appstate_load_playlist_songs_page(my_idx, 0, NCM_SONG_BATCH); - else rc = appstate_song_ensure_range(lo, hi); + else if (first) { + /* 首屏:快照式加载(detail 全量 trackIds + 物化首段)。ctx 校验代次/目标,过期自动丢弃。 */ + if (tidx < 0) rc = NCM_ERR_ARG; + else rc = appstate_load_playlist_songs_ctx(&tpl, tsearch, tgen, tidx, 0, NCM_SONG_BATCH); + } else { + /* 滑窗补载:物化视口区间(盘缓存优先,批网格补载)。 */ + rc = (lo < hi) ? appstate_song_ensure_range(lo, hi) : NCM_OK; + } pthread_mutex_lock(&g_plmu); - if (first) g_load_err = rc; /* 首屏失败 → 整页空态(点按重试) */ - else g_ens_err = rc; /* 补载失败 → snack + 占位行保留(滚动/点按再试) */ + if (first) { + if (rc == NCM_ERR_STALE) { + /* 打开 A 未完→已切到 B:本次结果过期(ctx 未写全局),丢弃,tick 对当前目标重拉。 */ + g_pl_stale = 1; g_load_err = 0; + } else if (rc != NCM_OK && rc != NCM_ERR_AUTH && g_st.track_ids_count > 0) { + /* 索引成功但曲目批部分失败(缺歌/单批网络):非致命——保留已物化 + snack, + * 不整页 Load Failed(修【92/200 首真机点开即「加载失败」】)。 */ + g_ens_err = rc; g_load_err = 0; + } else { + g_load_err = rc; /* 索引失败/鉴权失败 → 整页空态(点按重试) */ + } + g_plloaded_idx = tidx; + g_plloaded_gen = tgen; + } else { + g_ens_err = rc; /* 补载失败 → snack + 占位行保留(滚动/点按再试) */ + g_plloaded_idx = cur_active; + g_plloaded_gen = tgen; + } g_plloading = 0; - g_plloaded_idx = my_idx; g_plgen++; pthread_mutex_unlock(&g_plmu); return NULL; @@ -266,7 +305,19 @@ void playlist_refresh(void) if (g_plthr_on) return; g_load_err = 0; g_ens_err = 0; + /* 打开快照 + 代次(§26):锁内拷目标歌单,detail 加载不依赖 g_st.playlists[](会被 + * 回家时 home_worker 并发 memset 污染成空 id → 3 首歌/串数据);同时记录打开代次, + * 过期结果在 worker 完成时判 NCM_ERR_STALE 丢弃(防「开 A→开 B 的迟到结果串 B」)。 */ + const ncm_playlist_t *pcur = pl_cur(); + if (!pcur || !pcur->id[0]) { g_load_err = NCM_ERR_ARG; pl_apply(1); return; } + pthread_mutex_lock(&g_plmu); + g_tgt.pl = *pcur; + g_tgt.is_search = (g_st.active_playlist == NCM_PL_SEARCH); + g_tgt.idx = g_st.active_playlist; + g_tgt.gen = appstate_playlist_begin(g_tgt.idx); + pthread_mutex_unlock(&g_plmu); g_ens_first = 1; /* 首屏:重建索引 + 物化首段 */ + g_pl_stale = 0; g_plloading = 1; g_plthr_on = 1; if (pthread_create(&g_plthr, NULL, playlist_worker, NULL) != 0) { @@ -283,7 +334,15 @@ void playlist_tick(uint64_t now) g_placked = g_plgen; g_plthr_on = 0; g_plloading = 0; - if (g_plloaded_idx != g_st.active_playlist) { playlist_refresh(); return; } + /* §26 防串:结果过期(代次不匹配)/已切到别的歌单 → 先清窗口(不把旧歌单数据画在新歌单 + * 标题下),再对当前 active_playlist 重新发起。 */ + if (g_pl_stale || g_plloaded_idx != g_st.active_playlist || + g_plloaded_gen != appstate_playlist_open_gen()) { + g_pl_stale = 0; + appstate_playlist_reset(); + playlist_refresh(); + return; + } if (g_load_err != 0) { g_st.track_ids_count = 0; /* 首屏失败 → 整页空态(点按重试) */ pl_apply(1); diff --git a/patch_v2/ncm_app/bench/cache_evict_sim.c b/patch_v2/ncm_app/bench/cache_evict_sim.c index 745ef70..c5aaec1 100644 --- a/patch_v2/ncm_app/bench/cache_evict_sim.c +++ b/patch_v2/ncm_app/bench/cache_evict_sim.c @@ -28,6 +28,7 @@ #include #include #include +#include /* ---- 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; diff --git a/patch_v2/ncm_app/bench/playlist_race_sim.c b/patch_v2/ncm_app/bench/playlist_race_sim.c new file mode 100644 index 0000000..1647b3e --- /dev/null +++ b/patch_v2/ncm_app/bench/playlist_race_sim.c @@ -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 +#include +#include +#include + +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; +} diff --git a/patch_v2/ncm_app/core/ncm_cache.c b/patch_v2/ncm_app/core/ncm_cache.c index 06f9419..3099d14 100644 --- a/patch_v2/ncm_app/core/ncm_cache.c +++ b/patch_v2/ncm_app/core/ncm_cache.c @@ -20,6 +20,10 @@ #define MAX_LIMIT_GB 100 #define MAX_ENTRIES 4096 +/* §26 孤儿半成品回收:.tmp/.part 不进 LRU 池(防在播文件误删),但长期不续传的残体占磁盘。 + * LRU 扫描时顺手判 mtime——超过此天数的孤儿 .part/.tmp 惰性删除(in-flight 下载 mtime 新鲜安全)。 */ +#define NCM_ORPHAN_PART_DAYS 7 + typedef struct entry { char name[256]; uint64_t size; @@ -169,10 +173,21 @@ int ncm_cache_evict(const ncm_cache_t *c) { if (!ents) { closedir(d); return NCM_ERR_OOM; } int n = 0; uint64_t total = 0; + int64_t orphan_cutoff = (int64_t)time(NULL) - (int64_t)NCM_ORPHAN_PART_DAYS * 86400; struct dirent *e; while ((e = readdir(d)) && n < MAX_ENTRIES) { if (strncmp(e->d_name, "song_", 5) != 0) continue; - if (is_partial_name(e->d_name)) continue; /* 阶段3:.tmp/.part 归写者管理域,不淘汰 */ + if (is_partial_name(e->d_name)) { /* 阶段3:.tmp/.part 归写者管理域,不进 LRU 池 */ + /* §26 顺手续删:超过 7 天的孤儿 .part/.tmp(用户不再点该曲、不续传)惰性删除。 + * 在播/刚中断的 .tmp/.part mtime 新鲜(写端持续更新),不会被误删。 */ + char pb[NCM_CACHE_DIR_MAX + NAME_MAX + 8]; + snprintf(pb, sizeof pb, "%s/%s", c->dir, e->d_name); + struct stat sb; + if (stat(pb, &sb) == 0 && S_ISREG(sb.st_mode) && + (int64_t)sb.st_mtim.tv_sec < orphan_cutoff) + unlink(pb); + continue; + } char p[NCM_CACHE_DIR_MAX + NAME_MAX + 8]; snprintf(p, sizeof p, "%s/%s", c->dir, e->d_name); struct stat st; @@ -307,10 +322,20 @@ uint64_t ncm_cache_total_bytes(const ncm_cache_t *c) { DIR *d = opendir(c->dir); if (!d) return 0; uint64_t total = 0; + int64_t orphan_cutoff = (int64_t)time(NULL) - (int64_t)NCM_ORPHAN_PART_DAYS * 86400; struct dirent *e; while ((e = readdir(d))) { if (strncmp(e->d_name, "song_", 5) != 0) continue; - if (is_partial_name(e->d_name)) continue; /* 阶段3:半成品不计入池占用 */ + if (is_partial_name(e->d_name)) { /* 阶段3:半成品不计入池占用 */ + /* §26 顺手续删:超过 7 天的孤儿 .part/.tmp 惰性删除。 */ + char pb[NCM_CACHE_DIR_MAX + NAME_MAX + 8]; + snprintf(pb, sizeof pb, "%s/%s", c->dir, e->d_name); + struct stat sb; + if (stat(pb, &sb) == 0 && S_ISREG(sb.st_mode) && + (int64_t)sb.st_mtim.tv_sec < orphan_cutoff) + unlink(pb); + continue; + } char p[NCM_CACHE_DIR_MAX + NAME_MAX + 8]; snprintf(p, sizeof p, "%s/%s", c->dir, e->d_name); struct stat st; diff --git a/patch_v2/ncm_app/core/ncm_common.h b/patch_v2/ncm_app/core/ncm_common.h index f877de9..b12055e 100644 --- a/patch_v2/ncm_app/core/ncm_common.h +++ b/patch_v2/ncm_app/core/ncm_common.h @@ -31,6 +31,7 @@ enum { NCM_ERR_AUTH = -13, /* 未登录/鉴权失败 */ NCM_ERR_LIMIT = -14, /* 超出上限级 */ NCM_ERR_VIP = -15, /* VIP 专享(fee 门槛命中,未发 song_url 请求,§20) */ + NCM_ERR_STALE = -16, /* 异步结果过期(打开歌单 A 未完→打开 B,A 的迟到结果被丢弃,§26) */ }; /* ---------- 常用尺寸 ---------- */ diff --git a/patch_v2/ncm_app/ncm b/patch_v2/ncm_app/ncm index 2d19242..01177bf 100755 Binary files a/patch_v2/ncm_app/ncm and b/patch_v2/ncm_app/ncm differ diff --git a/patch_v2/ncm_app/ncm_host b/patch_v2/ncm_app/ncm_host index bd92a70..f9e3629 100755 Binary files a/patch_v2/ncm_app/ncm_host and b/patch_v2/ncm_app/ncm_host differ