Commit Graph
3 Commits
Author SHA1 Message Date
snowy-yang 69a50c2c31 refactor: worker 子系统全面异步化,拆掉同步转异步的桥接层
拆掉「同步调异步 / 异步桥同步」两类垫片的第二半(HTTP 线 B2 见前序提交)。
worker 侧此前是「起线程跑同步函数、函数体内部再用 asyncio.to_thread 把活塞给
另一条循环」——线程与协程互等,取消/暂停只能靠轮询。本轮把 worker 自身变成
协程,由 _start_task 直接挂到应用事件循环上,桥接层随之失去存在理由。

改动(按子系统):
- music_downloader:downloader / _aria2 / _concurrency / cover / _http /
  __main__ 全链路协程化。per-file 锁改 asyncio.Lock;计数闸改
  「active 计数 + await asyncio.sleep(0.5)」的可取消等待(原阻塞式 with 会让
  已取消任务仍要等全局名额腾出,卡死并拖后腿退款);aria2p 的同步 RPC 仍经
  to_thread,轮询改 asyncio.sleep;新增 core.http_client.iter_chunks,把**流式
  读取期**的 aiohttp.ClientError 收敛成 UpstreamNetworkError(请求建立阶段早已
  有这条路径,读取阶段此前没有,断流会绕过调用方的 UpstreamError 分类)。
  删除 get_http_session / _SESSION_LOCAL。
  models._RetryFromScratch 不再继承 requests.RequestException 而继承 Exception:
  它是「续传起点失效、残片已清、从头重下」,既不是确定性失败
  (DownloadException),也不是残片已保留的网络失败(UpstreamError),必须独立
  成类且在 except 链中先于二者捕获。
- music_api:podcast.py 全部函数协程化(直链解析内部改
  await NeteaseAPI.get_song_url);compat.py 删除——16 个死掉的同步门面早已
  清空,文件只剩空壳。
- services:_taskcore / _zipcore / batch / downloads / podcast / tasks 协程化。
  threading.Thread → loop.create_task(任务句柄字段 worker_thread →
  worker_task),ThreadPoolExecutor → asyncio.gather + asyncio.Semaphore,
  终态函数与结算回调全部改 await;监控线程里「worker 已死」判定改 task.done()。
  删除 services/streaming.py(同步流垫片,唯一消费者已迁到
  core.http_client.open)。zipfile 实例仍非线程安全,故「下载」并行、
  「入包」严格串行。
- web:路由层 130+ 处 to_thread 拆除——被包住的目标自己已是协程,再包一层只是
  把协程丢进线程里跑一圈。tasks_registry.start_task 直接 create_task。
  web/app.py 关闭段去掉 async_bridge.shutdown()(不再有第二条约循环)。
  web/routers/download.py 的播客端点补上 3 处漏掉的 await
  (extract_podcast_ref / get_radio_detail)——漏掉时不报错、只静默返回协程对象,
  靠 pyright 抓出来后修正。

留在 to_thread 上的都是**正当的阻塞卸载**:同步 SQLite 落库与账本结算、
mutagen 全量重写标签、zipfile 整段拷贝、shutil.rmtree、aria2p 的同步 RPC、
PBKDF2 的 10 万次迭代。ruff 对残留的本地元数据操作(exists/stat/os.replace/
往 .part 顺序写分块)按「微秒级、卸载的调度开销大于操作本身」放行,pyproject
逐条注明理由。

取消/暂停仍由 threading.Event 承载:置位方是请求线程/监控线程,读取方是 worker
协程,只有「置位」与「轮询读取」两种操作,既不需要跨循环唤醒也不会卡住事件
循环——比 asyncio.Event 更省心(ASYNC110 同理按此豁免)。

测试:10 个文件的替身边界同步迁移——网络替身从 core.sync_http 的
get_http_session 换成 downloader/服务模块的 _http.open + **真实** iter_chunks
(顺带钉住读取期异常收敛);`_run` 改为在独立线程里 asyncio.run(worker(...));
aria2 的假时钟从 time.sleep 换成 _POLL_INTERVAL=0;monitor 的线程句柄替身换成
done() 可控的轻量替身(asyncio.Task 的构造需要运行中的事件循环,而监控循环跑在
常驻线程里);podcast / 封面 / 结算包装等替身一律改 async。

门禁:ruff 全过 / pyright 0-0 / pytest 383 项全绿(0 失败)。

下一步 B2:auth_system 12 个模块改 aiosqlite 背板、web 层剩余 to_thread 收尾;
B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py(现仅
sync_http 自用)。
2026-09-28 19:36:56 +08:00
snowy-yang 9910e4dbec refactor: _fetch_via_http 拆解(94 行 / 圈 21 → 46 行 / 圈 5)
为什么:_fetch_via_http 是全仓内置下载通道的落盘实现,也是本文件此前最大的
圈复杂度所在。94 行的单体里叠着三段互不相关的职责——Range 起点校验、流式写盘
与体积上限、HTTP 错误分类——还共享一个 try/except/finally。上一轮拆
download_music_file 时它是唯一没动的重函数。

