diff --git a/package.json b/package.json index 93ae65e54..ebc449ee2 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "ci:build": "yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports", "test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js", "typecheck": "node scripts/typecheck.mjs", - "test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js", + "test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js", "test": "yarn test:node && yarn test:baseline", "test:browser": "playwright test", "test:browser:install": "playwright install chromium firefox webkit", diff --git a/packages/artplayer/ARCHITECTURE.md b/packages/artplayer/ARCHITECTURE.md index 7a2f6ccb1..ce1492421 100644 --- a/packages/artplayer/ARCHITECTURE.md +++ b/packages/artplayer/ARCHITECTURE.md @@ -48,7 +48,50 @@ installed versions. Candidate corrections have separate published-defect observa in test/public-behavior.test.js and test/browser/emitter.spec.js. The source type fixture checks event name/payload correlation, receiver types, custom channels and chain typing. Emitter owns registrations only; instance teardown must remove the relevant callbacks, -not clear unrelated subscribers before destroy dispatch. Resource scopes follow in CORE-03. +not clear unrelated subscribers before destroy dispatch. Internal resource scopes are described below. + +## Internal resource ownership + +`src/lifecycle/scope.ts` owns synchronous cleanup registrations. `resources.ts` supplies +DOM listener, timeout, animation frame, request-controller and Blob URL adapters; +it depends only on the scope type and native browser APIs. Neither file is exported +from Artplayer or Artplayer.utils. CORE-03 introduces and tests this boundary; +CORE-04 will connect instance construction/destruction. Existing Events, timers and +requests have not yet been transferred, so their historical lifecycle findings remain open. + +Create one ResourceScope for an owner and child() for a replaceable operation. +Call operation.dispose() on completion or replacement; it detaches from its parent, +while sibling operations stay live. Disposing an instance closes all remaining +children. add(cleanup) returns an idempotent release function. Registrations are removed +before invoking cleanup; disposal closes the scope first and unwinds in reverse order. +Late registrations are released immediately. Failures do not interrupt other cleanup: +dispose throws one ResourceCleanupError containing the original errors after unwinding. +Explicit release throws its own error immediately and is still consumed. Owners must +handle these errors at their lifecycle boundary without replacing a primary operation error. + +Cleanup returns undefined, deliberately rejecting async callbacks in strict TS. An +asynchronous media/SDK shutdown needs a separately awaited owner protocol; do not cast +its Promise to a synchronous disposer. Emitter subscriptions use a per-owner callback +and add(() => { emitter.off(name, callback) }); do not remove unrelated subscriptions. + +DOM adapters preserve function receivers and object handleEvent receivers, snapshot +capture, and release once/aborted subscriptions. They are internal registrations with +their own wrappers, not a replacement for the public Events.proxy identity contract. +Timer/RAF callbacks detach before invoking work and guard queued callbacks after +cancellation. They allocate nothing when closed. Request controllers are optional on +engines lacking AbortController: consumers still need closed/generation checks and +must own rejected fetch Promises. Release a completed request's operation scope to +avoid retaining its controller. objectURL only accepts a Blob it creates a URL for; +it never adopts a caller URL. A closed scope immediately revokes the newly created URL. + +Run `yarn test:unit` and `yarn typecheck` for scope failures, reentry, ownership, +controlled timers, queued RAF and type rejection cases. `resource-scope.spec.js` runs +an explicitly identified es2015 internal-source fixture in all three Playwright +engines with native DOM, RAF, timers, fetch abort and URL access. It is not evidence +that the player has adopted scopes or that an installed tarball exposes this API. +Fresh fetches test URL revocation; decoded image caches are not a reliable revocation +oracle. Keep adapter tests and owner integration tests separate and extend both when +moving an existing resource into this boundary. ## Observable utility behavior diff --git a/packages/artplayer/src/lifecycle/resources.ts b/packages/artplayer/src/lifecycle/resources.ts new file mode 100644 index 000000000..5088f590b --- /dev/null +++ b/packages/artplayer/src/lifecycle/resources.ts @@ -0,0 +1,85 @@ +import type ResourceScope from './scope' + +export function listen(scope: ResourceScope, target: EventTarget, name: string, callback: EventListenerOrEventListenerObject, options: AddEventListenerOptions = {}): () => void { + const { capture = false, once = false, signal } = options + if (scope.closed || signal?.aborted) + return () => {} + let release = () => {} + function listener(this: EventTarget, event: Event) { + if (once) + release() + if (scope.closed) + return + if (typeof callback === 'function') + callback.call(this, event) + else + callback.handleEvent(event) + } + target.addEventListener(name, listener, options) + release = scope.add(() => { + try { + target.removeEventListener(name, listener, capture) + } + finally { + signal?.removeEventListener('abort', release) + } + }) + signal?.addEventListener('abort', release, { once: true }) + return release +} + +export function timeout(scope: ResourceScope, callback: () => void, delay: number): () => void { + if (scope.closed) + return () => {} + let pending = true + let release = () => {} + const timer = setTimeout(() => { + if (!pending) + return + release() + if (!scope.closed) + callback() + }, delay) + release = scope.add(() => { + pending = false + clearTimeout(timer) + }) + return release +} + +export function animationFrame(scope: ResourceScope, callback: FrameRequestCallback): () => void { + if (scope.closed) + return () => {} + let pending = true + let release = () => {} + const frame = requestAnimationFrame((time) => { + if (!pending) + return + release() + if (!scope.closed) + callback(time) + }) + release = scope.add(() => { + pending = false + cancelAnimationFrame(frame) + }) + return release +} + +export function requestController(scope: ResourceScope): AbortController | undefined { + if (typeof AbortController === 'undefined') + return undefined + const controller = new AbortController() + scope.add(() => { + controller.abort() + }) + return controller +} + +export function objectURL(scope: ResourceScope, blob: Blob): string { + const url = URL.createObjectURL(blob) + scope.add(() => { + URL.revokeObjectURL(url) + }) + return url +} diff --git a/packages/artplayer/src/lifecycle/scope.ts b/packages/artplayer/src/lifecycle/scope.ts new file mode 100644 index 000000000..ca7b9caef --- /dev/null +++ b/packages/artplayer/src/lifecycle/scope.ts @@ -0,0 +1,65 @@ +export type Cleanup = () => undefined + +export class ResourceCleanupError extends Error { + constructor(readonly errors: unknown[]) { + super('Failed to release ArtPlayer resources') + this.name = 'ResourceCleanupError' + } +} + +export default class ResourceScope { + private cleanups = new Set<() => void>() + private disposed = false + + get closed(): boolean { + return this.disposed + } + + add(cleanup: Cleanup): () => void { + let active = true + const release = () => { + if (!active) + return + active = false + this.cleanups.delete(release) + cleanup() + } + if (this.closed) + release() + else + this.cleanups.add(release) + return release + } + + child(): ResourceScope { + const child = new ResourceScope() + const release = this.add(() => { + child.dispose() + }) + child.add(() => { + release() + }) + return child + } + + dispose(): void { + if (this.closed) + return + this.disposed = true + const errors: unknown[] = [] + // Unwind dependencies; release removes each registration before invoking it. + for (const release of Array.from(this.cleanups).reverse()) { + try { + release() + } + catch (error) { + if (error instanceof ResourceCleanupError) + errors.push(...error.errors) + else + errors.push(error) + } + } + if (errors.length) + throw new ResourceCleanupError(errors) + } +} diff --git a/refactor/baselines/resource-scope-validation.json b/refactor/baselines/resource-scope-validation.json new file mode 100644 index 000000000..4b87cb7f2 --- /dev/null +++ b/refactor/baselines/resource-scope-validation.json @@ -0,0 +1,692 @@ +{ + "task": "CORE-03", + "date": "2026-09-11", + "node": "v24.21.0", + "parentCommit": "362c6169987bb32c120e81ea267eeaa3bd8f63b8", + "source": { + "packages/artplayer/src/lifecycle/scope.ts": "e2188e8544b27cf8c7ac149e7146af628a2d6166c82b719a247adef5d85be5db", + "packages/artplayer/src/lifecycle/resources.ts": "2ff9cb71035ac5694c92a6b01f7454890bd652ba4b7774e8973500de7f7d204c", + "test/resource-scope.test.js": "f1b5bfb3fd863bee9493a26152aad6d110d47a8fc1f369da7e94c57d0538a123", + "test/types/resource-scope.ts": "a8c056914e8529c871891ed672030248d359a19f8571a5ac5a5f18952ea5def0", + "test/browser/resource-scope.spec.js": "0196bd9943c1ce2f6560f87156cd0de47606579c1a736bd46d4943179a121d3f" + }, + "ci": { + "command": "yarn ci:check", + "passed": 94, + "unit": 66, + "engineering": 4, + "baseline": 24, + "sourceTypes": 15, + "logSha256": "e4982bd5055665e0bb40dc41c2c1ba319a935bff22ef6565ad102c4403ce09b6" + }, + "build": { + "commands": [ + "yarn build artplayer", + "yarn build:i18n" + ], + "generatedGitDifference": false, + "logs": [ + { + "file": "core03-build.log", + "sha256": "21ffc4ba5064d496a5c5da4a3f5eaebe9bb6de240fdb44938f2858120e52d934" + }, + { + "file": "core03-i18n.log", + "sha256": "4b5a3b6f03affadff34eb605405c16cad36f9fb9c7b61e54a446148140048490" + } + ] + }, + "browser": { + "command": "yarn test:browser", + "candidateKind": "workspace-build", + "internalFixtureKind": "internal-source-fixture", + "stats": { + "startTime": "2026-09-10T16:19:36.559Z", + "duration": 28138.681, + "expected": 78, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "internal": [ + { + "engine": "chromium", + "version": "153.0.8010.12", + "platform": "win32", + "fixture": { + "kind": "internal-source-fixture", + "target": "es2015", + "sha256": "032ba327c57b8d94d005558c64bc153dfb2e1d6fcb80cf50adca0ae5cdd7f471" + }, + "pageErrors": [], + "controlledConsole": [ + "Failed to load resource: net::ERR_FILE_NOT_FOUND" + ], + "controlledFailedRequests": [ + { + "url": "http://127.0.0.1:8084/test/pattern.mp4", + "resourceType": "fetch", + "failure": { + "errorText": "net::ERR_ABORTED" + } + }, + { + "url": "blob:http://127.0.0.1:8084/bb671d68-fcc1-4374-ac45-06ea586b13fd", + "resourceType": "fetch", + "failure": { + "errorText": "net::ERR_FILE_NOT_FOUND" + } + } + ] + }, + { + "engine": "firefox", + "version": "155.0", + "platform": "win32", + "fixture": { + "kind": "internal-source-fixture", + "target": "es2015", + "sha256": "032ba327c57b8d94d005558c64bc153dfb2e1d6fcb80cf50adca0ae5cdd7f471" + }, + "pageErrors": [], + "controlledConsole": [], + "controlledFailedRequests": [] + }, + { + "engine": "webkit", + "version": "26.6", + "platform": "win32", + "fixture": { + "kind": "internal-source-fixture", + "target": "es2015", + "sha256": "032ba327c57b8d94d005558c64bc153dfb2e1d6fcb80cf50adca0ae5cdd7f471" + }, + "pageErrors": [], + "controlledConsole": [ + "Failed to load resource" + ], + "controlledFailedRequests": [ + { + "url": "http://127.0.0.1:8084/test/pattern.mp4", + "resourceType": "fetch", + "failure": { + "errorText": "Load request cancelled" + } + }, + { + "url": "blob:http://127.0.0.1:8084/884f53c0-917f-4730-be3f-32b8f0d5e2b8", + "resourceType": "fetch", + "failure": null + } + ] + } + ], + "cases": [ + { + "file": "chapter.spec.js", + "title": "published-only observations: blank title, NaN and once-only metadata are historical defects", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate clears titles on blank chapters, zero hover and replacement", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate rejects NaN and can recover after an invalid update", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate releases only its listeners and DOM across multiple instances and early destruction", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate ignores pending metadata initialization after immediate destroy", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate deduplicates stylesheet injection while the document is loading", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "published-only observations: blank title, NaN and once-only metadata are historical defects", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate clears titles on blank chapters, zero hover and replacement", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate rejects NaN and can recover after an invalid update", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate releases only its listeners and DOM across multiple instances and early destruction", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate ignores pending metadata initialization after immediate destroy", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate deduplicates stylesheet injection while the document is loading", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "published-only observations: blank title, NaN and once-only metadata are historical defects", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate clears titles on blank chapters, zero hover and replacement", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate rejects NaN and can recover after an invalid update", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate releases only its listeners and DOM across multiple instances and early destruction", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate ignores pending metadata initialization after immediate destroy", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "candidate deduplicates stylesheet injection while the document is loading", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "factory, registration, input normalization and safe title text", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "progress bars, real hover, title edges and boundary seek", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "sync update, Infinity, object identity, empty lists and invalid data", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "explicit source update and core-owned destruction", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "factory, registration, input normalization and safe title text", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "progress bars, real hover, title edges and boundary seek", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "sync update, Infinity, object identity, empty lists and invalid data", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "explicit source update and core-owned destruction", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "factory, registration, input normalization and safe title text", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "progress bars, real hover, title edges and boundary seek", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "sync update, Infinity, object identity, empty lists and invalid data", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "explicit source update and core-owned destruction", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "factory, registration, input normalization and safe title text", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "progress bars, real hover, title edges and boundary seek", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "sync update, Infinity, object identity, empty lists and invalid data", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "explicit source update and core-owned destruction", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "factory, registration, input normalization and safe title text", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "progress bars, real hover, title edges and boundary seek", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "sync update, Infinity, object identity, empty lists and invalid data", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "explicit source update and core-owned destruction", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "factory, registration, input normalization and safe title text", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "progress bars, real hover, title edges and boundary seek", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "sync update, Infinity, object identity, empty lists and invalid data", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "chapter.spec.js", + "title": "explicit source update and core-owned destruction", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "emitter.spec.js", + "title": "published: emitter event names and once across nested dispatch", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "emitter.spec.js", + "title": "candidate: emitter event names and once across nested dispatch", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "emitter.spec.js", + "title": "published: emitter event names and once across nested dispatch", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "emitter.spec.js", + "title": "candidate: emitter event names and once across nested dispatch", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "emitter.spec.js", + "title": "published: emitter event names and once across nested dispatch", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "emitter.spec.js", + "title": "candidate: emitter event names and once across nested dispatch", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "playback.spec.js", + "title": "published: real playback, pause, seek, switch and destroy", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "playback.spec.js", + "title": "candidate: real playback, pause, seek, switch and destroy", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "playback.spec.js", + "title": "controlled HTTP media failure emits a real video error", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "playback.spec.js", + "title": "Range, media identity and docs candidate routes", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "playback.spec.js", + "title": "published: real playback, pause, seek, switch and destroy", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "playback.spec.js", + "title": "candidate: real playback, pause, seek, switch and destroy", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "playback.spec.js", + "title": "controlled HTTP media failure emits a real video error", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "playback.spec.js", + "title": "Range, media identity and docs candidate routes", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "playback.spec.js", + "title": "published: real playback, pause, seek, switch and destroy", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "playback.spec.js", + "title": "candidate: real playback, pause, seek, switch and destroy", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "playback.spec.js", + "title": "controlled HTTP media failure emits a real video error", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "playback.spec.js", + "title": "Range, media identity and docs candidate routes", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "resource-scope.spec.js", + "title": "internal resource scope: native DOM, RAF, timer, fetch abort and URL release", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "resource-scope.spec.js", + "title": "internal resource scope: native DOM, RAF, timer, fetch abort and URL release", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "resource-scope.spec.js", + "title": "internal resource scope: native DOM, RAF, timer, fetch abort and URL release", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "published: subtitle object URL contains real VTT data", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "published: download starts and removes its temporary anchor", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "candidate: subtitle object URL contains real VTT data", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "candidate: download starts and removes its temporary anchor", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "candidate download releases the anchor even when click fails", + "engine": "chromium", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "published: subtitle object URL contains real VTT data", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "published: download starts and removes its temporary anchor", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "candidate: subtitle object URL contains real VTT data", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "candidate: download starts and removes its temporary anchor", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "candidate download releases the anchor even when click fails", + "engine": "firefox", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "published: subtitle object URL contains real VTT data", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "published: download starts and removes its temporary anchor", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "candidate: subtitle object URL contains real VTT data", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "candidate: download starts and removes its temporary anchor", + "engine": "webkit", + "status": "passed", + "retry": 0 + }, + { + "file": "utils.spec.js", + "title": "candidate download releases the anchor even when click fails", + "engine": "webkit", + "status": "passed", + "retry": 0 + } + ] + }, + "firstFailure": { + "stats": { + "startTime": "2026-09-10T16:15:26.658Z", + "duration": 33097.064, + "expected": 77, + "skipped": 0, + "unexpected": 1, + "flaky": 0 + }, + "engine": "firefox", + "assertion": "revokedRejected expected true, received false using an already decoded image", + "resolution": "Replace image decoding with fresh fetch before and after revocation; assert exact TypeError. Also abort an in-flight native fetch.", + "reportSha256": "8e2bbafe70e81957f37501901957598b3a88d4e5f272dcdaaedb56839f59118a" + }, + "limits": [ + "No player constructor/destroy integration until CORE-04", + "No package install rerun for this currently unreferenced internal module", + "No remote CI, device or SDK validation in this task" + ] +} diff --git a/refactor/changes/2026-09-11-CORE-03-resource-scope.md b/refactor/changes/2026-09-11-CORE-03-resource-scope.md new file mode 100644 index 000000000..a24408bf1 --- /dev/null +++ b/refactor/changes/2026-09-11-CORE-03-resource-scope.md @@ -0,0 +1,47 @@ +# CORE-03:内部资源作用域 + +## 结构与契约 + +新增核心 lifecycle/scope.ts 与 resources.ts:前者管理同步清理、父子作用域及异常汇总; +后者封装 DOM 监听、定时器、RAF、AbortController 与自有 Blob URL。没有新依赖, +没有把内部能力导出到 Artplayer/utils 或要求旧插件依赖它。 + +实例可持有长期 scope,切源、字幕请求等单次操作使用 child。子作用域结束会脱离父登记, +不会释放兄弟操作。清理先关闭作用域,再逆序释放;释放登记在调用前移除,重复和重入安全。 +单项失败不中断其他清理,最后统一抛出 ResourceCleanupError 并保留原始错误。 +显式 release 的错误直接传播,该登记仍只消费一次;后续所有者需在生命周期边界处理。 + +Cleanup 的返回类型为 undefined,严格拒绝 async/Promise 清理,避免同步 destroy 丢失异步失败。 +异步媒体/SDK 关闭将由对应包明确等待,不伪装成同步回收。Emitter 订阅用独立 callback 加入 +scope,并通过原 off API 释放;实例和操作各自只清理拥有的资源。 + +监听保留函数/对象接收者、固定 capture、处理 once 重入和外部 abort;计时器/RAF 完成后脱离 +登记,取消后即使已排队的回调被调用也不会工作。缺少 AbortController 时返回 undefined, +请求所有者必须继续防止过期回调。Blob URL 只释放本 helper 创建的 URL,不接管用户传入 URL。 + +对应 API-04/05/07/10:这是内部边界建立,不改变现有公开行为。CORE-04 才接入播放器的 +构造/销毁;当前 Events、历史 timer/request 尚未迁移,BASE-LIFE-04/05 与 BASE-PERF-01 +继续开放,不能把本任务的 scope 测试写成播放器泄漏已修复。 + +## 验证与维护 + +- test:unit 加入 resource-scope.test.js,共九项资源测试:异常汇总、重入、late add、 + 子作用域脱离、独立释放失败、订阅归属、受控 timer、已排队 RAF、DOM/abort/URL 与能力缺失。 +- test/types/resource-scope.ts 含四个必须拒绝的类型用例;全部严格源类型和消费者检查通过。 +- yarn ci:check:66 项单元、4 项工程、24 项冻结基线,共 94 项通过,无跳过。 +- 三浏览器 78 项通过;其中 3 项通过明确标注的 es2015 内部源码 fixture 验证原生 DOM、 + RAF、timer、进行中的 fetch 取消和 Blob URL 撤销。其余为既有发布/工作区播放与插件回归。 + 源码 fixture 不冒充安装 tarball;内部代码尚未由公开入口引用。 +- 负向控制只允许本 case 的精确 URL、fetch 类型及已观察的引擎错误;其他网络错误不被忽略。 +- yarn build artplayer 与 yarn build:i18n 通过,生成产物无 Git 差异。本次未重复执行安装打包; + 没有把上一任务的打包证据冒充本次新执行。 + +首次浏览器运行 77 通过、Firefox 1 失败:撤销后的 URL 仍能复用已解码图片,说明图片缓存 +不是 URL 可用性的可靠判断。测试改为撤销前读出内容、撤销后 fresh fetch 精确 TypeError; +同时验证进行中的 fetch 返回 AbortError。没有修改生产实现去迎合缓存,也没有重试或跳过。 +最初 Node RAF mock 误用了不存在的方法创建选项,已改为显式安装并恢复测试全局;生产不打补丁。 +执行指纹、首次失败摘要和最终状态见 [验证报告](../baselines/resource-scope-validation.json)。 + +[核心架构](../../packages/artplayer/ARCHITECTURE.md) 维护所有权、错误协议、内部/公开边界与修改入口。 +下一项 CORE-04 需接入实例登记、处理构造失败/重复销毁并补真实播放器回归,再按各任务迁移 +具体操作资源。本任务独立提交;回退提交即可恢复文件、脚本和文档。无推送、版本变更或发布。 diff --git a/refactor/plan.md b/refactor/plan.md index aa9902058..687f0311f 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。 -状态:todo 176 / doing 0 / blocked 0 / done 38 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 175 / doing 0 / blocked 0 / done 39 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -106,7 +106,7 @@ | --- | --- | --- | --- | --- | --- | --- | | CORE-01 | artplayer
迁移纯工具并冻结导出 | ENG-03, ENG-04, ENG-06, PILOT-01 | utils/time/property/format/file/error/subtitle 的分批 TS 迁移 | Artplayer.utils 导出/参数/绑定保持,纯逻辑与类型测试通过 | M | done | | CORE-02 | artplayer
迁移内部 Emitter | CORE-01, BASE-03 | 带类型事件映射的原 Emitter 实现 | ctx、once/off、重入/异常和链式返回保持 | H | done | -| CORE-03 | artplayer
建立内部资源作用域 | CORE-02 | 监听/订阅/定时器/RAF/请求/URL 的资源登记 | 实例与操作作用域分离,单项清理失败不阻断其他资源 | H | todo | +| CORE-03 | artplayer
建立内部资源作用域 | CORE-02 | 监听/订阅/定时器/RAF/请求/URL 的资源登记 | 实例与操作作用域分离,单项清理失败不阻断其他资源 | H | done | | CORE-04 | artplayer
修复初始化与销毁生命周期 | CORE-03, ENG-05, BASE-07 | 构造失败回收、重复/重入 destroy、instances 修复 | 事件顺序、removeHtml、多实例和销毁中异步有真实回归;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-04/05 并增加候选正向回归;协调 BASE-PERF-01 销毁后定时器与回调归属 | H | todo | | CORE-05 | artplayer
区分输入配置和内部配置 | CORE-01, ENG-04 | Option/ResolvedOption、默认值与校验整理 | 配置合并/错误时机不变,JS 输入仍运行时校验 | M | todo | | CORE-06 | artplayer
建立内部媒体与宿主类型 | CORE-05 | 原生 video、canvas shim、UI host 类型 | 不更改公开 art.video 用法,不以 HTMLVideoElement 断言掩盖 shim | M | todo | @@ -437,6 +437,7 @@ - PILOT-01: [记录](changes/2026-09-10-PILOT-01-chapter.md) [记录](baselines/pilot-validation.json) - CORE-01: [记录](changes/2026-09-10-CORE-01-typed-utils.md) [记录](baselines/core-utils-validation.json) - CORE-02: [记录](changes/2026-09-11-CORE-02-typed-emitter.md) [记录](baselines/emitter-validation.json) +- CORE-03: [记录](changes/2026-09-11-CORE-03-resource-scope.md) [记录](baselines/resource-scope-validation.json) - PKG-CHAPTER-01: [记录](changes/2026-09-10-PKG-CHAPTER-01-contract.md) [记录](baselines/chapter-contract.md) - PKG-CHAPTER-02: [记录](changes/2026-09-10-PKG-CHAPTER-02-tests.md) [记录](baselines/chapter-validation.json) - PKG-CHAPTER-03: [记录](changes/2026-09-10-PKG-CHAPTER-03-typescript-modules.md) [记录](baselines/chapter-migration-validation.json) diff --git a/refactor/progress.md b/refactor/progress.md index f2e05f7a9..a0b23fa64 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,9 @@ # 进度与证据 +## 当前实施:CORE-03 内部资源作用域完成 + +新增两个核心 TS 文件,明确同步清理、实例/操作作用域、异常汇总及原生资源适配。94 项 Node/基线与 78 项三浏览器检查通过;首次 Firefox 图片缓存误判已保留并改为 fresh fetch 验证。见 [交付](changes/2026-09-11-CORE-03-resource-scope.md)。当前 214 项,39 完成、175 待办;核心十个 TS 文件、chapter 五个。作用域尚未接入播放器,旧生命周期风险仍开放;下一项 CORE-04 直接改构造与销毁。 + ## 当前实施:CORE-02 Emitter TS 迁移完成 事件映射、payload/ctx 和链式类型已加入;九组普通契约保持,另修复原型事件名与嵌套快照重复 once。85 项 Node/基线、75 项三浏览器、三格式各 29 项 Emitter 检查通过;严格打包 27 项运行时/五组类型零诊断。见 [交付](changes/2026-09-11-CORE-02-typed-emitter.md)。当前 214 项,38 完成、176 待办。核心八个 TS 文件、chapter 五个 TS 文件;下一项 CORE-03 内部资源作用域。 diff --git a/refactor/tasks.json b/refactor/tasks.json index e69674df8..c7a57314e 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -789,11 +789,14 @@ "dependsOn": [ "CORE-02" ], - "status": "todo", + "status": "done", "risk": "H", "deliverable": "监听/订阅/定时器/RAF/请求/URL 的资源登记", "acceptance": "实例与操作作用域分离,单项清理失败不阻断其他资源", - "evidence": [] + "evidence": [ + "changes/2026-09-11-CORE-03-resource-scope.md", + "baselines/resource-scope-validation.json" + ] }, { "id": "CORE-04", diff --git a/test/browser/resource-scope.spec.js b/test/browser/resource-scope.spec.js new file mode 100644 index 000000000..395e358b7 --- /dev/null +++ b/test/browser/resource-scope.spec.js @@ -0,0 +1,75 @@ +import { createHash } from 'node:crypto' +import { build } from 'esbuild' +import { expect, test } from './fixtures.js' + +// Explicit internal-source fixture, not evidence of an installed public API. +const fixture = await build({ + stdin: { contents: 'export { default as ResourceScope } from \'./packages/artplayer/src/lifecycle/scope\'; export * from \'./packages/artplayer/src/lifecycle/resources\'', resolveDir: process.cwd() }, + bundle: true, + write: false, + format: 'iife', + globalName: 'ResourceFixture', + target: 'es2015', +}) + +test('internal resource scope: native DOM, RAF, timer, fetch abort and URL release', async ({ page, browserName, diagnostics }, testInfo) => { + await testInfo.attach('internal-source-fixture', { contentType: 'application/json', body: JSON.stringify({ kind: 'internal-source-fixture', target: 'es2015', sha256: createHash('sha256').update(fixture.outputFiles[0].text).digest('hex') }) }) + await page.goto('/test/player.html?core=candidate&chapter=published') + await page.addScriptTag({ content: fixture.outputFiles[0].text }) + const result = await page.evaluate(async () => { + const { ResourceScope, listen, timeout, animationFrame, requestController, objectURL } = window.ResourceFixture + const instance = new ResourceScope() + const operation = instance.child() + const button = document.createElement('button') + document.body.append(button) + const calls = [] + const options = { capture: true } + listen(operation, button, 'click', function () { + calls.push(this === button ? 'click' : 'wrong-receiver') + }, options) + options.capture = false + button.click() + const text = await (await fetch(objectURL(operation, new Blob(['scoped'])))).text() + const revoked = objectURL(operation, new Blob(['revoked'])) + const before = await (await fetch(revoked)).text() + const controller = requestController(operation) + await new Promise(resolve => animationFrame(operation, (time) => { + calls.push(typeof time) + resolve() + })) + await new Promise(resolve => timeout(operation, () => { + calls.push('timer') + resolve() + }, 0)) + animationFrame(operation, () => calls.push('cancelled-frame')) + timeout(operation, () => calls.push('cancelled-timer'), 0) + const pendingRequest = fetch('/test/pattern.mp4', { signal: controller.signal }).then(() => 'resolved', error => error.name) + operation.dispose() + button.click() + // Await independent native scheduling turns so cancelled callbacks would be observable. + await new Promise(resolve => requestAnimationFrame(resolve)) + await new Promise(resolve => setTimeout(resolve, 0)) + const abortName = await pendingRequest + // Image caches may retain decoded pixels; a fresh fetch tests URL availability. + let revokedError + try { + await fetch(revoked) + } + catch (error) { revokedError = error.name } + button.remove() + const instanceOpen = !instance.closed + instance.dispose() + return { text, before, calls, aborted: controller.signal.aborted, abortName, revokedError, instanceOpen, revoked, requestURL: new URL('/test/pattern.mp4', location.href).href } + }) + const { revoked, requestURL, ...behavior } = result + expect(behavior).toEqual({ text: 'scoped', before: 'revoked', calls: ['click', 'number', 'timer'], aborted: true, abortName: 'AbortError', revokedError: 'TypeError', instanceOpen: true }) + const expectedFailures = { + chromium: [{ url: requestURL, resourceType: 'fetch', failure: { errorText: 'net::ERR_ABORTED' } }, { url: revoked, resourceType: 'fetch', failure: { errorText: 'net::ERR_FILE_NOT_FOUND' } }], + firefox: [], + webkit: [{ url: requestURL, resourceType: 'fetch', failure: { errorText: 'Load request cancelled' } }, { url: revoked, resourceType: 'fetch', failure: null }], + } + for (const failure of diagnostics.failedRequests) + expect(expectedFailures[browserName]).toContainEqual(failure) + const expectedConsole = { chromium: ['Failed to load resource: net::ERR_FILE_NOT_FOUND'], firefox: [], webkit: ['Failed to load resource'] } + expect(diagnostics.consoleErrors).toEqual(expectedConsole[browserName]) +}) diff --git a/test/resource-scope.test.js b/test/resource-scope.test.js new file mode 100644 index 000000000..b198160d8 --- /dev/null +++ b/test/resource-scope.test.js @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test -- Control timers and native API failures. +import { test } from 'node:test' +import { loadModules } from './helpers/load.js' + +const source = 'packages/artplayer/src/lifecycle/' +const { ResourceScope, ResourceCleanupError, timeout, animationFrame, listen, requestController, objectURL, Emitter } = await loadModules({ + ResourceScope: `${source}scope`, + ResourceCleanupError: { file: `${source}scope`, name: 'ResourceCleanupError' }, + ...Object.fromEntries(['timeout', 'animationFrame', 'listen', 'requestController', 'objectURL'].map(name => [name, { file: `${source}resources`, name }])), + Emitter: 'packages/artplayer/src/utils/emitter', +}) + +test('scope closes before cleanup, unwinds children and collects failures without retaining registrations', () => { + const scope = new ResourceScope() + const calls = [] + const first = new Error('first') + const second = new Error('second') + scope.add(() => { + calls.push('oldest') + throw first + }) + const child = scope.child() + child.add(() => { + calls.push('child') + throw second + }) + scope.add(() => { + assert(scope.closed) + scope.dispose() + scope.add(() => { + calls.push('late') + }) + calls.push('newest') + }) + assert.throws(() => scope.dispose(), error => error instanceof ResourceCleanupError && assert.deepEqual(error.errors, [second, first]) === undefined) + assert.deepEqual(calls, ['late', 'newest', 'child', 'oldest']) + assert(child.closed) + scope.dispose() + assert.equal(calls.length, 4) +}) + +test('operation completion detaches from the live instance and siblings remain usable', () => { + const instance = new ResourceScope() + const operation = instance.child() + const sibling = instance.child() + let calls = 0 + const release = operation.add(() => { + calls++ + }) + release() + release() + operation.dispose() + assert.equal(calls, 1) + assert.equal(instance.closed, false) + assert.equal(sibling.closed, false) + // A completed operation must no longer be invoked by its former owner. + operation.dispose = () => { + throw new Error('retained operation') + } + instance.dispose() + assert(sibling.closed) + assert(instance.child().closed) +}) + +test('failed explicit release is consumed and cannot prevent later scope cleanup', () => { + const scope = new ResourceScope() + const error = new Error('release') + let calls = 0 + scope.add(() => { + calls++ + }) + const release = scope.add(() => { + throw error + }) + assert.throws(release, value => value === error) + release() + scope.dispose() + assert.equal(calls, 1) +}) + +test('scope owns subscriptions without removing unrelated emitter listeners', () => { + const emitter = new Emitter() + const scope = new ResourceScope() + const calls = [] + const owned = value => calls.push(value) + emitter.on('value', owned).on('value', () => calls.push('external')) + scope.add(() => { + emitter.off('value', owned) + }) + emitter.emit('value', 1) + scope.dispose() + emitter.emit('value', 2) + assert.deepEqual(calls, [1, 'external', 'external']) +}) + +test('timers finish or cancel independently and reject work after disposal', (context) => { + context.mock.timers.enable({ apis: ['setTimeout'] }) + const scope = new ResourceScope() + const calls = [] + timeout(scope, () => calls.push('finished'), 5) + timeout(scope, () => calls.push('cancelled'), 10)() + timeout(scope, () => calls.push('disposed'), 20) + context.mock.timers.tick(5) + scope.dispose() + timeout(scope, () => calls.push('late'), 0) + context.mock.timers.tick(100) + assert.deepEqual(calls, ['finished']) +}) + +test('queued RAF is guarded after cancellation, timestamp is forwarded and completed frames detach', (context) => { + const callbacks = [] + const cancelled = [] + const descriptors = Object.getOwnPropertyDescriptors(globalThis) + globalThis.requestAnimationFrame = callback => callbacks.push(callback) + globalThis.cancelAnimationFrame = id => cancelled.push(id) + context.after(() => { + for (const key of ['requestAnimationFrame', 'cancelAnimationFrame']) { + if (descriptors[key]) + Object.defineProperty(globalThis, key, descriptors[key]) + else + delete globalThis[key] + } + }) + const scope = new ResourceScope() + const calls = [] + animationFrame(scope, time => calls.push(time)) + callbacks[0](12) + animationFrame(scope, time => calls.push(time))() + callbacks[1](18) + animationFrame(scope, time => calls.push(time)) + scope.dispose() + callbacks[2](24) + animationFrame(scope, () => calls.push('late')) + assert.deepEqual(calls, [12]) + assert.deepEqual(cancelled, [1, 2, 3]) + assert.equal(callbacks.length, 3) +}) + +test('DOM once reentry, receiver, object listener, external abort and captured options', () => { + const scope = new ResourceScope() + const target = new EventTarget() + let calls = 0 + listen(scope, target, 'value', function () { + assert.equal(this, target) + calls++ + target.dispatchEvent(new Event('value')) + }, { once: true }) + target.dispatchEvent(new Event('value')) + assert.equal(calls, 1) + const controller = new AbortController() + const options = { signal: controller.signal, capture: false } + const listener = { handleEvent() { + assert.equal(this, listener) + calls++ + } } + listen(scope, target, 'object', listener, options) + options.capture = true + target.dispatchEvent(new Event('object')) + controller.abort() + target.dispatchEvent(new Event('object')) + scope.dispose() + listen(scope, target, 'object', listener) + target.dispatchEvent(new Event('object')) + assert.equal(calls, 2) +}) + +test('request cancellation and URL ownership are scoped; caller URLs stay available', async () => { + const instance = new ResourceScope() + const operation = instance.child() + const controller = requestController(operation) + const url = objectURL(operation, new Blob(['owned'])) + const external = URL.createObjectURL(new Blob(['external'])) + try { + assert.equal(await (await fetch(url)).text(), 'owned') + operation.dispose() + assert(controller.signal.aborted) + await assert.rejects(fetch(url)) + assert.equal(await (await fetch(external)).text(), 'external') + assert.equal(instance.closed, false) + assert(requestController(operation).signal.aborted) + } + finally { + URL.revokeObjectURL(external) + instance.dispose() + } +}) + +test('request controller capability absence does not prevent synchronous cleanup', (context) => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'AbortController') + Object.defineProperty(globalThis, 'AbortController', { configurable: true, value: undefined }) + context.after(() => Object.defineProperty(globalThis, 'AbortController', descriptor)) + const scope = new ResourceScope() + assert.equal(requestController(scope), undefined) + scope.dispose() +}) diff --git a/test/types/resource-scope.ts b/test/types/resource-scope.ts new file mode 100644 index 000000000..293e26e85 --- /dev/null +++ b/test/types/resource-scope.ts @@ -0,0 +1,25 @@ +import { animationFrame, listen, objectURL, requestController, timeout } from '../../packages/artplayer/src/lifecycle/resources' +import ResourceScope from '../../packages/artplayer/src/lifecycle/scope' + +const instance = new ResourceScope() +const operation = instance.child() +operation.add(() => { + document.body.removeAttribute('data-test') +}) +timeout(operation, () => {}, 10) +animationFrame(operation, time => time.toFixed()) +listen(operation, document, 'click', { handleEvent(event) { + event.preventDefault() +} }) +const url: string = objectURL(operation, new Blob()) +const signal: AbortSignal | undefined = requestController(operation)?.signal +void [url, signal] +// @ts-expect-error Cleanup is synchronous; async work needs a separately awaited owner. +operation.add(async () => {}) +// @ts-expect-error A cleanup cannot return a timer handle. +operation.add(() => 1) +// @ts-expect-error Delays must be numeric. +timeout(operation, () => {}, '10') +// @ts-expect-error Object URLs require a Blob. +objectURL(operation, 'existing-url') +instance.dispose()