diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts index eb0288d93d0..478ec1e33d7 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.recheck.test.ts @@ -23,6 +23,7 @@ vi.mock('@/lib/file-owner-runtime-target', () => { import { createTestStore } from '@/store/slices/store-test-helpers' import { attachRestoredTabConflictScan, + getRestoredTabReverificationRequestCountForTest, MAX_VERIFY_ATTEMPTS, requestRestoredTabReverification } from './editor-restored-tab-conflict-scan' @@ -129,3 +130,13 @@ it('does not let an absence answer from before a newer check resolve that check' expect(tab().externalMutation).toBe('changed') expect(tab().pendingDiskBaselineVerification).toBeFalsy() }) + +it("forgets a tab's check requests once the tab closes", async () => { + mocks.readRuntimeFileContent.mockResolvedValue({ content: 'v1', isBinary: false }) + detach = attachRestoredTabConflictScan(store) + requestRestoredTabReverification(FILE) + expect(getRestoredTabReverificationRequestCountForTest()).toBeGreaterThan(0) + + store.getState().closeFile(FILE) + expect(getRestoredTabReverificationRequestCountForTest()).toBe(0) +}) diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts index 256ab693cad..ea889325a80 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -26,14 +26,30 @@ const MAX_CONCURRENT_VERIFY_READS = 3 export const MAX_VERIFY_ATTEMPTS = VERIFY_FAST_ATTEMPTS + 10 // Why: a disk check requested while one is in flight must not be answered by the older read. +// A global sequence, so dropping a closed tab's entry can't let a reopened tab's request reuse an old read's number. const verifyRequestGenerationByFileId = new Map() +let verifyRequestGenerationSeq = 0 /** Asks for a fresh disk check of a tab; a read already in flight is followed by one more. */ export function requestRestoredTabReverification(fileId: string): void { - verifyRequestGenerationByFileId.set( - fileId, - (verifyRequestGenerationByFileId.get(fileId) ?? 0) + 1 - ) + verifyRequestGenerationSeq += 1 + verifyRequestGenerationByFileId.set(fileId, verifyRequestGenerationSeq) +} + +export function getRestoredTabReverificationRequestCountForTest(): number { + return verifyRequestGenerationByFileId.size +} + +function forgetClosedTabReverificationRequests(openFiles: readonly OpenFile[]): void { + if (verifyRequestGenerationByFileId.size === 0) { + return + } + const openIds = new Set(openFiles.map((file) => file.id)) + for (const fileId of verifyRequestGenerationByFileId.keys()) { + if (!openIds.has(fileId)) { + verifyRequestGenerationByFileId.delete(fileId) + } + } } function verifyRequestGeneration(fileId: string): number { @@ -284,6 +300,7 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { return } previousOpenFiles = nextOpenFiles + forgetClosedTabReverificationRequests(nextOpenFiles) scan() }) scan() diff --git a/src/renderer/src/hooks/editor-external-watch-overflow.test.ts b/src/renderer/src/hooks/editor-external-watch-overflow.test.ts index eeab418edec..b6a47af6717 100644 --- a/src/renderer/src/hooks/editor-external-watch-overflow.test.ts +++ b/src/renderer/src/hooks/editor-external-watch-overflow.test.ts @@ -26,6 +26,7 @@ vi.mock('@/components/editor/combined-diff/remember-view/combined-diff-view-cach import type { OpenFile } from '@/store/slices/editor' import { + getPendingOverflowProbeCountForTest, reconcileEditorTabsAfterOverflow, supersedeOverflowProbesForBatch } from './editor-external-watch-overflow' @@ -192,9 +193,27 @@ describe('reconcileEditorTabsAfterOverflow', () => { }, [touched] ) + expect(getPendingOverflowProbeCountForTest()).toBe(0) answer([{ exists: true }]) await new Promise((resolve) => setTimeout(resolve, 0)) expect(harness.scheduleReload).not.toHaveBeenCalled() + expect(getPendingOverflowProbeCountForTest()).toBe(0) + }) + + it('leaves no probe entry behind for a tab closed while its probe was pending', async () => { + setTabs(tab('a.ts')) + let answer: (rows: unknown) => void = () => {} + harness.runtimePathsExist.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + reconcileEditorTabsAfterOverflow(target) + expect(getPendingOverflowProbeCountForTest()).toBe(1) + + setTabs() + answer([{ exists: true }]) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(harness.scheduleReload).not.toHaveBeenCalled() + expect(getPendingOverflowProbeCountForTest()).toBe(0) }) }) diff --git a/src/renderer/src/hooks/editor-external-watch-overflow.ts b/src/renderer/src/hooks/editor-external-watch-overflow.ts index 8a5dced4c57..0fc9fda61b2 100644 --- a/src/renderer/src/hooks/editor-external-watch-overflow.ts +++ b/src/renderer/src/hooks/editor-external-watch-overflow.ts @@ -33,6 +33,10 @@ function claimProbeGeneration(fileId: string): number { return probeGenerationSeq } +export function getPendingOverflowProbeCountForTest(): number { + return probeGenerationByFileId.size +} + /** Drops pending existence answers for tabs this watch batch touched. */ export function supersedeOverflowProbesForBatch( batchPaths: EditorExternalWatchBatchPathIndex, @@ -52,10 +56,9 @@ export function supersedeOverflowProbesForBatch( touched.add(file.id) } } + // Why: the pending probe's generation can never match again, so its completion no longer owns an entry to delete. for (const fileId of touched) { - if (probeGenerationByFileId.has(fileId)) { - claimProbeGeneration(fileId) - } + probeGenerationByFileId.delete(fileId) } }