驱逐后不再补扫:删掉那条会把可见卡反复重读的自激回路,并如实标注超时预算
- 删掉 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。
This commit is contained in:
+24
-21
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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('预览缓存驱逐的判定契约', () => {
|
||||
|
||||
Reference in New Issue
Block a user