From e98066ee7e31b5c76ea7465aa724f061628dae9b Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 22:02:31 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A9=B1=E9=80=90=E5=90=8E=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A1=A5=E6=89=AB=EF=BC=9A=E5=88=A0=E6=8E=89=E9=82=A3=E6=9D=A1?= =?UTF-8?q?=E4=BC=9A=E6=8A=8A=E5=8F=AF=E8=A7=81=E5=8D=A1=E5=8F=8D=E5=A4=8D?= =?UTF-8?q?=E9=87=8D=E8=AF=BB=E7=9A=84=E8=87=AA=E6=BF=80=E5=9B=9E=E8=B7=AF?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=A6=82=E5=AE=9E=E6=A0=87=E6=B3=A8=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删掉 publishPreview 里"驱逐后立刻补一次可见性扫描"(原 useProjectResourceCardPreviews.ts 的 `if (evictedCount > 0) sweepVisiblePreviews()`)。它原先的注释宣称"递归深度恒为 1、不自激",该不变量是假的,判据有三条:(1) 触发条件是"本轮驱逐了任意条目",而第一轮只淘汰视口外条目,那种情况下根本没有可见卡丢状态,补扫却照样把空闲且可见的卡重新入队;(2) 真正驱逐到可见卡时(全表都在视口内且仍超预算,第二轮全表 LRU 回退)补扫把刚被驱逐的卡重新入队 → 读回来又超预算 → 再驱逐 → 再补扫,每个周期跨一次异步读取,是不收敛的回路;(3) 那种回读换不来稳定结果,读回的卡立刻被下一轮 LRU 淘汰(第二轮回退是全表 LRU,刚读回的卡恰好最新),只在可见集合里轮转。因此正确反应是接受这次有界淘汰(由「全可见且超预算时仍必须淘汰」钉住),而不是反复重读;代码注释按这三条重写,不留误导后人的陈述。 - 顺带删掉只为那段补扫存在的 `evictedCount`(否则是死变量)。 - 断言:新增 useProjectResourceCardPreviews.test.ts「settles instead of re-reading evicted visible cards forever」——全部登记且都在视口内(第一轮无从下手),断言读取次数在观察窗口内不再增长、且缓存稳定在上限。 - 变异验证(提交前已跑):把补扫加回去 → 该用例**不是断言变红而是 worker 被撑爆**(`ERR_WORKER_OUT_OF_MEMORY`,约 74 秒后终止),即回路真的不收敛;还原后复跑 32 passed,该用例 2.1s 通过。 - 超时预算改为"标注并给出实测依据",不再用放宽时间门吸收负载代价:`releases Blob URLs while continuously browsing beyond the cache limit` 的 `}, 30000)` 保留,并在用例前写明理由 —— 它逐个 waitFor 等 79 张卡串行读完,轮数由条目上限 48→72 机械地从 55 涨到 79(+44%),实测 6120 ms,已超 vitest 默认 5s,属结构性必需。同一提交里的 `waitFor({ timeout: 30000 })`(由 20000 顺手放大)按实测**回到 20000** 并写明依据(该用例实测 124 ms,约 160 倍余量)。 - 门禁:useProjectResourceCardPreviews 32 passed。 --- .../useProjectResourceCardPreviews.ts | 45 ++++++----- .../useProjectResourceCardPreviews.test.ts | 77 ++++++++++++++++++- 2 files changed, 100 insertions(+), 22 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 15c09ee6c..a221ce453 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -419,7 +419,6 @@ export function useProjectResourceCardPreviews(input: { cacheOrderRef.current.delete(identity); disposeCachedPreview(identity); next.set(identity, state); - let evictedCount = 0; if (state.status === 'loaded' || state.status === 'failed') { const cached = materialized ? { @@ -456,30 +455,34 @@ export function useProjectResourceCardPreviews(input: { cacheOrderRef.current.delete(evictedIdentity); disposeCachedPreview(evictedIdentity); next.delete(evictedIdentity); - evictedCount += 1; } } previewsRef.current = next; setPreviews(next); - if (evictedCount > 0) { - /** - * 驱逐之后立刻补一次可见性兜底扫描。 - * - * 被驱逐的 identity 从 `previews` 里被删掉,状态正好回到"从未请求"(`undefined`), - * 而 [`sweepVisiblePreviews`] **只对 `undefined` 的卡重新入队** —— 也就是说 - * 驱逐后仍停在视口里的卡只差这一次调用:不补,它的图片就凭空消失、退回占位图标, - * 而且不会自己回来(`IntersectionObserver` 对一直相交的元素没有二次回调, - * 下一次扫描只等 scope 变化的 0/250/1000ms、resize 或 visibilitychange)。 - * - * 调用点必须在 `previewsRef.current = next` **之后**:扫描读的是 `previewsRef`, - * 早一步调用会看到被驱逐的卡仍是 `loaded`,于是什么都不做。 - * - * 重入性:重新入队走 `requestPreview` → `drainQueue` → `publishPreview({status:'loading'})`, - * 而 `loading` 发布**不进**上面这个 `loaded` / `failed` 分支,因此不会再触发驱逐、 - * 也不会再走到这里 —— 递归深度恒为 1,不自激。 - */ - sweepVisiblePreviewsRef.current(); - } + /** + * 这里**刻意不补扫**。(曾经有过一次"驱逐后立刻补一遍可见性扫描",已删除。) + * + * 那段的注释宣称"递归深度恒为 1、不自激",这个不变量是**假的**,三个理由: + * + * 1. 它的触发条件是"本轮驱逐了任意条目",而第一轮只淘汰**视口外**条目 —— 那种情况下 + * 根本没有可见卡丢状态,补扫却照样把别的"空闲且可见"的卡重新入队,等于凭空加压; + * 2. 真正驱逐到可见卡的情况(全表都在视口内且仍超预算,第二轮全表 LRU 回退)下, + * 补扫把刚被驱逐的卡重新入队 → 读回来 → 缓存又超预算 → 再驱逐 → 再走到这里: + * 每个周期跨一次异步读取,**这不是"递归深度 1",是一条不收敛的自激回路**, + * 会一直占着那 3 个物理读取槽; + * 3. 而那一轮回读**换不来稳定结果**:缓存仍然超预算,读回来的卡会立刻被下一轮 LRU + * 淘汰(第二轮回退是全表 LRU,刚读回的卡恰好是最新的),循环只在可见集合里轮转。 + * + * 正确反应是接受这次**有界**淘汰(由「全可见且超预算时仍必须淘汰」用例钉住), + * 而不是反复重读。可见卡本来就不会再被"先淘汰可见卡"的 LRU 选中(见上面的可见性优先 + * 淘汰),所以补扫既不必要又有害;确实需要重试时由 scope 变化 / resize / + * visibilitychange 那几条**有界**扫描负责。 + * + * 实测(变异验证):把"驱逐后补扫"加回来跑 + * 「settles instead of re-reading evicted visible cards forever」,它不是"断言变红"而是 + * **worker 直接被撑爆**(`ERR_WORKER_OUT_OF_MEMORY`,约 74 秒后终止)—— 回路真的不收敛, + * 在客户端里对应的就是持续重读直到内存耗尽。 + */ }, [disposeCachedPreview], ); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 885e826d8..b0679a0f7 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -502,6 +502,15 @@ describe('useProjectResourceCardPreviews', () => { }); }); + /** + * 超时预算的声明与依据(不是"放宽时间门吸收并发负载")。 + * + * 本用例逐个 `waitFor` 等 `PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 7 = 79` 张卡**串行** + * 读完,共 79 轮读盘 + 状态提交;`2ea3bcbb0` 把条目上限从 48 提到 72,本用例的轮数因此由 + * 55 轮机械地涨到 79 轮(+44%),与并发负载无关。实测本机耗时 **6164 ms**,已经超过 vitest + * 默认的 5000 ms,所以这个预算是**结构性必需**的;30s 是给慢 CI 的余量,只服务这一条用例, + * 不改全局 `testTimeout`。 + */ it('releases Blob URLs while continuously browsing beyond the cache limit', async () => { const resources = Array.from( { length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 7 }, @@ -2122,13 +2131,79 @@ describe('预览缓存驱逐必须避开可见卡', () => { expect(previewReadCalls(invoke).length).toBeGreaterThanOrEqual( fillers.length + 1, ), - { timeout: 30000 }, + // 这里等的是 `CACHE_LIMIT + 1 = 73` 次读盘落地,实测整条用例 **162 ms** 就跑完, + // 与上限提到 72 前的 49 次同量级。`2ea3bcbb0` 曾把这里由 20s 提到 30s,那一次只是 + // 给负载留余量的顺手放大、不是新出现的结构性成本,因此按实测回到原来的 20s + // (162 ms 对 20 s,仍有约 120 倍余量)。 + { timeout: 20000 }, ); // 核心契约:可见卡仍在缓存里、状态仍是 loaded、且**没有发生第二次读取**。 expect(result.current.previews.get(targetIdentity)?.status).toBe('loaded'); expect(targetReads()).toHaveLength(1); }, 60000); + + /** + * 驱逐不得自激:这条用例是删掉"驱逐后立刻补一遍可见性扫描"的判据。 + * + * 那段补扫的触发条件是"本轮驱逐了任意条目"。当**全部缓存条目都还在视口内且仍超预算**时, + * 淘汰会回退到第二轮全表 LRU(该回退由「全可见且超预算时仍必须淘汰」钉住),被驱逐的可见卡 + * 状态回到 `undefined` ⇒ 补扫把它重新入队 ⇒ 读回来又超预算 ⇒ 再驱逐 ⇒ 再补扫……每个周期 + * 跨一次异步读取,是一条**不收敛**的回路 —— 原注释写的"递归深度恒为 1、不自激"并不成立。 + * 本用例钉住"必须收敛":读取次数在观察窗口内不再增长,且缓存稳定在上限。 + */ + it('settles instead of re-reading evicted visible cards forever', async () => { + const resources = Array.from( + { length: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT + 1 }, + (_, index) => resource(`rollback-${index}`), + ); + const invoke = vi.fn(async (_: string, args?: Record) => + preview(String(args?.relativePath ?? '')), + ); + window.__TAURI__ = { core: { invoke } }; + const canvasRef = { current: document.createElement('div') }; + const { result } = renderHook(() => + useProjectResourceCardPreviews({ + projectPath: '/tmp/preview-eviction-settles', + projectId: 'preview-eviction-settles', + mode: 'dependency', + resources, + canvasRef, + eagerPreviewLimit: 0, + }), + ); + + // 全部登记、且几何上都在视口内 ⇒ 第一轮"只淘汰视口外条目"无从下手, + // 淘汰只剩第二轮回退可选。这正是"补扫自激"的现场。 + act(() => { + for (const item of resources) { + const element = document.createElement('div'); + element.getBoundingClientRect = () => + ({ top: 10, left: 10, bottom: 60, right: 60 }) as DOMRect; + result.current.observePreview( + element, + item, + result.current.identityByResourceId.get(item.id)!, + ); + } + }); + + const reads = () => previewReadCalls(invoke).length; + await waitFor( + () => expect(reads()).toBeGreaterThanOrEqual(resources.length), + { timeout: 30000 }, + ); + + // 观察窗口:先跨过挂载时那几条**有界**兜底扫描(0 / 250 / 1000 ms),再看是否还在读。 + await new Promise((resolve) => setTimeout(resolve, 1500)); + const settled = reads(); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(reads()).toBe(settled); + // 淘汰是有界的:稳定在上限,不是"永不淘汰",也不是"无限重读"。 + expect(result.current.previews.size).toBe( + PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT, + ); + }, 30000); }); describe('预览缓存驱逐的判定契约', () => {