ENGINEERING CASE STUDIES

源码级故障复盘

从播放症状追到代码边界,用原始提交说明定位与修复取舍。

01 / CHROMIUM · HEVC · EME · TIZEN

Disney+ HEVC 播放失败:解密之前的 NAL 头检查

平台播放器收到样本之前,Chromium 的码流解析已将其拒绝。

01 故障描述

部分 Disney+ HEVC 影片在 Tizen Chromium 上无法播放。排查发现,样本在进入平台安全解密之前,已被 Chromium 的 NAL 头检查拒绝。

02 原因分析

MP4 demux / HEVC 样本转换→NAL 头检查 · 返回错误→尚未进入平台解密

H265NaluParser::AdvanceToNextNALU() 检查第二个头字节中的 nuh_temporal_id_plus1 是否非零。但在这类加密样本中,第二字节仍是密文,不应在解密前按明文语法校验。

① MP4 样本内的 NAL 布局 示意:clear_bytes = 5
位置 0–3NAL 长度 · 4 字节明文
位置 4NAL 头字节 1明文
位置 5NAL 头字节 2密文
位置 6 起后续数据本示例中加密

clear_bytes = 5 · 明文区域

cypher_bytes · 加密区域

② 将两个 NAL 头字节展开为位字段
第一个头字节 · 位置 4 · 明文
F
1 bit
type
6 bit
layer 高位
1 bit
第二个头字节 · 位置 5 · 本示例为密文
layer 低位
5 bit
temporal_id_plus1
3 bit

完整字段名:F = forbidden_zero_bit,type = nal_unit_type,layer = nuh_layer_id。

为什么是密文?clear_bytes = 5 只覆盖 4 字节长度和第一个头字节;第二个头字节已进入 cypher_bytes 对应的加密区域。

为什么返回错误?密文低 3 位恰好为 0 时,非零检查返回 kInvalidStream;错误可沿样本转换路径传播,导致播放失败。

不能假定 NAL 头第二字节一定是明文。应先看样本加密信息,再决定是否执行字段检查。

03 解决方案

版本对比确认新增检查后,我在 Tizen TV 分支跳过该项检查,让样本继续进入平台安全播放路径。

#if !BUILDFLAG(IS_TIZEN_TV)
  TRUE_OR_RETURN(nalu->nuh_temporal_id_plus1 != 0);
#endif

04 官方补丁对照

本人 Tizen 平台补丁2025-06-19按平台宏跳过检查查看 Tizen 平台完整 Patch ↗

早约 16 个月

官方 Chromium 同类修复2026-10-14仅在头部涉及加密区时跳过检查查看官方完整 Diff ↗

官方修复保留明文检查,并增加加密头部与明文头部的区分测试,佐证了“不能按明文语法检查密文字段”的问题机制。

源码追踪:从 MP4 demux 到平台解密
  1. SourceBuffer.appendBuffer()JS 向 MSE 追加媒体分片
  2. ChunkDemuxer / SourceBufferState运行 segment parser loop,调用 MP4StreamParser
  3. MP4StreamParser提取加密样本、时间戳与 DecryptConfig(含 subsamples)
  4. HEVCBitstreamConverter::ConvertAndAnalyzeFrame()长度前缀 → Annex B;分析码流,按需插入参数集
  5. H265NaluParser::AdvanceToNextNALU()AnalyzeAnnexB / InsertParamSetsAnnexB 路径调用 NAL 头解析
  6. TRUE_OR_RETURN(nalu->nuh_temporal_id_plus1 != 0)检查第二个头字节的低 3 位;不满足条件则返回 kInvalidStream
  7. DecryptingDemuxerStream → IEMEDrmBridge::DecryptTizen()样本解析通过并下传后,才进入 Tizen 的解密交接路径
  8. MediaPlayerESPlusPlayerTV::SubmitEsPacket()交付加密样本或 TZ handle,进入平台安全播放路径

固定版本:6bb9a6dec96db35094e37103a070a07157251e58。路径:media/formats/mp4/mp4_stream_parser.cc、media/formats/mp4/hevc.cc、media/parsers/h265_nalu_parser.cc、tizen_src/chromium_impl/media/filters/ieme_drm_bridge.cc。

证据范围与原始记录

时间先后来自公开 Git 记录,说明本人平台补丁早于这条官方修复;不表示上游参考或采纳了本人方案,也不主张最早发现。

图中四字节长度是示例,长度前缀不是 NAL 头的一部分。Annex B 转换需同步调整字节偏移。H.265 标准 ↗ · MP4 加密格式 ↗

官方 media 镜像 SHA:0b4a506f88ed8ee21281cd4a6cfacecf62eabf4e。官方 Review ↗

02 / WEBMEDIA · HLS WORKER · V8 · TIZEN

HLS Worker 内存超限:回收时机与电视应用预算

01 故障描述

三星电视上的 Hotstar HLS 播放场景中,开启 enableWorker 后内存持续抬升,触及约 600 MB 的应用预算,被系统终止。关闭 Worker 或推动 GC 后,历史测试中问题得到缓解。

开启 HLS Worker播放内存快速增长触及应用预算 · 系统终止

02 原因分析

