feat(ncm §24): 流媒体客户端边下边播阶段3——.part断点续传(Range/206/200/416)+LRU防淘汰(.tmp/.part跳过)+失败重试1次退避

按 devdocs/plan/NCM_STREAM_PLAY.md §8 阶段3:
- .part 断续传:中断留 .part,续传带 Range,206 追加 / 200 丢头段 / 416 删残留整首重下;URL 每次新拉
- LRU 防淘汰:evict/total_bytes 跳过 .tmp/.part(在播文件不被动过)
- 失败重试:预缓冲前网络失败重试 1 次,退避 800ms,重试拿新签名 URL(风控:0 真实请求,未部署)
- 顺手修:gb_to_bytes 返回 int 溢出致默认 10GB 上限失效 -> uint64_t
- 验证:stream_sim(续传 PCM 金标准+全矩阵)/player_flow_sim(P9/P10/P11)/cache_evict_sim(case7/8)/test_ncm_core 宿主+qemu-mipsel 全 PASS;build_ncm.sh 0 warning;ncm_host 离屏正常

md5 ncm f8f316a1e6f3bdd8f6b5921fdbb3f3a2
This commit is contained in:
MeowOS committed 2026-09-01 20:00:14 +08:00
1 parent fcff0b0ac1
commit 19e8fc93ed
14 files changed
+735 -93

No files matched your search

