Skip to content
Merged
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
154 changes: 154 additions & 0 deletions scripts/largeFileLoadRevision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,157 @@ test('large-file completion requires the current clean load revision and unchang
/loadRevisionByTab\.get\(activeId\) === fullLoadRevision[\s\S]*!targetTab\.isDirty[\s\S]*targetTab\.isEditing === initialIsEditing[\s\S]*targetTab\.isSplit === initialIsSplit/,
);
});

// #547. The revision above guarded the SECOND stage only. The first stage wrote
// the buffer, the encoding and the truncation flag unconditionally, across two
// awaits — so when two loads overlapped on one tab and the older one's preview
// read landed last, it overwrote the winner's complete buffer with its 50KB
// slice and re-raised `isTruncated`. Its own second stage was then correctly
// rejected by the revision it had just lost. Nothing retried, nothing recorded
// it, and from then on every save was refused.
//
// Two loads overlap because the startup path is delivered on two channels that
// nothing dedupes: `RunEvent::Opened` stashes it for `send_markdown_path` AND
// emits `file-path`, and argv is read by both.
//
// These drive the real TabManager and the real document session, so they lock
// the outcome rather than the shape of the guard.

const g = globalThis as any;
const runeEffect = (fn: () => void) => {
void fn;
};
runeEffect.root = (fn: () => unknown) => fn();
g.$state = (value: unknown) => value;
g.$state.raw = (value: unknown) => value;
g.$state.snapshot = (value: unknown) => value;
g.$derived = (value: unknown) => value;
g.$derived.by = (fn: () => unknown) => fn();
g.$effect = runeEffect;
g.window = g.window ?? {};

const PREVIEW_BYTES = 50000;
const FULL = `# big\n\n${'x'.repeat(PREVIEW_BYTES)}\n\ntail that must never be lost\n`;
const PARTIAL = FULL.slice(0, PREVIEW_BYTES);

/** Per-call delays, so the two concurrent loads can be ordered deliberately. */
let previewDelays: number[] = [];
let previewCall = 0;
let savedContent: string | null = null;

let handleInvoke: (cmd: string, args: any) => unknown = () => {
throw new Error('unexpected invoke');
};

g.window.__TAURI_INTERNALS__ = {
invoke: (cmd: string, args: any) => Promise.resolve(handleInvoke(cmd, args)),
};

const { tabManager } = await import('../src/lib/stores/tabs.svelte.js');
const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js');
const { settings } = await import('../src/lib/stores/settings.svelte.js');

const errors: string[] = [];
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

function makeSession() {
return createDocumentSession({
setShowHome: () => {},
currentFile: () => tabManager.activeTab?.path ?? '',
resetScrollHistory: () => {},
renderMarkdown: async (raw: string) => `<p>${raw.length}</p>`,
afterLoad: async () => {},
saveRecentFile: () => {},
deleteRecentFile: () => {},
setLoadingTabs: () => {},
measureInitialViewport: () => {},
isScrolling: () => false,
renderRichContent: () => {},
onError: (message) => errors.push(message),
selfWriteGraceMs: 400,
cancelPendingAutoSave: () => {},
askClose: async () => 'discard' as const,
onCloseSaveNewerEdits: () => {},
onCloseAutoSaveFailed: () => {},
});
}

function reset() {
tabManager.closeAll();
errors.length = 0;
previewCall = 0;
savedContent = null;
settings.openFileMode = 'preview';
handleInvoke = (cmd, args) => {
if (cmd === 'canonicalize_path') return '/docs/big.md';
if (cmd === 'open_markdown_preview') {
const delay = previewDelays[previewCall++] ?? 0;
return wait(delay).then(() => ['<p>preview</p>', PARTIAL, false, false, 'UTF-8']);
}
if (cmd === 'read_file_content_checked') return [FULL, false, 'UTF-8'];
if (cmd === 'save_file_content') {
savedContent = args.content;
return null;
}
throw new Error(`unexpected invoke: ${cmd}`);
};
}

/** Both startup channels deliver the same path; only the timing differs. */
async function loadTwice() {
const session = makeSession();
// The `file-path` listener does not await its load; `send_markdown_path` does.
const first = session.loadMarkdown('/docs/big.md');
const second = session.loadMarkdown('/docs/big.md');
await Promise.all([first, second]);
await wait(500);
return { session, tab: tabManager.activeTab! };
}