改法:拆成 7 个具名成员,主函数只留四步编排(构造 Range 头 → 发请求 →
定追加/重写并校验分片起点 → 流式落盘)。
  _range_headers                  续传时请求「起点之后的全部字节」
  _resolve_write_mode             续传且服务器确实给 206 才追加,否则从头重写
  _verify_content_range           校验 206 分片起点;无法确认就清残片从头下
  _stream_body                    逐块写盘 + 取消检查 + 体积硬上限(500MB)
  _safe_progress                  上报进度,回调异常吞掉(原两处 try/except 收敛)
  _http_status                    从 HTTPError 取状态码
  _is_deterministic_client_error  4xx 判定(408 例外,唯一保留可重试的 4xx)

复杂度(同口径 AST 实测,HEAD 与工作区用同一脚本):
  _fetch_via_http     94 行 / 圈 21 / 嵌套 7  →  46 行 / 圈 5 / 嵌套 3
  文件内最大圈复杂度    21(当时就是它)        →  16(余下最大是 download_music_file)
  文件行数 710 → 753(+43,全是新签名与 docstring)· 函数数 19 → 26
  口径说明:上一轮口头报的「圈 35」用的是另一脚本(另计入布尔运算与比较),
  本轮 HEAD 同口径实测为 21。以本轮为准——HEAD 与重构后由同一脚本测出,可比。

等价性证据(两道,互相独立):
1. 反向注入验证:把 HEAD 版 downloader 注入成 music_downloader.downloader,用同一套
   tests/test_downloader.py 跑 → 30 例**全过**。证明这批测试忠实编码了旧行为,
   因而对新实现构成等价性验证(含本次新增的 5xx 用例,它在旧实现上同样通过)。
2. AST 字面量多重集比对 + 逐条位置定位:数值/布尔 26 种、字符串 80 种,
   差异全部归因,且**没有一条用户可见文案或阈值被改动**:
     206  2→1:旧的两段独立 if(`!= 206` 归零 mode、`== 206` 校验起点)合并进
               _resolve_write_mode 的一个判断
     0   26→24:两处 progress 的 `or 0` 收敛成 expected_size(-1);
               两处 206 判断各含一个 `> 0` 合并成一个 `<= 0`(-1)
     'wb' 2→1:初始赋值与条件改写两处合并成一个 return
   500MB 阈值、各条错误文案原样保留。

重写条件表达式必须补分支穷举(字面量审计不管这一层),已逐路核对:
  resume_from <= 0                     → 'wb'(旧:不进任何 if,保持初始 'wb')
  resume_from > 0 且 status != 206     → 'wb'(旧:第一个 if 命中)
  resume_from > 0 且 status == 206     → 校验起点 + 'ab'(旧:第二个 if 命中)
三路与旧实现一致。

刻意保留的语义(是决定,不是疏漏):
- 超限清理仍**延后到 with 退出后**——Windows 无法删除仍被打开的文件,原实现在
  打开中 unlink 被静默吞掉,残片会留在磁盘上。
- 进度回调异常一律吞掉,绝不影响下载主流程。
- 4xx 里 408 是唯一例外(保留可重试);416 与「206 头被剥离解析不出起点」同口径
  清残片从头下(盲追加拼出的是坏文件)。
- 非 416/4xx 的错误走**裸 raise**(不用 `raise e`),保持原 traceback 不被改动。

测试:新增 1 例 test_5xx_is_retried_and_keeps_part —— 5xx 是服务端瞬时故障而非
确定性失败,须可重试且保留 .part 供续传。这条路径此前无覆盖,且它正是
_is_deterministic_client_error 与 4xx 的分界所在。

顺带记录一个验证工具自身的坑(第一次跑反向注入时 30 例全红,看似「测试不忠实」,
实为注入没生效、测试静默走了真实网络,报错为 Cannot connect to host cdn.test):
只覆盖 sys.modules['music_downloader.downloader'] 不够——pytest 的 monkeypatch
解析目标字符串走 _pytest.monkeypatch.resolve(),是「逐级 getattr 父包」而非纯
sys.modules 查找,包的 downloader 属性仍指向真模块。必须同时 setattr 父包属性。
教训:等价性验证的**工具本身**也要能证伪,否则结论会 180 度反转。

