test(jassub): [PKG-JASSUB-09] isolate synchronous native bitmap stalls

This commit is contained in:
Harvey Zhao committed 2026-09-15 18:24:49 +08:00
1 parent 319dccb605
commit eba8c29fa7
10 files changed
+8865 -16

No files matched your search

@@ -178,6 +178,13 @@ core versions. Lifecycle, hybrid and render-failure suites use the verified
installed candidate; without a map they build current source, unless an explicit
artifact is selected. The native-render suite retains its historical default in
source mode. The platform suite has no JASSUB wrapper/WASM and is a control only.
Its optional `ARTPLAYER_JASSUB_CONTROL_SINGLE_FLIGHT=true` bounds the native
Worker queue to one draw while retaining concurrent canvas readback. It measures
the synchronous createImageBitmap call separately from Promise settlement and
rejects readback stalls hidden by eventual pixels. Firefox has stalled with this
bound and on the script-free host; neither a smaller queue nor successful reruns
establishes a vendor fix. The native-call checkpoint in refactor records the
remaining rendering boundary without changing production rendering defaults.
The browser server maps the three worker/WASM URLs to installed package files,
checking archive digests and frozen resource baselines. The font remains a separate
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,11 @@
# PKG-JASSUB-09 Firefox 绘制诊断检查点
后续更精确的调用边界计时见
[2026-09-15 原生调用记录](2026-09-15-PKG-JASSUB-09-native-call.md)。新证据已在
不加载核心的原生页面观察到停顿,并将一次等待定位到 createImageBitmap 返回
Promise 之前。本页“Promise 等待”和“纯原生迄今通过”只描述当时的观测粒度,
不能继续用于排除原生停顿或认定异步等待阶段。
任务保持 doing。本轮未修改生产源码、默认选项、公开类型或发布产物;没有把一次
对照通过当作修复。接续 HEAD `7f185f698dbd303b00ca7bc0c21f51cf65c69a04` 的 hybrid
修复检查点,调查 JASSUB-FIREFOX-OFFSCREEN-01。
@@ -0,0 +1,82 @@
# PKG-JASSUB-09:区分原生调用阻塞和 Promise 等待
接续 HEAD `319dccb60593f6206418dc04b27b1170767fa716`。本检查点只改诊断测试及
证据,PKG-JASSUB-09/05 和 JASSUB-FIREFOX-OFFSCREEN-01 仍未完成。未修改生产
源码、vendor、声明、构建产物、依赖、版本或默认渲染模式。
## 已排除的假设和新边界
给原生对照增加显式 SINGLE_FLIGHT 后,任意时刻最多一个未确认的绘制任务。
在旧核心宿主上仍复现停顿,maxOutstanding=1;因此同时堆积多个 ImageBitmap
任务不是该次失败的必要条件。主线程仍按原来的方式读取画布,没有等待 Worker。
初版定时探针在停顿期间也没有执行,因此把 createImageBitmap 调用入口、同步
返回、Promise 结算分开记录。最终版本先构造 ImageData 再开始调用计时,避免
把像素缓冲区构造算入原生 API。一次精确边界运行中,旧核心宿主的调用耗时
10010ms,返回后约2ms即结算;同时主线程 drawImage 耗时10004ms,之后
getImageData 没有额外可测停顿。最终三浏览器运行对应耗时10006ms/10004ms。
这是原生调用阻塞的观测,不是 Promise 已返回后单纯长时间未结算的证据。
不同 realm 的 performance.now 原点不同,以上只比较各自内部时差。
更早的边界运行中,完全不加载 ArtPlayer/JASSUB 的原生页面也发生10003ms的
画布复制停顿;包含 ImageData 构造的创建表达式耗时10005ms。该用例最终拿到
期望颜色而显示 passed:expect.poll 不能中断正在阻塞的浏览器调用,成功的
晚到断言不足以证明没有停顿。不能继续用“native 通过”排除原生路径的问题。
新 SINGLE_FLIGHT 诊断在全部播放、seek、布局采样之后检查每次读取少于7000ms,
保留原像素、尺寸及时间前进断言;没有扩大原 expect/test 超时。
上述证据排除了“必须加载核心”和“必须存在多个并发创建任务”两个必要条件,
没有证明所有 JASSUB 截图失败都出自同一原因,也没有识别底层锁、原生堆栈、
浏览器缺陷修复版本或合适的生产降级策略。不能据此关闭原风险。
## 可维护入口
`test/browser/jassub-platform.spec.js` 保留默认 fillRect 和原有 bitmap/idle
控制,新增 `ARTPLAYER_JASSUB_CONTROL_SINGLE_FLIGHT=true`。它只在没有未确认
绘制时发送下一帧,记录跳过的回调、最大队列、逐任务ID及主线程采样耗时。
Worker 每250ms尝试报告尚未结算的任务,区分调用入口、返回、结算和绘制完成。
定时器随原有 Worker terminate 释放,不向页面引入长期存活的额外服务。
没有改动 JASSUB Worker 协议;这是仅含原生画布操作的独立控制 Worker。
保持固定 Node24.21.0/Yarn1.22.22,顺序运行并在每轮后归档共享 report/results:
```powershell
$env:ARTPLAYER_JASSUB_CONTROL_BITMAP = 'true'
$env:ARTPLAYER_JASSUB_CONTROL_SINGLE_FLIGHT = 'true'
$env:ARTPLAYER_JASSUB_CONTROL_IDLE_READBACK = 'false'
yarn test:browser test/browser/jassub-platform.spec.js --workers=1
$env:ARTPLAYER_JASSUB_CONTROL_IDLE_READBACK = 'true'
yarn test:browser test/browser/jassub-platform.spec.js --project=firefox --workers=1
$env:ARTPLAYER_JASSUB_CONTROL_BITMAP = 'false'
$env:ARTPLAYER_JASSUB_CONTROL_SINGLE_FLIGHT = 'false'
$env:ARTPLAYER_JASSUB_CONTROL_IDLE_READBACK = 'false'
yarn test:browser test/browser/jassub-platform.spec.js --workers=1
```
## 验证与限制
| 运行 | 结果 | 证据含义 |
| --- | --- | --- |
| 初版单任务+定时探针,Firefox三宿主 | 2 pass / 1 fail | 一个未确认任务也会停顿 |
| 创建表达式返回+采样计时,Firefox三宿主 | 2 pass / 1 fail | native 的 pass 内含10秒停顿;不能当作流畅通过 |
| 分离 ImageData 和原生调用,Firefox三宿主 | 2 pass / 1 fail | 确认约10秒在 API 返回 Promise 之前 |
| 最终单任务并行读取,三浏览器三宿主 | 8 pass / 1 fail | Firefox旧核心宿主再次复现;其余只代表本次结果 |
| 最终单任务且读取前等待 Worker,Firefox三宿主 | 3 pass | 改变观察调度的对照;不是生产修复 |
| 原默认 fillRect,三浏览器三宿主 | 9 pass | 基本绘制控制仍通过 |
浏览器为 Chromium153.0.8010.12、Firefox155.0、Windows WebKit26.6。
前两者使用真实 Worker、转移画布和 ImageBitmap;WebKit缺少transfer,实际验证
主线程fillRect能力回退,不是其 offscreen/bitmap 支持证明。所有运行无重试。
各中间版本的观测能力差异在机器记录中逐一标注,不把最终源码指纹套用到旧运行。
保留所有红结果以及 trace、失败截图、附件。机器摘要保存源文件/报告/日志哈希、
各宿主完整 Worker 时点和采样数据、错误及浏览器版本,见
[jassub-native-call-validation.json](../baselines/jassub-native-call-validation.json)。
原生源代码和单个浏览器调用的同步耗时已经足以改变下一步调查方向;继续获取
对应浏览器原生堆栈或独立最小复现,而不是继续缩小 JS 队列或静默关闭默认异步绘制。
提交前 scoped ESLint、工具链、计划/风险校验、风险测试和 diff 检查通过。
因为本轮没有生产/类型/依赖修改,不重复构建包或把此前类型/安装矩阵视作本轮结果。
回退本提交仅撤销诊断观测及记录,不撤销先前 hybrid 生命周期修复。无推送或发布。
+1 -1
View File
@@ -691,7 +691,7 @@
- PKG-DANMUKU-11: [记录](changes/2026-09-13-PKG-DANMUKU-11-visible-lifetime.md) [记录](baselines/danmuku-lifetime-validation.json)
- PKG-DANMUKU-12: [记录](changes/2026-09-13-PKG-DANMUKU-12-frame-sampling.md) [记录](baselines/danmuku-frame-sampling-validation.json)
- PKG-JASSUB-08: [记录](changes/2026-09-14-PKG-JASSUB-08-offscreen.md) [记录](baselines/jassub-offscreen-validation.json) [记录](baselines/jassub-offscreen-patch.json)
- PKG-JASSUB-09: [记录](changes/2026-09-14-PKG-JASSUB-09-hybrid.md) [记录](baselines/jassub-hybrid-validation.json) [记录](baselines/jassub-hybrid-patch.json) [记录](changes/2026-09-14-PKG-JASSUB-09-firefox-diagnostics.md) [记录](changes/2026-09-14-PKG-JASSUB-09-display.md) [记录](baselines/jassub-display-validation.json)
- PKG-JASSUB-09: [记录](changes/2026-09-14-PKG-JASSUB-09-hybrid.md) [记录](baselines/jassub-hybrid-validation.json) [记录](baselines/jassub-hybrid-patch.json) [记录](changes/2026-09-14-PKG-JASSUB-09-firefox-diagnostics.md) [记录](changes/2026-09-14-PKG-JASSUB-09-display.md) [记录](baselines/jassub-display-validation.json) [记录](changes/2026-09-15-PKG-JASSUB-09-native-call.md) [记录](baselines/jassub-native-call-validation.json)
- PKG-AUTO-THUMB-07: [记录](changes/2026-09-14-PKG-AUTO-THUMB-07-canvas.md) [记录](baselines/auto-thumbnail-canvas-validation.json)
- PKG-AUTO-THUMB-08: [记录](changes/2026-09-14-PKG-AUTO-THUMB-08-types.md) [记录](baselines/auto-thumbnail-types-validation.json)
- PKG-AUTO-THUMB-09: [记录](changes/2026-09-14-PKG-AUTO-THUMB-09-alias.md) [记录](baselines/auto-thumbnail-alias-validation.json)
+13
View File
@@ -1,5 +1,18 @@
# 进度与证据
## PKG-JASSUB-09 原生调用阻塞证据
单个未确认绘制任务仍复现Firefox停顿;精确计时显示createImageBitmap约10秒后
才返回Promise,主线程drawImage也阻塞约10秒。完全无核心脚本的原生宿主在
一次旧观测运行中亦停顿,却因最终颜色正确而显示pass;现增加显式读取耗时约束。
不能继续把该pass当作无原生停顿,或将这次等待归因于Promise已返回后的结算。
最终三浏览器控制8通过/1失败;读取前等待Worker的Firefox控制3通过;默认
fillRect控制9通过。失败和中间证据全部保留,详见[记录](changes/2026-09-15-PKG-JASSUB-09-native-call.md)及[摘要](baselines/jassub-native-call-validation.json)。
本轮只改诊断测试和维护文档,未修复底层Firefox问题;09/05与风险继续未完成。
任务总数不变:209 done、21 doing、43 todo。无生产源码、类型、版本或依赖变化。
## SITE-DASH-01 双语指南与示例修复
原DASH示例三项回归均失败:换源累积destroy监听器、不支持MSE仍创建控制插件、
+4 -2
View File
@@ -5901,9 +5901,11 @@
"test/browser/jassub-native.spec.js",
"test/browser/jassub-hybrid.spec.js",
"refactor/changes/2026-09-15-CI-01-jassub-installed.md",
"refactor/baselines/ci-jassub-installed-validation.json"
"refactor/baselines/ci-jassub-installed-validation.json",
"refactor/changes/2026-09-15-PKG-JASSUB-09-native-call.md",
"refactor/baselines/jassub-native-call-validation.json"
],
"compatibleResolution": "Isolate native media/compositor/readback and sequence effects without globally disabling default offscreen or deleting assertions. The pre-09 artifact also fails in the same sequence; do not claim the new hybrid guard caused or fixed this separate failure.",
"compatibleResolution": "Isolate native media/compositor/readback and sequence effects without globally disabling default offscreen or deleting assertions. The pre-09 artifact also fails in the same sequence; do not claim the new hybrid guard caused or fixed this separate failure. Single-flight controls also stall; API-entry/return timing locates a captured delay before createImageBitmap returns its Promise, alongside synchronous main-thread canvas readback. A script-free native host has the same ten-second observation despite an eventual-pixel pass. Retain latency evidence; neither core scripts nor concurrent creation are required for that control stall.",
"closureCriteria": "Identify and verify a compatible fix or a reviewed precise platform boundary with native controls. Preserve known red sequence and successful standalone comparison; candidate playback/fullscreen pixels and lifetime must be justified, not merely rerun to green."
},
{
+3 -1
View File
@@ -5366,7 +5366,9 @@
"baselines/jassub-hybrid-patch.json",
"changes/2026-09-14-PKG-JASSUB-09-firefox-diagnostics.md",
"changes/2026-09-14-PKG-JASSUB-09-display.md",
"baselines/jassub-display-validation.json"
"baselines/jassub-display-validation.json",
"changes/2026-09-15-PKG-JASSUB-09-native-call.md",
"baselines/jassub-native-call-validation.json"
]
},
{
+12
View File
@@ -385,6 +385,18 @@ The report records these distinctions and never infers a JASSUB fix from a contr
pass. Preserve each raw report/results directory before the next browser command.
See [the isolation record](../../refactor/changes/2026-09-14-PKG-JASSUB-09-firefox-diagnostics.md).
`ARTPLAYER_JASSUB_CONTROL_SINGLE_FLIGHT=true` submits at most one unacknowledged
draw, without pausing readback. It records skipped video callbacks and the maximum
outstanding count; this opt-in control also rejects individual canvas reads taking
7 seconds or more, even if expect.poll eventually receives the expected pixels.
Worker messages distinguish entry/return of createImageBitmap from Promise
settlement, and include IDs plus pending-task timer observations. Main-thread
samples separately time canvas copying and pixel reading. A native script-free
host has exhibited a roughly ten-second stall despite a green eventual-pixel
result; a single outstanding task does not eliminate the Firefox issue. These
timings identify API boundaries, not a native stack or the underlying cause.
See [the native-call record](../../refactor/changes/2026-09-15-PKG-JASSUB-09-native-call.md).
`ARTPLAYER_JASSUB_SCREENSHOT=true` adds a separate observation path to the native
JASSUB spec. `jassub-display.js` decodes a clipped composited page PNG using the
pinned root-only pngjs development dependency; it never copies the transferred
+37 -12
View File
@@ -3,6 +3,7 @@ import { expect, test } from './fixtures.js'
const bitmap = process.env.ARTPLAYER_JASSUB_CONTROL_BITMAP === 'true'
const idleReadback = process.env.ARTPLAYER_JASSUB_CONTROL_IDLE_READBACK === 'true'
const singleFlight = process.env.ARTPLAYER_JASSUB_CONTROL_SINGLE_FLIGHT === 'true'
for (const host of ['candidate', 'published', 'native']) {
test(`Native canvas and video control through seek/layout without JASSUB: ${host}`, async ({ page }, testInfo) => {
@@ -16,7 +17,7 @@ for (const host of ['candidate', 'published', 'native']) {
else {
await page.goto(`/test/player.html?core=${host}`)
}
await page.evaluate(async ({ host, bitmap, idleReadback }) => {
await page.evaluate(async ({ host, bitmap, idleReadback, singleFlight }) => {
let video
if (host === 'native') {
video = document.createElement('video')
@@ -33,7 +34,7 @@ for (const host of ['candidate', 'published', 'native']) {
const canvas = document.createElement('canvas')
document.body.append(canvas)
const readback = document.createElement('canvas')
const probe = window.nativeCanvasProbe = { video, canvas, received: 0, frames: 0, sent: 0, bitmap, idleReadback, reading: false, stages: [], errors: [], pixel: null, offscreen: 'transferControlToOffscreen' in canvas, closed: false }
const probe = window.nativeCanvasProbe = { video, canvas, received: 0, frames: 0, sent: 0, bitmap, idleReadback, singleFlight, skipped: 0, maxOutstanding: 0, reading: false, stages: [], samples: [], errors: [], pixel: null, offscreen: 'transferControlToOffscreen' in canvas, closed: false }
const draw = (ctx, canvas, message) => {
if (canvas.width !== message.width)
canvas.width = message.width
@@ -44,20 +45,27 @@ for (const host of ['candidate', 'published', 'native']) {
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
if (probe.offscreen) {
const source = `let canvas, ctx; const draw = ${draw.toString()}; onmessage = async ({data}) => {
const source = `let canvas, ctx; const pending = new Set(); const draw = ${draw.toString()};
setInterval(() => { if (pending.size) postMessage({stage: 'pending', ids: [...pending], at: performance.now()}); }, 250);
onmessage = async ({data}) => {
if (data.canvas) { canvas = data.canvas; ctx = canvas.getContext('2d'); return; }
if (${bitmap}) {
const pixels = new Uint8ClampedArray(data.width * data.height * 4);
for (let i = 0; i < pixels.length; i += 4) { pixels[i + (data.time < 5 ? 0 : 2)] = 255; pixels[i + 3] = 255; }
postMessage({stage: 'bitmap-start', time: data.time, at: performance.now()});
const image = await createImageBitmap(new ImageData(pixels, data.width, data.height));
postMessage({stage: 'bitmap-ready', time: data.time, at: performance.now()});
const imageData = new ImageData(pixels, data.width, data.height);
pending.add(data.id);
postMessage({stage: 'bitmap-start', id: data.id, time: data.time, at: performance.now()});
const promise = createImageBitmap(imageData);
postMessage({stage: 'bitmap-returned', id: data.id, time: data.time, at: performance.now()});
const image = await promise;
pending.delete(data.id);
postMessage({stage: 'bitmap-ready', id: data.id, time: data.time, at: performance.now()});
if (canvas.width !== data.width) canvas.width = data.width;
if (canvas.height !== data.height) canvas.height = data.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0); image.close();
} else { draw(ctx, canvas, data); }
postMessage({stage: 'drawn', time: data.time, at: performance.now()});
postMessage({stage: 'drawn', id: data.id, time: data.time, at: performance.now()});
};`
probe.workerUrl = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }))
probe.worker = new Worker(probe.workerUrl)
@@ -84,34 +92,44 @@ for (const host of ['candidate', 'published', 'native']) {
if (probe.closed)
return
probe.frames++
if (!probe.reading) {
if (!probe.reading && (!singleFlight || probe.sent === probe.received)) {
const rect = video.getBoundingClientRect()
probe.post({ width: Math.round(rect.width), height: Math.round(rect.height), time: video.currentTime })
probe.sent++
probe.maxOutstanding = Math.max(probe.maxOutstanding, probe.sent - probe.received)
probe.post({ id: probe.sent, width: Math.round(rect.width), height: Math.round(rect.height), time: video.currentTime })
}
else if (!probe.reading) {
probe.skipped++
}
if (video.requestVideoFrameCallback)
probe.handle = video.requestVideoFrameCallback(render)
else
probe.handle = requestAnimationFrame(render)
}
probe.state = () => ({ pixel: probe.pixel, width: canvas.width, height: canvas.height, time: video.currentTime, frames: probe.frames, sent: probe.sent, received: probe.received, quality: video.getVideoPlaybackQuality?.().totalVideoFrames })
probe.state = () => ({ pixel: probe.pixel, width: canvas.width, height: canvas.height, time: video.currentTime, frames: probe.frames, sent: probe.sent, received: probe.received, skipped: probe.skipped, maxOutstanding: probe.maxOutstanding, quality: video.getVideoPlaybackQuality?.().totalVideoFrames })
probe.sample = async () => {
if (idleReadback) {
probe.reading = true
if (probe.received < probe.sent)
await new Promise(resolve => probe.drain = resolve)
}
const sample = { start: performance.now(), sent: probe.sent, received: probe.received }
readback.width = canvas.width
readback.height = canvas.height
const ctx = readback.getContext('2d')
sample.copyStart = performance.now()
ctx.drawImage(canvas, 0, 0)
sample.drawn = performance.now()
probe.pixel = [...ctx.getImageData(0, 0, 1, 1).data]
sample.read = performance.now()
sample.pixel = probe.pixel
probe.samples.push(sample)
probe.reading = false
return probe.state()
}
await video.play()
render()
}, { host, bitmap, idleReadback })
}, { host, bitmap, idleReadback, singleFlight })
try {
await expect.poll(async () => (await page.evaluate(() => window.nativeCanvasProbe.sample())).pixel).toEqual([255, 0, 0, 255])
const before = await page.evaluate(() => window.nativeCanvasProbe.sample())
@@ -124,6 +142,8 @@ for (const host of ['candidate', 'published', 'native']) {
await video.play()
})
await expect.poll(async () => (await page.evaluate(() => window.nativeCanvasProbe.sample())).pixel).toEqual([0, 0, 255, 255])
if (singleFlight)
expect(await page.evaluate(() => window.nativeCanvasProbe.maxOutstanding)).toBe(1)
await page.evaluate((host) => {
if (host === 'native') {
Object.assign(window.nativeCanvasProbe.video.style, { position: 'fixed', inset: '0', width: '100vw', height: '100vh' })
@@ -135,11 +155,16 @@ for (const host of ['candidate', 'published', 'native']) {
await expect.poll(async () => (await page.evaluate(() => window.nativeCanvasProbe.sample())).width).toBeGreaterThan(before.width)
await expect.poll(async () => (await page.evaluate(() => window.nativeCanvasProbe.sample())).time).toBeGreaterThan(10.2)
await expect.poll(async () => (await page.evaluate(() => window.nativeCanvasProbe.sample())).pixel).toEqual([0, 0, 255, 255])
if (singleFlight) {
const samples = await page.evaluate(() => window.nativeCanvasProbe.samples)
// A successful predicate after a blocked browser call can outlive expect.poll's deadline.
expect(Math.max(...samples.map(sample => sample.read - sample.start)), 'Native canvas readback must not hide a stall behind eventual pixels').toBeLessThan(7000)
}
}
finally {
const evidence = await page.evaluate(() => {
const probe = window.nativeCanvasProbe
const state = { offscreen: probe.offscreen, bitmap: probe.bitmap, idleReadback: probe.idleReadback, stages: probe.stages, errors: probe.errors, coreLoaded: typeof window.Artplayer !== 'undefined', ...probe.state() }
const state = { offscreen: probe.offscreen, bitmap: probe.bitmap, idleReadback: probe.idleReadback, singleFlight: probe.singleFlight, stages: probe.stages, samples: probe.samples, errors: probe.errors, coreLoaded: typeof window.Artplayer !== 'undefined', ...probe.state() }
probe.closed = true
if (probe.video.cancelVideoFrameCallback)
probe.video.cancelVideoFrameCallback(probe.handle)