Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions packages/gui/src/components/cache/CacheProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usePrefs } from '@chia-network/api-react';
import React, { useMemo, useState, useEffect, useCallback, type ReactNode } from 'react';

const { cacheAPI } = window;
Expand All @@ -15,6 +16,14 @@ export default function CacheProvider(props: CacheProviderProps) {
const [cacheDirectory, setCacheDirectory] = useState<string | undefined>(undefined);
const [cacheSize, setCacheSize] = useState<number | undefined>(undefined);

// The main process only reads these preferences at startup; changes made at
// runtime live in CacheManager's memory, so they are persisted here in the
// renderer where all other preferences are written (prefs.yaml is rewritten
// from the renderer's snapshot on every preference save - a main process
// write would be clobbered by the next renderer save).
const [, setMaxCacheSizePref] = usePrefs<number | undefined>('maxCacheSize', undefined);
const [, setCacheFolderPref] = usePrefs<string | undefined>('cacheFolder', undefined);

const updateCacheSize = useCallback(async () => {
const size = await cacheAPI.getCacheSize();
setCacheSize(size);
Expand All @@ -30,9 +39,21 @@ export default function CacheProvider(props: CacheProviderProps) {
setMaxCacheSize(size);
}, []);

const handleCacheDirectoryChanged = useCallback(async () => {
const directory = await cacheAPI.getCacheDirectory();
setCacheDirectory(directory);
setCacheFolderPref(directory);
}, [setCacheFolderPref]);

const handleMaxCacheSizeChanged = useCallback(async () => {
const size = await cacheAPI.getMaxCacheSize();
setMaxCacheSize(size);
setMaxCacheSizePref(size);
}, [setMaxCacheSizePref]);

useEffect(() => {
const unbindCacheDirectoryChanged = cacheAPI.subscribeToDirectoryChange(updateCacheDirectory);
const unbindMaxCacheSizeChanged = cacheAPI.subscribeToMaxSizeChange(updateMaxCacheSize);
const unbindCacheDirectoryChanged = cacheAPI.subscribeToDirectoryChange(handleCacheDirectoryChanged);
const unbindMaxCacheSizeChanged = cacheAPI.subscribeToMaxSizeChange(handleMaxCacheSizeChanged);
const unbindSizeChanged = cacheAPI.subscribeToSizeChange(updateCacheSize);

updateCacheSize();
Expand All @@ -44,7 +65,13 @@ export default function CacheProvider(props: CacheProviderProps) {
unbindMaxCacheSizeChanged();
unbindSizeChanged();
};
}, [updateCacheSize, updateCacheDirectory, updateMaxCacheSize]);
}, [
updateCacheSize,
updateCacheDirectory,
updateMaxCacheSize,
handleCacheDirectoryChanged,
handleMaxCacheSizeChanged,
]);

