diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index 7b739d1b..f1bc6b82 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,23 @@ 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(); + 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 +389,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 +578,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 +831,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/main.ts b/src/main.ts index 903613d9..eb650db1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -542,7 +542,9 @@ 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(); + 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; } 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],