V8 external-memory 的回收时机与电视应用预算不匹配。大块分片数据尚未及时回收,与媒体缓冲及平台资源叠加,先触及产品限额。

  1. XHR 接收SharedBufferAppend(data, len)
    累积分片字节
  2. 读取响应ArrayBuffer分配响应并复制
    解除接收缓冲引用
  3. Worker 处理输入 / 输出交接transfer 传递所有权
    解析、重封装后返回
  4. V8 回收释放存在时间差解除持有 → GC / 清扫
    驻留量不一定立即下降

为什么开启 Worker 才触发?

Worker 将解析、重封装移出主线程,也增加独立 isolate 和消息交接。输入、输出及待回收存储可能在时间上重叠;主线程 GC 不等于同时回收 Worker。transfer 转移所有权,不是立即释放内存。

为什么 GC 没赶上预算?

V8 按各 isolate 的 external-memory 计数动态调整回收阈值,并分阶段回收。这个策略不直接对应电视应用约 600 MB 的总预算;回收触发或完成之前,产品限额可能已先耗尽。

03 解决方案

方案一 / 应用侧

关闭 HLS Worker

new Hls({ enableWorker: false });

将解析、重封装移回主线程,减少 Worker 环境与跨线程交接。在播放与交互仍满足要求时,这是受限内存设备上的合理取舍;浏览器原生解码、MSE 播放能力保持不变。

验收重点:长时间播放、Seek、切档及遥控响应,确认内存改善没有换来明显主线程卡顿。

方案二 / V8 平台侧

约束 external-memory 触发阈值

void set_limit(int64_t value) {
#if defined(OS_TIZEN_TV_PRODUCT)
  if (value > kExternalAllocationHardLimit) {
    return;
  }
#endif
  limit_.store(value, std::memory_order_relaxed);
}

kExternalAllocationHardLimit = 300 * 1024 * 1024。平台补丁限制过高的新阈值,让后续分配更容易进入既有内存压力回收路径。TV 构建中主线程和 Worker 的各个 isolate 均适用;300 MiB 是阈值约束,不是应用内存上限,也不是每次直接 full GC。

源码追踪:XHR → Worker → external-memory 回收
  1. XMLHttpRequest::DidReceiveData()SharedBuffer::Append() 累积原生接收缓冲;ReportMemoryUsageToV8() 报告占用
  2. XMLHttpRequest::ResponseArrayBuffer()CreateUninitializedOrNull() → GetBytes();复制为响应后清空 builder,创建响应并不依赖 Worker 开关
  3. TransmuxerInterface.push() → worker.postMessage()transfer 输入至 Worker;Transmuxer.push() 解析、重封装,再由 emitTransmuxComplete() transfer 音视频输出
  4. Isolate::AdjustAmountOfExternalAllocatedMemory()分配或计账更新作用于所属 isolate;正增量超过 external_memory_limit() 时进入压力处理
  5. Heap::ReportExternalMemoryPressure()根据压力与回收状态启动 / 推进增量标记,或执行完整 GC;不是应用总内存达到固定值就统一回收
  6. ArrayBufferSweeper → backing store 释放解除持有、标记、清扫与分配器归还页面有时间差;仍被消息或解析状态持有的数据不能靠 GC 强行释放

源码入口:core/xmlhttprequest/xml_http_request.cc、core/workers/worker_backing_thread.cc、v8/src/api/api.cc、v8/src/heap/heap.h、v8/src/heap/heap.cc、v8/src/heap/array-buffer-sweeper.cc。

Worker GC 基本原理与官方资料

独立的回收环境。HLS Worker 是执行 JS 解析、重封装的线程。Chromium 为它创建独立 V8 isolate,维护自己的 JS heap 和 external-memory 计数;页面主 isolate 的一次 GC 不等于同时收集 Worker。平台可以向 Worker isolates 发送内存压力通知。

解除持有 · 对象不可达GC 标记 / 清扫backing store 释放或重用

先判断能不能回收。GC 从栈、全局对象等根引用追踪存活对象。仍被消息、回调或解析状态持有的 buffer 不能回收;不可达后也需要等待相应标记和清扫。V8 采用分代、增量与并发等机制,避免每处理一段数据就暂停 JS 做完整回收。

大 buffer 另有记账。ArrayBuffer 的底层字节存储参与 external-memory 计账;XHR 接收缓冲也会报告原生占用。正向增量超过所属 isolate 的阈值时进入压力处理,按状态启动 / 推进标记或执行完整 GC。清空引用、清扫存储与分配器归还物理页面是不同阶段,进程内存不会保证同步下降。

原始补丁与复盘依据

6fdac8d146dc7a2e6a965e38b39da6daf592dd13 · [VD] Set hard limit for external memory increment
Author:Jinwoo Kwon · AuthorDate:2024-04-05 · CommitDate:2024-05-20。

工程分工:我负责 WebMedia 侧问题排查与方案对接,将修复方案交由 V8 团队提交。原始 Git 提交作者为 Jinwoo Kwon,保留在补丁记录中。

补丁说明直接记录了 M94 媒体播放中 ArrayBuffer backing store 回收频率不足、PSS 触及产品限额的问题。实现位于 Heap::ExternalMemoryAccounting::set_limit();拒绝超过 300 MiB 的新值,保留旧阈值,各 isolate 独立生效。

原始 Patch ↗ · Chromium M94 XHR ↗ · 对应 V8 压力回收 ↗