画面冻住,声音还在:如何识别一次 libvlc 依然称之为 Playing 的停滞
画面冻住并不是一种错误状态。引擎照旧上报 Playing,唯一变了的东西,是某个计数器增量掉到了零。
displayed +25 read +50,000 demux +48,000 audio +40 engine state: Playing
displayed +0 read +50,000 demux +48,000 audio +40 engine state: Playing
相邻的两次一秒采样。变的只有一个数字。字节照旧以同样的速率到达,解复用器照旧在消费它们,音频缓冲照旧送到声卡,而引擎在两行上都报 Playing,只要你不关掉这个频道,它就会一直这么报下去。屏幕上停着的,是最后一张送达视频输出的画面。这些常数是我们的测试夹具喂给检测器的,不是从真实频道上抓下来的。一条 4 Mbit/s 的流每秒读进大约 500,000 字节,比这些常数高一个数量级,而它的 read 和 demux 两个数字贴得远比这里 4% 的持续差距要近。能迁移过来的是形状。这一对采样里没有任何东西算错误,后面也不会跟着任何错误事件,而这四个增量,就是一个检测器能拿到的全部证据。
下面的一切,都是在我们的 Windows 桌面应用上、以 libvlc 3.0.21 配 LibVLCSharp 3.9.7 量出来的。统计 API 在 libvlc 的大版本之间变过,所以这些关于计数器的说法,请当成只针对 3.x 的说法来读。
画面冻住的整个过程里,libvlc 都在报 Playing
引擎状态回答的问题,比人们读进去的要窄得多。Playing 的意思是播放线程没有被停止、没有被暂停、也没有结束。它没有断言过去四秒里有画面到达屏幕,也没有断言那张确实到达了的画面里有任何内容。
我们的看门狗规格说明书,写下了整个子系统所依赖的那个前提。不管是画面冻结还是黑屏信号,libvlc 都会无限期停在 Playing;而 EncounteredError 只留给硬性的打开失败和协议失败,所以对观众真正会报上来的那两种故障模式,它几乎从不触发。这句话是我们对引擎的理解,不是从它的文档里摘出来的原话,也正因为如此,下面所有规则都不去问引擎它自己怎么样了。
能观察到的后果,就是我们不得不建起来的这套分类有多大。一共定义了九个类别:接入层没有字节、直播频道上悄无声息的流结束、引擎自己的错误、字节在到而解复用器什么都产不出、视频停住而音频还活着、数据在流而两者都停住、健康流水线上的黑帧、输入时钟停走,以及滚动窗口内反复重新缓冲。
这九个里有三个是搭引擎事件的车。一个是引擎的错误。另外两个压根不算错误,就是普通事件:一次流结束、或者一次没人按停的频道上的悄悄停止,以及一条被我们计入重新缓冲循环的缓存层上报。六个靠计数器推断,不过其中时钟停走那一类还要搭上引擎的时钟事件。而“九”这个数字夸大了这套系统真正说得出口的东西,因为时钟停走那一类在任何地方都不会被宣告。它的存在是为了佐证,它没有属于自己的分类代码。
故障卡片过去把这一切全扔掉,对每一种情况都只显示同一句话,于是一个来源拒绝连接的观众,和一个设备解不了这路视频的观众,读到的是一模一样的字。现在每个类别都映射到一个代码,而同一个类别在第一帧的两侧含义并不相同,所以这个映射把两者都带上了。连接宽限期之内没有字节,说明这条流从来没开始过。十分钟之后出现同样的停滞,说明它是停下来了。
视频冻结检测,就是四个计数器增量按固定顺序走一遍
一次采样是十一个字段:一个单调时间戳,加十个累计计数器,一趟从引擎里读出来。十个里有八个会跟上一次采样做差。另外两个,也就是丢掉的迟到画面和丢掉的音频缓冲,谁都不跟它们做差;当初收进来是想着万一有规则要用,而至今没有任何规则用过。判决由四个增量承担:
- 接入层读到的字节数。为零就意味着网络上什么都没到。
- 解复用器消费掉的字节数。read 非零而 demux 为零,意味着到达的字节里没有节目。
- 视频输出上已显示的画面数。这是首要的视频存活信号,而下一节讲的就是为什么不能看一眼就信它。
- 音频输出上已播放的音频缓冲数。视频冻结和整体冻结的区别,就靠它分。
已解码视频是唯一一个说得上话的辅助计数器。已显示画面那个计数器被判定为不可用时,由它顶上。已解码音频没有对应的位置。它只被读一次,用来证明存在一路音频流。剩下的一对是损坏单元和不连续,两者中任何一个出现尖峰,都会在一段时间里布防出缩短过的冻结窗口。
顺序比任何单个阈值都重要,而代码就地写明了原因:
// F5 / F6 presentation freezes. Suppressed while demux starvation is suspected: starvation
// stalls presentation too, and F4's longer window owns the classification (its ladder entry
// differs). F1 needs no such guard; it shares the shorter window and is checked first.
(注释大意:呈现层的冻结判定在怀疑解复用器饥饿时会让位,因为饥饿同样会卡住呈现,而窗口更长的那条规则拥有分类权,它进恢复阶梯的入口也不一样;接入层那条不需要这道护栏,它共用较短的窗口,而且先被检查。)
先测接入层,再测解复用器,最后测呈现层。接入层一死,它下游的一切都会跟着挨饿,所以字节停了四秒之后画面才停,那是症状而不是诊断;把它报成冻结,会让恢复阶梯从错误的一级切进去。我们当前的窗口是:零字节四秒、解复用器饥饿六秒、呈现层停滞四秒。这些是调参值,下一次有人去量的时候就会变;真正承载意义的是顺序。
怀疑存的是一个时刻,不是一个标志位。四个标记各自记着自己那个条件是什么时候开始的,而所谓判决,就是某个标记的年龄越过了它的窗口。一个正常节拍就把标记清回 null,年龄也就没了,所以窗口是一次减法,而不是每拍记一笔账。损坏尖峰是那个反证了这个形状的例外。它存的是到期时刻而不是起始时刻,没有任何东西能提前清掉它,也没有任何判决去读它的年龄。
在这些阈值看上去像普世真理之前,先说一句适用范围。上面这一切都是在裸 MPEG-TS 上标定的。我们会把来源类型作为 ts 或 hls 带着走,但那只用于遥测,以及还没有人写出来的、按类型分开的阈值。在 HLS 上,自适应解复用器会自己去做下游的拉取,所以顶层那个接入与解复用的划分,并不是本节说的那个意思,而且我们发出去的阈值里没有任何一个是 HLS 专有的。
为什么画面计数器会说一个健康频道在反复冻结?
有些视频输出路径和硬解路径会少报已显示画面,而你没有办法问出自己正走在哪一条上。我们自己的代码注释把这件事赖在接入模块头上,这不可能对。接入层位于解复用和解码的上游,它根本碰不到那个由视频输出核心去递增的计数器。注释是错的,而它描述的那个行为是真的。
所以在一条流开始出画之后的头三十秒里,状态机只看,不判。窗口结束时,它会经由五个分支落到三种立场之一:已显示计数器动了,那就信它;已显示计数器不动,但已解码视频动了,那它就成为备用信号;或者所有视频计数器都不动,而输入时钟在走、视频输出也存在,那就说明这条路径在少报,本次会话的冻结规则直接关掉。第四个分支抓的是标错了的频道,视频那边什么都没动、也没有出现视频输出,但音频在播,于是改由音频的存活信号接手。第五个分支的默认结果是信任这个计数器。
这一切之上还压着一道护栏。本次会话里至少有一个视频计数器动过一次之前,任何冻结判决都不成立,因为一个从来没动过的计数器,和一个在少报的计数器无法区分;而“一开始就真的是死的”那种情况属于启动分类器,它手上的证据不同,进阶梯的入口也不同。四十五个一秒节拍里音频在播、时钟在走、视频输出也在,而每个视频计数器都是平的,结果是什么都不宣告。
塑造了这套标定的边界情况是低帧率静态卡,比如一张台标卡,或者广播频道那种大约每秒更新半张画面的画面。在一个一秒一次的采样器上,这种频道每隔一拍就是零张已显示画面,跟冻结的特征一模一样。标定会识别出这个帧率,把冻结窗口从四秒放宽到十五秒,而放宽才是反直觉的那一半。直觉是一个可显示内容更少的频道应该更早下判断,而这么做的结果,是你手上每一张静态卡都会得到一次误判。静态卡彻底停住时依然抓得到,只是走的是更宽的那个窗口。
布防一个没有任何规则消费得了的怀疑,是要付代价的,而这个代价我们付过。音频停滞而视频健康,过去会在一个视频频道上拉起怀疑,可那里没有任何判决读得到它,于是这个会话就在那个状态里待了一辈子。采样快了一倍,而清空恢复账本的那个稳定信号从来没有发出去过。这个会话里后来的每一次故障,继承到的都是一套尝试次数已经用光的阶梯,直接跳到放弃卡片。一个成不了判决的怀疑不叫保守。那叫泄漏。
一次暂停,看上去和一次冻结一模一样
用户一按暂停,采样里的每一个计数器都会停下,而且停出来的花样,正好就是整体冻结会产生的那一种。
光看数字没法把这两者分开,所以暂停干脆不计时。它无限期地屏蔽判决,一次暂停背后十分钟的冻住计数器,什么都不会宣告。跳转拿到的则是五秒的有界屏蔽,因为跳转要么成了,要么没成。缓冲会挂起呈现层的判决,但让接入层那条规则继续布防着。理由是缓冲期间出现零字节,说明来源死了而不是链路慢;链路慢的时候,字节照样看得到在到。
重新打开是这套算术咬人的另一个地方。字节计数器是 32 位的,作用域是单个媒体,所以换上新媒体时它们从零重新开始,会话够长时它们会回绕。两种情况下增量都会变成负数,而六个存活计数器里只要有一个出现负增量,就重置基准、跳过这一拍的判定,而不是去报一整条流水线的停滞。
黑帧检测非看像素不可
一路黑屏信号照样解码、照样显示、照样出声、照样以正常速率拉字节。到目前为止的每一条规则,看到的都是一条健康的流。这是检测器里唯一一条必须去看像素的路径,它需要一张按 96 × 54 从视频输出上真抓下来的帧。
一个亮度阈值不够用,原因出在内容上。判决要看两条轴,外加重复:
- 黑色像素占比达到或超过 0.98,其中亮度 24 及以下的像素算黑。
- 亮度标准差不高于 4.0,正是这一条放过了那些暗但有结构的画面。
- 连续四个黑样本,正是这一条淡入淡出满足不了。
这两种内容上的失败都是由测试钉死的,不是靠嘴说的。一段夜景,大部分接近全黑,零星散着月光照出来的结构,它在两条轴上同时不通过。带黑边的画面算出来黑色占比不到一半,因为就算画幅很宽,黑边在整帧里也只占少数。
淡入淡出这条论证里有一个分支,我不该跳过。一次恢复确认之后的十秒里,要求的连续数从四个样本降到两个,按配置下限算大约三秒,实际还要更长,而一次慢速淡出撑得住这三秒。这是一次刻意的取舍。片刻之前给这个来源定罪的证据依然成立,所以一个回来之后还是坏的来源会被更快地再次定罪,而我们为此付出去的,正是那道防淡出的护栏。1500 毫秒同样是一条下限,而不是一个固定间隔。探测器按它重新布防,然后在下一条到达的消息上触发,所以真正落地的间隔更长。下面那次运行里是七秒,然后是九秒。
探测器什么时候跑,是一个带着可见代价的决定,而老实的说法比这个名字听上去要窄。它在一条流第一次出画之后跑一次扫描,以及在由视频停滞拉起的怀疑转入时再跑一次。开场那轮扫描一结束,只要会话保持健康,它就不会再跑。所以,一条本来健康的流在播放中途变黑,是不会产出任何判决的,而且这一点可复现。我们选择不去修它。要抓住它,代价是每一条流的每一次会话都多一次快照,为的是检测一件正在看的人一眼就看得见的事;而换台走开再换回来,本来就会把那轮扫描重新跑一遍。
第二条局限,我拿不出证据把它关掉。抓帧走的是引擎自己的快照调用,而从硬解表面回读,本来就是众所周知的、播放器会因为跟屏幕上内容无关的原因交回一张空白帧的地方。永远不到的那张快照,我们是处理了的。它是视频输出卡死的证据,用来佐证一次基于计数器的判决,但它自己从不单独宣告。到达了、却错误地是黑的那一张,我们没处理;而这种事在我们这条路径上多久发生一次,我手上没有任何测量。
我最不舒服的一部分,是探测器量了、然后又扔掉的那些东西。每一次采样都会对亮度平面算出一个 64 位均值哈希和一个平均亮度,两者都随消息一起走,而规则引擎两个都不读。一条冻在明亮静止画面上的流,出现在一个画面计数器被标定为不可信的频道上,正是“两次隔开的采样得到相同哈希”本该抓住的情况。我们不去比它们;而这种情况是不是常见到值得为它写一条规则,是一个我们答不上来的问题,原因在最后两节。哈希里有一个细节让我意外到愿意留下来说一说。纯色帧哈希出来每一位都是置上的,64 个比特位一个不落,因为每个格子的均值正好落在全局均值上,而比较用的是大于或等于。纯白和纯黑是同一个哈希。
这里最糟的一个 bug,是一次根本没在发生的冻结
读播放器的统计数据不是一次安全的调用。它的每一行都在解引用原生句柄,而这些句柄会在用户换台、离开页面、或者一次交换链重建让播放器退休的时候被引擎释放掉,何况采样器跑在自己的任务上。输掉这场竞争,抛出来的不是一个 catch 接得住的东西。libvlc 会以一次访问违例出错,进程就此消失,日志里什么都没有。所以采样器跑在播放器池用的那同一道闸门后面。
在那道闸门里面,一个孤立地看完全正确的缓存决定,制造了这个子系统发出去过的最糟的 bug。采样器把媒体包装器攥在手里,而不是每一拍都重新取一次,因为每秒取一次看着太浪费。LibVLCSharp 的媒体包装器会自己拿一份原生引用,所以在池线程上、引擎把它底下的媒体换掉之后,这个缓存下来的包装器依然有效。它继续作答。它从不抛异常。它返回的是那份已死媒体的计数器,而那些计数器不会动,这在状态机的每一条规则看来,都是一次彻底的字节停滞。
症状是一个健康的频道被报成了死掉的网络。代价是从一条能用的流上切走、换了来源,这一幕是 2026-07-24 23:24 的一次现场验收测试里实时看到的。现在压在修复代码上方的那条注释,把话说得很直白:
// Acquire and dispose the wrapper on every tick. A cached wrapper holds its own native
// retain, so after a pool-thread media swap it would keep returning the DEAD media's
// frozen counters without ever throwing, which reads as a net stall on a healthy stream
// (observed live: [...] 2026-07-24 23:24). One retain/release per second is negligible.
(注释大意:每一拍都重新取包装器、用完就释放。缓存下来的包装器自己持有一份原生引用,池线程换过媒体之后,它会一直返回那份已死媒体冻住的计数器,还从不抛异常,在一条健康的流上就被读成了网络停滞;每秒一次 retain/release 可以忽略不计。)
一个读着陈旧输入的检测器,不会安静地失败。它会以同样的置信度,对一个播得好好的频道给出同样的判决,然后照着它动手。看门狗里其他每一种失败,让你付出的是一次没抓到的停滞。这一种让你付出的是一次你自己发明出来的停滞,而观众会看到那次恢复。
一次冻结的十二秒里,实际发出来的是这些
下面是一次脚本化的会话,在现有的测试台架上跑过已发布的规则引擎,十二个一秒节拍。它是实际跑出来的,不是手工推演的,跑完之后那个临时驱动就删掉了。这些数字请当成台架里的夹具值来读。每一次快照请求都在 300 毫秒之后得到应答,跟真实宿主的表现一样,因为快照的预算是一秒。
会话开启,上报 Playing,并上报一路视频输出。然后:
| t | dRead | dDemux | dDisplayed | dAudio | 之后的状态 | 发出的效应 |
|---|---|---|---|---|---|---|
| 1 秒 | +50,000 | +48,000 | +25 | +40 | Starting | 无 |
| 2 秒 | +50,000 | +48,000 | +25 | +40 | Healthy | StateChanged、SetSampleRate 1 Hz、RequestSnapshot |
| 3 秒 | +50,000 | +48,000 | +25 | +40 | Healthy | 无 |
| 4 秒 | +50,000 | +48,000 | +25 | +40 | Healthy | 无 |
| 5 秒 | +50,000 | +48,000 | +25 | +40 | Healthy | 无 |
| 6 秒 | +50,000 | +48,000 | +25 | +40 | Healthy | 无 |
| 7 秒 | +50,000 | +48,000 | +0 | +40 | Suspect | StateChanged、SetSampleRate 2 Hz、RequestSnapshot |
| 8 秒 | +50,000 | +48,000 | +0 | +40 | Suspect | 无 |
| 9 秒 | +50,000 | +48,000 | +0 | +40 | Suspect | RequestSnapshot |
| 10 秒 | +50,000 | +48,000 | +0 | +40 | Suspect | 无 |
| 11 秒 | +50,000 | +48,000 | +0 | +40 | Recovering | StateChanged、FaultDeclared VideoFreeze、已持续 4000 毫秒 |
| 12 秒 | +50,000 | +48,000 | +0 | +40 | Recovering | 无 |
第 1 拍什么都不产出,因为一次采样构不成增量,它成了基准。第 2 拍是第一对被评估的采样,呈现层的进展得到证明,状态机离开 Starting。它同时又重申了一遍自己本来就在用的采样率,这一步多余,也无害。那一行上的快照请求就是开场扫描,宿主用真实内容回答了它,黑色像素 6%,标准差 41,于是扫描收尾。
第 3 到第 6 拍什么都不发。这正是想要的稳态。一条健康的流不产生任何效应,所以空日志才是成功的样子。
第 7 拍是画面停下的地方。视频停滞标记记下的是 7 秒这个时刻,状态机转入 Suspect,采样器翻倍到 2 Hz,探测器布防并要一帧。什么都不会宣告,因为年龄为零的标记不是判决。第 8 拍和第 10 拍是窗口在走,第 9 拍是探测器按自己的节奏重新采样,距上一次请求两秒,而不是配置里的 1500 毫秒,因为它是在到期时刻之后的下一条消息上触发的。这期间字节和音频一次都没含糊过。除了一条规则之外,这个会话在每一条规则看来都完美无缺。
第 11 拍是判决。视频停滞标记走到四秒,解复用器不在怀疑之列,音频在停滞期间一直在推进、现在也还在推进,所以这是一次视频冻结,不是整体冻结,而上报给执行器的异常持续时长是 4000 毫秒。把同一份脚本改成音频也在第 7 拍停下,同一行就会宣告整体冻结。再跑一次,让快照超时而不是返回内容,判决不变,但它的原因字符串会变成 video stalled while audio keeps playing (snapshot timeout observed)(视频停住而音频还在播,观察到快照超时)。卡死的输出起的是佐证作用,不是它做的决定。
第 12 拍什么都不发,因为状态机已经交棒了。围着这段代码的 Playback 测试套件有 108 个测试,截至写下这篇文章时全部通过。
一次判决换来五级台阶,其中一级还没造出来
判决是一架阶梯的输入,不是答案。一共五级:先暂停再播放这一下,只有整体冻结才有;把同一个 URL 完整地重新应用一遍;切到下一个候选;重建播放器本身;然后放弃。调台之后十五秒内发生的故障,入口在切候选那一级,而不是重新应用,因为一个从来没有证明过自己的来源,不配让人把刚刚失败的那个 URL 再试一次。成熟的会话仍然把重新应用放在最前面,这样十分钟之后打个嗝,不至于让观众付出一次换来源的代价。升级之前只有一次重启尝试,因为现场数据表明第二次从来不管用。
重建这一级目前是一段坦白的虚构。它没有宿主原语,在已发布的设置里是关掉的,而唯一会路由到它的那个条件,也就是一次超时的停止把原生会话卡死在那里,目前会直接结束前台阶梯,落到放弃。这样处理是保守的,也是对的;它同时也是一个缺口,而代码就在发生的地方这么写着。
两次尝试之间的退避是指数式的,第一次免费:基数乘以二的尝试序号次方再减一,封顶,然后向两边各抖动最多百分之二十。落到实处就是立刻发一次,然后一秒、三秒、七秒,依此类推直到上限。抖动是在封顶之后才乘上去的,不是之前,所以真正的天花板是三十六秒,不是上限暗示的三十秒。小事,但正是那种会让一张图在凌晨 3 点看起来不对劲的小事。
像素留在抓下它们的那台机器上
探测器会往应用自己的本地数据目录里写两个轮换的 PNG 文件,并就地覆盖它们。没有任何东西上传它们,也没有任何东西把它们发到别处;一帧的寿命只够被归约成两个数字和一个哈希,然后就被下一帧覆盖掉。故障判决进的是同一台机器上的本地日志,而日志里的频道,用的是一个 SHA-256 的前十六位十六进制字符来标识,绝不用 URL。
最后这一点,正是我没法告诉你“明亮画面冻住”这种情况有多常见的原因。遥测事件的形状写好了,类型也定好了,它们的文档注释里点名了一个批量上传器,而那个上传器并不存在。三个记录类型声明在那里,代码里任何地方都没有构造过它们。本文里每一句关于哪些故障模式重要的判断,都来自一次现场验收测试、一套故障注入装置和一个测试套件,而不是来自一整个装机群体。这是上面所有内容在可信度上一个实打实的限制。
如果你宁愿直接用这个检测器,而不是读关于它的文章,那么它在 My TV Player 的 Windows 应用里默认就是开着的,你可以注册一个免费账户试一试。它干好本职工作的时候,你是看不见它的。画面会卡上四秒然后回来,而屏幕上不会出现任何东西,告诉你曾经有过一次判决。
本文测量了什么37 主张,每项主张都有其背后的证据
| 索赔 | 证据 | 已计算 |
|---|---|---|
| 本文里的一切,都是在 Windows 端上以 libvlc 3.0.21 配 LibVLCSharp 3.9.7 量出来的。libvlc 4.x 的统计 API 不一样,所以这些关于计数器的说法只在 3.x 的范围内成立。clients/windows/docs/PLAYBACK-WATCHDOG-PLAN.md:7 pins the head to LibVLCSharp 3.9.7 / libvlc 3.0.21; docs/research/MTP-PlaybackWatchdog-Spec-v1.0.md:5 scopes the spec to libvlc 3.x with forward-compatible notes for 4.x. | 规格 | 不适用 |
| 不管是画面冻结还是黑屏信号,libvlc 都会无限期停在 Playing;而 EncounteredError 只留给硬性的打开失败和协议失败,所以对直播里最主要的那两种故障模式,它几乎从不触发。docs/research/MTP-PlaybackWatchdog-Spec-v1.0.md:18 (within the cited 15-19 block), header checked, no confidentiality marking. | 规格 | 不适用 |
| 一共定义了九个故障类别,F1 到 F9,其中八个真的有可能被宣告。F8 ClockStall 在任何地方都不会被宣告,也没有属于自己的分类代码。 | n = 9 | 2026年8月22日 |
| 九个类别里有三个是搭引擎事件的车。F3 是引擎自己的错误,F2 由 EndReached 或者一次悄无声息的 Stopped 宣告,F9 由 Buffering 宣告。其余的全靠计数器推断。 | n = 3 | 2026年8月22日 |
| 故障卡片过去对每一个类别都只显示同一句话;现在每个类别都映射到一个分类代码,而这个代码还取决于故障是落在第一帧之前还是之后。 | n = 1 | 2026年8月22日 |
| 一份统计样本是十一个字段:一个单调时间戳,加十个累计计数器。其中八个会做差;LostPictures 和 LostAudioBuffers 没有任何规则对它们做过差。 | n = 11 | 2026年8月22日 |
| 只有视频有备用计数器。DisplayedPictures 不可用时由 DecodedVideo 顶上;DecodedAudio 只用来证明存在一路音频流,没有任何规则把它当成存活信号来读。 | n = 1 | 2026年8月22日 |
| 判决的检查顺序是接入层第一、解复用器第二、呈现层最后;只要还在怀疑解复用器饥饿,冻结规则就一律退避。 | n = 1 | 2026年8月22日 |
| 当前的调参:F1 是 4 秒零字节,F4 是 6 秒,F5 和 F6 是 4 秒,低帧率静态卡是 15 秒。 | n = 1 | 2026年8月22日 |
| 四个可空的 since 标记分别记着各自的条件是什么时候开始的,一个正常节拍就把它们清掉。第五个信号,也就是损坏单元与不连续的尖峰,走的是反方向。它存的是一个到期时刻,在生效期间把冻结窗口缩短。 | n = 4 | 2026年8月22日 |
| 计数器模型是在裸 MPEG-TS 上标定的。来源类型会以“ts”或“hls”的形式带着走,用于遥测和将来按类型分开的阈值,而今天选项里没有任何一个阈值是 HLS 专有的。 | n = 1 | 2026年8月22日 |
| libvlc 3.x 的字节计数器是 32 位的,而且按媒体计,所以重新打开时它们从零开始,会话够长时它们会回绕。两种情况下增量都会变成负数。WatchdogStateMachine.cs:274-275 states both causes in place ("libvlc zeroes stats on reopen and int counters can wrap"). libvlc 3.x declares i_read_bytes and i_demux_read_bytes as int in libvlc_media_stats_t. | 规格 | 不适用 |
| 六个存活计数器里只要有一个出现负增量,就重置基准并跳过这一拍的判定。损坏单元和不连续的增量不在这道护栏里。 | n = 6 | 2026年8月22日 |
| 画面计数器会在一个 30 秒的窗口里、经由五个分支标定成 Trusted、FallbackDecodedVideo 或 Untrusted;而且冻结判决还要求本次会话里至少有一个视频计数器动过一次。 | n = 5 | 2026年8月22日 |
| 少报已显示画面的是视频输出路径和硬解路径,不是接入模块。WatchdogStateMachine.cs:441 says "this access/vout combo underreports". The access module sits upstream of demux and decode and cannot influence i_displayed_pictures, which the vout core increments. | 规格 | 不适用 |
| 45 个一秒节拍里,音频在播、时钟在走、视频输出也在,但每一个视频计数器都不动,结果是不宣告任何故障,落到 Untrusted。 | n = 45 | 2026年8月22日 |
| 测出来大约每秒半张画面的静态卡,会把冻结窗口放宽到 15 秒,而不是收窄它;静态卡彻底停住时,在放宽后的窗口上依然检测得到。 | n = 1 | 2026年8月22日 |
| 音频停滞而视频健康,这种情况能拉起怀疑,却永远走不到判决,于是一个会话会以更快的采样率在 SUSPECT 里待上一辈子,从不发出稳定信号,也不清掉恢复账本,后面的故障就继承了一套已经用光的阶梯,直接跳到放弃卡片。 | n = 1 | 2026年8月22日 |
| 用户暂停会无限期屏蔽住冻住的计数器;跳转只屏蔽 5 秒;缓冲会挂起呈现层的判决,而零字节那条规则照常运行。 | n = 3 | 2026年8月22日 |
| 当前的黑屏调参:亮度 24 及以下的像素算黑,黑色像素占到 98% 且亮度标准差不高于 4.0 就算一个黑样本,判决需要连续 4 个样本,抓帧按 96 × 54 进行。 | n = 1 | 2026年8月22日 |
| 一次恢复尝试确认之后的 10 秒内,判决所需的连续黑样本数降到 2 个。 | n = 1 | 2026年8月22日 |
| 1500 毫秒是重新布防的下限,不是间隔。探测器会在到期时刻之后到达的下一条消息上触发,所以真正落地的间隔更长:消息以 1 Hz 到达的地方是 2 秒,本文记录的那次运行正是如此。 | n = 1 | 2026年8月22日 |
| 夜景在两条黑屏判据上都不通过,带黑边的画面黑色占比不到一半;两者都由测试钉死为非黑。 | n = 2 | 2026年8月22日 |
| 探测器只在流第一次出画之后做一次性扫描,以及在由视频停滞拉起怀疑、状态转入 SUSPECT 时再跑一次。开场那轮扫描一结束,稳态健康期间它就再也不会跑,所以一条本来健康的流在播放中途变黑,不会产出任何判决。 | n = 2 | 2026年8月22日 |
| 探测器是通过引擎自己的快照调用来抓帧的,而从硬解表面回读快照,本来就是播放器会因为跟内容无关的原因交回一张空白帧的经典位置。抓不到的快照,我们当作旁证来处理;至于一次成功的回读错误地呈现为黑的频率有多高,我们没有任何测量。PlaybackEngine.Watchdog.cs:253 calls MediaPlayer.TakeSnapshot (libvlc_video_take_snapshot); the timeout path is handled at WatchdogStateMachine.cs:867-873. | 规格 | 不适用 |
| 每一次探测采样都会算出一个 64 位均值哈希和一个平均亮度,两者也都随消息一起送出去,而规则引擎两个都不读。 | n = 1 | 2026年8月22日 |
| 纯色帧算出来的均值哈希,每一位都是置上的,因为每个格子都正好落在全局均值上,而比较用的是大于或等于。所以纯白和纯黑在这个哈希下无法区分。 | n = 1 | 2026年8月22日 |
| libvlc 内部的释放后使用,冒出来的是一次原生访问违例,不是 CLR 异常,所以托管侧的 catch 根本不会执行。clients/windows/src/MyTvPlayer.Shared/Media/LiveTvPreviewMediaHelper.LibVlc.cs:165-180, reproduced 2026-08-19, 0xC0000005 in libvlc_media_player_release with a concurrent stats read. | 规格 | 不适用 |
| 缓存下来的 LibVLCSharp Media 包装器自己持有一份原生引用,所以在池线程上换过媒体之后,它会一直返回那份已死媒体的冻住计数器,而且从不抛异常。libvlc 3.x doxygen for libvlc_media_player_get_media: the returned media is reference counted and the caller must release it, which is why a cached LibVLCSharp Media keeps the old media alive and keeps answering. Recorded verbatim at clients/windows/src/MyTvPlayer.Shared/Media/Watchdog/PlaybackWatchdog.cs:322-325. | 规格 | 不适用 |
| 一个被缓存的媒体包装器,让采样器在一条健康的流上报出了已死媒体的冻住计数器,被读成了网络停滞。这是 2026-07-24 23:24 的一次现场验收测试里实时看到的。修法是每一拍都重新取包装器,用完就释放。 | n = 1 | 2026年7月24日 |
| 十二个脚本化的一秒节拍,在 2 秒到达 HEALTHY,7 秒进入 SUSPECT,11 秒宣告 F5 VideoFreeze,异常持续时长 4000 毫秒。同一份脚本让音频计数器也停下来,就会在同一拍宣告 F6 TotalFreeze;第三次运行让 SUSPECT 期间的快照超时,宣告的仍然是 F5,只是原因字符串扩成了“video stalled while audio keeps playing (snapshot timeout observed)”。 | n = 12 | 2026年8月22日 |
| Playback 测试套件跑 108 个测试,0 个失败。 | n = 108 | 2026年8月22日 |
| 五级:只有整体冻结才有的先暂停再播放这一下、一次完整的重新应用、切到下一个候选、重建播放器,然后放弃。升级之前只有一次重启尝试;调台后 15 秒内发生的故障,入口在切候选那一级,而不是重新应用。 | n = 5 | 2026年8月22日 |
| 重建播放器这一级还没有宿主原语,在已发布的设置里是关掉的,所以停止超时那一类情况目前会直接结束前台阶梯,落到放弃。 | n = 1 | 2026年8月22日 |
| 第 k 次尝试等待 min(基数 × (2^k - 1), 上限),再叠加对称抖动,所以第一次立刻发起,从第二次开始依次是 1 秒、3 秒、7 秒,一路到上限。抖动是在封顶之后才乘上去的,所以封顶后的等待对称地散布在 30 秒上下,真正的天花板是 36 秒。 | n = 1 | 2026年8月22日 |
| 探测器会往应用自己的本地数据目录里写两个轮换的 PNG 文件,并就地覆盖它们。故障事件进本地日志,日志里的频道用一个 SHA-256 的前 16 位十六进制字符来标识,绝不用 URL。 | n = 1 | 2026年8月22日 |
| 遥测记录类型声明了,却从来没有被构造过,而它们文档注释里点名的那个批量上传器并不存在。 | n = 3 | 2026年8月22日 |