Skip to content

Commit 89c9716

Browse files
jlobue10claude
andcommitted
Serialize cache size scans behind the coalescing window
The sizeChanged coalescer cleared its pending flag when the timer fired, so a burst arriving while a scan was still running scheduled another scan that overlapped the in-flight one on large caches. Track the running scan, mark it stale when events arrive mid-scan, and run a single follow-up scan after it settles so scans never overlap and the trailing notification still delivers a fresh size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Q2tBCuePZJeeCkDDxu1m3
1 parent 29fc2d3 commit 89c9716

2 files changed

Lines changed: 70 additions & 1 deletion

File tree

packages/gui/src/electron/CacheManager.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,57 @@ describe('CacheManager eviction', () => {
145145
expect(mockDownloadFile).toHaveBeenCalledTimes(2);
146146
});
147147

148+
it('does not overlap cache size scans when a scan outlives the coalescing window', async () => {
149+
jest.useFakeTimers();
150+
try {
151+
const cacheManager = new CacheManager({
152+
cacheDirectory,
153+
maxCacheSize: 1024,
154+
});
155+
await cacheManager.init();
156+
157+
let runningScans = 0;
158+
let maxConcurrentScans = 0;
159+
const scanResolvers: Array<() => void> = [];
160+
const getCacheSizeSpy = jest.spyOn(cacheManager, 'getCacheSize').mockImplementation(
161+
() =>
162+
new Promise<number>((resolve) => {
163+
runningScans += 1;
164+
maxConcurrentScans = Math.max(maxConcurrentScans, runningScans);
165+
scanResolvers.push(() => {
166+
runningScans -= 1;
167+
resolve(0);
168+
});
169+
}),
170+
);
171+
172+
const send = jest.fn();
173+
const fakeWindow = {
174+
webContents: { send },
175+
isDestroyed: () => false,
176+
on: jest.fn(),
177+
} as any;
178+
cacheManager.bindEvents(fakeWindow);
179+
180+
cacheManager.emit('sizeChanged');
181+
jest.advanceTimersByTime(500); // the first scan starts and stays in flight
182+
183+
cacheManager.emit('sizeChanged'); // burst arriving mid-scan
184+
jest.advanceTimersByTime(500); // previously this started an overlapping scan
185+
186+
expect(maxConcurrentScans).toBe(1);
187+
188+
scanResolvers.shift()?.();
189+
await Promise.resolve(); // let the first scan settle and reschedule
190+
jest.advanceTimersByTime(500); // the follow-up scan delivers the fresh size
191+
192+
expect(getCacheSizeSpy).toHaveBeenCalledTimes(2);
193+
expect(maxConcurrentScans).toBe(1);
194+
} finally {
195+
jest.useRealTimers();
196+
}
197+
});
198+
148199
it('treats a zero cache limit as unlimited when updating the setting', async () => {
149200
const payload = Buffer.from('cached payload');
150201
mockDownloadFile.mockImplementation(async (_url, localPath) => {

packages/gui/src/electron/CacheManager.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,21 +251,38 @@ export default class CacheManager extends EventEmitter {
251251

252252
// Download and invalidation bursts emit sizeChanged per file, and every
253253
// notification triggers a full cache-directory scan (here and again in the
254-
// renderer), so coalesce bursts into one trailing notification.
254+
// renderer), so coalesce bursts into one trailing notification. Scans are
255+
// also serialized: events arriving while a scan is running only mark it
256+
// stale, and one follow-up scan is scheduled after it finishes, so a scan
257+
// that outlives the coalescing window cannot overlap the next one.
255258
let sizeChangedTimeout: NodeJS.Timeout | undefined;
259+
let sizeScanRunning = false;
260+
let sizeChangedDuringScan = false;
261+
256262
const onSizeChanged = () => {
257263
if (sizeChangedTimeout) {
258264
return;
259265
}
266+
if (sizeScanRunning) {
267+
sizeChangedDuringScan = true;
268+
return;
269+
}
260270
sizeChangedTimeout = setTimeout(async () => {
261271
sizeChangedTimeout = undefined;
272+
sizeScanRunning = true;
262273
try {
263274
const size = await this.getCacheSize();
264275
if (!window.isDestroyed()) {
265276
window.webContents.send(CacheAPI.ON_SIZE_CHANGED, size);
266277
}
267278
} catch {
268279
// the next sizeChanged event delivers a fresh value
280+
} finally {
281+
sizeScanRunning = false;
282+
if (sizeChangedDuringScan) {
283+
sizeChangedDuringScan = false;
284+
onSizeChanged();
285+
}
269286
}
270287
}, 500);
271288
};
@@ -278,6 +295,7 @@ export default class CacheManager extends EventEmitter {
278295
this.off('cacheDirectoryChanged', onCacheDirectoryChanged);
279296
this.off('maxCacheSizeChanged', onMaxCacheSizeChanged);
280297
this.off('sizeChanged', onSizeChanged);
298+
sizeChangedDuringScan = false;
281299
if (sizeChangedTimeout) {
282300
clearTimeout(sizeChangedTimeout);
283301
sizeChangedTimeout = undefined;

0 commit comments

Comments
 (0)