644 字
3 分钟
同一集下载两次,得到一份损坏的音频

流声里有个 bug:开着「自动下载最新一集」,某天又手动点了同一集的下载,结果那集音频是坏的,点取消也没反应。

这个 bug 值得写,它是三个小问题叠在一起,漏掉哪个都修不干净。

两条路径撞在一起#

开了「自动下载最新一集」的节目,刷新缓存时会自动把最新一集存到本机。这时候我正好手点同一集,两条下载路径就同时起来了。

两条路径各自先判断一下「这集是不是在下载」。问题是这个判断和真正的下载之间,隔了两个 await——一个查 WiFi 设置,一个读存储。await 会让出事件循环,另一条路径就在这个空隙里挤进来。

于是两边都看到「没在下载」,都往下走。

两个字节流写同一个文件#

下载用 Dio,先写到一个临时文件 .part,下完再改名。这个临时文件的名字只由 guid 决定,所以两条路径打开的是同一个文件。

两个 Dio 各自按自己的节奏收数据、往里写,字节流交错落地。下出来的文件是坏的。

取消为什么没反应#

cancel token 原本按 guid 存一个。第二条路径启动时,把先启动那次的 token 顶掉了。

顶掉之后,先启动的那次再也取消不掉——点取消,取消的是后启动的那个。用户看到的就是「按了没反应」。

三处修法#

临时文件加唯一后缀。 时间戳加序号,两次并发下载各写各的,不再撞车。

cancel token 按 guid 存集合。 一个 guid 可能挂着好几个 token,取消时全停掉。清理时只摘掉自己那个,不能整条 remove,否则又把并发的那条抹了。

判重和占位不能跨 await。 这才是根子:

// 判重和占位之间不能跨 await
if (_inflightFeedByGuid.containsKey(episode.guid)) return;
_inflightFeedByGuid[episode.guid] = feed.id; // 先占位
final wifiOnly = await ...; // 再 await

把「占住这一集」前移到第一个 await 之前,另一条路径进来时看到的就已经是「在下载」。

占位还有个细节:如果 WiFi 检查不通过要还回去,不然这一集永远下不了。

之后#

现在同一集的并发下载各写各的临时文件,取消时一起停。损坏音频和「取消没反应」都没再出现。

这种事以后大概还会犯。教训就一条:判重和占位之间,别跨 await。

同一集下载两次,得到一份损坏的音频
https://mssworld.me/posts/concurrent-download-corruption/
作者
m.s.s
发布于
2026-11-11
许可协议
CC BY-NC-SA 4.0