验证:ruff 全过 · pyright 0/0 · pytest 275 passed(274→275)· 行尾 i/lf w/lf
与仓库一致 · 生产 data/ 未被触碰(全部用例走 tmp_path 沙箱)。
2026-09-27 08:05:44 +08:00
snowy-yang 8716617d5d refactor: download_music_file 拆解(205 行 / 圈 47 → 125 行 / 圈 20)
为什么:download_music_file 是**唯一**的音频落盘路径(单曲 worker、音乐批量 ZIP
都经它),205 行 / 圈复杂度 47 / 嵌套 7 的单体里叠着缓存校验、断点续传、内容嗅探、
体积上限、取消语义、重试退避、元数据限流七件事,却**零直接测试覆盖**
(test_zip_workers 里只有一个 stub)。任何一处被改坏都不会有测试报警。

改法:拆成 8 个具名方法,主函数只留「5 步管线 + 重试策略」:
  _cached_result          锁内复查缓存(命中即返回,坏缓存删除重下)
  _promote_complete_part  已下完未改名的 .part 直接转正,不发起网络请求
  _resume_offset          断点续传起点,并清理没有续传价值的残片
  _transfer               占全局名额 + 选通道(aria2c / 内置 aiohttp)
  _size_mismatch_error    完整性判定(已知期望精确相等 / 未知期望至少非空)
  _publish                嗅探 → 原子转正 → 写元数据
  _write_metadata_locked  标签信号量内的元数据写入(原三处重复的 with 收敛)
  _backoff_wait           退避秒数(原五处重复的 0.5*(attempt+1) 收敛)

复杂度(同口径 AST 实测):
  download_music_file  205 行 / 圈 47 / 嵌套 7  →  125 行 / 圈 20 / 嵌套 4
                       (125 行里 26 行是 docstring,正文 87 行)
  文件内嵌套 >=5 层     2 → 1(余下的是本次未动的 _fetch_via_http)
  文件内最大圈复杂度     47 → 35(同样落在未动的 _fetch_via_http 上)
  文件行数             614 → 710(+96,全是新签名与 docstring)

等价性证据(两道,互相独立):
1. 29 例行为契约测试**在未改动的原始实现上一次性全绿**(先写测试、后动代码;改完
   再跑仍全绿)——这批测试因此忠实编码了旧行为,构成等价性验证,而不是「照着新
   实现写的新行为测试」。断言只依赖返回值 / 磁盘上的文件 / 发往网络的请求,不引用
   任何内部函数名,故可跨后续重构复用。
2. AST 字面量多重集比对 + **逐条位置定位**:数值/布尔 27 种、字符串 80 种。差异
   全部归因,且**没有一条用户可见文案或阈值被改动**——
     0   +2:_resume_offset 的三个 return 0 取代原 resume_from = 0
     0.0 +1、0.5 −3、1 −3:四处重复的 0.5*(attempt+1) 收敛进 _backoff_wait
     None +16:新增方法的 6 处类型标注 + 4 处 return None + 2 处 is not None
     ''  +1:_size_mismatch_error 的内部哨兵(非用户可见)
   续传起点逻辑另做逐例核对:promote 命中 / existing<=0 / 超尺寸 / 部分 四路等价。

刻意保留的语义(是决定,不是疏漏):
- 退避睡眠仍在文件锁外执行——同名并发任务不陪睡。
- 缓存命中时若 write_meta 仍按本次请求重跑一次标签写入(幂等);write_meta=False
  则不写。
- 已转正(os.replace 之后)才发现取消时**不回滚文件**,只跳过元数据——原行为如此,
  已用 test_cancel_after_promote_keeps_file_but_skips_metadata 固化。
- 未知期望尺寸(file_size=0)走「非空即通过」+ 内容嗅探兜底;已知期望尺寸要求
  **精确相等**(放宽到容差会交付截断的坏文件还照扣次数)。
- 缓存有效性用 st_size != file_size(而非 >=):跨账号音质降级 / 上游变更都会让
  同 ID 同音质的实际文件不同,命中即返回会把旧文件当本次结果交付。

测试:新增 tests/test_downloader.py(29 例)——正常落盘与 .part 转正、空分块跳过、
进度回调(起点 / 每块 / 异常吞掉)、缓存命中不发请求、坏缓存重下、0 字节缓存拒绝、
续传发 Range 并追加、服务器忽略 Range 时重写、完整残片免网络转正、超尺寸残片丢弃、
206 起点错位 / 206 缺 Content-Range / 416 三路从头重下、CDN 4xx 不重试、408 可重试、
不完整保留 .part、下轮续传、未知尺寸非音频拒绝、体积上限、五种取消路径、元数据开关。

验证:ruff 全过 · pyright 0/0 · pytest 274 passed(245→274)· 生产 data/ 未被触碰
(全部用例走 tmp_path 沙箱)。
2026-09-27 07:56:01 +08:00