* fix(files): guard file polling races - Ignore sub-millisecond mtime jitter on a same-size file so an unchanged open file no longer loops through reload and flickers. - Swap externally changed text content into the open editor in place instead of clearing the loaded path and showing the load spinner. - Read content only after metadata changed, confirm it with a second read, and skip the swap when the file is unchanged, the buffer is dirty, or a newer local write landed. - Keep the stat baseline unchanged when a poll cannot observe content so a failed read is retried rather than treated as unchanged. - Fall back to a full reload for images, PDFs, binaries, and files above the content-poll byte limit, and when a poll returns binary content. - Serialize polls and dispose the poller on unmount, file switch, and directory change. - Add a `fresh` file read option that bypasses the content cache and the HTTP cache. * fix(files): invalidate stale polls after diagram saves --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
a005215458
commit
7ea24e3c50
@@ -26,6 +26,8 @@ import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
|||||||
import { GoToLineDialog } from './GoToLineDialog';
|
import { GoToLineDialog } from './GoToLineDialog';
|
||||||
import { MarkdownPreviewSearch } from './MarkdownPreviewSearch';
|
import { MarkdownPreviewSearch } from './MarkdownPreviewSearch';
|
||||||
import { PreviewToggleButton } from './PreviewToggleButton';
|
import { PreviewToggleButton } from './PreviewToggleButton';
|
||||||
|
import { createFileContentPoller } from './fileContentPoller';
|
||||||
|
import { hasFileStatChanged } from './fileStatChange';
|
||||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||||
import { languageByExtension, loadLanguageByExtension } from '@/lib/codemirror/languageByExtension';
|
import { languageByExtension, loadLanguageByExtension } from '@/lib/codemirror/languageByExtension';
|
||||||
@@ -314,6 +316,7 @@ const isFileMissingError = (error: unknown): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MAX_VIEW_CHARS = 200_000;
|
const MAX_VIEW_CHARS = 200_000;
|
||||||
|
const MAX_CONTENT_POLL_BYTES = 200_000;
|
||||||
type FileLineEnding = '\n' | '\r\n';
|
type FileLineEnding = '\n' | '\r\n';
|
||||||
|
|
||||||
// Fast cache key for pierre's line/highlight caches: content-derived (not a
|
// Fast cache key for pierre's line/highlight caches: content-derived (not a
|
||||||
@@ -931,6 +934,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const pendingDrawioPreviewFrameRef = React.useRef<number | null>(null);
|
const pendingDrawioPreviewFrameRef = React.useRef<number | null>(null);
|
||||||
const diagramEditorRef = React.useRef<React.ComponentRef<typeof DiagramEditor>>(null);
|
const diagramEditorRef = React.useRef<React.ComponentRef<typeof DiagramEditor>>(null);
|
||||||
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
|
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
|
||||||
|
const lastLoadedFileContentRef = React.useRef('');
|
||||||
|
const lastLoadedFileRevisionRef = React.useRef(0);
|
||||||
const activeFileLoadIdRef = React.useRef(0);
|
const activeFileLoadIdRef = React.useRef(0);
|
||||||
const loadingFilePathRef = React.useRef<string | null>(null);
|
const loadingFilePathRef = React.useRef<string | null>(null);
|
||||||
const [fileContentRevision, setFileContentRevision] = React.useState(0);
|
const [fileContentRevision, setFileContentRevision] = React.useState(0);
|
||||||
@@ -1573,10 +1578,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
};
|
};
|
||||||
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
|
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
|
||||||
|
|
||||||
const readFile = React.useCallback(async (path: string): Promise<string> => {
|
// `fresh` bypasses the content cache and HTTP cache so external-change polling
|
||||||
|
// compares against the file on disk rather than a cached copy.
|
||||||
|
const readFile = React.useCallback(async (path: string, cacheOptions?: { fresh?: boolean }): Promise<string> => {
|
||||||
const options = await resolveFileReadOptions(path);
|
const options = await resolveFileReadOptions(path);
|
||||||
if (files.readFile) {
|
if (files.readFile) {
|
||||||
const result = await files.readFile(path, { ...options, directory: root || undefined });
|
const result = await files.readFile(path, {
|
||||||
|
...options,
|
||||||
|
directory: root || undefined,
|
||||||
|
...cacheOptions,
|
||||||
|
});
|
||||||
return result.content ?? '';
|
return result.content ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1590,7 +1601,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
if (root) {
|
if (root) {
|
||||||
params.set('directory', root);
|
params.set('directory', root);
|
||||||
}
|
}
|
||||||
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`);
|
const response = await runtimeFetch(
|
||||||
|
`/api/fs/read?${params.toString()}`,
|
||||||
|
cacheOptions?.fresh ? { cache: 'no-store' } : undefined,
|
||||||
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed'));
|
throw new Error((error as { error?: string }).error || t('filesView.error.readFileFailed'));
|
||||||
@@ -1690,6 +1704,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
setFileContent(draftContent);
|
setFileContent(draftContent);
|
||||||
|
lastLoadedFileContentRef.current = contentToWrite;
|
||||||
|
lastLoadedFileRevisionRef.current += 1;
|
||||||
if (root && isPathWithinRoot(selectedFile.path, root)) {
|
if (root && isPathWithinRoot(selectedFile.path, root)) {
|
||||||
const relativePath = getDisplayPath(root, selectedFile.path);
|
const relativePath = getDisplayPath(root, selectedFile.path);
|
||||||
if (relativePath) {
|
if (relativePath) {
|
||||||
@@ -1819,6 +1835,19 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const applyLoadedTextContent = React.useCallback((content: string) => {
|
||||||
|
const editorContent = normalizeEditorLineEndings(content);
|
||||||
|
lastLoadedFileContentRef.current = content;
|
||||||
|
lastLoadedFileRevisionRef.current += 1;
|
||||||
|
setLoadedFileLineEnding(detectFileLineEnding(content));
|
||||||
|
setFileContent(editorContent);
|
||||||
|
diagramXmlRef.current = editorContent;
|
||||||
|
diagramSavedXmlRef.current = editorContent;
|
||||||
|
setDraftContent(editorContent.length > MAX_VIEW_CHARS
|
||||||
|
? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
|
||||||
|
: editorContent);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadSelectedFile = React.useCallback(async (node: FileNode) => {
|
const loadSelectedFile = React.useCallback(async (node: FileNode) => {
|
||||||
const loadId = activeFileLoadIdRef.current + 1;
|
const loadId = activeFileLoadIdRef.current + 1;
|
||||||
activeFileLoadIdRef.current = loadId;
|
activeFileLoadIdRef.current = loadId;
|
||||||
@@ -1896,14 +1925,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
setLoadedFilePath(node.path);
|
setLoadedFilePath(node.path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const editorContent = normalizeEditorLineEndings(content);
|
applyLoadedTextContent(content);
|
||||||
setLoadedFileLineEnding(detectFileLineEnding(content));
|
|
||||||
setFileContent(editorContent);
|
|
||||||
diagramXmlRef.current = editorContent;
|
|
||||||
diagramSavedXmlRef.current = editorContent;
|
|
||||||
setDraftContent(editorContent.length > MAX_VIEW_CHARS
|
|
||||||
? `${editorContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
|
|
||||||
: editorContent);
|
|
||||||
setLoadedFilePath(node.path);
|
setLoadedFilePath(node.path);
|
||||||
void readFileStat(node.path)
|
void readFileStat(node.path)
|
||||||
.then((stat) => {
|
.then((stat) => {
|
||||||
@@ -1970,7 +1992,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
setFileLoading(false);
|
setFileLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [expandPaths, isMobile, loadDirectory, readFile, readFileStat, removeOpenPathsByPrefix, resolveFileReadOptions, root, runtime.isDesktop, searchQuery, setSelectedPath, t]);
|
}, [applyLoadedTextContent, expandPaths, isMobile, loadDirectory, readFile, readFileStat, removeOpenPathsByPrefix, resolveFileReadOptions, root, runtime.isDesktop, searchQuery, setSelectedPath, t]);
|
||||||
|
|
||||||
const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => {
|
const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => {
|
||||||
if (!root) {
|
if (!root) {
|
||||||
@@ -2082,38 +2104,71 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
setFileContentRevision((revision) => revision + 1);
|
setFileContentRevision((revision) => revision + 1);
|
||||||
}), [selectedFile?.path]);
|
}), [selectedFile?.path]);
|
||||||
|
|
||||||
// Poll open file for external changes.
|
// Poll open file for external changes. Metadata is compared first so an
|
||||||
// When a change is detected, reset loadedFilePath so the effect above
|
// unchanged file never reads content, and a changed text file swaps content
|
||||||
// triggers a single reload — no double-load.
|
// in place; only other files fall back to a full reload.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!selectedFile?.path || loadedFilePath !== selectedFile.path) {
|
if (!selectedFile?.path || loadedFilePath !== selectedFile.path) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedPath = selectedFile.path;
|
||||||
|
// draw.io preview edits live in the XML refs, not the draft buffer, so an
|
||||||
|
// in-place content swap has to treat them as unsaved too.
|
||||||
|
const hasUnsavedChanges = () => isDirtyRef.current || (
|
||||||
|
isDrawioFile(selectedPath) && diagramXmlRef.current !== diagramSavedXmlRef.current
|
||||||
|
);
|
||||||
|
// Same exclusions as `isTextFile`: `isBinaryFile` covers PDFs, `isImageFile` covers SVG.
|
||||||
|
const contentPoller = !isBinaryFile(selectedPath) && !isImageFile(selectedPath) && !contentDetectedBinary
|
||||||
|
? createFileContentPoller({
|
||||||
|
readContent: () => readFile(selectedPath, { fresh: true }),
|
||||||
|
getLoadedContent: () => lastLoadedFileContentRef.current,
|
||||||
|
getLoadedRevision: () => lastLoadedFileRevisionRef.current,
|
||||||
|
isDirty: hasUnsavedChanges,
|
||||||
|
applyContent: (content) => {
|
||||||
|
// An external write can turn a text file binary; reload so the
|
||||||
|
// binary guards run instead of pasting binary into the editor.
|
||||||
|
if (looksLikeBinaryText(content)) {
|
||||||
|
setLoadedFilePath(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyLoadedTextContent(content);
|
||||||
|
},
|
||||||
|
maxBytes: MAX_CONTENT_POLL_BYTES,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let polling = false;
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
if (document.hidden) {
|
if (document.hidden || polling) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void readFileStat(selectedFile.path)
|
polling = true;
|
||||||
.then((latestStat) => {
|
void readFileStat(selectedPath)
|
||||||
|
.then(async (latestStat) => {
|
||||||
if (cancelled || !latestStat) {
|
if (cancelled || !latestStat) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousStat = lastLoadedFileStatRef.current;
|
const previousStat = lastLoadedFileStatRef.current;
|
||||||
if (!previousStat || previousStat.path !== selectedFile.path) {
|
if (!previousStat || previousStat.path !== selectedPath) {
|
||||||
lastLoadedFileStatRef.current = latestStat;
|
lastLoadedFileStatRef.current = latestStat;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const changedByMtime = latestStat.mtimeMs !== undefined
|
if (!hasFileStatChanged(previousStat, latestStat)) {
|
||||||
&& previousStat.mtimeMs !== undefined
|
return;
|
||||||
&& latestStat.mtimeMs !== previousStat.mtimeMs;
|
}
|
||||||
const changedBySize = latestStat.size !== previousStat.size;
|
|
||||||
|
|
||||||
if (!changedByMtime && !changedBySize) {
|
if (contentPoller && latestStat.size <= MAX_CONTENT_POLL_BYTES) {
|
||||||
|
// Only an observed read retires the change; a dirty buffer or a
|
||||||
|
// failed read leaves the baseline so the next tick retries.
|
||||||
|
const observed = await contentPoller.poll(latestStat.size);
|
||||||
|
if (observed && !cancelled) {
|
||||||
|
lastLoadedFileStatRef.current = latestStat;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2125,14 +2180,18 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
// Reset loadedFilePath so the effect above triggers a single reload.
|
// Reset loadedFilePath so the effect above triggers a single reload.
|
||||||
setLoadedFilePath(null);
|
setLoadedFilePath(null);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
polling = false;
|
||||||
|
});
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
contentPoller?.dispose();
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [loadedFilePath, readFileStat, selectedFile?.path]);
|
}, [applyLoadedTextContent, contentDetectedBinary, loadedFilePath, readFile, readFileStat, selectedFile?.path]);
|
||||||
|
|
||||||
const discardAndContinue = React.useCallback(() => {
|
const discardAndContinue = React.useCallback(() => {
|
||||||
const nextFile = pendingSelectFileRef.current;
|
const nextFile = pendingSelectFileRef.current;
|
||||||
@@ -2595,15 +2654,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
diagramXmlRef.current = xml;
|
applyLoadedTextContent(xml);
|
||||||
diagramSavedXmlRef.current = xml;
|
|
||||||
setDraftContent(xml);
|
|
||||||
const stat = await readFileStat(path).catch(() => null);
|
const stat = await readFileStat(path).catch(() => null);
|
||||||
if (stat) {
|
if (stat) {
|
||||||
lastLoadedFileStatRef.current = stat;
|
lastLoadedFileStatRef.current = stat;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}, [files, readFileStat, t]);
|
}, [applyLoadedTextContent, files, readFileStat, t]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { createFileContentPoller } from './fileContentPoller';
|
||||||
|
|
||||||
|
const MAX_BYTES = 200_000;
|
||||||
|
|
||||||
|
const deferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((done) => { resolve = done; });
|
||||||
|
return { promise, resolve };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('createFileContentPoller', () => {
|
||||||
|
test('does not reload unchanged content across repeated metadata false positives (issue #1489)', async () => {
|
||||||
|
let reads = 0;
|
||||||
|
const applied: string[] = [];
|
||||||
|
const poller = createFileContentPoller({
|
||||||
|
readContent: async () => {
|
||||||
|
reads += 1;
|
||||||
|
return 'same content';
|
||||||
|
},
|
||||||
|
getLoadedContent: () => 'same content',
|
||||||
|
getLoadedRevision: () => 0,
|
||||||
|
isDirty: () => false,
|
||||||
|
applyContent: (content) => applied.push(content),
|
||||||
|
maxBytes: MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await poller.poll(12)).toBe(true);
|
||||||
|
expect(await poller.poll(12)).toBe(true);
|
||||||
|
|
||||||
|
expect(reads).toBe(2);
|
||||||
|
expect(applied).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports a failed read as unobserved instead of unchanged', async () => {
|
||||||
|
const applied: string[] = [];
|
||||||
|
const poller = createFileContentPoller({
|
||||||
|
readContent: async () => { throw new Error('read failed'); },
|
||||||
|
getLoadedContent: () => 'before',
|
||||||
|
getLoadedRevision: () => 0,
|
||||||
|
isDirty: () => false,
|
||||||
|
applyContent: (content) => applied.push(content),
|
||||||
|
maxBytes: MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await poller.poll(6)).toBe(false);
|
||||||
|
expect(applied).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports a dirty buffer as unobserved', async () => {
|
||||||
|
let reads = 0;
|
||||||
|
const poller = createFileContentPoller({
|
||||||
|
readContent: async () => {
|
||||||
|
reads += 1;
|
||||||
|
return 'external edit';
|
||||||
|
},
|
||||||
|
getLoadedContent: () => 'before',
|
||||||
|
getLoadedRevision: () => 0,
|
||||||
|
isDirty: () => true,
|
||||||
|
applyContent: () => undefined,
|
||||||
|
maxBytes: MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await poller.poll(6)).toBe(false);
|
||||||
|
expect(reads).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reloads a same-size edit even when metadata cannot distinguish it', async () => {
|
||||||
|
let reads = 0;
|
||||||
|
const applied: string[] = [];
|
||||||
|
const poller = createFileContentPoller({
|
||||||
|
readContent: async () => {
|
||||||
|
reads += 1;
|
||||||
|
return 'abd';
|
||||||
|
},
|
||||||
|
getLoadedContent: () => 'abc',
|
||||||
|
getLoadedRevision: () => 0,
|
||||||
|
isDirty: () => false,
|
||||||
|
applyContent: (content) => applied.push(content),
|
||||||
|
maxBytes: MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await poller.poll(3)).toBe(true);
|
||||||
|
|
||||||
|
expect(reads).toBe(2);
|
||||||
|
expect(applied).toEqual(['abd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not overwrite a buffer that becomes dirty while polling', async () => {
|
||||||
|
const read = deferred<string>();
|
||||||
|
let dirty = false;
|
||||||
|
const applied: string[] = [];
|
||||||
|
const poller = createFileContentPoller({
|
||||||
|
readContent: () => read.promise,
|
||||||
|
getLoadedContent: () => 'before',
|
||||||
|
getLoadedRevision: () => 0,
|
||||||
|
isDirty: () => dirty,
|
||||||
|
applyContent: (content) => applied.push(content),
|
||||||
|
maxBytes: MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const polling = poller.poll(20);
|
||||||
|
dirty = true;
|
||||||
|
read.resolve('external edit');
|
||||||
|
await polling;
|
||||||
|
|
||||||
|
expect(applied).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not apply a transient read that changes during polling', async () => {
|
||||||
|
const reads = ['partial', 'settled', 'settled', 'settled'];
|
||||||
|
const applied: string[] = [];
|
||||||
|
const poller = createFileContentPoller({
|
||||||
|
readContent: async () => reads.shift() ?? 'settled',
|
||||||
|
getLoadedContent: () => 'before',
|
||||||
|
getLoadedRevision: () => 0,
|
||||||
|
isDirty: () => false,
|
||||||
|
applyContent: (content) => applied.push(content),
|
||||||
|
maxBytes: MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.poll(7);
|
||||||
|
expect(applied).toEqual([]);
|
||||||
|
|
||||||
|
expect(await poller.poll(7)).toBe(true);
|
||||||
|
expect(applied).toEqual(['settled']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows only one read in flight and ignores a disposed poll', async () => {
|
||||||
|
const read = deferred<string>();
|
||||||
|
let reads = 0;
|
||||||
|
const applied: string[] = [];
|
||||||
|
const poller = createFileContentPoller({
|
||||||
|
readContent: () => {
|
||||||
|
reads += 1;
|
||||||
|
return read.promise;
|
||||||
|
},
|
||||||
|
getLoadedContent: () => 'before',
|
||||||
|
getLoadedRevision: () => 0,
|
||||||
|
isDirty: () => false,
|
||||||
|
applyContent: (content) => applied.push(content),
|
||||||
|
maxBytes: MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = poller.poll(5);
|
||||||
|
await poller.poll(5);
|
||||||
|
poller.dispose();
|
||||||
|
read.resolve('after');
|
||||||
|
await first;
|
||||||
|
|
||||||
|
expect(reads).toBe(1);
|
||||||
|
expect(applied).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not overwrite after an ABA save during the confirmation read', async () => {
|
||||||
|
const firstRead = deferred<string>();
|
||||||
|
const secondRead = deferred<string>();
|
||||||
|
let reads = 0;
|
||||||
|
let loadedRevision = 0;
|
||||||
|
const applied: string[] = [];
|
||||||
|
const poller = createFileContentPoller({
|
||||||
|
readContent: () => ++reads === 1 ? firstRead.promise : secondRead.promise,
|
||||||
|
getLoadedContent: () => 'before',
|
||||||
|
getLoadedRevision: () => loadedRevision,
|
||||||
|
isDirty: () => false,
|
||||||
|
applyContent: (content) => applied.push(content),
|
||||||
|
maxBytes: MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const polling = poller.poll(20);
|
||||||
|
firstRead.resolve('external edit');
|
||||||
|
await Promise.resolve();
|
||||||
|
loadedRevision += 1;
|
||||||
|
secondRead.resolve('external edit');
|
||||||
|
await polling;
|
||||||
|
|
||||||
|
expect(reads).toBe(2);
|
||||||
|
expect(applied).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not read content above the polling byte limit', async () => {
|
||||||
|
let reads = 0;
|
||||||
|
const poller = createFileContentPoller({
|
||||||
|
readContent: async () => {
|
||||||
|
reads += 1;
|
||||||
|
return 'content';
|
||||||
|
},
|
||||||
|
getLoadedContent: () => 'before',
|
||||||
|
getLoadedRevision: () => 0,
|
||||||
|
isDirty: () => false,
|
||||||
|
applyContent: () => undefined,
|
||||||
|
maxBytes: MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await poller.poll(MAX_BYTES + 1)).toBe(false);
|
||||||
|
|
||||||
|
expect(reads).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
type FileContentPollerOptions = {
|
||||||
|
readContent: () => Promise<string>;
|
||||||
|
getLoadedContent: () => string;
|
||||||
|
getLoadedRevision: () => number;
|
||||||
|
isDirty: () => boolean;
|
||||||
|
applyContent: (content: string) => void;
|
||||||
|
maxBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createFileContentPoller = (options: FileContentPollerOptions) => {
|
||||||
|
let active = true;
|
||||||
|
let polling = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Resolves true only when the poll observed the file's current content. */
|
||||||
|
poll: async (size: number): Promise<boolean> => {
|
||||||
|
if (!active || polling || options.isDirty() || size > options.maxBytes) return false;
|
||||||
|
polling = true;
|
||||||
|
const loadedContent = options.getLoadedContent();
|
||||||
|
const loadedRevision = options.getLoadedRevision();
|
||||||
|
try {
|
||||||
|
const content = await options.readContent();
|
||||||
|
if (!active || options.isDirty() || loadedRevision !== options.getLoadedRevision()) return false;
|
||||||
|
if (content === loadedContent) return true;
|
||||||
|
|
||||||
|
const confirmedContent = await options.readContent();
|
||||||
|
if (!active || options.isDirty() || confirmedContent !== content || loadedRevision !== options.getLoadedRevision()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
options.applyContent(content);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// A failed read is not proof the file is unchanged; the next poll retries.
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
polling = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose: () => {
|
||||||
|
active = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { hasFileStatChanged } from './fileStatChange';
|
||||||
|
|
||||||
|
describe('hasFileStatChanged', () => {
|
||||||
|
test('ignores sub-millisecond mtime jitter on an unchanged file (issue #1489)', () => {
|
||||||
|
expect(hasFileStatChanged(
|
||||||
|
{ size: 100, mtimeMs: 1700000000123.456 },
|
||||||
|
{ size: 100, mtimeMs: 1700000000123.4561 },
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects size changes and meaningful mtime changes', () => {
|
||||||
|
expect(hasFileStatChanged({ size: 100, mtimeMs: 1 }, { size: 101, mtimeMs: 1 })).toBe(true);
|
||||||
|
expect(hasFileStatChanged({ size: 100, mtimeMs: 1 }, { size: 100, mtimeMs: 2 })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
type FileStatChangeInput = {
|
||||||
|
size: number;
|
||||||
|
mtimeMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Some filesystems report sub-millisecond mtime jitter for unchanged files.
|
||||||
|
const MIN_MTIME_CHANGE_MS = 1;
|
||||||
|
|
||||||
|
export const hasFileStatChanged = (
|
||||||
|
previous: FileStatChangeInput,
|
||||||
|
latest: FileStatChangeInput,
|
||||||
|
): boolean => latest.size !== previous.size || (
|
||||||
|
latest.mtimeMs !== undefined
|
||||||
|
&& previous.mtimeMs !== undefined
|
||||||
|
&& Math.abs(latest.mtimeMs - previous.mtimeMs) >= MIN_MTIME_CHANGE_MS
|
||||||
|
);
|
||||||
@@ -36,6 +36,25 @@ describe("content cache owner", () => {
|
|||||||
owner.dispose()
|
owner.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("bypasses metadata cache for an authoritative read", async () => {
|
||||||
|
let content = "old"
|
||||||
|
let reads = 0
|
||||||
|
const owner = createContentCachedFiles({
|
||||||
|
readFile: async (path: string) => {
|
||||||
|
reads += 1
|
||||||
|
return { path, content }
|
||||||
|
},
|
||||||
|
statFile: async () => ({ isFile: true, isDirectory: false, size: 3, mtimeMs: 1 }),
|
||||||
|
} as unknown as FilesAPI)
|
||||||
|
|
||||||
|
await owner.files.readFile!("file.ts")
|
||||||
|
content = "new"
|
||||||
|
expect((await owner.files.readFile!("file.ts")).content).toBe("old")
|
||||||
|
expect((await owner.files.readFile!("file.ts", { fresh: true })).content).toBe("new")
|
||||||
|
expect(reads).toBe(2)
|
||||||
|
owner.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
test("retries a read that overlaps a write", async () => {
|
test("retries a read that overlaps a write", async () => {
|
||||||
const firstRead = deferred<{ path: string; content: string }>()
|
const firstRead = deferred<{ path: string; content: string }>()
|
||||||
let content = "old"
|
let content = "old"
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ export function createContentCachedFiles(files: FilesAPI): { files: FilesAPI; di
|
|||||||
const capturedGeneration = generation;
|
const capturedGeneration = generation;
|
||||||
if (options?.allowOutsideWorkspace) return files.readFile!(path, options);
|
if (options?.allowOutsideWorkspace) return files.readFile!(path, options);
|
||||||
const key = cacheKey(path, options);
|
const key = cacheKey(path, options);
|
||||||
|
if (options?.fresh) {
|
||||||
|
removeEntry(key);
|
||||||
|
return files.readFile!(path, options);
|
||||||
|
}
|
||||||
const hit = cache.get(key);
|
const hit = cache.get(key);
|
||||||
if (!hit) return readFresh(key, path, options, capturedGeneration);
|
if (!hit) return readFresh(key, path, options, capturedGeneration);
|
||||||
const latest = await files.statFile?.(path, options).catch(() => null);
|
const latest = await files.statFile?.(path, options).catch(() => null);
|
||||||
|
|||||||
@@ -655,6 +655,7 @@ interface FileReadOptions {
|
|||||||
outsideFileGrant?: string;
|
outsideFileGrant?: string;
|
||||||
optional?: boolean;
|
optional?: boolean;
|
||||||
directory?: string;
|
directory?: string;
|
||||||
|
fresh?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilesAPI {
|
export interface FilesAPI {
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ describe('createWebFilesAPI', () => {
|
|||||||
cache: 'default',
|
cache: 'default',
|
||||||
headers: { 'x-opencode-directory': '/worktree-a' },
|
headers: { 'x-opencode-directory': '/worktree-a' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
runtimeFetchMock.mockResolvedValueOnce(new Response('fresh content'));
|
||||||
|
await api.readFile?.('/worktree-b/file.txt', { directory: '/worktree-a', fresh: true });
|
||||||
|
|
||||||
|
expect(runtimeFetchMock).toHaveBeenLastCalledWith('/api/fs/read', {
|
||||||
|
query: new URLSearchParams({ path: '/worktree-b/file.txt' }),
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: { 'x-opencode-directory': '/worktree-a' },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends the workspace directory header for downloads', async () => {
|
it('sends the workspace directory header for downloads', async () => {
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export const createWebFilesAPI = ({ getDirectory }: WebFilesAPIOptions): FilesAP
|
|||||||
}
|
}
|
||||||
const response = await runtimeFetch('/api/fs/read', {
|
const response = await runtimeFetch('/api/fs/read', {
|
||||||
query: params,
|
query: params,
|
||||||
cache: options?.optional ? 'no-store' : 'default',
|
cache: options?.optional || options?.fresh ? 'no-store' : 'default',
|
||||||
headers: directoryHeaders(getDirectory, options?.directory),
|
headers: directoryHeaders(getDirectory, options?.directory),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user