一首歌把我折腾了一整天:记一次 Electron 播放卡死问题的完整排查
今天修了一个很邪门的 bug。
收藏列表里有一首歌,叫《今天你要嫁给我(KTV版伴奏)》。点击以后,歌曲有时候能在后台播放,但软件界面直接卡死,鼠标点什么都没反应。
这个 bug 最开始是用户反馈给我的。当时他发来一条消息,说放 QQ 的这首歌会未响应:

我一开始没太当回事,觉得可能是某个音源又抽风了。直到自己点开这首歌试了一下——界面真的就卡死了,播放条还停在这首歌上:

更奇怪的是:
- 下载这首歌没问题;
- 本地音乐播放没问题;
- 播放其他在线歌曲没问题;
- 只有在线播放这首歌会出问题;
- 有时候界面卡死以后,声音还在继续播放。
一开始我以为只是某个音源返回了坏链接,后来才发现,这其实是两个问题叠在一起。
一个问题负责把 Electron 主进程卡死,另一个问题负责让这首歌的在线播放失败。
一开始以为是缓存的问题
这首歌和其他收藏歌曲不太一样。它没有专辑,也没有封面,保存下来的数据大概是这样:
{
"id": "tx:001ATbG82mXBqr",
"platform": "tx",
"title": "今天你要嫁给我 (KTV版伴奏)",
"artist": "蔡依林、陶喆",
"album": "",
"qualities": ["128k", "320k"],
"identity": {
"id": "001ATbG82mXBqr",
"songId": "125271795",
"strMediaMid": "000cqf9D0EjUYN"
}
}它的元数据明显比正常搜索出来的歌曲少很多,所以我最开始怀疑:
- 旧收藏数据格式不完整;
- 没有专辑和封面导致缓存 key 异常;
- 物理缓存里可能残留了旧文件;
- Electron 自定义协议返回音频时出了问题。
于是我先处理了缓存路径: - 没有专辑和封面的 QQ 歌曲不再读取旧物理缓存;
- 新解析出来的 URL 优先于旧缓存文件;
- 音频代理改成 Node 的
fetch; - 给代理响应补上音频需要的 CORS 响应头;
- 这类歌曲直接使用远程 URL,不再强行套物理缓存。
这些修改并不是完全没用,至少把缓存干扰排除了。但实际点击播放以后,还是会失败。
这时候播放路径里显示的是:
已获取真实播放链接 · 320k
正在加载音频
音频地址加载失败
播放器错误码 4
最终播放失败窗口虽然没有一开始那么容易直接卡死,但歌曲依然播不出来。
真正导致”卡死”的,是歌词
后来我启动真实开发版,反复点这首歌,同时看主进程状态。
现象很固定:
- 点击歌曲后,主进程 CPU 突然升高;
- 窗口开始无法点击;
- 音频却还在继续;
- 过一段时间以后,界面才恢复,或者直接自动切到下一首。
这说明播放器线程没有完全停掉,但 Electron 主进程的事件循环被某段同步代码占住了。
我把调用链顺了一遍:
PlatformService.lyricTencent
↓
decodeTencentLyric
↓
smart-lyric 的 qrc.parse然后把这首歌返回的 QQ 歌词原文打印出来,看到了一行非常奇怪的内容:
[103025,3998]Our (103025,270)love in这是 QQ 的 QRC 歌词格式。正常情况下,一行歌词大概是:
[开始时间,持续时间](词开始时间,词持续时间)歌词但这行内容在最后一个时间标记后面,还跟着一段普通文本。smart-lyric 里原来的解析逻辑在处理这种尾部文本时,循环指针没有继续向前移动。抽象出来大概是这样:
while (rem.length) {
const match = marker.exec(rem)
if (!match) {
// 这里没有正确消费 rem
continue
}
rem = rem.slice(...)
}只要遇到最后剩下的普通文本,rem 就一直不变,循环永远结束不了。
歌词解析是在 Electron 主进程里执行的,所以结果就是:
QRC 异常内容
↓
解析器死循环
↓
主进程事件循环被占满
↓
窗口无法响应点击
↓
但音频线程仍可能继续播放这也解释了为什么会出现”软件卡死,但后台还有声音”。
我没有继续硬修第三方解析器
确认问题以后,我没有直接去改 node_modules 里的 smart-lyric。
原因很简单:
- 依赖升级以后修改会丢;
- 这只是 QQ 歌词的一种特殊格式;
- 我们真正需要的只是把 QQ 返回的歌词转成应用内部格式;
- 没必要让整个项目依赖一个可能再次卡死的解析器。
所以我在PlatformService.ts里加了一个有限状态的 QRC 解析器。
核心思路只有一个:每次循环都必须推进游标。
while ((markerMatch = marker.exec(rest)) !== null) {
const textPart = rest.slice(cursor, markerMatch.index)
if (textPart) {
words.push({
text: textPart,
start: Number(markerMatch[1]),
duration: Number(markerMatch[2]),
})
}
cursor = markerMatch.index + markerMatch[0].length
}
const tail = rest.slice(cursor)
if (tail) {
const previous = words.at(-1)
const inferredStart = previous
? previous.start + Math.max(previous.duration, 0)
: lineStart
words.push({
text: tail,
start: inferredStart,
duration: Math.max(0, lineEnd - inferredStart),
})
}这里最关键的不是时间怎么推算,而是:
cursor = markerMatch.index + markerMatch[0].length无论遇到什么内容,解析都必须向前走,不能让同一段字符串被重复处理。
改完以后,我又用那条异常歌词单独跑了一遍,解析可以正常结束,主进程也不再出现 CPU 飙高的情况。
但这还没有解决在线播放失败。
在线播放失败,是另一件事
歌词死循环修好以后,软件不再彻底卡死了,但这首歌仍然会播不出来。
这次我直接绕过播放器,分别请求不同音质的真实音频地址。
结果如下:
songId=125271795, quality=128k
→ HTTP 206
→ 可以正常读取 MP3 数据
songId=125271795, quality=320k
→ HTTP 403
→ QQ 返回 error -1011
songId=125271795, quality=atmos
→ HTTP 206
→ 可以正常读取音频数据问题就很清楚了。
应用设置里的默认音质是:
preferredQuality: atmos但是这首收藏歌曲自己只声明了:
qualities: ["128k", "320k"]当前的音质选择逻辑会从歌曲声明的质量里往下找,最后选中:
320k然后音源给了一个看起来像真的、实际上返回 403 的地址。
播放器接收到这个地址以后,Chromium 报:
MediaError.code === 4也就是媒体资源无法解码或不支持。
之前我尝试在播放器层做失败降档:
320k 播放失败
↓
标记 320k 失败
↓
重新解析 128k但实际测试时,播放路径里没有稳定出现”降低音质重试”。音频的 error 事件、加载请求和重试状态之间存在竞态,有时候错误事件已经被旧的播放尝试消费掉了。
这类兜底逻辑可以保留,但不能把它当成唯一保障。
最终把修复放到了解析层
既然已经确定:
- 这类 QQ 旧收藏没有专辑和封面;
- 它声明了 128k 和 320k;
- 320k 对这首歌会返回坏链接;
- 128k 可以正常播放;
那就没必要每次都先请求一个确定会失败的 320k。
我在MusicService.ts里加了一个很小的规则:
function effectivePreferredQuality(track: Track, preferred: Quality): Quality {
if (
track.platform === 'tx' &&
!track.album?.trim() &&
!track.cover?.trim() &&
track.qualities.includes('128k') &&
preferred !== '128k'
) {
return '128k'
}
return preferred
}解析请求进入主流程时,先计算一次真正使用的音质:
const preferredQuality = effectivePreferredQuality(
request.track,
request.preferredQuality,
)
request = {
...request,
preferredQuality,
track: await this.prepareTrack(
request.track,
preferredQuality,
),
}这样做有几个好处:
- 不修改用户全局的默认音质;
- 不影响正常歌曲;
- 不需要播放器先撞一次坏链接;
- 下载、试听和在线播放都走同一个解析逻辑;
- 这首旧收藏歌曲会直接请求 128k。
这不是”把所有歌曲都降成 128k”,而是针对已确认的数据特征做一个很窄的兼容处理。
这次终于用真实开发版跑通了
修完以后,我没有只跑测试,也没有启动已经安装的旧版本,而是重新启动了真实开发版。
然后按正常操作复现:
- 打开收藏歌曲;
- 点击《今天你要嫁给我(KTV版伴奏)》;
- 打开播放路径;
- 等待至少十秒;
- 再点击其他界面。
最终播放路径变成了:
开始播放
开始解析播放链接
尝试音源解析 · 128k
已获取真实播放链接 · 128k
播放发生缓冲
正在加载音频
音频数据已就绪
播放成功实际进度也在持续增加:
0:00 → 0:14 → 0:46 → 1:54等待期间:
- 主进程 CPU 大约 1% 左右;
- Renderer 没有持续满载;
- 播放路径窗口可以正常打开和关闭;
- 收藏页仍然可以点击;
- 没有自动跳歌;
- 没有再次出现主进程卡死。
最后又重新跑了一遍项目检查:
npm run typecheck
npm test
npm run build
git diff --check结果:
40 个测试文件通过
210 个测试通过
构建成功
代码格式检查通过这次问题最后可以拆成两条线
回头看,这个 bug 之所以折腾这么久,是因为两个问题的表现混在了一起。
第一条线是卡死:
QQ 异常 QRC 歌词
↓
第三方解析器死循环
↓
Electron 主进程被占满
↓
窗口无法点击第二条线是在线播放失败:
旧收藏缺少元数据
↓
默认音质最终落到 320k
↓
音源返回失效地址
↓
Chromium 报媒体错误码 4
↓
播放器刷新或自动切歌没有专辑和封面不是卡死的直接原因,但它是这条特殊旧收藏路径的重要特征。
下载正常、本地播放正常,也说明音频文件本身没有问题。真正有问题的是在线播放时的两个环节:
- 歌词解析阻塞了主进程;
- 音质选择拿到了不可播放的远程地址。
最后的一点教训
这次我最大的教训是:遇到”软件卡死”,不能只盯着播放器。
我之前一直在看:
- 音频 URL;
- 缓存协议;
- CORS;
- 音质选择;
- 播放器重试。
这些方向确实都和问题有关,但它们解释不了”为什么窗口完全点不动”。
直到我看到主进程 CPU 异常,再把歌词解析链路单独拎出来,才找到真正的死循环。
另外,播放失败的兜底最好尽量靠近解析层。播放器的error事件本身就有竞态,等它报错以后再补救,往往已经晚了一步。
这首歌最后不是靠”再重试几次”播出来的,而是先承认它的 320k 地址就是坏的,然后在解析阶段避开它。
这类问题看起来只是”一首歌不能播放”,但真要查起来,缓存、音源、协议、播放器、歌词解析,每一层都可能有一点问题。也正因为如此,日志、真实请求和真实点击测试缺一不可。
评论