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

用户反馈:放 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”,而是针对已确认的数据特征做一个很窄的兼容处理。

这次终于用真实开发版跑通了

修完以后,我没有只跑测试,也没有启动已经安装的旧版本,而是重新启动了真实开发版。
然后按正常操作复现:

  1. 打开收藏歌曲;
  2. 点击《今天你要嫁给我(KTV版伴奏)》;
  3. 打开播放路径;
  4. 等待至少十秒;
  5. 再点击其他界面。
    最终播放路径变成了:
开始播放
开始解析播放链接
尝试音源解析 · 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 地址就是坏的,然后在解析阶段避开它。
    这类问题看起来只是”一首歌不能播放”,但真要查起来,缓存、音源、协议、播放器、歌词解析,每一层都可能有一点问题。也正因为如此,日志、真实请求和真实点击测试缺一不可。