+90
View File
@@ -195,6 +195,96 @@ int main(void)
CHECK(ncm_cache_total_bytes(&g_c) == 0, "total=%llu after clear (expect 0)",
(unsigned long long)ncm_cache_total_bytes(&g_c));
/* ⑦ 阶段3:播放中的 .tmp / 中断残体 .part 不被 LRU 淘汰,且不计入池占用。
* 「正在下载播放的文件」= .tmp(写穿缓存态);「上次中断的可续传残体」= .part。
* 二者在 evict/total_bytes 中被跳过——防正在听的歌被缓存上限挤掉(§5.1)。 */
printf("== case 7: evict skips .tmp/.part (playing file not evicted; stage3 §5.1) ==\n");
cleanup();
ncm_cache_set_limit_gb(&g_c, 1); /* 1GB(int 内,避免既有 gb_to_bytes int 溢出 bug 干扰本用例) */
make_cached("a", ".mp3", mb(600), 1000); /* 600MB 最旧 */
make_cached("b", ".mp3", mb(600), 1100); /* 600MB */
/* 在途 .tmp(边下边播写穿缓存态):song_b.mp3.tmp */
{
char p[600];
ncm_cache_path(&g_c, "b", ".mp3", p, sizeof p);
strncat(p, ".tmp", sizeof p - strlen(p) - 1);
int fd = open(p, O_WRONLY | O_CREAT, 0644);
if (ftruncate(fd, (off_t)mb(300)) != 0) printf(" truncate tmp err\n");
close(fd);
}
/* 中断残体 .part(可续传):song_d.mp3.part */
{
char p[600];
ncm_cache_path(&g_c, "d", ".mp3", p, sizeof p);
strncat(p, ".part", sizeof p - strlen(p) - 1);
int fd = open(p, O_WRONLY | O_CREAT, 0644);
if (ftruncate(fd, (off_t)mb(300)) != 0) printf(" truncate part err\n");
close(fd);
}
CHECK(ncm_cache_total_bytes(&g_c) == mb(1200),
"total excludes .tmp/.part (=%lluMB, expect 1200MB)",
(unsigned long long)(ncm_cache_total_bytes(&g_c) / (1024*1024)));
ncm_cache_evict(&g_c);
CHECK(!cached_exists("a", ".mp3"), "oldest committed a evicted");
CHECK(cached_exists("b", ".mp3"), "b kept (<=limit)");
/* .tmp/.part 文件仍在(未被打包进 LRU 淘汰) */
{
char tp[600], pp[600];
ncm_cache_path(&g_c, "b", ".mp3", tp, sizeof tp); strncat(tp, ".tmp", sizeof tp-strlen(tp)-1);
ncm_cache_path(&g_c, "d", ".mp3", pp, sizeof pp); strncat(pp, ".part", sizeof pp-strlen(pp)-1);
struct stat st;
CHECK(stat(tp, &st) == 0, "playing .tmp survives evict");
CHECK(stat(pp, &st) == 0, "resume .part survives evict");
}
CHECK(ncm_cache_total_bytes(&g_c) == mb(600),
"total after evict excludes partials (=%lluMB, expect 600MB)",
(unsigned long long)(ncm_cache_total_bytes(&g_c) / (1024*1024)));
/* ⑧ 阶段3:部分文件 API 快检——part_size 检测 / open_resume 追加 / abort_part 保留 /
* abort 删除 的命名契约(供 player_manager 续传链路)。 */
printf("== case 8: .part resume API contract (stage3 §5.2) ==\n");
cleanup();
{
/* 用 ncm_cache_open_resume 打开上一步遗留 .part 无(已 cleanup)——先造一个真 .part */
char p[600];
ncm_cache_path(&g_c, "r", ".mp3", p, sizeof p);
snprintf(p + strlen(p), sizeof p - strlen(p), ".part");
int fd = open(p, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) { (void)!write(fd, "ABCDEF", 6); close(fd); }
uint64_t sz = 0;
CHECK(ncm_cache_part_size(&g_c, "r", ".mp3", &sz) == NCM_OK && sz == 6,
"part_size reports 6 (got %llu)", (unsigned long long)sz);
ncm_cache_writer_t *w = NULL;
CHECK(ncm_cache_open_resume(&g_c, "r", ".mp3", &w) == NCM_OK, "open_resume ok");
CHECK(ncm_cache_write(w, (const unsigned char *)"GH", 2) == NCM_OK, "resume append write");
CHECK(ncm_cache_commit(w) == NCM_OK, "resume commit (rename .part->final)");
/* commit 后 .part 转正:lookup 命中且内容=ABCDEFGH,.part 消失 */
char path[600];
CHECK(ncm_cache_lookup(&g_c, "r", ".mp3", path, sizeof path) == NCM_OK, "resume commit lookup hit");
FILE *f = fopen(path, "rb");
char buf[16] = {0}; int n = 0;
if (f) { n = (int)fread(buf, 1, sizeof buf - 1, f); fclose(f); }
CHECK(n == 8 && memcmp(buf, "ABCDEFGH", 8) == 0, "committed bytes=%.8s (expect ABCDEFGH)", buf);
CHECK(ncm_cache_part_size(&g_c, "r", ".mp3", &sz) == NCM_ERR_NOENT, ".part gone after commit");
/* abort_part 保留残体 / abort 删除:造两个 .part 分别走两路 */
ncm_cache_path(&g_c, "s", ".mp3", p, sizeof p);
snprintf(p + strlen(p), sizeof p - strlen(p), ".part");
fd = open(p, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) { (void)!write(fd, "1234", 4); close(fd); }
CHECK(ncm_cache_open_resume(&g_c, "s", ".mp3", &w) == NCM_OK, "s open_resume");
CHECK(ncm_cache_abort_part(w) == NCM_OK, "s abort_part");
struct stat st2;
CHECK(stat(p, &st2) == 0, "abort_part keeps .part (resumable)");
CHECK(ncm_cache_open_resume(&g_c, "s", ".mp3", &w) == NCM_OK, "s reopen_resume");
CHECK(ncm_cache_abort(w) == NCM_OK, "s abort (delete)");
CHECK(stat(p, &st2) != 0, "abort deletes .part");
/* part_remove 显式删除 */
fd = open(p, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) { (void)!write(fd, "x", 1); close(fd); }
CHECK(ncm_cache_part_remove(&g_c, "s", ".mp3") == NCM_OK, "part_remove ok");
CHECK(stat(p, &st2) != 0, "part_remove deleted .part");
}
cleanup();
printf("%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS");
return g_fail ? 1 : 0;
+112 -10
View File
@@ -66,6 +66,9 @@ int appstate_song_copy(int gidx, ncm_song_t *out)
return 0;
}
int appstate_song_fetch(int gidx, ncm_song_t *out) { return appstate_song_copy(gidx, out) == 0 ? NCM_OK : NCM_ERR_ARG; }
/* §23:player_manager 改走 appstate_queue_*(队列来源)——sim 恒 PLAYLAST 队列 = 窗口 */
int appstate_queue_count(void) { return g_st.track_ids_count; }
int appstate_queue_song(int idx, ncm_song_t *out) { return appstate_song_copy(idx, out); }
/* ---- 缓存/网络 stub ----
* g_cache_ok=1 → lookup 命中(给真实存在的 /tmp 文件,免下载);
@@ -80,6 +83,15 @@ static char g_last_ext[8]; /* ncm_cache_open 收到的扩展名 */
static int g_url_calls; /* §20:song_url_auto 调用数(fee 门禁不得发请求) */
static int g_url_req_level = -1;/* §20:song_url_auto 收到的档位(VIP 钳顶断言用) */
static int g_vip = NCM_VIP_NONE;/* §20:account stub 注入的会员态 */
/* ---- 阶段3 续传/重试 stub 状态 ---- */
static uint64_t g_part_size; /* >0=模拟存在 .part 残体(续传起点) */
static int g_http_status; /* ncm_http_download_status 回填的响应状态(默认 200) */
static char g_last_range[64];/* 收到的 Range 头("bytes=...";无则空串) */
static int64_t g_resume_opens; /* ncm_cache_open_resume 调用数 */
static int64_t g_part_removes; /* ncm_cache_part_remove 调用数 */
static int g_writer_resume;/* 当前 writer 是否续传打开(模拟 .part 被删/保留) */
static int g_416_first; /* >0: 首个下载请求回 416(续传被拒),其后按 g_http_status */
static int64_t now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (int64_t)ts.tv_sec*1000 + ts.tv_nsec/1000000; }
/* §20:account stub(真实实现在 app/logic/account.c,不链入本 sim) */
int account_vip(void) { return g_vip; }
@@ -100,22 +112,41 @@ int ncm_cache_lookup(const ncm_cache_t *c, const char *id, const char *ext, char
return NCM_OK;
}
static char g_tmp_path[] = "/tmp/pf_sim_dl.tmp"; /* 下载路径 stub 的 .tmp(§21:stream_src 要真实打开) */
static int g_commits; /* §21:commit 移到曲尾 dispose(边下边播),P7 等收尾用 */
static void pf_make_tmp(void) { FILE *f = fopen(g_tmp_path, "wb"); if (f) fclose(f); } /* stream_src 要真实打开 */
int ncm_cache_open(const ncm_cache_t *c, const char *id, const char *ext, ncm_cache_writer_t **w)
{
(void)c;
snprintf(g_last_key, sizeof g_last_key, "%s", id);
snprintf(g_last_ext, sizeof g_last_ext, "%s", ext ? ext : "");
FILE *f = fopen(g_tmp_path, "wb"); /* 建空 .tmp:stream_src_open_stream 需存在 */
if (f) fclose(f);
pf_make_tmp();
g_writer_resume = 0;
*w = (ncm_cache_writer_t *)calloc(1, 64); return NCM_OK;
}
const char *ncm_cache_writer_tmp_path(const ncm_cache_writer_t *w) { (void)w; return g_tmp_path; }
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; }
static int g_commits; /* §21:commit 移到曲尾 dispose(边下边播),P7 等收尾用 */
int ncm_cache_commit(ncm_cache_writer_t *w) { free(w); g_cache_ok = 1; g_commits++; return NCM_OK; }
int ncm_cache_abort(ncm_cache_writer_t *w) { free(w); return NCM_OK; }
int ncm_cache_abort(ncm_cache_writer_t *w)
{
if (g_writer_resume) g_part_size = 0; /* 模拟:删除续传 writer 的 .part(续传失败→整首重下) */
g_writer_resume = 0; free(w); return NCM_OK;
}
int ncm_cache_abort_part(ncm_cache_writer_t *w) { g_writer_resume = 0; free(w); return NCM_OK; } /* 保留 .part */
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; }
/* ---- 阶段3 续传 stub ---- */
int ncm_cache_part_size(const ncm_cache_t *c, const char *id, const char *ext, uint64_t *sz)
{ (void)c; (void)id; (void)ext; if (!g_part_size) return NCM_ERR_NOENT; *sz = g_part_size; return NCM_OK; }
int ncm_cache_open_resume(const ncm_cache_t *c, const char *id, const char *ext, ncm_cache_writer_t **w)
{
(void)c;
snprintf(g_last_key, sizeof g_last_key, "%s", id);
snprintf(g_last_ext, sizeof g_last_ext, "%s", ext ? ext : "");
pf_make_tmp(); g_writer_resume = 1; g_resume_opens++;
*w = (ncm_cache_writer_t *)calloc(1, 64); return NCM_OK;
}
int ncm_cache_part_remove(const ncm_cache_t *c, const char *id, const char *ext)
{ (void)c; (void)id; (void)ext; g_part_removes++; g_part_size = 0; return NCM_OK; }
int ncm_api_song_url_auto(const char *dev, const char *ck, const char *id, int level,
ncm_play_url_t *out, int *out_level)
@@ -133,20 +164,42 @@ int ncm_api_song_url_auto(const char *dev, const char *ck, const char *id, int l
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)
static int g_dl_fail; /* 阶段3:>0 时下载返回 NCM_ERR_NET(模拟预缓冲前网络失败) */
int ncm_http_download_status(const ncm_http_req_t *req, ncm_http_write_cb cb, void *ctx,
size_t maxb, ncm_http_resp_t *resp, int *status_out)
{
(void)req; (void)maxb;
g_downloads++;
if (g_dl_delay_ms > 0) usleep((useconds_t)g_dl_delay_ms * 1000);
int st = (g_416_first && g_downloads == 1) ? 416 : (g_http_status ? g_http_status : 200);
if (status_out) *status_out = st; /* 状态感知:首 body 前写入(续传分支读它) */
g_last_range[0] = 0;
if (req && req->extra_hdrs) { /* 捕获 Range 头(续传断言) */
const char *r = strstr(req->extra_hdrs, "Range: bytes=");
if (r) {
r += strlen("Range: bytes=");
int i = 0;
while (r[i] && r[i] != '\r' && r[i] != '\n' && i < (int)sizeof g_last_range - 1) { g_last_range[i] = r[i]; i++; }
g_last_range[i] = 0;
}
}
if (st == 416) g_part_size = 0; /* 模拟:续传被拒后残体删除(重试走整首) */
memset(resp, 0, sizeof *resp);
resp->status = st;
if (g_dl_fail) return NCM_ERR_NET; /* 预缓冲前网络失败(无 body 投放) */
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;
if (st == 416) { /* 416:投一个 error body → cb 置 need_fresh 并 abort */
if (cb(ctx, chunk, 8) != 0) return NCM_ERR_FULL;
return NCM_ERR_HTTP;
}
for (int i = 0; i < 8; i++) /* 512KB 总分发(进度回调走 8 次) */
if (cb(ctx, chunk, sizeof chunk) != 0) return NCM_ERR_FULL;
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)
{ return ncm_http_download_status(req, cb, ctx, maxb, resp, NULL); }
/* ---- 解码器 stub(§19:player_manager 走 ncm_dec_* 分发层;§21:open 改 ncm_dec_open_src):
* g_eof 前恒有数据(P1 用 g_reads_left 控制播完点) ---- */
@@ -366,6 +419,7 @@ int main(void)
int cb = g_commits;
for (int t = 0; t < 3000 && g_commits == cb; t += 10) usleep(10 * 1000);
}
g_eof = 0; g_reads_left = -1; /* 上一步非 VIP 部分设了 g_eof=1 让 song0 收尾;SVIP song1 需正常播 */
g_vip = NCM_VIP_SVIP; g_cache_ok = 0; g_url_calls = 0; g_url_req_level = -1;
player_manager_play(1);
CHECK(wait_state(NCM_PC_PLAYING, 3000), "svip song playing");
@@ -406,6 +460,54 @@ int main(void)
g_fee = 0; g_eof = 1;
player_manager_set_quality(NCM_LEVEL_STANDARD, 10);
printf("== P9: .part 续传 -> Range 头 + 206 追加(阶段3 §5.2)==\n");
usleep(300 * 1000);
g_eof = 0; g_reads_left = -1; g_cache_ok = 0; g_url_rc = NCM_OK; g_fee = 0;
g_part_size = 200 * 1024; /* .part 残体 = 200KB(> 预缓冲门槛 128KB) */
g_http_status = 206; g_416_first = 0; g_dl_fail = 0; g_dl_delay_ms = 0;
g_resume_opens = 0; g_last_range[0] = 0; g_vip = NCM_VIP_NONE;
int cb0 = g_commits;
player_manager_play(0);
CHECK(wait_state(NCM_PC_PLAYING, 4000), "206 resume song playing");
for (int t = 0; t < 200 && !g_last_range[0]; t += 10) usleep(10 * 1000); /* 等 dl 线程写 Range */
CHECK(g_resume_opens >= 1, "resume opened (ncm_cache_open_resume=%lld)", (long long)g_resume_opens);
CHECK(strcmp(g_last_range, "204800-") == 0, "Range header=%s (expect 204800- = 200KB)", g_last_range);
g_eof = 1;
player_manager_stop(); /* 阻断顺延,等 commit 落盘 */
for (int t = 0; t < 3000 && g_commits == cb0; t += 10) usleep(10 * 1000);
CHECK(g_commits > cb0, "206 resume committed (commits %d->%d)", cb0, g_commits);
printf("== P10: 续传 416 -> 删 .part 整首重下(重试无 Range,阶段3 §5.2)==\n");
usleep(300 * 1000);
g_eof = 0; g_reads_left = -1; g_cache_ok = 0; g_url_rc = NCM_OK; g_fee = 0;
g_part_size = 64 * 1024; /* .part = 64KB(< 预缓冲门槛 → 触发 prebuffer 失败 → 重试) */
g_http_status = 200; g_416_first = 1; g_dl_fail = 0; g_dl_delay_ms = 0;
g_resume_opens = 0; g_downloads = 0; g_last_range[0] = 0; g_vip = NCM_VIP_NONE;
g_eof = 0; g_reads_left = -1;
player_manager_play(0);
CHECK(wait_state(NCM_PC_PLAYING, 5000), "416 -> fresh retry song playing");
CHECK(g_downloads == 2, "downloads=%d (expect 2: 续传 416 + 整首重下)", g_downloads);
CHECK(g_resume_opens == 1, "resume_opens=%lld (expect 1: 仅首试续传,重试整首)",
(long long)g_resume_opens);
CHECK(g_last_range[0] == 0, "retry no Range (残体已删,整首重下)");
g_eof = 1; player_manager_stop();
printf("== P11: 预缓冲前网络失败 -> 重试退避序列(阶段3 §4.1 风控)==\n");
usleep(300 * 1000);
g_eof = 0; g_reads_left = -1; g_cache_ok = 0; g_url_rc = NCM_OK; g_fee = 0;
g_part_size = 0; g_http_status = 200; g_416_first = 0; g_dl_fail = 1; g_dl_delay_ms = 0;
g_downloads = 0; g_vip = NCM_VIP_NONE;
int64_t t0 = now_ms();
player_manager_play(0);
/* 首试瞬时失败(NCM_ERR_NET,无 body)→ 退避 800ms → 重试 1 次 → 仍失败 → set_err 停播 */
CHECK(wait_downloads(2, 4000), "retry happened (downloads=2)");
int64_t dt = now_ms() - t0;
CHECK(dt >= 700,
"backoff sequence: 2nd download at +%lldms (expect >= 700ms 退避,非连环轰炸)", (long long)dt);
CHECK(wait_state(NCM_PC_STOPPED, 4000), "retry exhausted -> STOPPED");
CHECK(player_manager_error() != 0, "err set after retry exhausted (rc=%d)", player_manager_error());
g_dl_fail = 0;
usleep(200 * 1000);
player_manager_stop_thread();
printf(g_fail ? "== PLAYER FLOW SIM: FAIL ==\n" : "== PLAYER FLOW SIM: ALL PASS ==\n");
+122 -2
View File
@@ -116,6 +116,7 @@ typedef struct {
int jitter_ms; /* 每块延迟 ±jitter */
double cut_frac; /* >0: 写到该比例即断流(finish 带错误) */
int flaky; /* 断续网:每 ~6 块额外停 100-400ms */
uint64_t start; /* 起点偏移(续传:从 .part 大小续写;0=从头) */
stream_src_t *src;
ncm_cache_writer_t *w;
int rc; /* NCM_OK / 负 */
@@ -125,7 +126,7 @@ static void *net_thread(void *arg)
{
net_t *nt = (net_t *)arg;
const size_t CH = 4096;
size_t off = 0;
size_t off = (size_t)nt->start;
size_t cut_at = (nt->cut_frac > 0) ? (size_t)(nt->size * nt->cut_frac) : (size_t)-1;
unsigned rng = 12345;
int chunk_no = 0;
@@ -211,7 +212,7 @@ static int run_case(const char *tag, const unsigned char *data, size_t size,
printf(" FAIL stream open\n"); return -1;
}
net_t nt = { data, size, rate_Bps, jitter_ms, cut_frac, flaky, src, w, NCM_OK };
net_t nt = { data, size, rate_Bps, jitter_ms, cut_frac, flaky, 0, src, w, NCM_OK };
pthread_t nth;
double t0 = now_s();
if (pthread_create(&nth, NULL, net_thread, &nt) != 0) { printf(" FAIL pthread\n"); return -1; }
@@ -312,6 +313,119 @@ static int run_case(const char *tag, const unsigned char *data, size_t size,
return 0;
}
/* 续传用例(阶段3 §5.2):会话1 中断下载存 .part,会话2 从 .part 续传 → 完整 PCM 金标准比对。
* 不含 http/Range(那是 player_flow_sim 的职责)——本用例验证「.part 前缀已在盘上、续写不被重复、
* 读端从头跟播、commit 后完整等于源文件」的写穿缓存续传契约。 */
static int run_resume_case(const char *tag, const unsigned char *data, size_t size,
uint64_t rate_Bps, int jitter_ms, int flaky, double resume_frac,
const pcm_buf_t *golden)
{
case_result_t rr;
memset(&rr, 0, sizeof rr);
char dir[] = "/tmp/ncm_stream_resume_XXXXXX";
if (!mkdtemp(dir)) { printf(" FAIL mkdtemp\n"); return -1; }
char cdir[600];
snprintf(cdir, sizeof cdir, "%s/cache", dir);
ncm_cache_t cache;
ncm_cache_init(&cache, cdir);
char key[32];
static int rcase_no = 0;
snprintf(key, sizeof key, "rs%d", rcase_no++);
/* ---- 会话1:中断下载,写 resume_frac 比例 → abort_part 存 .part ---- */
ncm_cache_writer_t *w1 = NULL;
if (ncm_cache_open(&cache, key, "mp3", &w1) != NCM_OK) { printf(" FAIL s1 open\n"); return -1; }
stream_src_t *src1 = NULL;
if (stream_src_open_stream(&src1, ncm_cache_writer_tmp_path(w1)) != 0) { printf(" FAIL s1 stream\n"); return -1; }
net_t nt1 = { data, size, rate_Bps, jitter_ms, resume_frac, flaky, 0, src1, w1, NCM_OK };
pthread_t n1;
pthread_create(&n1, NULL, net_thread, &nt1);
pthread_join(n1, NULL);
stream_src_close(src1);
ncm_cache_abort_part(w1); /* .tmp → .part(保留前缀字节) */
uint64_t part_size = 0;
if (ncm_cache_part_size(&cache, key, "mp3", &part_size) != NCM_OK || part_size == 0) {
printf(" FAIL .part not parked\n"); return -1;
}
uint64_t exp_part = (uint64_t)((double)size * resume_frac);
CHECK(part_size == exp_part, ".part size=%llu (expect %llu 前缀)",
(unsigned long long)part_size, (unsigned long long)exp_part);
/* ---- 会话2:续传(.part 追加)+ 读端从头跟播 → 完整 PCM ---- */
ncm_cache_writer_t *w2 = NULL;
if (ncm_cache_open_resume(&cache, key, "mp3", &w2) != NCM_OK) { printf(" FAIL s2 resume open\n"); return -1; }
stream_src_t *src2 = NULL;
if (stream_src_open_stream_at(&src2, ncm_cache_writer_tmp_path(w2), part_size) != 0) {
printf(" FAIL s2 stream\n"); return -1;
}
net_t nt2 = { data, size, rate_Bps, jitter_ms, 0, flaky, part_size, src2, w2, NCM_OK };
pthread_t n2;
double t0 = now_s();
pthread_create(&n2, NULL, net_thread, &nt2);
/* 预缓冲(前缀 ≥128KB 则立即满足;不足则等续写下足) */
double wd_t0 = now_s();
int wr;
while ((wr = stream_src_wait(src2, 128 * 1024, 100)) == 0)
if (now_s() - wd_t0 > 120) { printf(" FAIL resume prebuffer watchdog\n"); g_fail = 1; _exit(1); }
ncm_decoder_t *dec = NULL;
int rate = 0, ch = 0, bits = 0;
if (ncm_dec_open_src(&dec, src2, &rate, &ch, &bits, NULL) != 0) {
printf(" FAIL resume dec open\n"); return -1;
}
if (audio_out_open(rate, ch, bits) != 0) { printf(" FAIL audio open\n"); g_fail = 1; _exit(1); }
static unsigned char pcm[4096 * 2 * 4];
int64_t decoded = 0;
for (;;) {
if (now_s() - t0 > 150) { printf(" FAIL resume watchdog\n"); g_fail = 1; _exit(1); }
int64_t fr = ncm_dec_read(dec, pcm, 4096);
if (fr == 0) break;
if (rr.first_sound_s < 0) rr.first_sound_s = now_s() - t0;
if (audio_out_write(pcm, (int)fr) != (int)fr) { printf(" FAIL audio write\n"); g_fail = 1; _exit(1); }
pcm_put(&rr.pcm, pcm, (size_t)fr * (size_t)ch * (size_t)bits / 8);
decoded += fr;
if (rate > 0) usleep((useconds_t)(fr * 1000000 / rate));
}
rr.frames = decoded;
rr.wall_s = now_s() - t0;
stream_src_stats(src2, &rr.underruns, &rr.wait_ms);
audio_out_close();
ncm_dec_close(dec);
pthread_join(n2, NULL);
stream_src_abort(src2); /* 已完成则无副作用 */
/* 续传玩法:读端从头跟播,产出 PCM 应与整首离线解码逐字节一致(金标准) */
CHECK(rr.pcm.n == golden->n && memcmp(rr.pcm.b, golden->b, golden->n) == 0,
"续传 PCM 与整首离线解码逐字节一致 (%zu/%zu)", rr.pcm.n, golden->n);
free(rr.pcm.b);
/* commit 转正 + 校验最终文件与源逐字节一致 */
int ck = ncm_cache_commit(w2);
CHECK(ck == 0, "resume commit rc=%d", ck);
if (ck == 0) {
char fp[700];
if (ncm_cache_path(&cache, key, "mp3", fp, sizeof fp) == NCM_OK) {
FILE *f = fopen(fp, "rb");
if (!f) { printf(" FAIL committed missing\n"); g_fail = 1; }
else {
unsigned char *got = (unsigned char *)malloc(size);
size_t rn = fread(got, 1, size, f);
int extra = fgetc(f);
fclose(f);
CHECK(rn == size && extra == EOF && memcmp(got, data, size) == 0,
"committed == source (%zu/%zu bytes)", rn, size);
free(got);
}
}
}
stream_src_close(src2);
printf(" [%s] resume .part=%llu wall=%.2fs first_sound=%.2fs frames=%lld underruns=%d\n",
tag, (unsigned long long)part_size, rr.wall_s, rr.first_sound_s,
(long long)rr.frames, rr.underruns);
return 0;
}
int main(int argc, char **argv)
{
(void)argc;
@@ -447,6 +561,12 @@ int main(int argc, char **argv)
free(fg.b);
}
/* 阶段3:.part 续传——中断 40% → 存 .part → 续传(略过已下前缀)→ 完整 PCM 金标准比对 */
printf("== CASE resume@10MB/s (40%% cut) ==\n");
run_resume_case("resume@10MB/s", data, size, 10485760u, 0, 0, 0.4, &golden);
printf("== CASE resume@400kbps (40%% cut, 慢网续传) ==\n");
run_resume_case("resume@400kbps", data, size, 50000u, 10, 1, 0.4, &golden);
free(golden.b); free(golden_cut.b); free(data);
printf(g_fail ? "== STREAM SIM: FAIL ==\n" : "== STREAM SIM: ALL PASS ==\n");
return g_fail;