fix(editor): drop per-tab probe and disk-check generations once superseded or closed (#31128)

Follow-up to #30415. A watch event that superseded a pending overflow existence
probe left the tab's entry behind forever, and the dirty-tab disk-check request
map never removed anything. Superseding now deletes the entry, and closed tabs'
disk-check requests are dropped by the scan's existing open-tabs subscription.
The disk-check map uses a global sequence so a reopened tab can't reuse an old
read's number.
This commit is contained in:
Neil
2026-10-20 06:17:04 -07:00
committed by GitHub
parent a18b44bfc0
commit 1c5d4ee550
4 changed files with 57 additions and 7 deletions
@@ -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)
})
@@ -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<string, number>()
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()
@@ -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)
})
})
@@ -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)
}
}