From 2641e28889c8c9c17edbbdb279b8529b5a7ef60c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:57:36 -0700 Subject: [PATCH] fix(persistence): invalidate hash after stale rename --- .../persistence-async-write-syscalls.test.ts | 86 +++++++++++-------- src/main/persistence.ts | 4 +- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/src/main/persistence-async-write-syscalls.test.ts b/src/main/persistence-async-write-syscalls.test.ts index 8f8d1e94eeb..2d6d581599e 100644 --- a/src/main/persistence-async-write-syscalls.test.ts +++ b/src/main/persistence-async-write-syscalls.test.ts @@ -198,6 +198,23 @@ function recordFsCalls(dir: string): void { fsCalls.recording = true } +function delayNextDataFileRename(dir: string): ReturnType & { + started: Promise +} { + const release = deferred() + const started = deferred() + let held = false + fsCalls.waitAsync = (fn, target) => { + if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) { + return null + } + held = true + started.resolve() + return release.promise + } + return { ...release, started: started.promise } +} + function seedStaleBackup(dir: string): void { const path = `${dataFile(dir)}.bak.0` writeFileSync(path, '{"stale":true}', 'utf-8') @@ -362,27 +379,19 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { it('a sync checkpoint vetoes an async write already parked on rename', async () => { const dir = makeDir() const store = await createStore(dir) - const renameRelease = deferred() - const renameStarted = deferred() - fsCalls.waitAsync = (fn, target) => { - if (fn !== 'rename' || target === dataFile(dir) || !target.startsWith(dataFile(dir))) { - return null - } - renameStarted.resolve() - return renameRelease.promise - } + const rename = delayNextDataFileRename(dir) recordFsCalls(dir) store.updateUI({ sidebarWidth: 501 }) vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) const pending = store.waitForPendingWrite() - await renameStarted.promise + await rename.started store.updateUI({ sidebarWidth: 502 }) store.flushOrThrow() expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(502) - renameRelease.resolve() + rename.resolve() await pending fsCalls.recording = false fsCalls.waitAsync = null @@ -416,24 +425,14 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { it('the throwing async barrier drains mutations made during its write', async () => { const dir = makeDir() const store = await createStore(dir) - const renameRelease = deferred() - const renameStarted = deferred() - let held = false - fsCalls.waitAsync = (fn, target) => { - if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) { - return null - } - held = true - renameStarted.resolve() - return renameRelease.promise - } + const rename = delayNextDataFileRename(dir) recordFsCalls(dir) store.updateUI({ sidebarWidth: 601 }) const barrier = store.flushPendingOrThrowAsync() - await renameStarted.promise + await rename.started store.updateUI({ sidebarWidth: 602 }) - renameRelease.resolve() + rename.resolve() await barrier fsCalls.recording = false @@ -443,24 +442,14 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { it('bounds a best-effort flush to one state generation', async () => { const dir = makeDir() const store = await createStore(dir) - const renameRelease = deferred() - const renameStarted = deferred() - let held = false - fsCalls.waitAsync = (fn, target) => { - if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) { - return null - } - held = true - renameStarted.resolve() - return renameRelease.promise - } + const rename = delayNextDataFileRename(dir) recordFsCalls(dir) store.updateUI({ sidebarWidth: 621 }) const flush = store.flushPendingAsync() - await renameStarted.promise + await rename.started store.updateUI({ sidebarWidth: 622 }) - renameRelease.resolve() + rename.resolve() await flush fsCalls.recording = false @@ -517,6 +506,29 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(632) }) + it('rewrites a matching hash after a superseded rename installed stale state', async () => { + const dir = makeDir() + const store = await createStore(dir) + store.updateUI({ sidebarWidth: 641 }) + await store.flushPendingOrThrowAsync() + const rename = delayNextDataFileRename(dir) + recordFsCalls(dir) + + store.updateUI({ sidebarWidth: 642 }) + vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) + const staleWrite = store.waitForPendingWrite() + await rename.started + store.updateUI({ sidebarWidth: 641 }) + rename.resolve() + await staleWrite + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(642) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + fsCalls.recording = false + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(641) + }) + it('the throwing async barrier drains mutations made during sidecar I/O', async () => { const dir = makeDir() const store = await createStore(dir) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index cb58aaddf1c..f74f5d92644 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3978,9 +3978,11 @@ export class Store { this.inFlightAsyncTmpFile = null } } - // Why re-check gen: a sync flush during the rename await may have written fresher state; don't record a stale hash over it. + // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. if (renamed && this.writeGeneration === gen) { this.lastWrittenStateHash = stateHash + } else if (renamed) { + this.lastWrittenStateHash = null } if (renamed) { this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen)