DLNA 渲染端排障实战:从 20s 卡顿到 stale subscriber 的两周追凶之旅
平台:Rockchip RK3568 Android 11 盒子 + Platinum UPnP + PAD 端 QQ Music HD
时间线:~20 个 hypothesis 迭代,~10 次 pcap 抓取,~15 个 logcat 分析
最终落地:14 个保留的修复,2 个回退,1 个证伪后转给上游
一、项目背景
我们维护一个 Android DLNA 盒子(基于 Platinum/Neptune UPnP 库 + Android MediaPlayer + Rockchip rockit 媒体框架)。PAD 端用户用 QQ Music HD 投歌过来播放。本来基础功能都跑得通,但用户反复反馈一系列让人抓狂的 bug:
- "切到第 4 首歌就卡 20 秒"
- "切歌后 PAD 显示暂停但盒子还在响"
- "断开重投之后盒子直接没声音"
- "拖动进度条之后 PAD 进度条就冻了"
- "突然闪退"
- "突然 SIGSEGV 崩溃"
下面把整个排障过程整理成一份可复用的经验。
二、所有修改一览(按时间顺序)
| Hyp | 改动概述 | 状态 | 文件 |
|---|---|---|---|
| H1~H21 | 状态机重构(playbackIntent / prepareSeq / pendingSeek / dedup) | 保留 | PlayerActivity.java |
| H170 | OnSetAVTransportURI 推 metadata 5 个状态变量 | 保留 | PltMediaRendererDelegate.cpp |
| H172~H175 | LastChange 节流速率反复试,最终 = 0 | 保留 | PltMediaRenderer.cpp |
| H176-X | 去掉 onStop 入口的同步 STOPPED push | 保留 | PlayerActivity.java |
| H176-Y | audioOnPrepared 后 1s/3s 重推 PLAYING | 保留 | PlayerActivity.java |
| SUBSCRIBE Timeout=60s + 无锁 m_Subscribers.Clear() | 回退(SIGSEGV) | tff_dlna.cpp / PltService.cpp | |
| H178 | startAudioPlayback 兜底 IllegalStateException | 保留 | PlayerActivity.java |
| H179 | onPause prepared 后 3 秒内忽略 | 保留 | PlayerActivity.java |
| H180 | SUBSCRIBE Timeout 用 PLT 默认 1800s | 保留 | tff_dlna.cpp |
| H181 | 移除 H177 的 Clear() 修 SIGSEGV | 保留 | PltService.cpp |
| H182 | 显式 setAudioAttributes(USAGE_MEDIA) | 保留 | PlayerActivity.java |
| H183 | 检测空 SetURL(断开信号)推 STOPPED | 保留 | DlnaServiceTask.java |
| H184 | m_Lock 保护下清同 callback URL 老订阅 | 保留(关键) | PltService.cpp |
| H185 A/B/C | seek 异步 race 完整修复(守卫 + safety timeout) | 保留 | PlayerActivity.java |
| TRANSITIONING 翻转(证伪是 QQ HD bug,没必要做) | 未实施 | --- |
最终代码:14 个修复落地,1 个移除 H177 的 SIGSEGV 修复(H181),1 个 H186 没做(证明是 QQ HD bug)。
三、几个值得讲的关键问题
3.1 QQ HD 的"20 秒 penalty box"之谜
症状 :用户快速切歌时,前 3 次都顺畅,第 4 次开始 Stop 跟 SetURL 之间会整整等 20 秒 。一旦触发,当前会话所有后续切歌都 20s,直到 QQ HD App 重启才恢复。
初始假设:我们的渲染端响应慢,QQ HD 在等超时。
调查过程:
- 抓我们的 pcap + 抓竞品(Neptune-based)的 pcap 对比
- 发现竞品能扛 6 次快速切歌,完全没有 20s 间隔
- SOAP 响应时间对比,我们和竞品都在 100ms 以内 --- 不是 SOAP 慢
真相 :QQ Music HD 客户端有一个会话级 "慢渲染端"标记 ,一旦触发就一直保留到 App 重启。触发条件不是单次响应慢,而是累积观察到状态机不稳定:
- 多个 SID 同时活跃返回不同 NOTIFY
- 某些 NOTIFY 返回 HTTP 412
- 状态变化序列不"干净"
scss
PAD 内部状态(推测):
session.renderer["box-uuid"] = {
isSlow: false,
cooldownMs: 0,
}
if (观察到状态机异常) {
renderer.isSlow = true
renderer.cooldownMs = 20000 // 固定 20s
}
// 后续每条命令
function sendCommand(cmd) {
if (renderer.isSlow) {
wait(renderer.cooldownMs) // 卡 20s
}
actuallySend(cmd)
}
清除标记的方法:
- ✅ QQ Music HD 强制停止重开
- ✅ PAD 重启
- ❌ DLNA 断开重投不行(这只是 UPnP 协议层重连,App 进程状态没动)
最终解决:H184 把"状态机不干净"的触发条件根除(见 3.3),penalty box 自然不再触发。
3.2 教训最深的 SIGSEGV:H177→H181 翻车记
起因 :用户反馈"切几首后盒子完全无声、PAD 进度卡老位置"。我猜测是 stale subscriber 累积 导致 NOTIFY 错乱。
H177 的天真实现:
scss
// PltService::ProcessNewSubscription
// 新 SUBSCRIBE 进来时,把所有老订阅清掉
if (m_Subscribers.GetItemCount() > 0) {
m_Subscribers.Clear(); // ← 没拿锁直接清
}
几分钟后用户反馈崩溃:
less
#00 NPT_List<NPT_Reference<PLT_EventSubscriber>>::Detach()
#01 NPT_List::Erase()
#02 PLT_Service::NotifyChanged() ← 这个在另一个线程遍历 m_Subscribers
#03 PLT_Service::SetStateVariables()
#04 TFF_DlnaNotifyStatus
#05 DlnaPlayerJni::notifyPlayStatus
根因 :PLT_Service::NotifyChanged() 持有 m_Lock 遍历订阅链表发 NOTIFY,遍历过程中我在另一个线程没拿锁就 Clear()。链表节点被释放,迭代器 use-after-free,SIGSEGV。
H181 回退 + H184 重做:
scss
// ProcessNewSubscription
{
NPT_AutoLock lock(m_Lock); // ← 跟 NotifyChanged 同把锁
// 安全地遍历、移除同 callback URL 的老订阅
if (callback_urls[0] == '<') {
char* szURLs = (char*)(const char*)callback_urls;
// ... 解析 URL ...
for (;;) {
auto found = m_Subscribers.Find(PLT_EventSubscriberFinderByCallbackURL(url));
if (!found) break;
m_Subscribers.Erase(found);
}
}
m_Subscribers.Add(subscriber);
}
核心教训:
改动第三方库的内部数据结构时,先 grep 看它在哪些地方被访问,搞清楚锁的契约。不要假设"加锁就在某个特定函数里加,不加我也能用"。Platinum 的 m_Lock 不是装饰,是 NotifyChanged 跟 ProcessNewSubscription 之间的硬约束。
3.3 pcap 抓出真相:4 个 stale SID 同时活着
H184 之前我们一直猜 PAD 的行为。直到一次完整 pcap 分析。
抓包过滤器:
ini
ip.addr == 192.168.41.234 and (http or ssdp)
发现 :盒子每次状态变化发 4 个 NOTIFY 出去,分别带 4 个不同 SID:
| SID | SEQ 值 | 创建时间 |
|---|---|---|
83c2083a... |
116 | 启动时第 1 个订阅 |
b5ddd80f... |
82 | 中间某次重连 |
63f24fde... |
59 | 中间某次重连 |
b3fe2ce4... |
4 | 最近一次重连 |
致命证据 :PAD 对其中至少 1 个 SID 的 NOTIFY 回 HTTP 412 Precondition Failed------它自己也不认得这个 SID 了。
scss
盒子发 NOTIFY (SID A) → PAD 回 200 ✅
盒子发 NOTIFY (SID B) → PAD 回 200 ✅
盒子发 NOTIFY (SID C) → PAD 回 200 ✅
盒子发 NOTIFY (SID D) → PAD 回 412 ❌ "我不认识这个 SID"
链路:
- PAD 每次重连发新 SUBSCRIBE(CALLBACK 都是
http://192.168.41.234:49154/) - PAD 断开时只发了一部分 UNSUBSCRIBE(QQ HD 的 bug,4 次断开只发了 3 次完整的)
- 我们这边 lease=1800s(默认),老订阅 30 分钟不死
- 累积 4 个 SID 全部活跃
- 每个状态变化广播 4 份 NOTIFY
- PAD 自己内部状态混乱,部分 SID 它都不记得了 → 返回 412
- PAD UI 可能正好绑在它"不记得"的那个 SID 上 → UI 永远不刷新
H184 修复后再抓包:
| 指标 | H184 前 | H184 后 |
|---|---|---|
| 同时活跃 SID 数 | 4 | 1 |
| 每次状态变化 NOTIFY 个数 | 4 | 1 |
| 412 总数 | 每个 burst 1 个 | 3 个(全程) |
| 切歌间隔 | 20~63 秒 | 8~12 秒(纯手动 UI 时间) |
意外收益:QQ HD 的 penalty box 也不再触发了!干净的单 SID 状态机让 QQ HD 不再把我们标记为"慢渲染"。
3.4 Seek 异步 race:Java vs Native 线程
症状:拖动进度条后 PAD 进度冻结到下一首歌。
H185-A :加 seekInProgress 标志,加 OnSeekCompleteListener,让 progressRunnable 在 seek 期间跳过 mp 位置查询。
H185-B:意识到还有 SOAP GetPositionInfo path,加同样的守卫。
H185-C:依然没修好。看日志:
makefile
17:31:58.644 onSeek 进入 (target=149000)
17:31:58.656 ⚠️ GetPositionInfo → 9059 ← mp 旧位置!
17:31:58.668 ⚠️ GetPositionInfo → 9059 ← 又一次旧位置
17:31:58.703 GetPositionInfo → 149052
根因 :seekInProgress 标志我写在 handler.post(Runnable) 里------这要主线程调度才执行。但 PAD 的 onGetPlayInfo 跑在 native 线程,不等主线程。两次 PAD 轮询在 12ms/24ms 内拿到 mp 旧位置 9059。
ini
T+0ms onSeek 回调 (native 线程)
└─ handler.post(...) 调度 ← seekInProgress=true 在这里
↓ 等主线程
T+12ms ⚠️ PAD 另一 native 线程发 GetPositionInfo
⚠️ 守卫还没生效,返回旧值
T+30ms 主线程跑 handler.post,设 seekInProgress=true(太晚了)
修复:把守卫提到 native 回调入口同步设置。
arduino
public void onSeek(final DlnaPlayer player, final int nSeek) {
// ⭐ 立刻同步设置,volatile 跨线程立即可见
seekInProgress = true;
currentPosition = nSeek;
// 之后再调度主线程任务
handler.post(...) → mp.seekTo
}
教训 :JNI 回调跟主线程是两套线程。volatile 字段从 JNI 回调入口同步设置才能让另一个 native 线程立刻看到。调度到主线程的代码即使是"立即执行"也有几十 ms 调度延迟。
最终发现:手机版 QQ Music 同样的盒子代码 seek 完全正常,HD 版有客户端 bug。我们这边能做的都做了。
四、值得保留的经验
4.1 调试方法论:Hypothesis-driven Debugging
每个修改都带一个 H<number> 标签:
arduino
// H184: 安全版的 stale subscriber 清理。
// 抓包确认:QQ Music HD(PAD)每次 disconnect-reconnect 会发新 SUBSCRIBE
// 但只对其中**一部分**老订阅发 UNSUBSCRIBE。久而久之我们这里会累计 4 个
// 以上活跃 SID...
// 跟 H177 的关键区别:这次**在 m_Lock 保护下**操作。
带来的好处:
- 每个改动有明确假设,方便事后回溯哪个假设错了
- 注释解释了为什么这么写(H184 与 H177 的区别),未来维护者不会误以为"这是冗余代码"再删一遍
- 失败的假设也留有记录(H177 SIGSEGV → H181 回退 + 注释),避免再次踩坑
4.2 看协议不看应用层
bug 调查初期我们花了大量时间分析 PlayerActivity 的 Java 状态机。直到抓包发现:问题在 GENA 订阅层------再多 Java 层逻辑都不影响 SID 累积。
经验:DLNA / UPnP 这类有清晰协议分层的系统,先看协议层(pcap),再看应用层(logcat)。协议层数据不会骗人,应用层日志可能漏关键事件。
4.3 race condition 的固定排查清单
- 变量是 volatile 吗? (跨线程可见性)
- 赋值是原子的吗? (int/boolean 是,long/double 不一定)
- 谁可能在另一个线程读? (JNI 回调?UI 主线程?libupnp 内部线程?)
- 这把锁的契约是什么? (grep 看库代码所有用到这把锁的地方)
H177 SIGSEGV 和 H185 race 都是因为忽略了第 3、4 条。
4.4 工具与命令速查
tshark 提取 SSDP byebye
perl
tshark -r capture.pcap -Y "ssdp" -V 2>&1 | grep -c "ssdp:byebye"
找特定 SID 的 NOTIFY
perl
tshark -r capture.pcap -Y "ip.src == BOX_IP and http.request.method == "NOTIFY"" \
-V 2>&1 | grep -E "^Frame [0-9]+:|^ SID:|^ SEQ:"
找 NOTIFY 的 HTTP 响应
vbscript
tshark -r capture.pcap \
-Y "ip.src == PAD_IP and http.response and frame.time_relative >= START and frame.time_relative <= END" \
-T fields -e frame.number -e tcp.dstport -e http.response.code
Logcat 抓特定 tag
lua
adb logcat -v time | findstr /R "PlayerActivity DlnaService rockit" > debug.log
4.5 代码注释的"两个时间维度"
经验:所有非平凡改动都至少注释两件事:
- WHY 此刻这么写 --- 当前的业务/技术约束
- WHY NOT 之前的写法 / 替代方案 --- 历史教训
例子(H181 注释):
arduino
// H181: 移除 H177 的 m_Subscribers.Clear() 调用。
// 用户报告 SIGSEGV:
// #00 NPT_List::Detach
// ...
// 也就是 NotifyChanged 正在遍历 m_Subscribers 发 NOTIFY 时,我们这边的
// ProcessNewSubscription 在另一个线程把链表 Clear() 了 → 崩。
//
// PLT 内部对 m_Subscribers 的修改需要走它自己的锁路径,我们直接 Clear
// 没拿这把锁就是 use-after-free。
3 年后某个维护者想"减少冗余"删掉这块逻辑时,能立刻知道:这块不能动。
五、协议层架构梳理
最后留一份完整的 PAD ↔ 盒子交互流程图,作为本次调研的副产品。
5.1 协议栈分层
scss
┌────────────────────────────────────────────────────┐
│ Application Layer │ QQ Music HD ⇄ PlayerActivity │
├────────────────────────────────────────────────────┤
│ GENA (HTTP/TCP) │ SUBSCRIBE / UNSUBSCRIBE │
│ │ NOTIFY(状态变化推送) │
├────────────────────────────────────────────────────┤
│ SOAP (HTTP/TCP) │ Stop / Play / SetURI 等动作 │
│ │ GetPositionInfo 等查询 │
├────────────────────────────────────────────────────┤
│ SSDP (HTTP/UDP) │ M-SEARCH(PAD 发现盒子) │
│ multicast 1900 │ NOTIFY ssdp:alive / byebye │
└────────────────────────────────────────────────────┘
5.2 PAD UI 数据源(关键洞察)
java
PAD UI(QQ Music HD 上的播放器界面)
├─ 播放/暂停图标 ◄─── GENA NOTIFY (LastChange/TransportState)
├─ 进度条 ◄────────── SOAP GetPositionInfo 轮询(独立通道)
├─ 时长显示 ◄───────── SOAP GetPositionInfo 的 TrackDuration
└─ 歌曲名/封面 ◄────── 自己缓存的 metadata
UI 的不同元素用不同信源,所以会出现"进度条在走但状态显示暂停"这种诡异现象------polling 通道工作正常,NOTIFY 通道断了。
5.3 一次完整投歌的时序
scss
PAD 盒子
│ ─── M-SEARCH ──────► (UDP 1900 multicast)
│ ◄─── 200 OK (LOCATION) ───
│ ─── GET device.xml ──►
│ ◄─── 200 OK ──────
│ ─── SUBSCRIBE AVT ──► (HTTP/TCP)
│ ◄─── 200 OK (SID) ── ← H184 在这里清同 URL 老订阅
│ ◄─── NOTIFY (initial event) ──
│ ─── Stop ──────►
│ ─── SetAVTransportURI ──►
│ ◄─── NOTIFY (URI 变化) ──
│ ─── Play ──────►
│ ◄─── NOTIFY (TRANSITIONING) ──
│ ◄─── NOTIFY (PLAYING) ── ← audioOnPrepared 触发
│
│ === 播放中 ===
│ ─── GetPositionInfo ──► (每 1s)
│ ◄─── 200 OK (pos+dur) ──
│
│ === 用户拖动进度 ===
│ ─── Seek(target) ──►
│ ◄─── 200 OK ── ← H185 在这里同步守卫
│ ◄─── NOTIFY (PLAYING + target) ──
│ ─── GetPositionInfo ──► (PAD 急速轮询)
│ ◄─── 守卫期返回 target,OnSeekCompleteListener 后返回 mp 实际 pos
│
│ === 切歌 ===
│ ─── Stop ──────►
│ ─── SetAVTransportURI(new URL) ──►
│ ─── Play ──────►
│ ◄─── NOTIFY (URI 变化) ──
│ ◄─── NOTIFY (PLAYING + 0s) ──
│
│ === 断开 ===
│ ─── SetAVTransportURI("") ──► ← H183 检测到,推 STOPPED
│ ─── UNSUBSCRIBE ──►
六、总结
这次排障的核心收获不在某个 hypothesis 本身,而在调查方法:
- 抓包不撒谎,日志可能漏事件 --- 协议层证据优先
- race condition 排查要画时序图 --- 不能凭感觉
- 库内部数据结构动之前先看锁约定 --- 否则 SIGSEGV 等你
- 客户端 bug 也是客观事实 --- 验证渠道:换控制器测、看协议层数据、对比手机版/HD 版
- 每个改动写 H 标签注释:当前 WHY + 历史 WHY NOT
- 回退也要记账 --- H177→H181 的回退保留注释,比直接删干净更有价值
最终 14 个修复落地,让一个原本"切歌卡 20 秒、断开重连无声、进度凭运气"的 DLNA 渲染端跑成了"切歌秒响应、断开重连干净恢复"的稳定产品。剩下的最后一个边角问题(QQ HD 版拖动进度后 UI 冻结)通过手机版对照测试证明是客户端 bug,转给上游处理。
附录:本次修复保留清单(生产环境)
ini
H175 LastChange rate = 0
H176-X 去掉 onStop 入口的同步 STOPPED push
H176-Y audioOnPrepared 后 1s/3s 重推 PLAYING
H178 startAudioPlayback 兜底 IllegalStateException
H179 onPause prepared 后 3s 内忽略
H180 SUBSCRIBE Timeout 用 PLT 默认 1800s
H182 setAudioAttributes(USAGE_MEDIA, CONTENT_TYPE_MUSIC)
H183 断开信号(空 SetURL)同步推 STOPPED
H184 m_Lock 保护下清同 callback URL 老订阅 ⭐
H185 seek 异步 race 修复(守卫 + safety timeout)