test('an overtaken load cannot leave the tab holding its 50KB slice', async () => {
reset();
// The first load's preview read is slow, so it lands after the second load
// has already completed the tab. This is the ordering that stranded it.
previewDelays = [120, 10];
const { tab } = await loadTwice();

assert.equal(tab.rawContent, FULL, 'the complete file must survive the stale preview');
assert.notEqual(tab.isTruncated, true);
});

test('a tab left by overlapping loads can still be saved, in full', async () => {
reset();
previewDelays = [120, 10];
const { session, tab } = await loadTwice();

assert.equal(await session.saveContent(tab.id), true, `save was refused: ${errors.join('; ')}`);
// The refusal is the only thing between this state and a truncated write,
// so a save that succeeds must be a save of the whole document.
assert.equal(savedContent, FULL);
});

test('overlapping loads settle on the whole file whichever preview read wins', async () => {
for (const delays of [
[10, 120], // the first load lands first — the ordering that always worked
[0, 0], // both resolve on the same tick
[120, 10], // the first load lands last
]) {
reset();
previewDelays = delays;
const { tab } = await loadTwice();
assert.equal(tab.rawContent, FULL, `stranded a slice with delays ${delays.join(',')}`);
assert.notEqual(tab.isTruncated, true, `left truncated with delays ${delays.join(',')}`);
}
});

test('a single load of a large file is unaffected', async () => {
reset();
previewDelays = [0];
const session = makeSession();
await session.loadMarkdown('/docs/big.md');
await wait(400);

const tab = tabManager.activeTab!;
assert.equal(tab.rawContent, FULL);
assert.notEqual(tab.isTruncated, true);
});
20 changes: 20 additions & 0 deletions src/lib/sessions/documentSession.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,17 @@ export function createDocumentSession(options: DocumentSessionOptions) {

const fullLoadRevision = (loadRevisionByTab.get(activeId) ?? 0) + 1;
loadRevisionByTab.set(activeId, fullLoadRevision);
// #547: every read below is an await, and this load may be overtaken
// while one is in flight — the startup path is delivered on two
// channels that nothing dedupes (`RunEvent::Opened` both stashes the
// path for `send_markdown_path` and emits `file-path`; argv is read by
// both as well), so two loads can run on one tab. The full-load stage
// already refuses to apply a stale result; the first stage did not, and
// an older preview landing last overwrote the winner's complete buffer
// with its 50KB slice, re-raising `isTruncated` — after which every save
// is refused and nothing retries. Same revision test, applied to every
// write a load makes.
const isCurrentLoad = () => loadRevisionByTab.get(activeId) === fullLoadRevision;
const isMarkdown = hasMarkdownLinkExtension(filePath);
const tab = tabManager.tabs.find((item) => item.id === activeId);

Expand Down Expand Up @@ -470,6 +481,10 @@ export function createDocumentSession(options: DocumentSessionOptions) {
} else {
[, content, isFull, lossy, encoding] = (await invoke('open_markdown_preview', { path: filePath, maxBytes: 50000 })) as [string, string, boolean, boolean, string];
}
// Ahead of the encoding verdict, not just the buffer: a prefix's
// detected encoding can differ from the whole file's, and
// `tab.encoding` is what the save writes with.
if (!isCurrentLoad()) return;
// Decided on every load, before the buffer can reach a writer.
// Both branches report both, so this also CLEARS the flag on a
// file the user has since converted to UTF-8 — and repoints the
Expand All @@ -479,6 +494,7 @@ export function createDocumentSession(options: DocumentSessionOptions) {
lossySaveWarnedTabs.delete(activeId);
if (pendingNavigateTabId) tabManager.navigate(pendingNavigateTabId, filePath, pathKey);
const processed = await options.renderMarkdown(content, filePath, foldsForTab(activeId));
if (!isCurrentLoad()) return;
tabManager.updateTabContent(activeId, processed);
// `isFull === false` means this is only the leading slice of a
// large file. Marking the tab keeps anything downstream from
Expand Down Expand Up @@ -532,6 +548,10 @@ export function createDocumentSession(options: DocumentSessionOptions) {
}
} else {
const [content, lossy, encoding] = (await invoke('read_file_content_checked', { path: filePath })) as [string, boolean, string];
// Same race, same guard: this branch reads the whole file, so it
// cannot strand a slice, but a stale one still overwrites the
// winner's buffer and encoding and flips the tab into the editor.
if (!isCurrentLoad()) return;
tabManager.setTabDecodedLossy(activeId, lossy);
tabManager.setTabEncoding(activeId, encoding);
lossySaveWarnedTabs.delete(activeId);
Expand Down