From c96e36bf450533b5fd2ec61dc6ca86fa6fe1be8e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 03:14:22 +0200 Subject: [PATCH 1/3] test(persistence): cover edits made after a read outage leaves nothing pending Undoing every pending edit during a read outage, or rolling back an import whose save could not read data.json, stops the read retry. Edits made once the file reads again never reach disk. Both routes fail on the current writer. The unload test now also covers a refused background write, which must show no failure Notice, and the startup refusal test attaches its writer after loading, as onload does. --- src/main.persistence.test.ts | 70 ++++++++++++++++++- .../PodNotesSettingsTab.import.test.ts | 50 +++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index 7b739d1b..a92c71d5 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -330,7 +330,7 @@ describe("PodNotes persistence integration", () => { expect(JSON.parse(text)).toMatchObject({ schemaVersion: 2, defaultVolume: 0.5 }); }); - it("leaves a data.json it cannot read unchanged and refuses every save", async () => { + it("leaves a data.json it cannot read unchanged and refuses every save, even once it becomes readable", async () => { const saveData = vi.fn().mockResolvedValue(undefined); const plugin = makePlugin(saveData, { dataFileExists: true }); const privateFeedUrl = "podnotes-feed-url-00000000-0000-4000-8000-000000000000"; @@ -344,11 +344,24 @@ describe("PodNotes persistence integration", () => { vi.spyOn(console, "error").mockImplementation(() => undefined); await plugin.loadSettings(); - await plugin.saveSettings(); + expect(plugin.settings).toEqual(DEFAULT_SETTINGS); + const clock = manualClock(); + // onload creates the writer after loading settings. + attachDataFileWriter(plugin, clock); + const recovered = vi.fn(async () => + JSON.parse(JSON.stringify(encodePodNotesData(DEFAULT_SETTINGS))), + ); + Object.assign(plugin, { loadData: recovered }); + plugin.settings.defaultVolume = 0.5; + await saveInBackground(plugin); + clock.advance(10_000); + clock.runAll(); + await settled(); await expect(plugin.saveSettingsStrict()).rejects.toThrow(/could not read/); + expect(recovered).not.toHaveBeenCalled(); + expect(clock.pending()).toBe(0); expect(saveData).not.toHaveBeenCalled(); - expect(plugin.settings).toEqual(DEFAULT_SETTINGS); expect(values.get(privateFeedUrl)).toBe("https://user:token@example.com/feed.xml"); }); @@ -377,6 +390,24 @@ describe("PodNotes persistence integration", () => { expect(clock.pending()).toBe(0); }); + it("starts no write and shows no failure Notice when unload flushes an edit after Obsidian starts closing", async () => { + const saveData = vi.fn().mockResolvedValue(undefined); + const plugin = makePlugin(saveData); + const clock = manualClock(); + attachDataFileWriter(plugin, clock); + const notice = vi.spyOn(obsidian, "Notice"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + plugin.settings.defaultVolume = 0.5; + Object.assign(plugin, { writeRefusal: "obsidian-closing" }); + await (plugin as unknown as { writer: { unload(): Promise } }).writer.unload(); + + expect(plugin.loadData).toHaveBeenCalledTimes(1); + expect(saveData).not.toHaveBeenCalled(); + expect(notice).not.toHaveBeenCalled(); + expect(clock.pending()).toBe(0); + }); + it("keeps this device's queue when the synced file's queue had to be repaired", async () => { const saveData = vi.fn().mockResolvedValue(undefined); const plugin = makePlugin(saveData); @@ -548,11 +579,15 @@ describe("PodNotes persistence integration", () => { loadData: vi.fn().mockResolvedValue(older), }); vi.spyOn(console, "warn").mockImplementation(() => undefined); + const clock = manualClock(); + attachDataFileWriter(plugin, clock); await plugin.onExternalSettingsChange(); plugin.settings.defaultVolume = 0.5; + await saveInBackground(plugin); await expect(plugin.saveSettingsStrict()).rejects.toThrow(/older version/); + expect(clock.pending()).toBe(0); expect(saveData).not.toHaveBeenCalled(); expect(values.size).toBe(0); }); @@ -797,6 +832,35 @@ describe("PodNotes persistence integration", () => { expect(clock.delays).toEqual([10_000]); }); + it("saves an edit made once it reads again, after the edits it could not save were undone", async () => { + const { plugin, file, clock, notice, retry } = setup(); + const hide = vi.spyOn(Notice.prototype, "hide"); + file.readable = false; + plugin.settings.skipForwardLength = 25; + await saveInBackground(plugin); + plugin.settings.skipForwardLength = 15; + await saveInBackground(plugin); + await retry(); + expect(clock.pending()).toBe(0); + + file.readable = true; + plugin.settings.skipBackwardLength = 33; + await saveInBackground(plugin); + await saveInBackground(plugin); + const armed = { pending: clock.pending(), delays: [...clock.delays] }; + clock.runAll(); + await settled(); + + expect(await plugin.loadData()).toMatchObject({ + skipForwardLength: 15, + skipBackwardLength: 33, + }); + expect(armed).toEqual({ pending: 1, delays: [10_000, 0] }); + expect(notice.mock.calls).toEqual([[refusal, 0]]); + expect(hide).toHaveBeenCalledTimes(1); + expect(clock.pending()).toBe(0); + }); + it("warns once per streak while writes and then reads fail, and saves every edit once both work", async () => { const { plugin, file, clock, notice, retry } = setup(); file.writable = false; diff --git a/src/ui/settings/PodNotesSettingsTab.import.test.ts b/src/ui/settings/PodNotesSettingsTab.import.test.ts index d4781efe..bed0ca3b 100644 --- a/src/ui/settings/PodNotesSettingsTab.import.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.import.test.ts @@ -756,6 +756,56 @@ describe("PodNotesSettingsTab settings import", () => { } }); + it("saves a podcast added once data.json reads again, after an import that could not read it was rolled back", async () => { + const { plugin, unbind, applyImport, disk, clock } = setup(async () => {}); + let readable = false; + const loadData = plugin.loadData.bind(plugin); + const { adapter } = plugin.app.vault; + const read = adapter.read.bind(adapter); + Object.assign(plugin, { loadData: async () => (readable ? loadData() : undefined) }); + Object.assign(adapter, { + read: async (path: string) => { + if (!readable) throw new Error("EACCES: permission denied"); + return read(path); + }, + }); + const notice = vi.spyOn(obsidian, "Notice"); + const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); + try { + await applyImport({ savedFeeds: { Imported: feed("Imported") } }); + clock.advance(10_000); + clock.runAll(); + await settled(); + expect(clock.pending()).toBe(0); + + readable = true; + savedFeeds.update((feeds) => ({ ...feeds, Later: feed("Later") })); + const armed = clock.pending(); + clock.runAll(); + await settled(); + + expect(disk().savedFeeds).toEqual({ + Before: feed("Before"), + Remote: feed("Remote"), + Later: feed("Later"), + }); + expect(armed).toBe(1); + expect(clock.pending()).toBe(0); + expect(notice.mock.calls).toEqual([ + [ + "PodNotes could not read .obsidian/plugins/podnotes/data.json, so it leaves the file unchanged and saves nothing until the file can be read again.", + 0, + ], + [ + "Could not import PodNotes settings or persist its rollback. The safest recovered settings remain active for this session.", + 10000, + ], + ]); + } finally { + unbind(); + } + }); + it.each([ ["intact", feed("Before")], ["unreadable", null], From 457a276875fc7a7996683b73259596fc9c1a1d32 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 03:16:02 +0200 Subject: [PATCH 2/3] fix(persistence): keep retrying a data.json read after the pending edits are undone While data.json could not be read at runtime, saveSettings dropped every request, and only the writer's read retry could lift the refusal. That retry stopped once nothing was pending, as after undoing every edit or rolling back a failed import, so edits made after the file read again stayed in memory and were lost on quit. saveSettings now hands a request under that refusal to the writer's new retryPending, which arms the existing checkpoint retry when a loaded file has pending work. The retry keeps its 10 s spacing, arms nothing while nothing is pending, and still arms nothing under the startup refusal, before any file was loaded. The other refusals still drop requests. --- src/main.ts | 5 ++++- src/persistence/writer.test.ts | 20 ++++++++++++++++++++ src/persistence/writer.ts | 8 ++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index 903613d9..e59436bb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -542,7 +542,10 @@ export default class PodNotes extends Plugin implements IPodNotes { } saveSettings(): Promise { - if (this.isReady && !this.writeRefusal) this.writer?.request(); + if (!this.isReady) return Promise.resolve(); + if (!this.writeRefusal) this.writer?.request(); + // Only a read lifts this refusal, and the writer's retry reads first. + else if (this.writeRefusal === "unreadable-data-file") this.writer?.retryPending(); return Promise.resolve(); } diff --git a/src/persistence/writer.test.ts b/src/persistence/writer.test.ts index 242c1ec6..f886730e 100644 --- a/src/persistence/writer.test.ts +++ b/src/persistence/writer.test.ts @@ -380,6 +380,26 @@ describe("DataFileWriter saves", () => { expect(clock.pending()).toBe(0); }); + it("retries pending work once, 10 s after the last save, and nothing when nothing is pending", async () => { + const { access, clock, state, writer } = setup(); + writer.retryPending(); + expect(clock.pending()).toBe(0); + + state.memory.defaultVolume = 0.5; + await writer.save(); + clock.advance(4_000); + state.memory.defaultVolume = 0.25; + writer.retryPending(); + writer.retryPending(); + expect(clock.delays).toEqual([6_000]); + + clock.advance(6_000); + clock.runAll(); + await vi.waitFor(() => expect(state.disk?.defaultVolume).toBe(0.25)); + expect(access.write).toHaveBeenCalledTimes(2); + expect(clock.pending()).toBe(0); + }); + it("never mistakes progress it took from disk for an edit made here", async () => { const { state, writer } = setup(); state.memory.defaultVolume = 0.5; diff --git a/src/persistence/writer.ts b/src/persistence/writer.ts index 2268766c..86da42ff 100644 --- a/src/persistence/writer.ts +++ b/src/persistence/writer.ts @@ -98,6 +98,13 @@ export class DataFileWriter { return (this.chain ?? Promise.resolve()).catch(() => undefined); } + /** Retry pending work at the checkpoint interval; nothing before a file was loaded. */ + retryPending(): void { + if (this.base !== null && (this.failedWrite || this.hasUnsavedEdits())) { + this.scheduleCheckpoint(); + } + } + /** Save now. Rejects when the save fails; a failed write is the caller's to report. */ save(): Promise { this.cancelTimer(); @@ -191,6 +198,7 @@ export class DataFileWriter { try { disk = await this.file.read(this.base); } catch (error) { + // A failed read can refuse later saves until a read succeeds, so retry what is pending. if (this.failedWrite || this.hasUnsavedEdits()) this.scheduleCheckpoint(); throw error; } From 34c910f7c4acffe982b6e8ffc1f4a0f4d2207d7a Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 03:21:42 +0200 Subject: [PATCH 3/3] chore(persistence): remove redundant retry comments --- src/main.persistence.test.ts | 1 - src/main.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index a92c71d5..f1bc6b82 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -346,7 +346,6 @@ describe("PodNotes persistence integration", () => { await plugin.loadSettings(); expect(plugin.settings).toEqual(DEFAULT_SETTINGS); const clock = manualClock(); - // onload creates the writer after loading settings. attachDataFileWriter(plugin, clock); const recovered = vi.fn(async () => JSON.parse(JSON.stringify(encodePodNotesData(DEFAULT_SETTINGS))), diff --git a/src/main.ts b/src/main.ts index e59436bb..eb650db1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -544,7 +544,6 @@ export default class PodNotes extends Plugin implements IPodNotes { saveSettings(): Promise { if (!this.isReady) return Promise.resolve(); if (!this.writeRefusal) this.writer?.request(); - // Only a read lifts this refusal, and the writer's retry reads first. else if (this.writeRefusal === "unreadable-data-file") this.writer?.retryPending(); return Promise.resolve(); }