Skip to content

Commit fbd596e

Browse files
committed
fix(persistence): harden interrupted-write recovery and Windows flush
1 parent 937aca4 commit fbd596e

2 files changed

Lines changed: 78 additions & 8 deletions

File tree

‎src/utils/__tests__/safeWriteJson.test.ts‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ vi.mock("fs/promises", async () => {
2727
rename: vi.fn(actual.rename),
2828
unlink: vi.fn(actual.unlink),
2929
access: vi.fn(actual.access),
30+
stat: vi.fn(actual.stat),
31+
open: vi.fn(actual.open),
3032
mkdtemp: vi.fn(actual.mkdtemp),
3133
rm: vi.fn(actual.rm),
3234
readdir: vi.fn(actual.readdir),
3335
mkdir: vi.fn(actual.mkdir),
3436
}
35-
// fs.stat and fs.lstat are available via { ...actual }
37+
// fs.lstat is available via { ...actual }
3638

3739
return mockedFs
3840
})
@@ -587,6 +589,60 @@ describe("safeWriteJson", () => {
587589
expect(leftovers).toEqual([])
588590
})
589591

592+
it.each(["EACCES", "EIO", "EPERM", undefined])(
593+
"should preserve committed content when canonical access fails with %s",
594+
async (code) => {
595+
const backupPath = path.join(tempDir, `.${path.basename(currentTestFilePath)}.bak_1_stale.tmp`)
596+
await fs.writeFile(backupPath, JSON.stringify({ stale: true }))
597+
// Directory access succeeds; only the canonical-path check fails.
598+
vi.mocked(fs.access)
599+
.mockResolvedValueOnce(undefined)
600+
.mockRejectedValueOnce(Object.assign(new Error("Canonical access failed"), { code }))
601+
const merge = vi.fn((existing: unknown) => existing)
602+
603+
await safeWriteJson(currentTestFilePath, {}, { merge })
604+
605+
expect(merge).toHaveBeenCalledWith({ initial: "content" }, {})
606+
expect(await readFileContent(currentTestFilePath)).toEqual({ initial: "content" })
607+
expect(await fs.readdir(tempDir)).toEqual([path.basename(currentTestFilePath)])
608+
},
609+
)
610+
611+
it.each(["ENOENT", "EACCES", "EIO"])(
612+
"should recover from a readable backup when another backup stat fails with %s",
613+
async (code) => {
614+
await fs.unlink(currentTestFilePath)
615+
const unreadableBackup = path.join(tempDir, `.${path.basename(currentTestFilePath)}.bak_1_bad.tmp`)
616+
const readableBackup = path.join(tempDir, `.${path.basename(currentTestFilePath)}.bak_2_good.tmp`)
617+
await fs.writeFile(unreadableBackup, JSON.stringify({ stale: true }))
618+
await fs.writeFile(readableBackup, JSON.stringify({ committed: true }))
619+
const actual = await vi.importActual<typeof import("fs/promises")>("fs/promises")
620+
vi.mocked(fs.stat).mockImplementation(async (backupPath) => {
621+
if (backupPath === unreadableBackup) {
622+
throw Object.assign(new Error("Backup stat failed"), { code })
623+
}
624+
return actual.stat(backupPath)
625+
})
626+
const merge = vi.fn((existing: unknown) => existing)
627+
628+
await safeWriteJson(currentTestFilePath, {}, { merge })
629+
630+
expect(merge).toHaveBeenCalledWith({ committed: true }, {})
631+
expect(await readFileContent(currentTestFilePath)).toEqual({ committed: true })
632+
expect(await fs.readdir(tempDir)).toEqual([path.basename(currentTestFilePath)])
633+
},
634+
)
635+
636+
test("should open the replacement with write access for flushing", async () => {
637+
const open = vi.mocked(fs.open)
638+
open.mockClear()
639+
640+
await safeWriteJson(currentTestFilePath, { flushed: true })
641+
642+
expect(open).toHaveBeenCalledExactlyOnceWith(expect.stringContaining(".new_"), "r+")
643+
expect(await readFileContent(currentTestFilePath)).toEqual({ flushed: true })
644+
})
645+
590646
test("should garbage collect orphaned replacement files when the canonical path exists", async () => {
591647
const initialData = { message: "Initial content" }
592648

‎src/utils/safeWriteJson.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,32 @@ async function recoverInterruptedReplacement(
101101
}
102102

103103
let restoredFrom: string | null = null
104+
let canonicalPathMissing = false
104105

105106
try {
106107
await fs.access(absoluteFilePath)
107-
} catch {
108+
} catch (error: unknown) {
109+
canonicalPathMissing = typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"
110+
}
111+
112+
if (canonicalPathMissing) {
108113
// The canonical path is absent. An interrupted two-step replacement
109114
// leaves the committed content in the newest backup, so restore it
110115
// and keep readers on the last committed state.
111-
const backupStats = await Promise.all(
112-
orphanedPaths
113-
.filter((orphanPath) => path.basename(orphanPath).startsWith(backupPrefix))
114-
.map(async (backupPath) => ({ backupPath, mtimeMs: (await fs.stat(backupPath)).mtimeMs })),
115-
)
116+
const backupStats = (
117+
await Promise.all(
118+
orphanedPaths
119+
.filter((orphanPath) => path.basename(orphanPath).startsWith(backupPrefix))
120+
.map(async (backupPath) => {
121+
try {
122+
return { backupPath, mtimeMs: (await fs.stat(backupPath)).mtimeMs }
123+
} catch {
124+
// Skip leftovers removed or made unreadable since the directory listing.
125+
return null
126+
}
127+
}),
128+
)
129+
).filter((entry): entry is { backupPath: string; mtimeMs: number } => entry !== null)
116130
if (backupStats.length > 0) {
117131
backupStats.sort((a, b) => b.mtimeMs - a.mtimeMs)
118132
const newestBackupPath = backupStats[0].backupPath
@@ -369,7 +383,7 @@ async function _streamDataToFile(targetPath: string, data: unknown, prettyPrint
369383
*/
370384
async function _flushFileToDisk(targetPath: string): Promise<void> {
371385
try {
372-
const handle = await fs.open(targetPath, "r")
386+
const handle = await fs.open(targetPath, "r+")
373387
try {
374388
await handle.sync()
375389
} finally {

0 commit comments

Comments
 (0)