Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 66 additions & 3 deletions src/main.persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
});

Expand Down Expand Up @@ -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<void> } }).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);
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,9 @@ export default class PodNotes extends Plugin implements IPodNotes {
}

saveSettings(): Promise<void> {
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();
}

Expand Down
20 changes: 20 additions & 0 deletions src/persistence/writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/persistence/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ export class DataFileWriter<Doc> {
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<void> {
this.cancelTimer();
Expand Down Expand Up @@ -191,6 +198,7 @@ export class DataFileWriter<Doc> {
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;
}
Expand Down
50 changes: 50 additions & 0 deletions src/ui/settings/PodNotesSettingsTab.import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading