fix(auto-thumbnail): [PKG-AUTO-THUMB-12] release stalled metadata loads

This commit is contained in:
Harvey Zhao committed 2026-09-15 12:31:32 +08:00
1 parent d3aa4bbfe4
commit 42ddead0cd
20 files changed
+6048 -20

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Verify metadata wait ownership and cancellation.
import test from 'node:test'
import { autoThumbnailCandidate, autoThumbnailEnvironment } from './helpers/auto-thumbnail.js'
const implementation = await autoThumbnailCandidate()
async function setup() {
const env = autoThumbnailEnvironment(implementation)
const result = await env.factory({ width: 80, number: 1 })(env.art)
assert.equal(result.name, 'artplayerPluginAutoThumbnail')
env.art.emit('video:loadedmetadata')
return env
}
function released(env, video = env.videos[0]) {
assert.equal(env.attached.has(video), false)
assert.equal(video.src, undefined)
assert.equal(video.onloadedmetadata, null)
assert.equal(video.onerror, null)
}
test('Auto-thumbnail metadata timeout releases a stalled decoder and ignores late metadata', async () => {
const env = await setup()
const metadata = env.videos[0].onloadedmetadata
const deadline = [...env.timers.values()][0]
assert(deadline, 'Metadata loading must have an owned deadline')
assert.equal(deadline.delay, 30000)
deadline.callback()
released(env)
assert.equal(env.timers.size, 0)
assert.equal(env.warnings.length, 1)
assert.match(env.warnings[0][1].message, /metadata timed out/)
metadata()
deadline.callback()
assert.equal(env.canvases.length, 0)
assert.equal(env.updates.length, 0)
assert.equal(env.warnings.length, 1)
env.art.emit('destroy')
})
for (const scenario of ['destroy', 'restart', 'error', 'metadata']) {
test(`Auto-thumbnail metadata deadline becomes inert after ${scenario}`, async () => {
const env = await setup()
const deadline = [...env.timers.values()][0]
assert(deadline, 'Metadata loading must have an owned deadline')
if (scenario === 'metadata') {
env.metadata()
const frameDeadline = [...env.timers.values()][0]
assert.notEqual(frameDeadline, deadline)
deadline.callback()
assert.equal(env.timers.size, 1, 'A queued metadata timeout cannot cancel the new frame wait')
env.seeked()
env.finish()
assert.equal(env.updates.length, 1)
}
else if (scenario === 'error') {
env.videos[0].error = new Error('Media failed')
env.videos[0].onerror()
}
else {
env.art.emit(scenario)
}
deadline.callback()
released(env)
assert.equal(env.timers.size, 0)
assert.equal(env.warnings.length, scenario === 'error' ? 1 : 0)
env.art.emit('destroy')
})
}
test('Auto-thumbnail stalled replacement retains the previous usable sheet', async () => {
const env = await setup()
env.metadata()
env.seeked()
env.finish()
const sheet = env.art.thumbnails
env.art.emit('restart')
env.art.emit('video:loadedmetadata')
const deadline = [...env.timers.values()][0]
assert(deadline, 'Replacement metadata loading must have a deadline')
deadline.callback()
released(env, env.videos[1])
assert.equal(env.art.thumbnails, sheet)
assert.equal(env.urls.has(sheet.url), true)
assert.equal(env.updates.length, 1)
env.art.emit('destroy')
assert.equal(env.urls.size, 0)
})
test('Auto-thumbnail metadata timer registration cannot start loading after destruction', async () => {
const env = autoThumbnailEnvironment(implementation)
await env.factory({ width: 80, number: 1 })(env.art)
const schedule = env.box.setTimeout
env.box.setTimeout = (...args) => {
env.art.emit('destroy')
return schedule(...args)
}
env.art.emit('video:loadedmetadata')
released(env)
assert.equal(env.timers.size, 0)
assert.equal(env.canvases.length, 0)
})
@@ -3,6 +3,73 @@ import { autoThumbnailCandidate } from '../helpers/auto-thumbnail.js'
import { expect, test } from './fixtures.js'
const implementation = await autoThumbnailCandidate()
for (const scenario of ['timeout', 'destroy', 'restart']) {
test(`Auto-thumbnail candidate native pending metadata releases on ${scenario}`, async ({ page, request }, testInfo) => {
await page.goto('/test/player.html?core=published')
await page.setContent('<!doctype html><div></div>')
const caseId = `${testInfo.testId}-${testInfo.retry}`
await page.addScriptTag({ content: implementation.code })
await page.evaluate(async (caseId) => {
const schedule = window.setTimeout
const clear = window.clearTimeout
const warn = console.warn
const probe = window.metadataProbe = { timers: new Map(), warnings: [], updates: [], listeners: new Map() }
window.setTimeout = (callback, delay, ...args) => {
const id = schedule(callback, delay, ...args)
if (delay === 30000)
probe.timers.set(id, callback)
return id
}
window.clearTimeout = (id) => {
probe.timers.delete(id)
clear(id)
}
console.warn = (...args) => probe.warnings.push(args.map(String))
probe.restore = () => {
window.setTimeout = schedule
window.clearTimeout = clear
console.warn = warn
}
probe.art = {
option: { url: `/test/pending-thumbnail-metadata.mp4?case=${encodeURIComponent(caseId)}` },
on(name, callback) { probe.listeners.set(name, callback) },
off(name) { probe.listeners.delete(name) },
get thumbnails() { return probe.updates.at(-1) },
set thumbnails(value) { probe.updates.push(value) },
}
probe.result = await window.artplayerPluginAutoThumbnail({ width: 80, number: 1 })(probe.art)
probe.listeners.get('video:loadedmetadata')()
probe.video = document.querySelector('video')
}, caseId)
const requestLog = `/test/requests.json?case=${encodeURIComponent(caseId)}`
await expect.poll(async () => (await (await request.get(requestLog)).json()).length).toBeGreaterThan(0)
const mediaRequests = await (await request.get(requestLog)).json()
const evidence = await page.evaluate((scenario) => {
const probe = window.metadataProbe
const video = probe.video
const before = { connected: video.isConnected, readyState: video.readyState, source: video.getAttribute('src'), timers: probe.timers.size }
const deadline = [...probe.timers.values()][0]
if (scenario === 'timeout')
deadline?.()
else probe.listeners.get(scenario)()
const after = { connected: video.isConnected, readyState: video.readyState, source: video.getAttribute('src'), metadataHandler: video.onloadedmetadata, errorHandler: video.onerror, timers: probe.timers.size, canvases: document.querySelectorAll('canvas').length }
deadline?.()
probe.listeners.get('destroy')?.()
probe.restore()
return { before, after, result: probe.result, warnings: probe.warnings, updates: probe.updates.length }
}, scenario)
await testInfo.attach('auto-thumbnail-metadata-deadline', { contentType: 'application/json', body: JSON.stringify({ provenance: implementation.provenance, sha256: hash(implementation.code), scenario, mediaRequests, virtualDeadline: true, ...evidence }) })
expect(evidence.result).toEqual({ name: 'artplayerPluginAutoThumbnail' })
expect(evidence.before).toMatchObject({ connected: true, readyState: 0, timers: 1 })
expect(evidence.before.source).toContain('pending-thumbnail-metadata.mp4')
expect(evidence.after).toEqual({ connected: false, readyState: 0, source: null, metadataHandler: null, errorHandler: null, timers: 0, canvases: 0 })
expect(evidence.updates).toBe(0)
expect(evidence.warnings).toHaveLength(scenario === 'timeout' ? 1 : 0)
if (scenario === 'timeout')
expect(evidence.warnings[0].join(' ')).toContain('metadata timed out')
})
}
for (const variant of ['destroy', 'restart', 'complete', 'frame-destroy', 'frame-restart', 'alias-complete', 'encoding-timeout']) {
const useDefault = variant === 'alias-complete'
const scenario = useDefault ? 'complete' : variant
+6
View File
@@ -181,6 +181,12 @@ const server = http.createServer((req, res) => {
res.writeHead(503, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }).end('Intentional media failure')
return
}
if (url.pathname === '/test/pending-thumbnail-metadata.mp4') {
// Native media backends may bypass browser request interception. Keep this
// real HTTP response pending, with a server-side bound if a test fails.
res.setTimeout(10000, () => res.destroy())
return
}
if (files.has(url.pathname)) {
if (url.pathname === '/test/vast-skippable.xml' && ['http://imasdk.googleapis.com', 'https://imasdk.googleapis.com'].includes(req.headers.origin)) {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin)