fix(sessions): don't evict loaded images whose waiters haven't taken them

With every cached image in use, a newly loaded entry (refs 0) was the only
evictable one and got revoked before its waiting hook acquired it, leaving
the image stuck on the loading placeholder; two loads finishing together
could also evict each other. Count pending waiters per image and skip
images that still have one when evicting.
This commit is contained in:
Jason committed 2026-10-04 00:05:19 +08:00
1 parent f5a4bdc977
commit 3abbdf28a1
2 files changed
+69 -3

No files matched your search

+23 -3
View File
@@ -292,12 +292,26 @@ interface CachedImage {
const imageCache = new Map<string, CachedImage>();
const imageLoads = new Map<string, Promise<string>>();
/** 每张图还在等加载结果、尚未占用的等待方个数;有等待方的图不淘汰 */
const imageWaiters = new Map<string, number>();
/** 超出上限时按最久未用淘汰没人在用的图并 revoke */
const startWaiting = (key: string) =>
imageWaiters.set(key, (imageWaiters.get(key) ?? 0) + 1);
const stopWaiting = (key: string) => {
const left = (imageWaiters.get(key) ?? 0) - 1;
if (left > 0) imageWaiters.set(key, left);
else imageWaiters.delete(key);
};
/**
* 超出上限时按最久未用淘汰没人在用的图并 revoke。
* 加载完、等待方还没来得及占用的图也算在用(几张图同时加载完时会互相淘汰)
*/
const evictImages = () => {
for (const [key, entry] of imageCache) {
if (imageCache.size <= SESSION_IMAGE_CACHE_SIZE) return;
if (entry.refs > 0) continue;
if (entry.refs > 0 || imageWaiters.has(key)) continue;
URL.revokeObjectURL(entry.url);
imageCache.delete(key);
}
@@ -347,6 +361,7 @@ export const clearSessionImageCache = () => {
imageCache.forEach((entry) => URL.revokeObjectURL(entry.url));
imageCache.clear();
imageLoads.clear();
imageWaiters.clear();
};
export interface SessionImageResult {
@@ -387,11 +402,16 @@ export const useSessionImage = (
take();
} else {
setState({ url: null, isLoading: true, error: null });
startWaiting(key);
loadImageUrl(key, providerId!, sourcePath!, image!).then(
() => {
if (!cancelled) take();
stopWaiting(key);
// 已卸载就不占用;不再等它了,补一次淘汰以免超出上限
if (cancelled) evictImages();
else take();
},
(error: unknown) => {
stopWaiting(key);
if (!cancelled) setState({ url: null, isLoading: false, error });
},
);
+46
View File
@@ -413,6 +413,52 @@ describe("useSessionImage", () => {
held.unmount();
});
it("上限内的图全在用时,新加载的那张也拿得到 URL,不会被立刻淘汰", async () => {
const mounted = [];
for (let i = 0; i < SESSION_IMAGE_CACHE_SIZE; i += 1) {
const hook = renderHook(() =>
useSessionImage("codex", "/a", image(`/busy-${i}.png`)),
);
await waitFor(() => expect(hook.result.current.url).toBeTruthy());
mounted.push(hook);
}
const extra = renderHook(() =>
useSessionImage("codex", "/a", image("/extra.png")),
);
await waitFor(() => expect(extra.result.current.isLoading).toBe(false));
expect(extra.result.current.url).toBe(
`blob:${SESSION_IMAGE_CACHE_SIZE + 1}`,
);
expect(revokeObjectURL).not.toHaveBeenCalled();
extra.unmount();
mounted.forEach((hook) => hook.unmount());
});
it("上限内的图全在用时,两张同时加载完也都拿得到 URL,不会互相淘汰", async () => {
const mounted = [];
for (let i = 0; i < SESSION_IMAGE_CACHE_SIZE; i += 1) {
const hook = renderHook(() =>
useSessionImage("codex", "/a", image(`/busy-${i}.png`)),
);
await waitFor(() => expect(hook.result.current.url).toBeTruthy());
mounted.push(hook);
}
const resolvers: Array<(value: ArrayBuffer) => void> = [];
api.getImage.mockImplementation(
() => new Promise<ArrayBuffer>((ok) => resolvers.push(ok)),
);
const x = renderHook(() => useSessionImage("codex", "/a", image("/x.png")));
const y = renderHook(() => useSessionImage("codex", "/a", image("/y.png")));
await act(async () => {
resolvers.forEach((resolve) => resolve(new ArrayBuffer(3)));
});
await waitFor(() => expect(y.result.current.isLoading).toBe(false));
expect(x.result.current.url).toBeTruthy();
expect(y.result.current.url).toBeTruthy();
expect(revokeObjectURL).not.toHaveBeenCalled();
[x, y, ...mounted].forEach((hook) => hook.unmount());
});
it("加载中卸载:不再更新状态", async () => {
let resolve: (value: ArrayBuffer) => void = () => {};
let reject: (error: Error) => void = () => {};