const context = useMemo(() => {
const { getCacheDirectory, getCacheSize, getMaxCacheSize, ...rest } = cacheAPI;
Expand Down
14 changes: 12 additions & 2 deletions packages/gui/src/components/nfts/NFTPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export default function NFTPreview(props: NFTPreviewProps) {
}
`;

const cachedURI = await getURI(preview.uri);
const cachedURI = await getURI(preview.uri, { maxSize: ignoreSizeLimit ? -1 : undefined });
if (!cachedURI || !cachedURI.startsWith('cache://')) {
setPreviewContent(undefined, signal);
return;
Expand All @@ -237,7 +237,17 @@ export default function NFTPreview(props: NFTPreviewProps) {
setError(e as Error, signal);
}
},
[preview, fit, getURI, previewFileType, disableInteractions, isDarkMode, setPreviewContent, setError],
[
preview,
fit,
getURI,
ignoreSizeLimit,
previewFileType,
disableInteractions,
isDarkMode,
setPreviewContent,
setError,
],
);

useEffect(() => {
Expand Down
5 changes: 0 additions & 5 deletions packages/gui/src/components/settings/LimitCacheSize.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { usePrefs } from '@chia-network/api-react';
import { AlertDialog, ButtonLoading, Flex, Form, TextField, useOpenDialog } from '@chia-network/core';
import { Trans } from '@lingui/macro';
import React, { useEffect } from 'react';
Expand All @@ -16,8 +15,6 @@ export default function LimitCacheSize() {
const openDialog = useOpenDialog();
const { maxCacheSize, setMaxCacheSize } = useCache();

const [, setCacheLimitSize] = usePrefs(`cacheLimitSize`, 0);

const methods = useForm<FormData>({
defaultValues: {
maxCacheSize,
Expand Down Expand Up @@ -45,8 +42,6 @@ export default function LimitCacheSize() {

const newValue = Number(values.maxCacheSize) * MB_SIZE;

// todo move it ti electron/main
setCacheLimitSize(newValue);
await setMaxCacheSize(newValue);

await openDialog(
Expand Down
221 changes: 221 additions & 0 deletions packages/gui/src/electron/CacheManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

type DownloadFile = typeof import('./utils/downloadFile').default;

const mockDownloadFile = jest.fn<ReturnType<DownloadFile>, Parameters<DownloadFile>>();

jest.mock('electron', () => ({
BrowserWindow: jest.fn(),
dialog: {
showOpenDialog: jest.fn(),
},
}));

jest.mock('./utils/downloadFile', () => ({
__esModule: true,
default: mockDownloadFile,
MAX_FILE_SIZE_EXCEEDED_ERROR: 'Maximum file size exceeded',
}));

jest.mock('./utils/ipcMainHandle', () => ({
__esModule: true,
default: jest.fn(),
}));

const CacheManager = jest.requireActual<typeof import('./CacheManager')>('./CacheManager').default;

describe('CacheManager eviction', () => {
let cacheDirectory: string;

beforeEach(async () => {
mockDownloadFile.mockReset();
cacheDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'chia-cache-manager-'));
});

afterEach(async () => {
await fs.rm(cacheDirectory, { recursive: true, force: true });
});

it('does not evict a just-downloaded file that fits within the configured total size', async () => {
const payload = Buffer.alloc(600, 7);
mockDownloadFile.mockImplementation(async (_url, localPath) => {
await fs.writeFile(localPath, payload);
return {
'content-type': 'image/png',
};
});

const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();

await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload);
await expect(cacheManager.getCacheSize()).resolves.toBeLessThanOrEqual(1024);
});

it('keeps a completed download cached when cache housekeeping fails', async () => {
const payload = Buffer.from('cached payload');
mockDownloadFile.mockImplementation(async (_url, localPath) => {
await fs.writeFile(localPath, payload);
return {
'content-type': 'image/png',
};
});

const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();

// A concurrent invalidation can delete files mid-scan and make the
// post-download size check fail — that must not poison the download.
jest
.spyOn(cacheManager, 'getCacheSize')
.mockRejectedValueOnce(new Error("ENOENT: no such file or directory, stat '/cache/other-chiacache'"));

await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload);
await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload);
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
});

it('ignores files that vanish while the cache size is being measured', async () => {
const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();

await fs.writeFile(path.join(cacheDirectory, 'aaaa-chiacache'), Buffer.alloc(100));
// a broken symlink stats like a file deleted between readdir and stat
await fs.symlink(path.join(cacheDirectory, 'missing-target'), path.join(cacheDirectory, 'bbbb-chiacache'));

await expect(cacheManager.getCacheSize()).resolves.toBe(100);
});

it('evicts without failing when a file vanishes during the eviction scan', async () => {
const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();

await fs.writeFile(path.join(cacheDirectory, 'aaaa-chiacache'), Buffer.alloc(200));
await fs.symlink(path.join(cacheDirectory, 'missing-target'), path.join(cacheDirectory, 'bbbb-chiacache'));

await expect(cacheManager.setMaxCacheSize(100)).resolves.toBeUndefined();
await expect(fs.stat(path.join(cacheDirectory, 'aaaa-chiacache'))).rejects.toThrow('ENOENT');
});

it('does not retry a timed-out download on the next access', async () => {
mockDownloadFile.mockRejectedValue(new Error('Request timed out after 30000ms of inactivity'));

const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();

await expect(cacheManager.getContent('https://example.com/nft.png')).rejects.toThrow('Request timed out');
await expect(cacheManager.getContent('https://example.com/nft.png')).rejects.toThrow('Request timed out');
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
});

it('retries an aborted download on the next access', async () => {
const payload = Buffer.from('cached payload');
mockDownloadFile.mockRejectedValueOnce(new Error('Request aborted')).mockImplementation(async (_url, localPath) => {
await fs.writeFile(localPath, payload);
return {
'content-type': 'image/png',
};
});

const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();

await expect(cacheManager.getContent('https://example.com/nft.png')).rejects.toThrow('Request aborted');
await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload);
expect(mockDownloadFile).toHaveBeenCalledTimes(2);
});

it('does not overlap cache size scans when a scan outlives the coalescing window', async () => {
jest.useFakeTimers();
try {
const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();

let runningScans = 0;
let maxConcurrentScans = 0;
const scanResolvers: Array<() => void> = [];
const getCacheSizeSpy = jest.spyOn(cacheManager, 'getCacheSize').mockImplementation(
() =>
new Promise<number>((resolve) => {
runningScans += 1;
maxConcurrentScans = Math.max(maxConcurrentScans, runningScans);
scanResolvers.push(() => {
runningScans -= 1;
resolve(0);
});
}),
);

const send = jest.fn();
const fakeWindow = {
webContents: { send },
isDestroyed: () => false,
on: jest.fn(),
} as any;
cacheManager.bindEvents(fakeWindow);

cacheManager.emit('sizeChanged');
jest.advanceTimersByTime(500); // the first scan starts and stays in flight

cacheManager.emit('sizeChanged'); // burst arriving mid-scan
jest.advanceTimersByTime(500); // previously this started an overlapping scan

expect(maxConcurrentScans).toBe(1);

scanResolvers.shift()?.();
await Promise.resolve(); // let the first scan settle and reschedule
jest.advanceTimersByTime(500); // the follow-up scan delivers the fresh size

expect(getCacheSizeSpy).toHaveBeenCalledTimes(2);
expect(maxConcurrentScans).toBe(1);
} finally {
jest.useRealTimers();
}
});

it('treats a zero cache limit as unlimited when updating the setting', async () => {
const payload = Buffer.from('cached payload');
mockDownloadFile.mockImplementation(async (_url, localPath) => {
await fs.writeFile(localPath, payload);
return {
'content-type': 'image/png',
};
});

const cacheManager = new CacheManager({
cacheDirectory,
maxCacheSize: 1024,
});
await cacheManager.init();
await cacheManager.getContent('https://example.com/nft.png');

await cacheManager.setMaxCacheSize(0);

expect(cacheManager.maxCacheSize).toBe(0);
await expect(cacheManager.getContent('https://example.com/nft.png')).resolves.toEqual(payload);
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
});
});
Loading