test(hls): [PKG-HLS-SDK-01] retain controller evidence across page crashes

This commit is contained in:
Harvey Zhao committed 2026-09-15 17:15:28 +08:00
1 parent ea3174daa2
commit e0c5cf27de
11 files changed
+17131 -15

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,96 @@
# PKG-HLS-SDK-01 控制器现场与 Firefox 诊断限制
来源 HEAD:`ea3174daa28e698af3db2ff36aecf4ef2da54470`。本次是进行中任务的检查点,
不是插件验收完成;HLS-PLAYBACK-01 与 HLS-CRASH-01 继续 open。
## 取得的证据与下一步变化
未改动的分组集成用例在 Firefox 155.0 / Hls.js 1.7.2 / 发布核心 / 候选插件上
连续执行 20 次均通过。这只能说明本轮未再现,不推翻先前 0.999146 秒停滞、
空媒体缓冲及没有后续 high 视频请求的失败。不能把重复通过作为关闭风险的依据。
对固定 SDK 增加可选 `--controller-state`:在已有事件及 buffer/fragment 事件上
读取主/音频控制器、独立 SourceBuffer、当前/上一片段、endList 和片段跟踪状态。
仍用原来最多 400 条的事件队列,未插入 play/seek、关闭 Worker、改变 SDK 参数或
扩大断言超时。未开启时不安装观察函数,也不增加这些观察事件。
启用后还通过页面绑定异步复制事件到 Node 的独立400条队列,页面崩溃后仍保留已收到的
记录;最后尚未送达的事件可能丢失,receivedDuring 仅表示宿主收到时的阶段。
无穷大的 flush 终点写为字符串 Infinity,避免 JSON 把它变成 null 后产生歧义。
新的成功对照也出现过主控制器 ENDED、video.buffered 为空,但视频 SourceBuffer
仍有 0–12 秒数据,音频 SourceBuffer 暂时为空的组合。其后正常恢复并双向切档实播。
因此,单独看到 ENDED 和 video.buffered=[] 不足以认定根因;后续失败需要同时保留
音频/视频缓冲、片段追踪和 flush 顺序,不能据此强行 reset 主控制器。
## 实现与验证
`test/helpers/hls-controller-state.js` 是可序列化进页面的只读快照函数,仅用于
两个冻结 SDK。fragmentTracker 归属 stream/audio controller;1.5.17 的
sourceBuffer 字典与 1.7.2 的 sourceBuffers 数组分别读取。loaded 对象会含循环
引用,只记录是否存在,不导出整个对象。销毁后不读取失效的公共档位 getter。
1.5.17 的 audio controller 从 networkControllers 中按 playlistType 识别;
1.7.2 则可读取 audioStreamController,不能因字段缺失误报没有音频控制器。
原生 range getter 失败时返回单独 diagnosticError,不覆盖播放器原失败。
新增四项 Node/独立 VM 测试覆盖页面序列化、循环引用、旧版缓冲布局、销毁 getter
和采集错误;与既有固定 SDK 来源测试合计 5 通过,定向 lint 通过。
最终观察器实际运行:直接 native video 两 SDK 各一次,发布核心+候选插件两 SDK
各一次;四次均通过且均取得非空独立缓冲、片段跟踪和 endList 记录,无采集错误。
两版都有音频控制器状态,并验证 Node 收到记录与页面队列数量相同。
原默认模式 Hls.js 1.7.2 另运行一次通过,未开启 controller/Worker observer 或 SDK logs。
这五次不是全包矩阵。原集成测试的 20 次在新增观察器前执行,单独记录。
最初七次观察运行虽完成播放,但 tracker 路径错误导致字段缺失;修正归属后又做
四次,确认 tracker 可读,不过 1.5.17 的独立缓冲字段仍未收集。补上旧缓冲布局的
早期四次和默认一次也保存;之后补齐页面外保留及1.5音频定位,再做最终验证。
所有这些日志分别保留,不能把早期部分附件当作完整控制器证据。
lint 首轮三处 runner 说明/格式问题已修正;没有更新 SDK 或媒体来制造通过。
## 本轮再次捕获的真实崩溃
上述改进期间,直接 native video 的两个诊断序列各发生一次 Hls.js 1.5.17 页面崩溃。
第一个序列为一失败一通过,首个1.5.17就在 high-group 阶段崩溃,无更早SDK、ArtPlayer
或插件;当时只有页内队列,崩溃后读不到现场。第二个序列为三通过一失败,失败是第三项
1.5.17,Node 在页面失联后仍保留97条已收到事件。两个序列退出码均为1,最终通过不覆盖它们。
第二次最后记录:播放位置0.381837、readyState=4、高度90、9帧;先发audio flush,
再选择音轨、高组,最后发video flush。最后video SourceBuffer仍为0–8秒,audio为空,
两者updating=true,main从PARSED进入IDLE,endList为空。此时尚未执行destroy。
crash事件在宿主low-group阶段收到,后续high-group等待抛出Target crashed;保留两种
阶段信息,不把宿主阶段标签当精确原生崩溃位置。最后收到的事件不一定是最后执行的事件。
这些记录增强了独立SDK/Firefox路径证据,但没有定位原生模块、异常代码或堆栈;
尤其不能把此页面崩溃与1.7.2的播放停滞合并成同一个已证明根因。
## Firefox 原生崩溃证据的限制
实际安装目录 firefox-1543/firefox/omni.ja 的
`chrome/toolkit/content/global/buildconfig.html` 明确包含 `--disable-crashreporter`。
同目录 playwright.cfg 第350行锁定 toolkit.crashreporter.enabled=false,且没有
crashreporter/minidump-analyzer 可执行文件。自动导航 about:buildconfig 超时后,
改为只读提取本地归档成员;并未改写浏览器、偏好或系统注册表。
[Mozilla 文档](https://firefox-source-docs.mozilla.org/toolkit/crashreporter/crashreporter/index.html)
说明环境变量可控制已构建的 crash reporter,但不能让本机已禁用的编译功能出现。
所以这里没有添加一个虚假的“设置变量即可收集 minidump”选项,也没有声称取得原生堆栈。
后续需要针对本任务创建的准确进程收集 Windows 原生异常,或使用具备相应功能的独立
浏览器对照。可选工具依据见
[Microsoft ProcDump](https://learn.microsoft.com/en-us/sysinternals/downloads/procdump)。
本轮仅查阅该工具说明,未安装、附加调试器、设置系统级崩溃捕获或上传报告。
## 重跑与交接
```sh
yarn test:browser hls-sdk.spec.js --project=firefox --grep "1.7.2.*published core: group changes" --repeat-each=20 --workers=1
node refactor/scripts/hls-sdk-diagnostic.mjs --sequence 1.5.17,1.7.2 --switch-boundary immediate --controller-state --capture-before-destroy
node refactor/scripts/hls-sdk-diagnostic.mjs --sequence 1.5.17,1.7.2 --host published --plugin --switch-boundary selected --controller-state --capture-before-destroy
node --test refactor/scripts/hls-controller-state.test.mjs refactor/scripts/hls-sdk-matrix.test.mjs
```
Windows / Node 24.21.0 / Yarn Classic 1.22.22;两个 SDK 都保持 enableWorker=true。
原分组停滞与独立原生页面崩溃仍未定位,后续优先获取失败现场或进程异常证据;
不要再把无观察器的重复绿色结果作为新修复。来源、实际样本和日志指纹见
[机器证据](../baselines/hls-controller-validation.json)。
回退本检查点仅撤销诊断采集、相应测试与说明。没有生产源码、公开 API、类型、
依赖、锁文件或分发产物变化,没有推送、部署或发布。
+10
View File
@@ -99,3 +99,13 @@ worker 切源/清理覆盖两个 SDK 与新旧核心/插件四组合;候选插
本子任务暂未完成:一次 Firefox 分组切换后的销毁出现 Target crashed,HLS-CRASH-01
仍待定位。后续完整矩阵和重复通过不能抹去首次失败;详见
[进行中记录](changes/2026-09-12-PKG-HLS-SDK-01-integration.md)。
后续的分组停滞与原生崩溃保持独立风险。固定 SDK 诊断现在可加
`--controller-state` 同时记录控制器、音视频 SourceBuffer 和片段追踪;该观察器
位于 test/helpers/hls-controller-state.js,不进入生产插件。默认不开启,避免把
额外观察导致的时序变化当修复。2026-09-15 的20次原测试通过仍不关闭风险;
成功对照也能短暂出现 ENDED+空媒体缓冲,必须看独立缓冲和后续恢复。
本机 Firefox 编译参数禁用了 crash reporter,不能承诺环境变量能生成 minidump。
随后直接1.5.17两次切组崩溃均在destroy之前;可选观察器向Node复制事件后,
其中一次页面失联仍保留97条事件。此处有最后收到的音视频flush状态,尚无原生堆栈。
详情及后续取证方向见[控制器检查点](changes/2026-09-15-PKG-HLS-SDK-01-controller-state.md)。
+1 -1
View File
@@ -573,7 +573,7 @@
- PKG-HLS-02: [记录](hls-validation.md) [记录](baselines/hls-sdk.json) [记录](baselines/hls-validation.json) [记录](changes/2026-09-11-PKG-HLS-02-tests.md)
- PKG-HLS-03: [记录](changes/2026-09-12-PKG-HLS-03-modules.md) [记录](baselines/hls-modules-validation.json) [记录](hls-validation.md)
- PKG-HLS-04: [记录](changes/2026-09-12-PKG-HLS-04-types.md) [记录](baselines/hls-types-validation.json) [记录](hls-validation.md)
- PKG-HLS-SDK-01: [记录](changes/2026-09-12-PKG-HLS-SDK-01-integration.md) [记录](baselines/hls-sdk-validation.json) [记录](baselines/hls-sdk-diagnostics.json) [记录](changes/2026-09-14-PKG-HLS-SDK-01-http-teardown.md) [记录](baselines/hls-http-teardown-validation.json) [记录](changes/2026-09-15-PKG-HLS-SDK-01-switch-boundary.md) [记录](baselines/hls-switch-boundary-validation.json) [记录](changes/2026-09-15-PKG-HLS-SDK-01-sdk-sequence.md) [记录](baselines/hls-sdk-sequence-validation.json)
- PKG-HLS-SDK-01: [记录](changes/2026-09-12-PKG-HLS-SDK-01-integration.md) [记录](baselines/hls-sdk-validation.json) [记录](baselines/hls-sdk-diagnostics.json) [记录](changes/2026-09-14-PKG-HLS-SDK-01-http-teardown.md) [记录](baselines/hls-http-teardown-validation.json) [记录](changes/2026-09-15-PKG-HLS-SDK-01-switch-boundary.md) [记录](baselines/hls-switch-boundary-validation.json) [记录](changes/2026-09-15-PKG-HLS-SDK-01-sdk-sequence.md) [记录](baselines/hls-sdk-sequence-validation.json) [记录](changes/2026-09-15-PKG-HLS-SDK-01-controller-state.md) [记录](baselines/hls-controller-validation.json)
- PKG-DASH-01: [记录](changes/2026-09-12-PKG-DASH-01-contract.md) [记录](baselines/dash-control-contract.md) [记录](baselines/dash-control-release.json)
- PKG-DASH-02: [记录](changes/2026-09-12-PKG-DASH-02-tests.md) [记录](dash-validation.md) [记录](baselines/dash-validation.json)
- PKG-DASH-03: [记录](changes/2026-09-12-PKG-DASH-03-runtime.md) [记录](baselines/dash-runtime.json) [记录](dash-validation.md)
+17
View File
@@ -1,5 +1,22 @@
# 进度与证据
## PKG-HLS-SDK-01 控制器与原生诊断检查点
原Firefox1.7.2切组测试20次通过,不关闭历史停滞。新增可选controller-state,
读取固定SDK两版的控制器、独立音视频缓冲与片段跟踪,异步保留到Node队列。
最终直接宿主/发布核心各两次通过并取得两版完整字段,默认模式一次通过;回归与
SDK来源测试5项、lint通过。早期缺tracker/旧缓冲/音频字段的记录保留为部分证据。
本轮另两次直接1.5.17在destroy之前页面崩溃,无ArtPlayer/插件,其中一次是该
浏览器序列首项;新增Node采集在第二次保住97条事件,最后处于audio/video flush
与切高组阶段。仍无原生异常代码或堆栈,不将该崩溃和1.7.2停滞合并为同一根因。
本机Firefox归档确认--disable-crashreporter,后续需限定任务进程的原生异常取证。
详见[记录](changes/2026-09-15-PKG-HLS-SDK-01-controller-state.md)与
[证据](baselines/hls-controller-validation.json)。
本任务继续doing,两项HLS风险继续open;207 done、21 doing、43 todo,总271不变。
没有生产代码、公开API、依赖、版本或分发修改,没有推送或发布。
## SITE-DANMUKU-01 双语指南和示例验证
新增 Danmuku 英文指南/侧栏,按源码修正中文 Promise/owner、加载、回调、mount、
+9 -4
View File
@@ -2815,11 +2815,13 @@
"refactor/changes/2026-09-14-PKG-HLS-SDK-01-http-teardown.md",
"refactor/baselines/hls-http-teardown-validation.json",
"refactor/changes/2026-09-15-PKG-HLS-SDK-01-sdk-sequence.md",
"refactor/baselines/hls-sdk-sequence-validation.json"
"refactor/baselines/hls-sdk-sequence-validation.json",
"refactor/changes/2026-09-15-PKG-HLS-SDK-01-controller-state.md",
"refactor/baselines/hls-controller-validation.json"
],
"compatibleResolution": "直接原生 video(无 ArtPlayer/插件)和候选核心已有 page crashed;候选核心无观察器也复现。继续区分 SDK、Firefox/Playwright 与媒体销毁,不静默改核心 destroy 顺序或调用方 SDK 配置。",
"closureCriteria": "取得可解释的崩溃诊断或直接 SDK 对照;验证处置和新旧核心/worker/分组销毁矩阵,不能仅以再次通过关闭。",
"workspaceState": "2026-09-14 direct HTTP/no ArtPlayer/plugin/Worker observer:35 diagnostics,31 passed4 failed. Worker reset-first fails1/10 and1/5; no-worker10/10 and optional pre-destroy snapshot5/5 pass. SDK-first also fails2/5. Three phase-recorded crashes occur during final-state collection after destroy returned. Installed Firefox TargetRegistry observer receives oop-frameloader-crashed; no native stack/root cause established. Do not substitute no-worker/snapshot success or change core teardown order; risk remains open. 2026-09-15 sequence: direct native video first 1.5.17 case reports page crash in high-group phase before explicit destroy; no earlier SDK case, ArtPlayer, plugin, route, SDK logger or Worker observer. Three ordered sequences plus one default invocation total 8 passed/1 failed. Native cause remains unproven; do not restrict investigation to teardown or merge distinct signals into a proven shared cause."
"workspaceState": "2026-09-14 direct HTTP/no ArtPlayer/plugin/Worker observer:35 diagnostics,31 passed4 failed. Worker reset-first fails1/10 and1/5; no-worker10/10 and optional pre-destroy snapshot5/5 pass. SDK-first also fails2/5. Three phase-recorded crashes occur during final-state collection after destroy returned. Installed Firefox TargetRegistry observer receives oop-frameloader-crashed; no native stack/root cause established. Do not substitute no-worker/snapshot success or change core teardown order; risk remains open. 2026-09-15 sequence: direct native video first 1.5.17 case reports page crash in high-group phase before explicit destroy; no earlier SDK case, ArtPlayer, plugin, route, SDK logger or Worker observer. Three ordered sequences plus one default invocation total 8 passed/1 failed. Native cause remains unproven; do not restrict investigation to teardown or merge distinct signals into a proven shared cause. 2026-09-15 installed Firefox 1543 local omni.ja buildconfig includes --disable-crashreporter; playwright.cfg locks crash reporter off. No native stack collected. Environment-variable-only capture cannot restore a compile-disabled component; next evidence needs scoped Windows native debugging or a separately validated browser build. Later in this checkpoint, direct 1.5.17 crashed twice before destroy (one first-in-sequence); no ArtPlayer/plugin. Optional Node event queue retained 97 events in the second crash, ending during overlapping audio/video flush and high selection at media time 0.381837. Crash receipt was low-group; following assertion failure was high-group. No native module/exception/stack established."
},
{
"id": "HLS-PLAYBACK-01",
@@ -2836,10 +2838,13 @@
"refactor/changes/2026-09-12-PKG-HLS-SDK-01-integration.md",
"refactor/baselines/hls-sdk-validation.json",
"refactor/changes/2026-09-15-PKG-HLS-SDK-01-switch-boundary.md",
"refactor/baselines/hls-switch-boundary-validation.json"
"refactor/baselines/hls-switch-boundary-validation.json",
"refactor/changes/2026-09-15-PKG-HLS-SDK-01-controller-state.md",
"refactor/baselines/hls-controller-validation.json"
],
"compatibleResolution": "区分 SDK、测试媒体/时序和宿主问题,保留真实档位/解码断言及 trace;不扩大等待或只验证菜单。",
"closureCriteria": "直接 Hls 与新旧核心对照定位切换停滞,验证处置和正常/连续组切换,不能以重复通过关闭。"
"closureCriteria": "直接 Hls 与新旧核心对照定位切换停滞,验证处置和正常/连续组切换,不能以重复通过关闭。",
"workspaceState": " 2026-09-15 unchanged integration 20/20 passes do not close historical failure. Optional --controller-state now captures fixed SDK controller/buffer/fragment tracker state. Final direct and published-core controls pass for both SDKs; successful cases can briefly show main ENDED and empty media ranges while video SourceBuffer remains populated. Original failure still needs those independent buffers and tracker state; no forced-reset workaround."
},
{
"id": "AUDIO-LIFE-01",
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- The baseline runner verifies page-serialized diagnostics in an isolated Node VM.
import { test } from 'node:test'
import { runInNewContext } from 'node:vm'
import { snapshotHlsControllers } from '../../test/helpers/hls-controller-state.js'
test('fixed SDK controller observer serializes in a fresh realm and avoids cyclic loaded data', () => {
const snapshot = runInNewContext(`(${snapshotHlsControllers.toString()})`)
const source = { buffered: { length: 1, start: () => 0, end: () => 12 }, updating: false }
const body = { sn: 5, level: 1, type: 'main', start: 10, duration: 2 }
const loaded = { frag: body }
body.loader = { loaded }
const entity = { body, loaded, buffered: true }
const fragmentTracker = Object.freeze({ endListFragments: { main: entity }, fragments: { main_1_5: entity } })
const hls = Object.freeze({
streamController: Object.freeze({ state: 'ENDED', level: 1, nextLoadPosition: 12, altAudio: 2, mediaBuffer: source, videoBuffer: source, fragPrevious: body, fragmentTracker }),
bufferController: Object.freeze({ sourceBuffers: [['video', source]], mediaSource: { readyState: 'open' } }),
})
const result = JSON.parse(JSON.stringify(snapshot(hls)))
assert.equal(result.main.state, 'ENDED')
assert.equal(result.main.mediaBuffer, 'video')
assert.deepEqual(result.main.buffered, [[0, 12]])
assert.deepEqual(result.endList.main, { fragment: { sn: 5, level: 1, type: 'main', start: 10, duration: 2 }, buffered: true, loaded: true })
assert.deepEqual(result.tracked, [{ key: 'main_1_5', buffered: true, loaded: true }])
assert.equal(entity.loaded, loaded)
assert.equal(source.buffered.length, 1)
})
test('detached and destroyed SDK snapshots do not invoke invalid public level getters', () => {
const hls = {
streamController: null,
bufferController: null,
fragmentTracker: null,
get currentLevel() {
throw new Error('destroyed getter')
},
}
assert.equal(snapshotHlsControllers(hls).diagnosticError, undefined)
assert.equal(snapshotHlsControllers(undefined).diagnosticError, undefined)
assert.equal(snapshotHlsControllers({ streamController: { state: 'STOPPED' } }).main.state, 'STOPPED')
})
test('SDK 1.5 buffer dictionary and audio controller tracker remain readable', () => {
const source = { updating: false, buffered: { length: 0 } }
const result = snapshotHlsControllers({ bufferController: { sourceBuffer: { audio: source } }, networkControllers: [{ playlistType: 'main' }, { playlistType: 'audio', state: 'IDLE', fragmentTracker: { fragments: {}, endListFragments: {} } }] })
assert.deepEqual(result.buffers, [{ type: 'audio', updating: false, buffered: [] }])
assert.deepEqual(result.endList, {})
assert.deepEqual(result.tracked, [])
assert.equal(result.audio.state, 'IDLE')
})
test('native range getter failure is recorded without replacing the player failure', () => {
const hls = { streamController: {
get mediaBuffer() {
throw new Error('released buffer')
},
} }
assert.equal(snapshotHlsControllers(hls).diagnosticError, 'Error: released buffer')
})
+28 -9
View File
@@ -6,13 +6,15 @@ import path from 'node:path'
import process from 'node:process'
import { parseArgs } from 'node:util'
import { expect, firefox } from '@playwright/test'
import { snapshotHlsControllers } from '../../test/helpers/hls-controller-state.js'
import { observeWorkers } from '../../test/helpers/worker-observer.js'
import { ensureArchive, hash, readMember, refactorDir } from './releases.mjs'
// Direct SDK control: no ArtPlayer or plugin is loaded, and no worker output is substituted.
export async function diagnose({ version = '1.7.2', iterations = 5, transport = 'http', teardown = 'reset-first', worker = true, host = 'direct', plugin = false, sdkLogs = false, workerObserver = false, captureBeforeDestroy = false, switchBoundary = 'switched', prefill = false, sequence } = {}) {
export async function diagnose({ version = '1.7.2', iterations = 5, transport = 'http', teardown = 'reset-first', worker = true, host = 'direct', plugin = false, sdkLogs = false, workerObserver = false, controllerState = false, captureBeforeDestroy = false, switchBoundary = 'switched', prefill = false, sequence } = {}) {
const runnerSHA256 = hash(fs.readFileSync(new URL(import.meta.url)))
const observerSHA256 = hash(fs.readFileSync(new URL('../../test/helpers/worker-observer.js', import.meta.url)))
const controllerObserverSHA256 = hash(fs.readFileSync(new URL('../../test/helpers/hls-controller-state.js', import.meta.url)))
assert(Number.isInteger(iterations) && iterations > 0 && iterations <= 50)
assert(['http', 'route'].includes(transport))
assert(['reset-first', 'sdk-first'].includes(teardown))
@@ -76,6 +78,14 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
const wait = predicate => expect.poll(() => page.evaluate(predicate), { timeout: 7000 }).toBe(true)
const entry = { iteration, version: versions[iteration], status: 'running', errors: [], requests: [], phase: 'setup', crashes: [] }
results.push(entry)
if (controllerState) {
entry.observedEvents = []
await page.exposeFunction('__recordHlsControllerState', (event) => {
entry.observedEvents.push({ receivedDuring: entry.phase, ...event })
if (entry.observedEvents.length > 400)
entry.observedEvents.shift()
})
}
page.on('pageerror', error => entry.errors.push(error.message))
page.on('crash', () => {
entry.errors.push('page crashed')
@@ -93,6 +103,8 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
}
try {
await page.goto(origin)
if (controllerState)
await page.addScriptTag({ content: `window.snapshotHlsControllers = ${snapshotHlsControllers.toString()};` })
if (workerObserver)
await page.evaluate(observeWorkers)
await page.addScriptTag({ url: `${origin}/hls-${entry.version}.js` })
@@ -101,7 +113,7 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
await page.addScriptTag({ url: `${origin}/artplayer.js` })
if (plugin)
await page.addScriptTag({ url: `${origin}/plugin.js` })
await page.evaluate(({ worker, teardown, host, plugin, sdkLogs }) => {
await page.evaluate(({ worker, teardown, host, plugin, sdkLogs, controllerState }) => {
let video
let hls
let art
@@ -109,7 +121,10 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
const buffer = ranges => Array.from({ length: ranges.length }, (_, index) => [ranges.start(index), ranges.end(index)])
const state = () => ({ time: video.currentTime, readyState: video.readyState, height: video.videoHeight, paused: video.paused, frames: video.getVideoPlaybackQuality().totalVideoFrames, buffered: buffer(video.buffered), seekable: buffer(video.seekable) })
const record = (event, data) => {
records.push({ at: performance.now(), event, data, media: state() })
const snapshot = { at: performance.now(), event, data, media: state(), ...(controllerState ? { controllers: window.snapshotHlsControllers(hls) } : {}) }
records.push(snapshot)
if (controllerState)
window.__recordHlsControllerState(snapshot).catch(() => {})
if (records.length > 400)
records.shift()
}
@@ -117,8 +132,11 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
function attach(element) {
video = element
hls = new window.Hls({ enableWorker: worker, startLevel: 0, debug: sdkLogs ? logger : false })
for (const key of ['ERROR', 'AUDIO_TRACKS_UPDATED', 'AUDIO_TRACK_SWITCHING', 'AUDIO_TRACK_SWITCHED', 'LEVEL_SWITCHING', 'LEVEL_SWITCHED', 'FRAG_BUFFERED', 'BUFFER_FLUSHED']) {
hls.on(window.Hls.Events[key], (_, data) => record(key, { id: data.id, level: typeof data.level === 'number' ? data.level : undefined, fatal: data.fatal, details: typeof data.details === 'string' ? data.details : undefined, frag: data.frag && { type: data.frag.type, sn: data.frag.sn, level: data.frag.level }, tracks: data.audioTracks?.map(track => ({ id: track.id, name: track.name, group: track.groupId })) }))
const eventKeys = ['ERROR', 'AUDIO_TRACKS_UPDATED', 'AUDIO_TRACK_SWITCHING', 'AUDIO_TRACK_SWITCHED', 'LEVEL_SWITCHING', 'LEVEL_SWITCHED', 'FRAG_BUFFERED', 'BUFFER_FLUSHED']
if (controllerState)
eventKeys.push('BUFFER_FLUSHING', 'FRAG_LOADING', 'FRAG_LOADED', 'BUFFER_APPENDED')
for (const key of eventKeys) {
hls.on(window.Hls.Events[key], (_, data) => record(key, { id: data.id, type: data.type, startOffset: data.startOffset, endOffset: typeof data.endOffset === 'number' && !Number.isFinite(data.endOffset) ? String(data.endOffset) : data.endOffset, level: typeof data.level === 'number' ? data.level : undefined, fatal: data.fatal, details: typeof data.details === 'string' ? data.details : undefined, frag: data.frag && { type: data.frag.type, sn: data.frag.sn, level: data.frag.level }, tracks: data.audioTracks?.map(track => ({ id: track.id, name: track.name, group: track.groupId })) }))
}
for (const name of ['waiting', 'stalled', 'seeking', 'seeked', 'playing', 'pause', 'ended', 'loadedmetadata', 'canplay', 'error'])
video.addEventListener(name, () => record(name))
@@ -175,7 +193,7 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
record('destroy:end')
},
}
}, { worker, teardown, host, plugin, sdkLogs })
}, { worker, teardown, host, plugin, sdkLogs, controllerState })
entry.phase = 'initial-playback'
await wait(() => window.direct.video?.readyState >= 3)
await page.evaluate(() => window.direct.video.play())
@@ -273,7 +291,7 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
finally {
entry.failurePhase = entry.status === 'failed' ? entry.phase : undefined
entry.phase = 'final-state'
entry.state = await page.evaluate(() => ({ coreLoaded: typeof window.Artplayer, pluginLoaded: typeof window.artplayerPluginHlsControl, workers: window.workerEvidence, media: window.direct?.state(), events: window.direct?.records, sdk: { version: window.Hls?.version, level: window.direct?.hls.currentLevel, audioTrack: window.direct?.hls.audioTrack, mainState: window.direct?.hls.streamController?.state, audioState: window.direct?.hls.audioStreamController?.state } })).catch(error => ({ error: error.message }))
entry.state = await page.evaluate(() => ({ coreLoaded: typeof window.Artplayer, pluginLoaded: typeof window.artplayerPluginHlsControl, workers: window.workerEvidence, media: window.direct?.state(), events: window.direct?.records, controllers: window.snapshotHlsControllers?.(window.direct?.hls), sdk: { version: window.Hls?.version, level: window.direct?.hls.currentLevel, audioTrack: window.direct?.hls.audioTrack, mainState: window.direct?.hls.streamController?.state, audioState: window.direct?.hls.audioStreamController?.state } })).catch(error => ({ error: error.message }))
entry.phase = 'trace-stop'
await context.tracing.stop({ path: path.join(directory, `${iteration}.zip`) }).catch((error) => {
entry.traceError = error.message
@@ -295,7 +313,7 @@ export async function diagnose({ version = '1.7.2', iterations = 5, transport =
await browser?.close()
server.closeAllConnections()
await new Promise(resolve => server.close(resolve))
fs.writeFileSync(path.join(directory, 'report.json'), `${JSON.stringify({ version: sequence ? undefined : version, iterations: versions.length, sequence: versions, transport, teardown, worker, host, plugin, sdkLogs, workerObserver, captureBeforeDestroy, switchBoundary, prefill, runnerSHA256, observerSHA256, browserVersion, release: sequence ? undefined : releases[0], releases, inputs: Object.fromEntries([...files].map(([file, bytes]) => [file, hash(bytes)])), results }, null, 2)}\n`)
fs.writeFileSync(path.join(directory, 'report.json'), `${JSON.stringify({ version: sequence ? undefined : version, iterations: versions.length, sequence: versions, transport, teardown, worker, host, plugin, sdkLogs, workerObserver, controllerState, captureBeforeDestroy, switchBoundary, prefill, runnerSHA256, observerSHA256, controllerObserverSHA256, browserVersion, release: sequence ? undefined : releases[0], releases, inputs: Object.fromEntries([...files].map(([file, bytes]) => [file, hash(bytes)])), results }, null, 2)}\n`)
console.log(`Diagnostic report: ${directory}`)
}
return results
@@ -314,13 +332,14 @@ const { values, tokens } = parseArgs({
'plugin': { type: 'boolean', default: false },
'sdk-logs': { type: 'boolean', default: false },
'observe-workers': { type: 'boolean', default: false },
'controller-state': { type: 'boolean', default: false },
'capture-before-destroy': { type: 'boolean', default: false },
'switch-boundary': { type: 'string', default: 'switched' },
'prefill': { type: 'boolean', default: false },
},
})
assert(!values.sequence || !tokens.some(token => token.kind === 'option' && ['version', 'iterations'].includes(token.name)), 'Use --sequence or --version/--iterations, not both')
diagnose({ version: values.version, iterations: Number(values.iterations), transport: values.transport, teardown: values.teardown, worker: !values['no-worker'], host: values.host, plugin: values.plugin, sdkLogs: values['sdk-logs'], workerObserver: values['observe-workers'], captureBeforeDestroy: values['capture-before-destroy'], switchBoundary: values['switch-boundary'], prefill: values.prefill, sequence: values.sequence?.split(',') }).then((results) => {
diagnose({ version: values.version, iterations: Number(values.iterations), transport: values.transport, teardown: values.teardown, worker: !values['no-worker'], host: values.host, plugin: values.plugin, sdkLogs: values['sdk-logs'], workerObserver: values['observe-workers'], controllerState: values['controller-state'], captureBeforeDestroy: values['capture-before-destroy'], switchBoundary: values['switch-boundary'], prefill: values.prefill, sequence: values.sequence?.split(',') }).then((results) => {
process.exitCode = results.some(result => result.status !== 'passed') ? 1 : 0
}).catch((error) => {
console.error(error)
+3 -1
View File
@@ -2154,7 +2154,9 @@
"changes/2026-09-15-PKG-HLS-SDK-01-switch-boundary.md",
"baselines/hls-switch-boundary-validation.json",
"changes/2026-09-15-PKG-HLS-SDK-01-sdk-sequence.md",
"baselines/hls-sdk-sequence-validation.json"
"baselines/hls-sdk-sequence-validation.json",
"changes/2026-09-15-PKG-HLS-SDK-01-controller-state.md",
"baselines/hls-controller-validation.json"
]
},
{
+10
View File
@@ -146,6 +146,16 @@ one explicit capability/error-cleanup test covers that limitation and its 14 pla
skipped with a reason. Chromium/Firefox run the full four-combination HLS matrix. This is not
Safari playback approval; see [HLS validation](../../refactor/hls-validation.md) for evidence and open gates.
The standalone `refactor/scripts/hls-sdk-diagnostic.mjs --controller-state` option
installs `helpers/hls-controller-state.js` only for a fixed-SDK diagnostic. It reads
the 1.5.17 buffer dictionary or 1.7.2 buffer array and the controllers' shared
fragment tracker, keeping cyclic loaded objects out of JSON. The 1.5 audio stream
controller is located by playlistType in networkControllers. Observations are also
copied asynchronously into a bounded Node queue, retaining received events after
a page crash; the last undelivered events may be lost. Default diagnostics
do not enable this observer. A missing or errored snapshot is incomplete evidence;
neither transient empty media ranges nor repeated success resolves HLS-PLAYBACK-01.
`playback.spec.js` tests published/current core with current chapter, real decoded frame colors,
media time advancing, pause, seek, source change to the existing docs video, destroy and real
HTTP failure. These are initial smoke contracts, not full chapter or lifecycle coverage.
+35
View File
@@ -0,0 +1,35 @@
// Self-contained so the fixed-SDK diagnostic can install this function in a page.
// These private fields are evidence only, never dependencies of a player plugin.
export function snapshotHlsControllers(hls) {
const ranges = source => source && Array.from({ length: source.length }, (_, index) => [source.start(index), source.end(index)])
const fragment = value => value && ({ sn: value.sn, level: value.level, type: value.type, start: value.start, duration: value.duration })
const controller = value => value && ({
state: value.state,
level: value.level,
nextLoadPosition: value.nextLoadPosition,
altAudio: value.altAudio,
mediaBuffer: value.mediaBuffer === value.videoBuffer && value.videoBuffer ? 'video' : value.mediaBuffer === value.media && value.media ? 'media' : 'other',
buffered: ranges(value.mediaBuffer?.buffered),
current: fragment(value.fragCurrent),
previous: fragment(value.fragPrevious),
})
try {
const audio = hls?.audioStreamController || hls?.networkControllers?.find(value => value.playlistType === 'audio')
const tracker = hls?.streamController?.fragmentTracker || audio?.fragmentTracker
const bufferController = hls?.bufferController
const buffers = Array.isArray(bufferController?.sourceBuffers)
? bufferController.sourceBuffers
: bufferController?.sourceBuffer && Object.entries(bufferController.sourceBuffer)
return {
main: controller(hls?.streamController),
audio: controller(audio),
mediaSourceState: hls?.bufferController?.mediaSource?.readyState,
buffers: buffers?.map(([type, source]) => ({ type, updating: source?.updating, buffered: ranges(source?.buffered) })),
endList: tracker && Object.fromEntries(Object.entries(tracker.endListFragments || {}).map(([type, entity]) => [type, { fragment: fragment(entity.body), buffered: !!entity.buffered, loaded: !!entity.loaded }])),
tracked: tracker && Object.entries(tracker.fragments || {}).map(([key, entity]) => ({ key, buffered: !!entity?.buffered, loaded: !!entity?.loaded })),
}
}
catch (error) {
return { diagnosticError: String(error) }
}
}