task-89: 清理页面卸载时的所有 timer、请求和临时 URL
新增纯 TS 页面卸载清理注册表 page-cleanup-registry:统一登记 blob 临时 URL(dispose 时 revokeUrl 释放)、AbortController(abort)与 timer 清理委托(clearTimers)。dispose 幂等且后注册拒绝并计数; revokeUrl 抛错不中断其余条目;maxTrackedUrls 上限只保留最近注册的 URL。8 个测试覆盖默认、批量、幂等、空、单元素、上限驱逐、非法 hooks 与 revoke 故障恢复。
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* 页面卸载清理注册表(Task 89)。
|
||||||
|
*
|
||||||
|
* 统一管理页面级需在卸载时释放的资源:
|
||||||
|
* - 临时 URL:createObjectURL 产生的 blob URL 通过 revokeUrl 释放;
|
||||||
|
* - 请求:AbortController 在卸载时 abort(registerRequestController);
|
||||||
|
* - timer:dispose 时委托 hooks.clearTimers()(配合 categorized-timers
|
||||||
|
* 的 clearScope 全量清理)。
|
||||||
|
*
|
||||||
|
* dispose 幂等:第二次及以后的调用不再重复清理;dispose 后注册被拒绝
|
||||||
|
* 并计数。revokeUrl 抛错不中断其余条目的清理;maxTrackedUrls(默认不
|
||||||
|
* 限)用于限制同一页面累计注册的 URL 数量,超过时只保留最近注册的。
|
||||||
|
*/
|
||||||
|
export interface PageCleanupRegistryHooks {
|
||||||
|
/** 卸载时清空全部 timer */
|
||||||
|
clearTimers: () => void
|
||||||
|
/** 卸载时释放单个 blob URL */
|
||||||
|
revokeUrl: (url: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageCleanupRegistryOptions {
|
||||||
|
/** 累计跟踪的 URL 上限(0 表示不限制),超过时只保留最近注册的 */
|
||||||
|
maxTrackedUrls?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPageCleanupRegistry(
|
||||||
|
hooks: PageCleanupRegistryHooks,
|
||||||
|
options: PageCleanupRegistryOptions = {},
|
||||||
|
) {
|
||||||
|
if (typeof hooks?.clearTimers !== 'function') {
|
||||||
|
throw new Error('clearTimers 必须是函数')
|
||||||
|
}
|
||||||
|
if (typeof hooks?.revokeUrl !== 'function') {
|
||||||
|
throw new Error('revokeUrl 必须是函数')
|
||||||
|
}
|
||||||
|
const maxTrackedUrls = options.maxTrackedUrls ?? 0
|
||||||
|
const urls: string[] = []
|
||||||
|
const controllers: AbortController[] = []
|
||||||
|
let disposed = false
|
||||||
|
let disposeCount = 0
|
||||||
|
let rejectedCount = 0
|
||||||
|
|
||||||
|
function registerObjectUrl(url: string): string {
|
||||||
|
if (disposed) {
|
||||||
|
rejectedCount += 1
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if (!url) return url
|
||||||
|
urls.push(url)
|
||||||
|
if (maxTrackedUrls > 0 && urls.length > maxTrackedUrls) {
|
||||||
|
urls.splice(0, urls.length - maxTrackedUrls)
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerRequestController(controller: AbortController) {
|
||||||
|
if (disposed) {
|
||||||
|
rejectedCount += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
controllers.push(controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose(): number {
|
||||||
|
if (disposed) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
disposed = true
|
||||||
|
let released = 0
|
||||||
|
for (const url of urls) {
|
||||||
|
try {
|
||||||
|
hooks.revokeUrl(url)
|
||||||
|
} catch {
|
||||||
|
/* 单个 URL 释放失败不中断其余清理 */
|
||||||
|
}
|
||||||
|
released += 1
|
||||||
|
}
|
||||||
|
urls.length = 0
|
||||||
|
for (const controller of controllers) {
|
||||||
|
try {
|
||||||
|
controller.abort()
|
||||||
|
} catch {
|
||||||
|
/* abort 失败不中断其余清理 */
|
||||||
|
}
|
||||||
|
released += 1
|
||||||
|
}
|
||||||
|
controllers.length = 0
|
||||||
|
hooks.clearTimers()
|
||||||
|
released += 1
|
||||||
|
disposeCount += 1
|
||||||
|
return released
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get trackedUrlCount() {
|
||||||
|
return urls.length
|
||||||
|
},
|
||||||
|
get requestCount() {
|
||||||
|
return controllers.length
|
||||||
|
},
|
||||||
|
get disposed() {
|
||||||
|
return disposed
|
||||||
|
},
|
||||||
|
get disposeCount() {
|
||||||
|
return disposeCount
|
||||||
|
},
|
||||||
|
get rejectedCount() {
|
||||||
|
return rejectedCount
|
||||||
|
},
|
||||||
|
registerObjectUrl,
|
||||||
|
registerRequestController,
|
||||||
|
dispose,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageCleanupRegistry = ReturnType<typeof createPageCleanupRegistry>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createPageCleanupRegistry } from '../src/shared/page-cleanup-registry.ts'
|
||||||
|
|
||||||
|
interface CleanupHooks {
|
||||||
|
clearTimers: () => void
|
||||||
|
revokeUrl: (url: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHooks(): CleanupHooks & { cleared: string[] } {
|
||||||
|
const cleared: string[] = []
|
||||||
|
return {
|
||||||
|
cleared,
|
||||||
|
clearTimers: () => cleared.push('timers'),
|
||||||
|
revokeUrl: (url: string) => cleared.push(`url:${url}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test_task_089_cleanup_normal_default_path', () => {
|
||||||
|
const hooks = createHooks()
|
||||||
|
const registry = createPageCleanupRegistry(hooks)
|
||||||
|
const url = registry.registerObjectUrl('blob:http://x/1')
|
||||||
|
assert.equal(url, 'blob:http://x/1')
|
||||||
|
assert.equal(registry.trackedUrlCount, 1)
|
||||||
|
assert.equal(registry.disposeCount, 0)
|
||||||
|
const result = registry.dispose()
|
||||||
|
assert.equal(result, 2)
|
||||||
|
assert.equal(registry.disposed, true)
|
||||||
|
assert.equal(registry.trackedUrlCount, 0)
|
||||||
|
assert.deepEqual(hooks.cleared, ['url:blob:http://x/1', 'timers'])
|
||||||
|
assert.equal(registry.disposeCount, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_089_cleanup_normal_multiple_items', () => {
|
||||||
|
const hooks = createHooks()
|
||||||
|
const registry = createPageCleanupRegistry(hooks)
|
||||||
|
registry.registerObjectUrl('blob:http://x/1')
|
||||||
|
registry.registerObjectUrl('blob:http://x/2')
|
||||||
|
registry.registerObjectUrl('blob:http://x/3')
|
||||||
|
const aborted: string[] = []
|
||||||
|
const makeController = (n: number) =>
|
||||||
|
({ abort: () => aborted.push(`abort:${n}`) }) as unknown as AbortController
|
||||||
|
registry.registerRequestController(makeController(1))
|
||||||
|
registry.registerRequestController(makeController(2))
|
||||||
|
assert.equal(registry.trackedUrlCount, 3)
|
||||||
|
assert.equal(registry.requestCount, 2)
|
||||||
|
const result = registry.dispose()
|
||||||
|
assert.equal(result, 6)
|
||||||
|
assert.equal(registry.trackedUrlCount, 0)
|
||||||
|
assert.equal(registry.requestCount, 0)
|
||||||
|
assert.deepEqual(aborted, ['abort:1', 'abort:2'])
|
||||||
|
assert.deepEqual(
|
||||||
|
hooks.cleared,
|
||||||
|
['url:blob:http://x/1', 'url:blob:http://x/2', 'url:blob:http://x/3', 'timers'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_089_cleanup_normal_repeated_operation_is_idempotent', () => {
|
||||||
|
const hooks = createHooks()
|
||||||
|
const registry = createPageCleanupRegistry(hooks)
|
||||||
|
const url = registry.registerObjectUrl('blob:http://x/1')
|
||||||
|
registry.dispose()
|
||||||
|
registry.dispose()
|
||||||
|
registry.dispose()
|
||||||
|
assert.equal(registry.disposed, true)
|
||||||
|
assert.equal(registry.trackedUrlCount, 0)
|
||||||
|
assert.equal(hooks.cleared.length, 2, '第二次及以后的 dispose 不再重复清理')
|
||||||
|
// dispose 后注册被拒绝
|
||||||
|
registry.registerObjectUrl('blob:http://x/2')
|
||||||
|
assert.equal(registry.trackedUrlCount, 0)
|
||||||
|
assert.equal(registry.disposeCount, 1)
|
||||||
|
assert.equal(registry.rejectedCount, 1)
|
||||||
|
assert.equal(url, 'blob:http://x/1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_089_cleanup_boundary_empty_input', () => {
|
||||||
|
const hooks = createHooks()
|
||||||
|
const registry = createPageCleanupRegistry(hooks)
|
||||||
|
assert.equal(registry.trackedUrlCount, 0)
|
||||||
|
assert.equal(registry.requestCount, 0)
|
||||||
|
const result = registry.dispose()
|
||||||
|
assert.equal(result, 1, 'dispose 至少执行 clearTimers')
|
||||||
|
assert.deepEqual(hooks.cleared, ['timers'])
|
||||||
|
// 空字符串 URL 不注册
|
||||||
|
registry.registerObjectUrl('')
|
||||||
|
assert.equal(registry.trackedUrlCount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_089_cleanup_boundary_single_item', () => {
|
||||||
|
const hooks = createHooks()
|
||||||
|
const registry = createPageCleanupRegistry(hooks)
|
||||||
|
registry.registerObjectUrl('blob:http://x/single')
|
||||||
|
const result = registry.dispose()
|
||||||
|
assert.equal(result, 2)
|
||||||
|
assert.equal(hooks.cleared.length, 2)
|
||||||
|
assert.equal(hooks.cleared[0], 'url:blob:http://x/single')
|
||||||
|
assert.equal(hooks.cleared[1], 'timers')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_089_cleanup_boundary_limit_and_overflow', () => {
|
||||||
|
const hooks = createHooks()
|
||||||
|
const registry = createPageCleanupRegistry(hooks, { maxTrackedUrls: 3 })
|
||||||
|
for (let i = 1; i <= 5; i++) registry.registerObjectUrl(`blob:http://x/${i}`)
|
||||||
|
assert.equal(registry.trackedUrlCount, 3, '超过上限只保留最近 3 个')
|
||||||
|
const result = registry.dispose()
|
||||||
|
assert.equal(result, 4)
|
||||||
|
assert.ok(hooks.cleared.includes('url:blob:http://x/3'))
|
||||||
|
assert.ok(hooks.cleared.includes('url:blob:http://x/5'))
|
||||||
|
assert.ok(!hooks.cleared.includes('url:blob:http://x/1'), '被驱逐的 URL 不再由注册表 revoke')
|
||||||
|
assert.equal(hooks.cleared.length, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_089_cleanup_invalid_input_rejected', () => {
|
||||||
|
assert.throws(() => createPageCleanupRegistry({} as never), /clearTimers 必须是函数/)
|
||||||
|
assert.throws(
|
||||||
|
() => createPageCleanupRegistry({ clearTimers: () => {} } as never),
|
||||||
|
/revokeUrl 必须是函数/,
|
||||||
|
)
|
||||||
|
const hooks = createHooks()
|
||||||
|
const registry = createPageCleanupRegistry(hooks, { maxTrackedUrls: 0 })
|
||||||
|
assert.equal(registry.registerObjectUrl('blob:http://x/1'), 'blob:http://x/1', 'maxTrackedUrls 为 0 时不限上限')
|
||||||
|
assert.equal(registry.trackedUrlCount, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_089_cleanup_dependency_failure_releases_resources', () => {
|
||||||
|
let urlFailures = 0
|
||||||
|
const hooks: CleanupHooks & { cleared: string[] } = {
|
||||||
|
cleared: [],
|
||||||
|
clearTimers: () => hooks.cleared.push('timers'),
|
||||||
|
revokeUrl: (url) => {
|
||||||
|
if (url === 'blob:http://x/poison') {
|
||||||
|
urlFailures += 1
|
||||||
|
throw new Error('revoke down')
|
||||||
|
}
|
||||||
|
hooks.cleared.push(`url:${url}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const registry = createPageCleanupRegistry(hooks)
|
||||||
|
registry.registerObjectUrl('blob:http://x/a')
|
||||||
|
registry.registerObjectUrl('blob:http://x/poison')
|
||||||
|
registry.registerObjectUrl('blob:http://x/b')
|
||||||
|
// revoke 抛错不中断清理,全部条目仍被尝试释放
|
||||||
|
const result = registry.dispose()
|
||||||
|
assert.equal(result, 4)
|
||||||
|
assert.equal(urlFailures, 1)
|
||||||
|
assert.equal(registry.trackedUrlCount, 0)
|
||||||
|
assert.ok(hooks.cleared.includes('url:blob:http://x/a'))
|
||||||
|
assert.ok(hooks.cleared.includes('url:blob:http://x/b'))
|
||||||
|
assert.ok(hooks.cleared.includes('timers'))
|
||||||
|
// 错误可恢复:同一注册表 dispose 后状态一致(幂等)
|
||||||
|
registry.dispose()
|
||||||
|
assert.equal(registry.disposeCount, 1)
|
||||||
|
assert.equal(urlFailures, 1)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user