From 6200a41b715a2ad20dee8a941ad1981bca5ef308 Mon Sep 17 00:00:00 2001 From: MeowOS Date: Tue, 1 Sep 2026 05:29:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(ncm):=20=E4=B8=89=E8=BF=9E=E4=BF=AE=20?= =?UTF-8?q?=E2=80=94=20=E5=B0=81=E9=9D=A2=E5=88=87=E6=AD=8C=E4=B8=8D?= =?UTF-8?q?=E6=9B=B4=E6=96=B0/=E7=BC=93=E5=86=B2=E6=97=A0=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E5=A4=B1=E8=B4=A5=E6=97=A0=E5=A3=B0/=E6=9A=82?= =?UTF-8?q?=E5=81=9C=E5=88=87=E6=AD=8C=E8=BE=B9=E7=95=8C=20(REPORT=20?= =?UTF-8?q?=C2=A718)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cover worker 忙等死循环(成功后不清目标,每圈解码+换入+gen++,CPU 100%+每帧整盘重绘) → 成功清目标转怠速; 失败指数退避 0.5-4s 连 8 次放弃+WARN 日志; 去重加在拉目标; cover_get 持锁+cover_put 修绘制期 UAF; g_host_mock 防真网守卫 - 封面兜底污染修复: 不再把单曲 al_pic 写进 g_st.cover_url(否则 al_pic 缺失曲回落成 上一首封面且撞去重 → 封面永停); plr_page_enter 强制重估自愈; pl_page_enter 回填歌单封面 - 缓冲 UI: BUFFER 态「缓冲中… N%」(g_play.dl_done/total, 下载回调累加); 播放错误中文 错误条+snack(旧版 err 无 UI 消费者=无声死等); STOPPED 态播放键=重试; play/next 清 err - 写失败 set_err 修连环跳歌(旧版静默 break 被外层当自然播完,流死时跳完歌单+狂发 song_url); still_current 护栏修下载途中切歌的状态/快照过期覆盖 - 验证(0 真实请求): cover_chain_sim 7 组+player_flow_sim P1-P5 全 PASS, 旧代码对照 T2/P2/P4 必败坐实根因; audiod_chain_sim 14/14; pagination/window/core+qemu 回归 PASS; 宿主+mipsel 自有代码 0 warning; 9 页离屏渲染 rc=0 (含新 mock 页 playerbuf/playererr) - ncm md5 50d41bbf4a379e925699ba013b997ffd (未部署真机, 留主会话) --- .gitignore | 3 + patch_v2/ncm_app/REPORT.md | 132 +++++++++++ patch_v2/ncm_app/app/logic/appstate.c | 3 +- patch_v2/ncm_app/app/logic/cover.c | 57 ++++- patch_v2/ncm_app/app/ncm_app_internal.h | 6 +- patch_v2/ncm_app/app/play/player_manager.c | 42 +++- patch_v2/ncm_app/app/test/test_ncm_app.c | 25 +- patch_v2/ncm_app/app/ui/player.c | 70 +++++- patch_v2/ncm_app/app/ui/playlist.c | 8 +- patch_v2/ncm_app/bench/cover_chain_sim.c | 237 +++++++++++++++++++ patch_v2/ncm_app/bench/player_flow_sim.c | 257 +++++++++++++++++++++ patch_v2/ncm_app/ncm | Bin 958988 -> 962236 bytes patch_v2/ncm_app/ncm_host | Bin 1002760 -> 1003048 bytes 13 files changed, 820 insertions(+), 20 deletions(-) create mode 100644 patch_v2/ncm_app/bench/cover_chain_sim.c create mode 100644 patch_v2/ncm_app/bench/player_flow_sim.c diff --git a/.gitignore b/.gitignore index a6f0a0b..2b77778 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,6 @@ patch_v2/tools/tonepcm2 patch_v2/ncm_app/cache_evict_sim patch_v2/ncm_app/pagination_sim patch_v2/ncm_app/audiod_chain_sim +patch_v2/ncm_app/window_sim +patch_v2/ncm_app/cover_chain_sim +patch_v2/ncm_app/player_flow_sim diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index 1a208e4..fd54773 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -1454,3 +1454,135 @@ b2b6869990c8ef3906c100f18c7194e8 bench/window_sim.c - **meta 盘缓存无版本化清理**:结构体再改版靠 ver/size 判 miss 自动重建;无 TTL (歌曲改名/换封面不主动失效——与 ncm_cache 同级的「缓存可再生」取舍)。 - **host 构建有 SDK `meow_ui_vol.c`「system 返回值忽略」告警**(SDK 源,非本 App;mipsel musl 无)。 + +--- + +## 18. 三连修:封面切歌不更新 + 缓冲无反馈/失败无声 + 暂停/切歌边界稳健性(2026-09-01,用户三连报) + +> 范围:仅 `patch_v2/ncm_app/`(ui/play/logic/bench/test + 本文件)+ 根 `.gitignore`(新增 3 个 sim +> 二进制忽略条目)。未动 `sdk/`、`meow_launcherd/`、`meow_audiod/`、`vendor/`。 +> 风控纪律:本批真实 NCM 请求 **0 次**(全部离线 stub/sim;并新增 mock 防真网守卫,见 §18.5)。 +> **本批未部署真机**(部署留主会话统一做)。 + +### 18.1 问题①:封面只在第一首歌出现、切歌不更新 —— 根因(三因叠加) + +1. **cover worker 忙等死循环(主因,CPU 饥饿)**:`cover_worker` 换入成功后**不清目标 URL** + (`g_tgt_url` 残留),主循环每圈 缓存读 200KB + JPEG 解码 + free/换入 + `g_gen++`,成功路径 + **无 sleep** → 首张封面到位后 worker 永久 100% CPU;同时 `cover_changed()` 恒真 → 播放页 + tick 每帧整盘重绘(黑胶行扫 sqrt + cover blit)。单核 MIPS 上 UI 线程被饿到极低帧率, + 慢网络下新封面请求(10s 超时级)看似永不完成。sim 实证:旧代码 settle 后 1.2s 内 + `cover_changed` 翻动 **119 次**(新代码 0 次)。 +2. **封面兜底值污染(确定性子根因)**:`player_play_song` 把本曲 `al_pic` 经 + `appstate_set_playing_song` 写进 `g_st.cover_url`(本意=歌单封面兜底)。此后 al_pic 缺失的 + 歌曲回落到的是**上一首的专辑图 URL**,且撞 `cover_request` 去重(== 当前展示)→ 封面永远 + 停在上一首。 +3. **绘制期 UAF**:`cover_get()` 无锁返回裸指针,UI 绘制期间 worker 换入即 `meow_img_free` + 同一张图(忙等循环下每 ~1/100s 一次)→ use-after-free/撕裂绘制风险。 + +**修法**(cover.c + player.c + playlist.c): +- worker 换入成功即 `g_tgt_url[0]=0` 转怠速(250ms 轮询);下载/解码失败**指数退避** + 0.5s→4s、连续 8 次放弃并清目标(UI 重进页会重新提交),每次失败 WARN 日志(不再无声)。 +- `cover_request` 去重加判「在拉目标」(g_tgt_url)+ `g_host_mock` 直接返回(0 网络纪律)。 +- `cover_get()` 改**持锁返回** + 新增 `cover_put()`,两个绘制调用点(player 黑胶/playlist + 头块)配对使用,UAF 闭合。 +- `player_play_song` 不再用 al_pic 污染兜底(`appstate_set_playing_song(idx, NULL)`), + `g_st.cover_url` 回归「歌单封面兜底」单语义。 +- `plr_page_enter` 清零 `g_last_meta_id`/`g_last_song` 强制重估(重进播放页自愈: + 离开期间切歌/封面失败后回来首个 tick 即重请求当前曲封面)。 +- `pl_page_enter` 补请求歌单封面——旧版 `playlist_open` 只 `cover_cache_reset` 清掉、无人 + 再请求 → 歌单头块封面永远占位 ♪(顺手回填,缓存命中后开销≈一次盘读+解码)。 + +### 18.2 问题②:点歌后全程无反馈、失败无声死等 —— 根因与修法 + +- **下载本就不在 UI 线程**(核查结论):song_url 获取 + 流式落盘全在播放线程 + (`run_song`→`song_local_file`),UI 无阻塞点;「像卡死」= ①无缓冲 UI ②错误无出口 + ③问题①的 CPU 忙等拉低整机帧率 三感叠加。 +- `g_play.err/errmsg` **无任何 UI 消费者**(`player_manager_error()` 全仓零调用)→ + VIP/版权/超时全部无声。修法: + - 播放页歌词区三态:`NCM_PC_BUFFER` →「缓冲中… N%」(新增 `g_play.dl_done/dl_total`, + 下载回调锁内累加,song_url 的 size 做分母,未知则转 KB;tick 按 ~2KB 粒度刷动态带); + `err!=0` → 红字中文错误条(无音源 VIP/版权 / 登录过期 / 网络失败)+「点播放键重试」, + 错误沿弹 snack;有词时歌词照旧。 + - 播放键在 `STOPPED 且有当前曲`(出错后)= **重试**(`player_manager_play(cur)`), + 图标在 PAUSED/STOPPED 画三角、PLAYING/BUFFER 画暂停。 + - `player_manager_play/next` 清 `err` 并重置下载计数(旧错误不残留)。 + +### 18.3 问题③:暂停/恢复/切歌边界稳健性 + +- **写失败连环跳歌(真根因级)**:`audio_out_write` 失败旧版静默 `break` → 外层当 + 「自然播完」`next(1)` → 流死时每首都写失败 → 连环跳完整个歌单且每跳一首发一次 + song_url 请求(风控隐患 + 用户看到的「放不了」)。修:写失败 `set_err(NCM_ERR_IO)` + → STOPPED 停住、cur 不动、UI 出错误条。sim 实证:旧代码写失败后 err=0、cur 冲到 -1; + 新代码 err=NCM_ERR_IO、cur 不变、writes=1 即停。 +- **下载途中切歌竞态**:旧版旧曲下载完成后继续开解码/开音频并无条件 + `state=PLAYING` + 写过期快照 → 把新曲的 BUFFER 覆盖成 PLAYING(UI 假「在播」)+ 封面/歌名 + 短暂回跳。修:`still_current()` 护栏——物化后/下载失败后/下载完成后三处校验 + `cur_song==idx && state==BUFFER`,否则静默回外层(缓存已落盘不亏)。sim P4 实证新旧差异。 +- 流死重开链(A2 已有 resume 自动 reopen)UI 侧闭环:被抢占→PAUSED(播/停图标正确)、 + resume reopen 失败 → STOPPED+错误条(可重试);`audiod pause rc=-9` 路径不动(pause 失败 + 仅记日志,恢复时走 reopen)。 +- 已知边界(不改):下载/ song_url 不可中途取消(ncm_http 无 cancel),切歌响应最坏 + 多等一个在途请求超时(30s 上限,正常秒级);BUFFER 态点暂停无效(语义:缓冲未开播)。 + +### 18.4 验证记录(0 真实 NCM 请求) + +| 项 | 方式 | 结果 | +|---|---|---| +| 封面链 | `bench/cover_chain_sim.c`(新;真实 cover.c+ncm_cache,stub HTTP/假 img) | **新代码 7 组全 PASS**:首请求出图/定居零忙等/切歌换图+cover_current.jpg 同步/去重/失败×2退避成功/连败 8 次放弃保留旧图/空 URL+reset。**旧代码对照**:T2 忙等 119 次/1.2s FAIL(坐实根因①) | +| 播放状态机 | `bench/player_flow_sim.c`(新;真实 player_manager.c 播放线程,stub 解码/音频/网络) | **新代码 P1-P5 全 PASS**:自然顺延(err=0)/写失败置错停住不跳歌/无音源错误条/下载中切歌无过期状态覆盖/错误后重试。**旧代码对照**:P2(err=0+cur=-1 连环跳歌)、P4(旧曲覆盖 PLAYING+过期快照)FAIL | +| audiod 链回归 | `bench/audiod_chain_sim`(真实 audiod_host 假 socket) | 14/14 PASS(含抢占/流死重开,问题③链路回归) | +| 滑窗回归 | pagination_sim + window_sim(重编新 appstate.c) | 全 PASS | +| core 回归 | test_ncm_core_host + `qemu-mipsel out/test_ncm_core_mipsel` | 全 PASS | +| 宿主构建 | build_ncm_host.sh | 自有代码 **0 warning**(仅 SDK meow_ui_vol.c 既有告警) | +| 宿主离屏渲染 | home/playlist/player/playerbuf/playererr/settings/search/login/lands | 全 rc=0;playerbuf 出「缓冲中… 25%」位(宿主 mock 字体无 CJK,中文留白为既有宿主限制,设备 GBK 字库正常)、playererr 错误条+播放三角 | +| mipsel | build_ncm.sh(自有代码 -Wall -Wextra) | **0 warning**;text 868772 / bss 1080900(与 §17 持平) | + +### 18.5 md5(本批产物 + 改动文件) + +``` +50d41bbf4a379e925699ba013b997ffd ncm # mipsel 静态 App(新;未部署,留主会话) +87de141c6a2719bb2b93807f282077de ncm_host # 宿主离屏调试二进制 +6715607f561c5df83b251569b0ae2b7c app/logic/cover.c +d6aee631734622526befb977d4e3cddf app/logic/appstate.c +ca22772678de6e293747c9ef4890b80f app/ncm_app_internal.h +2d0eb786ec78f48e7e0625f4bd7a74c9 app/play/player_manager.c +d2bdfbed5569af760119d0ffb785e43e app/ui/player.c +c596763af26fd0c687286a250fccd90a app/ui/playlist.c +08fc30f6ddf77c638ca4a8f3a20aef0e app/test/test_ncm_app.c +30b163bfc2c880b819f8168b10895161 bench/cover_chain_sim.c +fee4204e945866f5a07ca7d191fa83e3 bench/player_flow_sim.c +``` + +### 18.6 出入清单 + +``` +修改: + app/logic/cover.c # worker 成功清目标转怠速(修忙等死循环)+失败指数退避 8 次放弃+ + # WARN 日志+去重加在拉目标+g_host_mock 防真网+cover_get 持锁/cover_put(修 UAF) + app/ui/player.c # 缓冲三态 UI(缓冲中…N%/错误条+重试)+播放键 STOPPED=重试+enter 自愈重估+ + # 下载进度 tick 刷新+错误沿 snack+cover_put 配对+不污染封面兜底 + app/ui/playlist.c # cover_put 配对+pl_page_enter 请求歌单封面(头块回填) + app/play/player_manager.c# dl_done/dl_total 进度+play/next 清 err+still_current 切歌护栏三处+ + # 写失败 set_err(修连环跳歌) + app/logic/appstate.c # set_playing_song 注释对齐(cover_url 仅兜底语义) + app/ncm_app_internal.h # cover_put/player_manager_dl 声明+g_play.dl_*+cover_get 语义注释 + app/test/test_ncm_app.c # mock 新页 playerbuf/playererr(缓冲/错误态离屏渲染) + .gitignore # window_sim/cover_chain_sim/player_flow_sim 二进制忽略 + ncm / ncm_host # 重编产物 +新增: + bench/cover_chain_sim.c # 封面链离线回归(7 组,含旧代码对照实证忙等根因) + bench/player_flow_sim.c # 播放状态机离线回归(P1-P5,含旧代码对照实证跳歌/竞态根因) + REPORT.md # 本批记录(§18) +(未动 sdk/、meow_launcherd/、meow_audiod/、vendor/;未部署真机;未动设备系统/恢复区) +``` + +### 18.7 诚实出入 / 遗留 + +- **真机未部署未验**(留主会话)。真机验收点:①切歌封面跟随(含离开播放页再进)②点歌即见 + 「缓冲中… N%」、失败出中文错误条+可点播放键重试 ③歌单头块封面回填 ④退出播放页再进无残旧封面。 +- **切歌响应延迟下限**:在途 song_url/下载不可取消(ncm_http 无 cancel 口),连切时旧请求 + 跑完才轮新曲(正常秒级;30s 超时兜底)。要更低延迟需 core 层加取消位,本批未动 core。 +- **封面失败放弃后**靠「重进播放页/切歌」重新武装(enter 自愈),无定时重试——弱网长期 + 挂机时封面可能缺席但不影响播放。 +- 播放页 BUFFER 态进度条仍画 0:00 标尺(缓冲文案在歌词区),未做进度条一体化——观感项。 +- meow_audiod_host 工作区有他人改动(其他 agent 在改 audiod),本批未 add/未提交该文件。 diff --git a/patch_v2/ncm_app/app/logic/appstate.c b/patch_v2/ncm_app/app/logic/appstate.c index 2dae430..1e2a0ec 100644 --- a/patch_v2/ncm_app/app/logic/appstate.c +++ b/patch_v2/ncm_app/app/logic/appstate.c @@ -397,7 +397,8 @@ void appstate_apply_playlist_cover(int idx) ncm_strlcpy(g_st.cover_url, g_st.playlists[idx].cover, sizeof g_st.cover_url); } -/* 设置播放页当前歌曲 + 封面 URL(player 页 UI 读)。idx=-1 表示清除。 */ +/* 设置播放页当前歌曲下标。cover_url 非空时一并更新 g_st.cover_url(歌单封面兜底值); + * §18 起调用方传 NULL——兜底值由 appstate_apply_playlist_cover 维护,勿用单曲 al_pic 污染。 */ void appstate_set_playing_song(int idx, const char *cover_url) { g_st.playing_song = idx; diff --git a/patch_v2/ncm_app/app/logic/cover.c b/patch_v2/ncm_app/app/logic/cover.c index c923d92..88c5945 100644 --- a/patch_v2/ncm_app/app/logic/cover.c +++ b/patch_v2/ncm_app/app/logic/cover.c @@ -4,6 +4,15 @@ * 线程模型:一个持久的 cover 工作线程负责「目标 URL → 下载 → 解码 → 换入」;UI 线程只经 * cover_request(url)(提交目标)与 cover_get()/cover_changed()(读取/判变)+ cover_ack()(消费)。 * 工作线程不碰 meow_ui(绘制在 UI 线程 tick 判 cover_changed 后触发 invalidate)。 + * + * §18 修复(切歌封面不更新/CPU 忙等根因): + * - worker 换入成功后**清目标**(g_tgt_url[0]=0)转怠速——旧版成功后不清目标,每圈 + * 缓存读+JPEG 解码+换入+g_gen++ 无 sleep 死循环:CPU 占满、cover_changed() 恒真导致 + * 播放页每帧整盘重绘,慢网络下新封面请求被饿到表现如「切歌封面不更新」。 + * - 下载/解码失败**指数退避**重试(0.5s→4s),连续 8 次放弃(UI 重进页会重新提交目标); + * 失败写 WARN 日志(排查资产),不再无声死等。 + * - 绘制安全:cover_get() 持锁返回、cover_put() 解锁——旧版 UI 绘制期 worker 可 free + * 同一 meow_img(use-after-free);调用方须在 cover_get 后配对 cover_put。 */ #include "../ncm_app_internal.h" #include @@ -78,12 +87,20 @@ static void save_cover_raw(const char *url, size_t len) } } +#define COVER_RETRY_MAX 8 /* 连续失败放弃次数(UI 重进页/切歌会重新提交目标) */ + static void *cover_worker(void *arg) { (void)arg; + int backoff_ms = 0, fails = 0; while (!g_quit) { pthread_mutex_lock(&g_cm); - if (!g_tgt_url[0]) { pthread_mutex_unlock(&g_cm); usleep(250 * 1000); continue; } + if (!g_tgt_url[0]) { + pthread_mutex_unlock(&g_cm); + backoff_ms = 0; fails = 0; + usleep(250 * 1000); + continue; + } char url[512]; ncm_strlcpy(url, g_tgt_url, sizeof url); pthread_mutex_unlock(&g_cm); @@ -104,7 +121,25 @@ static void *cover_worker(void *arg) if (!img) { blen = 0; img = decode_cover(url, &blen); - if (!img) { usleep(500 * 1000); continue; } + if (!img) { + /* 失败:指数退避重试,连续 COVER_RETRY_MAX 次放弃(不无声死等; + * 目标保留期间 UI 每帧重请求由 cover_request 去重挡住)。 */ + fails++; + if (fails >= COVER_RETRY_MAX) { + meow_log(MEOW_LOG_WARN, "cover: give up after %d fails url=%.64s", fails, url); + pthread_mutex_lock(&g_cm); + if (strcmp(g_tgt_url, url) == 0) g_tgt_url[0] = 0; + pthread_mutex_unlock(&g_cm); + backoff_ms = 0; fails = 0; + continue; + } + meow_log(MEOW_LOG_WARN, "cover: fetch failed (%d) url=%.64s retry in %dms", + fails, url, backoff_ms ? backoff_ms : 500); + if (g_app_quit) return NULL; + usleep(backoff_ms ? backoff_ms * 1000 : 500 * 1000); + backoff_ms = backoff_ms ? (backoff_ms * 2 > 4000 ? 4000 : backoff_ms * 2) : 500; + continue; + } if (g_st.ctx.cache.dir[0]) { /* 写前检查:池大小超限先按 LRU 淘汰(与 mp3 同池;封面约 200KB,先腾空间) */ ncm_cache_evict(&g_st.ctx.cache); @@ -117,6 +152,7 @@ static void *cover_worker(void *arg) } } } + backoff_ms = 0; fails = 0; pthread_mutex_lock(&g_cm); /* 目标仍是 url 才换入(期间未提交更早) */ @@ -126,6 +162,7 @@ static void *cover_worker(void *arg) ncm_strlcpy(g_cover_url, url, sizeof g_cover_url); save_cover_raw(url, blen); g_gen++; + g_tgt_url[0] = 0; /* 目标达成即清:worker 转怠速(修忙等死循环,§18) */ } else { meow_img_free(img); } @@ -138,8 +175,11 @@ static void *cover_worker(void *arg) void cover_request(const char *url) { if (!url || !url[0]) return; + if (g_host_mock) return; /* 宿主 mock = 0 网络纪律(mock URL 不得发真实 HTTPS) */ pthread_mutex_lock(&g_cm); - if (g_cover_url[0] && strcmp(g_cover_url, url) == 0) { pthread_mutex_unlock(&g_cm); return; } + /* 去重:已在展示 或 已在拉取途中的同一 URL 不重复提交。 */ + if ((g_cover_url[0] && strcmp(g_cover_url, url) == 0) || + (g_tgt_url[0] && strcmp(g_tgt_url, url) == 0)) { pthread_mutex_unlock(&g_cm); return; } ncm_strlcpy(g_tgt_url, url, sizeof g_tgt_url); pthread_mutex_unlock(&g_cm); if (!g_thr_on) { @@ -151,14 +191,17 @@ void cover_request(const char *url) } } -/* UI 线程:读取当前封面(不拥有)。 */ +/* UI 线程:读取当前封面——**持锁返回**,绘制完必须配对 cover_put()(§18 修 UAF: + * 旧版 get 后无锁绘制,worker 换入时 free 同一张图 → use-after-free)。 */ meow_img *cover_get(void) { - meow_img *im; pthread_mutex_lock(&g_cm); - im = g_cover_img; + return g_cover_img; +} + +void cover_put(void) +{ pthread_mutex_unlock(&g_cm); - return im; } /* 是否有新封面待绘制(UI tick 判变)。 */ diff --git a/patch_v2/ncm_app/app/ncm_app_internal.h b/patch_v2/ncm_app/app/ncm_app_internal.h index 58b3369..a751c8c 100644 --- a/patch_v2/ncm_app/app/ncm_app_internal.h +++ b/patch_v2/ncm_app/app/ncm_app_internal.h @@ -85,6 +85,8 @@ typedef struct ncm_play_run { int seq; /* 播放代际(每次 start 递增,用于区分过期信号)*/ int err; /* 最近一次播放错误(0=无) */ char errmsg[96]; + int64_t dl_done; /* 本曲已下载字节(缓冲期进度反馈,§18) */ + int64_t dl_total; /* 本曲标称大小(song_url 返回;0=未知) */ int has_track; /* 当前是否有可播曲目(含 url 获取中) */ /* 播放曲快照(REPORT §17):run_song 起播时把歌曲对象整体拷入(mu 保护), * 之后播放/歌词/IPC/UI 全读快照而非窗口——窗口滑出当前播放曲也不影响播放。 */ @@ -303,7 +305,8 @@ int lyric_state(void); /* 0=空闲/未拉 1=加载中 2=已拉完(可能 /* cover */ void cover_request(const char *url); /* 提交目标封面 URL(后台线程下载+解码) */ -meow_img *cover_get(void); /* 当前封面(不拥有;NULL=无) */ +meow_img *cover_get(void); /* 当前封面(**持锁返回**;NULL=无;用后必须 cover_put) */ +void cover_put(void); /* 配对 cover_get 释放锁(§18 修绘制期 UAF) */ int cover_changed(void); /* 1=有新封面待绘制(UI tick 判变) */ void cover_ack(void); /* UI 消费一次(已见当前版本) */ const char *cover_current_path(void); /* 封面原始文件路径(供 launcher 媒体卡片读);NULL=无 */ @@ -335,6 +338,7 @@ int64_t player_manager_dur_ms(void); int player_manager_load_lyric(void); /* 为当前曲目拉歌词并解析 */ int player_manager_error(void); int player_manager_current_song(ncm_song_t *out); /* 播放曲快照锁内拷贝;0=有效 -1=无(REPORT §17) */ +void player_manager_dl(int64_t *done, int64_t *total); /* 本曲下载进度(字节;total=0 未知,§18) */ /* songmeta(app/logic/songmeta.c):歌曲元数据本地缓存(/meta/.bin), * 拉过的 song_detail 结果序列化落盘,再次入窗免重复请求(REPORT §17)。 */ diff --git a/patch_v2/ncm_app/app/play/player_manager.c b/patch_v2/ncm_app/app/play/player_manager.c index 5977352..9194470 100644 --- a/patch_v2/ncm_app/app/play/player_manager.c +++ b/patch_v2/ncm_app/app/play/player_manager.c @@ -41,6 +41,15 @@ int64_t player_manager_pos_ms(void) { int64_t p; pthread_mutex_lock(&g_play.mu); int64_t player_manager_dur_ms(void) { int64_t d; pthread_mutex_lock(&g_play.mu); d = g_play.dur_ms; pthread_mutex_unlock(&g_play.mu); return d; } int player_manager_error(void) { int e; pthread_mutex_lock(&g_play.mu); e = g_play.err; pthread_mutex_unlock(&g_play.mu); return e; } +/* 本曲下载进度(UI 缓冲反馈,§18)。total=0 表示未知(song_url 未给 size / 缓存命中)。 */ +void player_manager_dl(int64_t *done, int64_t *total) +{ + pthread_mutex_lock(&g_play.mu); + if (done) *done = g_play.dl_done; + if (total) *total = g_play.dl_total; + pthread_mutex_unlock(&g_play.mu); +} + /* 播放曲快照(REPORT §17):run_song 起播时物化并拷入 g_play.play_song(mu 保护), * 之后播放/歌词/IPC/UI 一律读快照——窗口滑出当前播放曲不影响播放,不许崩。 */ int player_manager_current_song(ncm_song_t *out) @@ -70,6 +79,9 @@ void player_manager_play(int song_idx) g_play.cmd = 0; g_play.state = NCM_PC_BUFFER; g_play.pos_ms = 0; + g_play.err = 0; /* 新播放意图清旧错误(UI 错误条随新点歌消失) */ + g_play.dl_done = 0; + g_play.dl_total = 0; g_play.seq++; pthread_mutex_unlock(&g_play.mu); } @@ -101,6 +113,9 @@ void player_manager_next(int dir) g_play.cmd = 0; g_play.state = NCM_PC_BUFFER; g_play.pos_ms = 0; + g_play.err = 0; + g_play.dl_done = 0; + g_play.dl_total = 0; g_play.seq++; } pthread_mutex_unlock(&g_play.mu); @@ -120,6 +135,10 @@ void player_manager_stop(void) /* ---- 每首歌的播放体(在外层循环内调用)---- */ static int download_cb(void *ctx, const unsigned char *data, size_t len) { + /* 下载进度反馈(UI「缓冲中… N%」,§18):回调在播放线程,锁内仅累加。 */ + pthread_mutex_lock(&g_play.mu); + g_play.dl_done += (int64_t)len; + pthread_mutex_unlock(&g_play.mu); return ncm_cache_write((ncm_cache_writer_t *)ctx, data, len); } @@ -138,6 +157,9 @@ static int song_local_file(const char *id, char *path, size_t psz, int64_t *byte int rc = ncm_api_song_url_auto(g_st.ctx.device_id, g_st.ctx.cookie, id, g_level, &u); if (rc != NCM_OK) return rc; if (!u.url[0]) return NCM_ERR_NOTFOUND; + pthread_mutex_lock(&g_play.mu); + g_play.dl_total = (u.size > 0) ? u.size : 0; /* 缓冲进度% 分母(0=未知只转菊花) */ + pthread_mutex_unlock(&g_play.mu); /* 写前检查:池大小超限先按 LRU 淘汰(此线程尚未开 temp,无在途文件被误删风险) */ ncm_cache_evict(&g_st.ctx.cache); @@ -183,6 +205,16 @@ static void set_err(int err, const char *msg) pthread_mutex_unlock(&g_play.mu); } +/* 切歌竞态护栏(§18):物化/下载期间用户可能已切歌——只有仍是当前目标且处于 BUFFER + * (本次播放意图未过期)才继续,否则直接回外层(外层会以新 cur 重跑 run_song)。 */ +static int still_current(int idx) +{ + pthread_mutex_lock(&g_play.mu); + int ok = (g_play.cur_song == idx) && (g_play.state == NCM_PC_BUFFER); + pthread_mutex_unlock(&g_play.mu); + return ok; +} + /* 一首歌:下载→解码→输出,直到结束或被打断。返回后由外层处理下一首。 */ static void run_song(int idx) { @@ -193,6 +225,7 @@ static void run_song(int idx) int fr = appstate_song_fetch(idx, &s); if (fr != NCM_OK) { set_err(fr, "load failed"); return; } } + if (!still_current(idx)) return; /* 物化期间已切歌/停止:不写过期快照 */ pthread_mutex_lock(&g_play.mu); g_play.play_song = s; g_play.play_song_valid = 1; @@ -202,11 +235,13 @@ static void run_song(int idx) char path[512]; int rc = song_local_file(s.id, path, sizeof path, &file_bytes); if (rc != NCM_OK) { + if (!still_current(idx)) return; /* 下载期间已切歌:静默放弃(缓存已落盘不亏) */ const char *m = (rc == NCM_ERR_NOTFOUND) ? "no stream (VIP/copyright)" : (rc == NCM_ERR_AUTH) ? "session expired" : "load failed"; set_err(rc, m); return; } + if (!still_current(idx)) return; /* 下载完成但已切歌:勿开解码/勿动状态 */ ncm_strlcpy(g_path, path, sizeof g_path); g_file_bytes = file_bytes; @@ -287,7 +322,12 @@ static void run_song(int idx) break; } int frames = (int)(got / 2); - if (audio_out_write(pcm, frames) != frames) break; + /* 写失败(流死/audiod 断链):置错停播——旧版静默 break 会被外层当「自然播完」 + * 自动顺延下一首,流死时连环跳歌并狂发 song_url 请求(§18 修)。 */ + if (audio_out_write(pcm, frames) != frames) { + if (!g_app_quit) set_err(NCM_ERR_IO, "audio write failed"); + break; + } frames_done += frames; /* 更新进度(~200ms 节流)*/ int64_t pos = frames_done * 1000 / 44100; diff --git a/patch_v2/ncm_app/app/test/test_ncm_app.c b/patch_v2/ncm_app/app/test/test_ncm_app.c index 771f60d..469a7ee 100644 --- a/patch_v2/ncm_app/app/test/test_ncm_app.c +++ b/patch_v2/ncm_app/app/test/test_ncm_app.c @@ -90,6 +90,8 @@ static int page_id(const char *name) if (strcmp(name, "playlist") == 0) return ACT_PLAYLIST; if (strcmp(name, "player") == 0) return ACT_PLAYER; if (strcmp(name, "lands") == 0) return ACT_PLAYER; /* 横屏布局 */ + if (strcmp(name, "playerbuf") == 0) return ACT_PLAYER; /* §18 缓冲态 */ + if (strcmp(name, "playererr") == 0) return ACT_PLAYER; /* §18 错误态 */ if (strcmp(name, "settings") == 0) return ACT_SETTINGS; if (strcmp(name, "search") == 0) return ACT_SEARCH; return -1; @@ -146,6 +148,25 @@ int main(int argc, char **argv) g_play.play_song = g_st.songs[0]; /* 播放快照(§17) */ g_play.play_song_valid = 1; pthread_mutex_unlock(&g_play.mu); + } else if (strcmp(name, "playerbuf") == 0) { /* §18:缓冲态(25% 下载进度) */ + mock_playlists(); mock_songs(); + pthread_mutex_lock(&g_play.mu); + g_play.state = NCM_PC_BUFFER; + g_play.cur_song = 0; + g_play.play_song = g_st.songs[0]; + g_play.play_song_valid = 1; + g_play.dl_done = 1048576; + g_play.dl_total = 4194304; + pthread_mutex_unlock(&g_play.mu); + } else if (strcmp(name, "playererr") == 0) { /* §18:错误态(无音源) */ + mock_playlists(); mock_songs(); + pthread_mutex_lock(&g_play.mu); + g_play.state = NCM_PC_STOPPED; + g_play.cur_song = 0; + g_play.play_song = g_st.songs[0]; + g_play.play_song_valid = 1; + g_play.err = NCM_ERR_NOTFOUND; + pthread_mutex_unlock(&g_play.mu); } else if (strcmp(name, "settings") == 0) { g_st.logged_in = 1; ncm_strlcpy(g_st.nickname, "Tester", sizeof g_st.nickname); @@ -181,7 +202,7 @@ int main(int argc, char **argv) if (strcmp(name, "login") == 0 || strcmp(name, "loginlive") == 0) v = login_view_create(); else if (strcmp(name, "home") == 0) v = home_view_create(); else if (strcmp(name, "playlist") == 0) v = playlist_view_create(); - else if (strcmp(name, "player") == 0 || strcmp(name, "lands") == 0) v = player_view_create(); + else if (page_id(name) == ACT_PLAYER) v = player_view_create(); /* player/lands/playerbuf/playererr */ else if (strcmp(name, "settings") == 0) v = settings_view_create(); else if (strcmp(name, "search") == 0) v = search_view_create(); if (!v) { fprintf(stderr, "bad page\n"); return 1; } @@ -192,7 +213,7 @@ int main(int argc, char **argv) meow_view_page_push(pid); /* player 页:其 on_tick 通常驱动进度;这里直接再画一次确保状态就绪 */ meow_ui_paint_full(); - if (strcmp(name, "player") == 0 || strcmp(name, "lands") == 0) { + if (page_id(name) == ACT_PLAYER) { player_tick(meow_ui_time_ms()); } if (strcmp(name, "search") == 0) { diff --git a/patch_v2/ncm_app/app/ui/player.c b/patch_v2/ncm_app/app/ui/player.c index d32813b..a85015c 100644 --- a/patch_v2/ncm_app/app/ui/player.c +++ b/patch_v2/ncm_app/app/ui/player.c @@ -25,6 +25,7 @@ static ncm_rect_t r_cont; static int g_custom_disc, g_custom_dyn; static int g_landscape; static int g_last_pos_ms, g_last_state, g_last_line, g_last_song; +static int g_last_dl, g_last_err; /* 缓冲进度粒度值 / 错误沿(§18) */ static char g_last_meta_id[24]; /* 已上封面的元数据 id(快照滞后 settle 时纠正封面/歌名) */ /* 运行期几何(player_compute_geom 填充;各带绘制物/命中共用)。 */ @@ -155,7 +156,7 @@ static void plr_draw_disc(void *ctx) } } - /* 封面:方盖内切于圆 + 四角盖回盘色 → 封面成圆。 */ + /* 封面:方盖内切于圆 + 四角盖回盘色 → 封面成圆。(cover_get 持锁,绘完 cover_put,§18) */ meow_img *cov = cover_get(); int hS = S / 2; if (cov) meow_ui_img_cover(cx - hS, cy - hS, S, cov); @@ -167,6 +168,7 @@ static void plr_draw_disc(void *ctx) } meow_ui_text_center(cx, cy - 10, "\u266a", th->font_body, pa->red); } + cover_put(); for (int dy = -R; dy <= R; dy++) { int half = (int)sqrt((double)(R*R - dy*dy)); if (half <= hS) continue; @@ -232,7 +234,9 @@ static void plr_draw_dyn(void *ctx) int st = player_manager_state(); draw_ctl(g_cprev_x, g_ctl_y, g_cr - 6, 0, th->surfaceContainer, th->onSurface); - draw_ctl(g_cplay_x, g_ctl_y, g_cr, (st == NCM_PC_PAUSED) ? 1 : 2, pa->red, pa->on_red); + /* 暂停/停止(出错待重试)都画「播放」三角;播放/缓冲画暂停符 */ + draw_ctl(g_cplay_x, g_ctl_y, g_cr, + (st == NCM_PC_PAUSED || st == NCM_PC_STOPPED) ? 1 : 2, pa->red, pa->on_red); draw_ctl(g_cnext_x, g_ctl_y, g_cr - 6, 3, th->surfaceContainer, th->onSurface); /* 歌词区(分隔线 + 滚动歌词,当前行红高亮居中) */ @@ -241,7 +245,32 @@ static void plr_draw_dyn(void *ctx) int line_h = (g_landscape) ? 24 : 26; int center_y = g_lyr.y + g_lyr.h / 2; int lcx = g_lyr.x + g_lyr.w / 2; - if (n == 0) { + int perr = player_manager_error(); + if (perr != 0) { + /* §18:播放错误明示(旧版 err 无 UI 消费者 → 失败无声死等) */ + const char *em = (perr == NCM_ERR_NOTFOUND) ? "无法播放:无音源(VIP/版权)" + : (perr == NCM_ERR_AUTH) ? "登录已过期,请重新登录" + : "加载失败,请检查网络"; + meow_ui_text_center(lcx, center_y - 20, em, 20, pa->red); + meow_ui_text_center(lcx, center_y + 10, "点播放键重试 / 切下一首", 20, th->onSurfaceVariant); + } + else if (st == NCM_PC_BUFFER) { + /* §18:缓冲反馈(song_url 获取 + 下载全程不再无提示) */ + int64_t dd = 0, dt = 0; + player_manager_dl(&dd, &dt); + char bbuf[48]; + if (dt > 0) { + int pct = (int)(dd * 100 / dt); + if (pct > 100) pct = 100; + snprintf(bbuf, sizeof bbuf, "缓冲中… %d%%", pct); + } else if (dd > 0) { + snprintf(bbuf, sizeof bbuf, "缓冲中… %lldKB", (long long)(dd / 1024)); + } else { + snprintf(bbuf, sizeof bbuf, "缓冲中…"); + } + meow_ui_text_center(lcx, center_y - 10, bbuf, 20, th->onSurfaceVariant); + } + else if (n == 0) { /* 二期:歌词后台加载,状态区分「加载中 / 真的无词」 */ meow_ui_text_center(lcx, center_y - 10, lyric_state() == 2 ? "暂无歌词" : "歌词加载中…", @@ -293,8 +322,10 @@ void player_on_tap(int x, int y) if (ah >= 0) { player_set_screen(!g_landscape); return; } } if (x >= g_cplay_x - g_cr && x <= g_cplay_x + g_cr && y >= g_ctl_y - g_cr && y <= g_ctl_y + g_cr) { - if (player_manager_state() == NCM_PC_PAUSED) player_manager_pause(0); - else player_manager_pause(1); + int st = player_manager_state(); + if (st == NCM_PC_PAUSED) player_manager_pause(0); + else if (st == NCM_PC_STOPPED && cur_song() >= 0) player_manager_play(cur_song()); /* 出错后点播放=重试(§18) */ + else player_manager_pause(1); return; } if (x >= g_cprev_x - CTL_RR && x <= g_cprev_x + CTL_RR && y >= g_ctl_y - CTL_RR && y <= g_ctl_y + CTL_RR) { @@ -314,12 +345,15 @@ void player_play_song(int idx) { if (idx < 0 || idx >= g_st.track_ids_count) return; /* 黑胶封面用真实专辑图(song_detail 的 al.picUrl,经 ncm_cache 缓存/限幅); - * 滑窗未物化(占位行直点)时回落歌单封面,元数据由播放线程物化后快照接管(§17)。 */ + * 滑窗未物化(占位行直点)时回落歌单封面,元数据由播放线程物化后快照接管(§17)。 + * §18:g_st.cover_url 只作「歌单封面兜底」——不再把本曲 al_pic 写进去 + * (旧写法污染兜底值:al_pic 缺失的歌会回落成**上一首的封面**,且撞上 + * cover_request 去重 → 封面永远停在上一首,切歌不更新的确定性子根因)。 */ ncm_song_t s; const char *cover = (appstate_song_copy(idx, &s) == 0 && s.al_pic[0]) ? s.al_pic : g_st.cover_url; player_manager_play(idx); - appstate_set_playing_song(idx, cover); + appstate_set_playing_song(idx, NULL); cover_request(cover); meow_view_page_push(ACT_PLAYER); } @@ -375,6 +409,22 @@ void player_tick(uint64_t now) g_last_line = line; meow_ui_invalidate(g_dyn_r.x, g_dyn_r.y, g_dyn_r.w, g_dyn_r.h); /* 歌词 */ } + /* §18:缓冲进度前进(~2KB 粒度)刷动态带(「缓冲中… N%」);仅缓冲态需要。 */ + int64_t dd = 0, dtot = 0; + player_manager_dl(&dd, &dtot); + int dq = (int)(dd / 2048); + if (dq != g_last_dl) { + g_last_dl = dq; + if (st == NCM_PC_BUFFER) + meow_ui_invalidate(g_dyn_r.x, g_dyn_r.y, g_dyn_r.w, g_dyn_r.h); + } + /* §18:错误沿——错误条出现/消失刷动态带 + 新错误弹 snack(旧版 err 无任何 UI 出口)。 */ + int perr = player_manager_error(); + if (perr != g_last_err) { + g_last_err = perr; + meow_ui_invalidate(g_dyn_r.x, g_dyn_r.y, g_dyn_r.w, g_dyn_r.h); + if (perr) ncm_snack("播放失败"); + } } static void plr_page_create(meow_view *v, void *ctx) @@ -390,6 +440,7 @@ static void plr_page_create(meow_view *v, void *ctx) g_custom_dyn = meow_draw_custom_register(11, g_dyn_r.x, g_dyn_r.y, g_dyn_r.w, g_dyn_r.h, plr_draw_dyn, NULL); g_last_pos_ms = -1; g_last_state = -1; g_last_line = -1; g_last_song = -1; + g_last_dl = -1; g_last_err = 0; g_last_meta_id[0] = 0; } @@ -399,6 +450,11 @@ static void plr_page_enter(meow_view *v, void *ctx) ((player_ctrl *)ctx)->active = 1; const ncm_pal_t *pa = ncm_palette(g_dark); meow_ui_set_page_bg(pa->player_bg); /* 播放页恒沉浸,不随系统明暗整体反转 */ + /* §18 自愈:重进播放页强制重估(g_last_meta_id 只在 create 清过一次——离开期间 + * 切歌/封面失败后回来没有重评估路径;清零后首个 tick 按当前快照重请求封面+刷歌名)。 */ + g_last_meta_id[0] = 0; + g_last_song = -1; + g_last_dl = -1; meow_ui_paint_full(); if (!g_host_mock) player_manager_load_lyric(); } diff --git a/patch_v2/ncm_app/app/ui/playlist.c b/patch_v2/ncm_app/app/ui/playlist.c index 5c84262..955c50f 100644 --- a/patch_v2/ncm_app/app/ui/playlist.c +++ b/patch_v2/ncm_app/app/ui/playlist.c @@ -99,13 +99,14 @@ static void pl_draw(void *ctx) /* 封面头块卡片 */ ncm_fill_round(r_card.x, r_card.y, r_card.w, r_card.h, 16, th->surfaceContainer); - meow_img *cov = cover_get(); + meow_img *cov = cover_get(); /* 持锁返回,绘完 cover_put(§18) */ ncm_fill_round(r_cov.x, r_cov.y, r_cov.w, r_cov.h, 12, pa->red_ctr); if (cov) meow_ui_img_cover(r_cov.x, r_cov.y, r_cov.w, cov); else { meow_ui_text_center(r_cov.x + r_cov.w / 2, r_cov.y + (r_cov.h - th->font_body) / 2, "\u266a", th->font_body, pa->red); /* ♪ */ } + cover_put(); const ncm_playlist_t *pl = (g_st.active_playlist >= 0 && g_st.active_playlist < g_st.playlist_count) ? &g_st.playlists[g_st.active_playlist] : NULL; const char *nm = pl ? pl->name : "歌单名"; @@ -418,6 +419,11 @@ static void pl_page_enter(meow_view *v, void *ctx) ((playlist_ctrl *)ctx)->active = 1; const meow_theme *th = meow_theme_get(); meow_ui_set_page_bg(th->bg); + /* §18:头块封面回填——封面槽全局单张(与播放页黑胶共享),进歌单页重新请求歌单封面 + * (旧版只在 playlist_open 里 cover_cache_reset 清掉,无人再请求 → 头块永远占位 ♪)。 */ + if (g_st.active_playlist >= 0 && g_st.active_playlist < g_st.playlist_count && + g_st.playlists[g_st.active_playlist].cover[0]) + cover_request(g_st.playlists[g_st.active_playlist].cover); playlist_refresh(); } diff --git a/patch_v2/ncm_app/bench/cover_chain_sim.c b/patch_v2/ncm_app/bench/cover_chain_sim.c new file mode 100644 index 0000000..dce5b2f --- /dev/null +++ b/patch_v2/ncm_app/bench/cover_chain_sim.c @@ -0,0 +1,237 @@ +/* bench/cover_chain_sim.c — 封面链离线回归(0 真实请求,REPORT §18)。 + * + * 驱动真实 app/logic/cover.c(worker 线程/ncm_cache LRU/cover_current.jpg 落盘), + * HTTP 端点为本地合成 stub(按 URL 生成可区分内容,可注入失败次数模拟慢/坏网络), + * meow_img 为假句柄(嵌内容哈希,不真解码 JPEG——测的是链路逻辑不是解码器)。 + * + * 用例: + * T1 首次请求 → 封面出现(内容哈希对得上 URL); + * T2 定居后 worker **不忙等**:cover_changed() 在 1.2s 内不得再置位 + * (§18 前:成功后不清目标 → 每圈解码+换入+g_gen++ 死循环,CPU 占满且播放页每帧 + * 整盘重绘——本用例在旧代码上必 FAIL); + * T3 切歌换 URL → 封面换入新内容 + cover_current.jpg 同步更新(launcher 媒体卡片读它); + * T4 重复请求同一 URL → 去重(0 新增 HTTP,0 换入); + * T5 网络失败 ×2 后退避重试成功(不无声死等); + * T6 连续失败 ≥8 次放弃(目标清除,不永久占 worker); + * T7 空/NULL URL 忽略;cover_cache_reset 清空。 + * + * 编译(宿主,ncm_app 目录下;weak cover_put 兼容 §18 前的 cover.c 做对照): + * gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ + * -Icore -Ivendor/jsmn -c bench/cover_chain_sim.c -o /tmp/cs.o + * gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ + * -Icore -Ivendor/jsmn -c app/logic/cover.c -o /tmp/cs_cover.o + * gcc -O2 -ffunction-sections -DJSMN_HEADER -Icore -c core/ncm_cache.c -o /tmp/cs_cache.o + * gcc -O2 -ffunction-sections -DJSMN_HEADER -Icore -c core/ncm_store.c -o /tmp/cs_store.o + * gcc -O2 -Wl,--gc-sections -o cover_chain_sim /tmp/cs.o /tmp/cs_cover.o /tmp/cs_cache.o \ + * /tmp/cs_store.o -lpthread + * 运行:./cover_chain_sim (0 网络;PASS/FAIL 退出码) + */ +#include "../app/ncm_app_internal.h" +#include +#include +#include +#include +#include +#include + +ncm_app_state_t g_st; +volatile int g_app_quit = 0; +int g_host_mock = 0; + +/* ---- 公共 stub ---- */ +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; +} + +static int g_log_warns; +void meow_log(meow_log_level lvl, const char *fmt, ...) +{ + if (lvl >= MEOW_LOG_WARN) g_log_warns++; + (void)fmt; +} + +/* ---- 假 meow_img:嵌内容 FNV 哈希(链路验证用,不真解码) ---- */ +struct meow_img { unsigned long hash; int len; int freed; }; + +static unsigned long fnv1a(const unsigned char *p, size_t n) +{ + unsigned long h = 1469598103934665603UL; + for (size_t i = 0; i < n; i++) { h ^= p[i]; h *= 1099511628211UL; } + return h; +} + +meow_img *meow_img_load_mem(const void *buf, int len) +{ + if (!buf || len <= 0) return NULL; + meow_img *im = (meow_img *)malloc(sizeof *im); + if (!im) return NULL; + im->hash = fnv1a((const unsigned char *)buf, (size_t)len); + im->len = len; + im->freed = 0; + return im; +} + +void meow_img_free(meow_img *im) +{ + if (!im) return; + im->freed = 1; + free(im); +} + +/* §18 前的 cover.c 无 cover_put:weak 空实现让同一 sim 可对照旧代码(新代码强符号覆盖)。 */ +__attribute__((weak)) void cover_put(void) { } + +/* ---- HTTP stub:内容 = djb2(url) 填充 4KB(每 URL 唯一);可注入失败/延迟 ---- */ +static int g_http_calls; +static int g_fail_next; /* 剩余注入失败次数 */ +static int g_delay_ms; /* 模拟慢网络 */ + +static unsigned long djb2(const char *s) +{ + unsigned long h = 5381; + while (*s) h = ((h << 5) + h) + (unsigned char)*s++; + return h; +} + +int ncm_http_request(const ncm_http_req_t *req, char *buf, size_t buf_sz, ncm_http_resp_t *resp) +{ + g_http_calls++; + if (g_fail_next > 0) { g_fail_next--; return NCM_ERR_IO; } + if (g_delay_ms > 0) usleep((useconds_t)g_delay_ms * 1000); + size_t n = 4096 < buf_sz ? 4096 : buf_sz; + unsigned long h = djb2(req->url); + for (size_t i = 0; i < n; i++) buf[i] = (char)((h >> ((i % 8) * 8)) ^ (unsigned char)i); + memset(resp, 0, sizeof *resp); + resp->status = 200; + resp->body = buf; + resp->body_len = n; + resp->content_length = n; + return NCM_OK; +} + +/* URL 期望内容哈希(与 stub 生成式一致) */ +static unsigned long url_hash(const char *url) +{ + unsigned char tmp[4096]; + unsigned long h = djb2(url); + for (size_t i = 0; i < sizeof tmp; i++) tmp[i] = (unsigned char)((h >> ((i % 8) * 8)) ^ (unsigned char)i); + return fnv1a(tmp, sizeof tmp); +} + +static int g_fail; +#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) + +/* 轮询等待封面内容变成 want_hash(≤ ms)。返回 1=达成。 */ +static int wait_cover_hash(unsigned long want, int ms) +{ + for (int t = 0; t < ms; t += 20) { + meow_img *im = cover_get(); + unsigned long h = im ? im->hash : 0; + cover_put(); + if (h == want) return 1; + usleep(20 * 1000); + } + return 0; +} + +static unsigned long cur_cover_hash(void) +{ + meow_img *im = cover_get(); + unsigned long h = im ? im->hash : 0; + cover_put(); + return h; +} + +/* 读 cover_current.jpg 并哈希(验证 launcher 媒体卡片读的文件同步更新)。 */ +static unsigned long curjpg_hash(void) +{ + char path[600]; + snprintf(path, sizeof path, "%s/cover_current.jpg", g_st.cache_dir); + FILE *f = fopen(path, "rb"); + if (!f) return 0; + unsigned char b[4096]; + size_t n = fread(b, 1, sizeof b, f); + fclose(f); + return n ? fnv1a(b, n) : 0; +} + +#define URL_A "http://p1.music.126.net/albumA/xxx.jpg" +#define URL_B "http://p1.music.126.net/albumB/yyy.jpg" +#define URL_C "http://p1.music.126.net/albumC/zzz.jpg" +#define URL_D "http://p1.music.126.net/albumD/www.jpg" + +int main(void) +{ + if (system("rm -rf /tmp/ncm_cover_sim") != 0) { printf("FAIL rm /tmp cache\n"); return 1; } + memset(&g_st, 0, sizeof g_st); + snprintf(g_st.cache_dir, sizeof g_st.cache_dir, "/tmp/ncm_cover_sim"); + ncm_cache_init(&g_st.ctx.cache, g_st.cache_dir); + + printf("== T1: first request -> cover appears ==\n"); + cover_request(URL_A); + CHECK(wait_cover_hash(url_hash(URL_A), 3000), "cover A shown (hash match)"); + CHECK(g_http_calls == 1, "http calls=%d (expect 1)", g_http_calls); + CHECK(curjpg_hash() == url_hash(URL_A), "cover_current.jpg == A"); + + printf("== T2: settle -> worker idle (no busy loop) ==\n"); + cover_ack(); + int churn = 0; + for (int t = 0; t < 1200; t += 10) { if (cover_changed()) churn++; usleep(10 * 1000); } + CHECK(churn == 0, "cover_changed churn in 1.2s = %d (expect 0; §18 前=忙等死循环)", churn); + CHECK(g_http_calls == 1, "http calls=%d (still 1)", g_http_calls); + + printf("== T3: switch song -> cover switches + cover_current.jpg updated ==\n"); + cover_request(URL_B); + CHECK(wait_cover_hash(url_hash(URL_B), 3000), "cover switched to B"); + CHECK(curjpg_hash() == url_hash(URL_B), "cover_current.jpg == B"); + CHECK(g_http_calls == 2, "http calls=%d (expect 2)", g_http_calls); + cover_ack(); + usleep(400 * 1000); + CHECK(!cover_changed(), "no churn after switch"); + + printf("== T4: re-request same url -> dedup ==\n"); + cover_request(URL_B); + usleep(500 * 1000); + CHECK(g_http_calls == 2, "http calls=%d (still 2, dedup)", g_http_calls); + + printf("== T5: flaky network x2 -> backoff retry succeeds ==\n"); + g_fail_next = 2; + cover_request(URL_C); + CHECK(wait_cover_hash(url_hash(URL_C), 8000), "cover C after retries"); + CHECK(g_http_calls >= 5, "http calls=%d (>=5: 2 fails + 1 ok for C)", g_http_calls); + CHECK(g_log_warns >= 2, "warn logs=%d (failures logged, 不无声)", g_log_warns); + + printf("== T6: permanent failure -> give up after %d tries ==\n", 8); + g_fail_next = 100; + int before = g_http_calls; + cover_request(URL_D); + usleep(25 * 1000 * 1000 / 10); /* ~2.5s 不够 8 次退避(0.5+1+2+4*5=23.5s),分段等 */ + for (int t = 0; t < 30 && g_http_calls - before < 8; t++) usleep(1000 * 1000); + int tries = g_http_calls - before; + CHECK(tries == 8, "give up after tries=%d (expect 8)", tries); + CHECK(cur_cover_hash() == url_hash(URL_C), "old cover C retained after give-up"); + usleep(600 * 1000); + CHECK(g_http_calls - before == 8, "no further retries after give-up (calls=%d)", + g_http_calls - before); + + printf("== T7: null/empty + reset ==\n"); + cover_request(NULL); + cover_request(""); + CHECK(g_http_calls == before + 8, "null/empty ignored (calls=%d)", g_http_calls); + cover_cache_reset(); + CHECK(cur_cover_hash() == 0, "reset -> cover cleared"); + g_fail_next = 0; + + printf(g_fail ? "== COVER CHAIN SIM: FAIL ==\n" : "== COVER CHAIN SIM: ALL PASS ==\n"); + return g_fail; +} diff --git a/patch_v2/ncm_app/bench/player_flow_sim.c b/patch_v2/ncm_app/bench/player_flow_sim.c new file mode 100644 index 0000000..0f007bd --- /dev/null +++ b/patch_v2/ncm_app/bench/player_flow_sim.c @@ -0,0 +1,257 @@ +/* bench/player_flow_sim.c — 播放线程状态机离线回归(0 真实请求,REPORT §18)。 + * + * 驱动真实 app/play/player_manager.c(外层 player_thread + run_song 全链路), + * 解码器/音频输出/网络/缓存全部本地 stub(可注入:写失败、url 失败、慢下载)。 + * + * 用例: + * P1 自然播完 → 自动顺延下一首(曲终不停在原地,err=0); + * P2 音频写失败(流死/audiod 断链)→ **置错停播,cur_song 不动**——§18 前静默 break + * 被外层当「自然播完」连环跳歌+狂发 song_url(本用例在旧代码上 FAIL); + * P3 song_url 无音源 → STOPPED + NCM_ERR_NOTFOUND + errmsg(UI 有错误可显示); + * P4 下载途中切歌 → 旧曲下载完成后**不得**把状态改回 PLAYING/写过期快照 + * (still_current 护栏;旧代码在切歌后 ~下载完成点 会把 BUFFER 覆盖成 PLAYING); + * P5 错误后 play(cur) 重试 → 状态重新走 BUFFER→PLAYING,err 清零。 + * + * 编译(宿主,ncm_app 目录下): + * gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ + * -Icore -Ivendor/jsmn -Ivendor/minimp3 -c bench/player_flow_sim.c -o /tmp/pf.o + * gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ + * -Icore -Ivendor/jsmn -Ivendor/minimp3 -c app/play/player_manager.c -o /tmp/pf_pm.o + * gcc -O2 -Wl,--gc-sections -o player_flow_sim /tmp/pf.o /tmp/pf_pm.o -lpthread -lm + * 运行:./player_flow_sim (0 网络;PASS/FAIL 退出码) + */ +#include "../app/ncm_app_internal.h" +#include "minimp3_ex.h" +#include +#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 = 1; +int g_host_mock = 0; + +/* ---- 公共 stub ---- */ +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; +} +void meow_log(meow_log_level lvl, const char *fmt, ...) { (void)lvl; (void)fmt; } +void meow_audio_on_preempt(meow_audio_preempt_fn cb, void *ctx) { (void)cb; (void)ctx; } + +/* ---- appstate stub:窗内全命中,id=1000+idx ---- */ +int appstate_song_copy(int gidx, ncm_song_t *out) +{ + if (gidx < 0 || gidx >= g_st.track_ids_count) return -1; + memset(out, 0, sizeof *out); + snprintf(out->id, sizeof out->id, "%d", 1000 + gidx); + snprintf(out->name, sizeof out->name, "Song%d", gidx); + out->duration_ms = 180000; + return 0; +} +int appstate_song_fetch(int gidx, ncm_song_t *out) { return appstate_song_copy(gidx, out) == 0 ? NCM_OK : NCM_ERR_ARG; } + +/* ---- 缓存/网络 stub ---- + * g_cache_ok=1 → lookup 命中(给真实存在的 /tmp 文件,免下载); + * 否则走下载:song_url(g_url_rc 可注入失败) → http_download(g_dl_delay_ms 模拟慢) → commit。 */ +static int g_cache_ok = 1; +static int g_url_rc = NCM_OK; +static int g_dl_delay_ms = 0; +static int g_downloads; /* http_download 调用数(song_url 请求放大检测) */ + +int ncm_cache_lookup(const ncm_cache_t *c, const char *id, const char *ext, char *out, size_t out_sz) +{ + (void)c; (void)id; (void)ext; + if (!g_cache_ok) return NCM_ERR_NOENT; + snprintf(out, out_sz, "/tmp/pf_sim.mp3"); + return NCM_OK; +} +int ncm_cache_open(const ncm_cache_t *c, const char *id, const char *ext, ncm_cache_writer_t **w) +{ (void)c; (void)id; (void)ext; *w = (ncm_cache_writer_t *)calloc(1, 64); return NCM_OK; } +int ncm_cache_write(ncm_cache_writer_t *w, const unsigned char *d, size_t l) { (void)w; (void)d; (void)l; return NCM_OK; } +int ncm_cache_commit(ncm_cache_writer_t *w) { free(w); g_cache_ok = 1; return NCM_OK; } +int ncm_cache_abort(ncm_cache_writer_t *w) { free(w); return NCM_OK; } +int ncm_cache_evict(const ncm_cache_t *c) { (void)c; return NCM_OK; } +void ncm_cache_set_limit_gb(ncm_cache_t *c, int gb) { (void)c; (void)gb; } + +int ncm_api_song_url_auto(const char *dev, const char *ck, const char *id, int level, ncm_play_url_t *out) +{ + (void)dev; (void)ck; (void)id; (void)level; + if (g_url_rc != NCM_OK) return g_url_rc; + memset(out, 0, sizeof *out); + snprintf(out->url, sizeof out->url, "http://stub/%s.mp3", id); + out->size = 4 * 1024 * 1024; + return NCM_OK; +} + +int ncm_http_download(const ncm_http_req_t *req, ncm_http_write_cb cb, void *ctx, + size_t maxb, ncm_http_resp_t *resp) +{ + (void)req; (void)maxb; + g_downloads++; + if (g_dl_delay_ms > 0) usleep((useconds_t)g_dl_delay_ms * 1000); + unsigned char chunk[65536]; + memset(chunk, 0x5A, sizeof chunk); + for (int i = 0; i < 8; i++) /* 512KB 总分发(进度回调会走 8 次) */ + if (cb(ctx, chunk, sizeof chunk) != 0) break; + memset(resp, 0, sizeof *resp); + resp->status = 200; + return NCM_OK; +} + +/* ---- 解码器 stub:g_eof 前恒有数据(P1 用 g_reads_left 控制播完点) ---- */ +static int g_eof; +static int g_reads_left = -1; /* >=0: 剩余读次数后 EOF;-1: 直到 g_eof */ + +int mp3dec_ex_open(mp3dec_ex_t *dec, const char *file_name, int flags) +{ (void)dec; (void)file_name; (void)flags; return 0; } +void mp3dec_ex_close(mp3dec_ex_t *dec) { (void)dec; } +int mp3dec_ex_seek(mp3dec_ex_t *dec, uint64_t position) { (void)dec; (void)position; return 0; } +size_t mp3dec_ex_read(mp3dec_ex_t *dec, mp3d_sample_t *buf, size_t samples) +{ + (void)dec; + if (g_eof) return 0; + if (g_reads_left == 0) return 0; + if (g_reads_left > 0) g_reads_left--; + memset(buf, 0, samples * sizeof(mp3d_sample_t)); + return samples; +} + +/* ---- 音频输出 stub:g_write_fail 注入写失败 ---- */ +static int g_write_fail; +static int g_writes; +static int g_opens; /* audio_out_open 次数(每首 run_song 开一次) */ + +int audio_out_open(int rate, int ch, int bits) { (void)rate; (void)ch; (void)bits; g_opens++; return 0; } +int audio_out_write(const short *s, int frames) +{ (void)s; g_writes++; return g_write_fail ? -1 : frames; } +int audio_out_pause(int paused) { (void)paused; return 0; } +void audio_out_close(void) { } +int audio_out_buffered_ms(void) { return 0; } +void audio_out_set_target(int ms) { (void)ms; } + +/* ---- 歌词(未被调用的 worker 会被 gc-sections 丢弃;lyric_clear 由 worker 用,防御性提供) ---- */ +void lyric_clear(void) { } +int lyric_parse(const char *a, const char *b) { (void)a; (void)b; return 0; } + +static int g_fail; +#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 int state(void) { return player_manager_state(); } + +static int wait_state(int want, int ms) +{ + for (int t = 0; t < ms; t += 10) { if (state() == want) return 1; usleep(10 * 1000); } + return 0; +} + +static int wait_err(int want, int ms) /* 等错误码出现(初始态可能已是 STOPPED,等状态有竞态) */ +{ + for (int t = 0; t < ms; t += 10) { if (player_manager_error() == want) return 1; usleep(10 * 1000); } + return 0; +} + +static int wait_downloads(int want, int ms) +{ + for (int t = 0; t < ms; t += 10) { if (g_downloads >= want) return 1; usleep(10 * 1000); } + return 0; +} + +int main(void) +{ + /* 假 mp3 文件(cache 命中路径 stat 用) */ + FILE *f = fopen("/tmp/pf_sim.mp3", "wb"); + if (!f) { printf("FAIL tmp file\n"); return 1; } + for (int i = 0; i < 4096; i++) fputc(i & 0xFF, f); + fclose(f); + + memset(&g_st, 0, sizeof g_st); + g_st.track_ids_count = 3; + for (int i = 0; i < 3; i++) g_st.song_ids[i] = 1000 + i; + g_st.level = NCM_LEVEL_STANDARD; + g_st.cache_limit_gb = 10; + + if (player_manager_start() != 0) { printf("FAIL start\n"); return 1; } + + printf("== P1: natural finish -> auto-advance, err=0 ==\n"); + g_cache_ok = 1; g_reads_left = -1; g_eof = 0; g_write_fail = 0; + player_manager_play(0); + CHECK(wait_state(NCM_PC_PLAYING, 2000), "song0 playing (持续读流态可捕获)"); + /* stub 解码/写瞬时完成,整列顺排在一次 poll 内跑完(cur 直接冲到 -1)—— + * 改用 audio_out_open 计数判定:3 首全开过 = 自然顺延链走通。 */ + g_reads_left = 1; /* song0 再读 1 次即 EOF → 自然播完 */ + for (int t = 0; t < 4000 && state() != NCM_PC_STOPPED; t += 20) { + if (g_reads_left <= 0) g_reads_left = 1; + usleep(20 * 1000); + } + CHECK(g_opens == 3, "auto-advance chain: audio opens=%d (expect 3 首全开)", g_opens); + CHECK(state() == NCM_PC_STOPPED && player_manager_error() == 0, + "end of playlist -> STOPPED err=%d (expect 0)", player_manager_error()); + + printf("== P2: write fail (stream dead) -> STOPPED + err, cur unchanged (不连环跳歌) ==\n"); + g_reads_left = -1; g_eof = 0; g_write_fail = 1; g_writes = 0; + player_manager_play(1); + CHECK(wait_err(NCM_ERR_IO, 2000), "err=NCM_ERR_IO after write fail (rc=%d)", player_manager_error()); + CHECK(wait_state(NCM_PC_STOPPED, 1000), "stopped after write fail"); + usleep(500 * 1000); + CHECK(player_manager_playing_song() == 1, "cur=%d (expect 1, 未跳歌)", player_manager_playing_song()); + CHECK(state() == NCM_PC_STOPPED, "stays STOPPED (no cascade)"); + CHECK(g_writes <= 2, "writes=%d (≤2: 第一次失败即停,不连环)", g_writes); + g_write_fail = 0; + + printf("== P3: no stream url -> STOPPED + NOTFOUND + errmsg ==\n"); + g_url_rc = NCM_ERR_NOTFOUND; g_cache_ok = 0; + player_manager_play(2); + CHECK(wait_err(NCM_ERR_NOTFOUND, 2000), "err=NOTFOUND on no-stream (rc=%d)", player_manager_error()); + CHECK(wait_state(NCM_PC_STOPPED, 1000), "stopped on no-stream"); + pthread_mutex_lock(&g_play.mu); + const char *em = g_play.errmsg; + int has_msg = em[0] != 0; + pthread_mutex_unlock(&g_play.mu); + CHECK(has_msg, "errmsg set (UI 可显示)"); + g_url_rc = NCM_OK; + + printf("== P4: switch during slow download -> no stale PLAYING overwrite ==\n"); + g_cache_ok = 0; g_dl_delay_ms = 250; g_eof = 0; g_reads_left = -1; + player_manager_play(0); + CHECK(wait_downloads(1, 3000), "song0 download started"); + player_manager_play(1); /* 旧曲下载途中切歌 */ + /* 旧曲下载 +250ms 完成;新代码 still_current 护栏直接返回(状态保持 BUFFER), + * 旧代码会在此点把 state 覆盖成 PLAYING。+280ms 落在新曲下载窗口内(~+300 才开始)。 */ + usleep(280 * 1000); + CHECK(state() == NCM_PC_BUFFER, + "after switch+during new download state=%d (expect BUFFER=%d; 旧代码被旧曲改成 PLAYING)", + state(), NCM_PC_BUFFER); + CHECK(wait_state(NCM_PC_PLAYING, 3000), "new song eventually playing"); + ncm_song_t snap; + CHECK(player_manager_current_song(&snap) == 0 && strcmp(snap.id, "1001") == 0, + "snapshot id=%s (expect 1001, 无过期快照)", snap.id); + g_eof = 1; /* 让新曲自然收尾 */ + usleep(200 * 1000); + g_dl_delay_ms = 0; + + printf("== P5: retry after error -> BUFFER->PLAYING, err cleared ==\n"); + g_eof = 0; g_reads_left = -1; g_url_rc = NCM_ERR_NOTFOUND; + g_cache_ok = 0; + player_manager_play(2); /* 制造错误 */ + CHECK(wait_err(NCM_ERR_NOTFOUND, 2000), "error state ready (rc=%d)", player_manager_error()); + g_url_rc = NCM_OK; /* 网络恢复 */ + player_manager_play(player_manager_playing_song()); /* 重试(UI 播放键路径) */ + CHECK(wait_state(NCM_PC_PLAYING, 3000), "retry playing"); + CHECK(player_manager_error() == 0, "err cleared on retry"); + g_eof = 1; + + usleep(200 * 1000); + player_manager_stop_thread(); + printf(g_fail ? "== PLAYER FLOW SIM: FAIL ==\n" : "== PLAYER FLOW SIM: ALL PASS ==\n"); + return g_fail; +} diff --git a/patch_v2/ncm_app/ncm b/patch_v2/ncm_app/ncm index 0ea3a61da034b8a8bf22ecd61ca7ed3ea330fa3f..1db939896be314fa7e957eadf2b234c33c2d70c9 100755 GIT binary patch delta 320487 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
cQ5&_hRySa6$IsWHsk%Tzc7AEMzu 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