From e8923bc6a542e4a4ad0d2a8bb27a94cbeee93011 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 26 Sep 2026 23:01:49 +0000 Subject: [PATCH] fix(ai): cancel the deferred startup model sync when the plugin unloads The 5 s post-layout-ready auto-sync timer was never cleared, so disabling, reloading or updating QuickAdd within that window still ran a full model sync (including the models.dev download) from the unloaded instance. Register a cleanup that cancels it, and skip scheduling if the plugin unloads before layout-ready. Co-authored-by: Christian Bager Bach Houmann --- src/ai/startupModelSync.test.ts | 81 +++++++++++++++++++++++++++++++++ src/ai/startupModelSync.ts | 35 ++++++++++++++ src/main.ts | 10 ++-- 3 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 src/ai/startupModelSync.test.ts create mode 100644 src/ai/startupModelSync.ts diff --git a/src/ai/startupModelSync.test.ts b/src/ai/startupModelSync.test.ts new file mode 100644 index 000000000..bb28cf839 --- /dev/null +++ b/src/ai/startupModelSync.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { scheduleStartupModelSync } from "./startupModelSync"; + +/** A fake plugin: layout-ready is fired by the test; unload runs registered cleanups. */ +function fakeHost() { + const cleanups: Array<() => void> = []; + const layoutReady: Array<() => void> = []; + return { + app: { + workspace: { + onLayoutReady: (cb: () => void) => void layoutReady.push(cb), + }, + } as any, + register: (cb: () => void) => void cleanups.push(cb), + fireLayoutReady: () => layoutReady.splice(0).forEach((cb) => cb()), + unload: () => cleanups.splice(0).forEach((cb) => cb()), + }; +} + +describe("scheduleStartupModelSync", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("syncs once, 5 s after layout-ready, while the plugin stays loaded", () => { + const host = fakeHost(); + const sync = vi.fn(); + scheduleStartupModelSync(host, sync); + + vi.advanceTimersByTime(10_000); + expect(sync).not.toHaveBeenCalled(); // layout not ready yet + + host.fireLayoutReady(); + vi.advanceTimersByTime(4_999); + expect(sync).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(sync).toHaveBeenCalledTimes(1); + }); + + it("does not sync when the plugin unloads during the delay", () => { + const host = fakeHost(); + const sync = vi.fn(); + scheduleStartupModelSync(host, sync); + + host.fireLayoutReady(); + vi.advanceTimersByTime(1_000); + host.unload(); + vi.advanceTimersByTime(60_000); + + expect(sync).not.toHaveBeenCalled(); + }); + + it("does not schedule a sync when the plugin unloads before layout-ready", () => { + const host = fakeHost(); + const sync = vi.fn(); + scheduleStartupModelSync(host, sync); + + host.unload(); + host.fireLayoutReady(); + vi.advanceTimersByTime(60_000); + + expect(sync).not.toHaveBeenCalled(); + }); + + it("leaves only the live instance's sync after a reload", () => { + const oldInstance = fakeHost(); + const newInstance = fakeHost(); + const oldSync = vi.fn(); + const newSync = vi.fn(); + + scheduleStartupModelSync(oldInstance, oldSync); + oldInstance.fireLayoutReady(); + vi.advanceTimersByTime(1_000); + oldInstance.unload(); + scheduleStartupModelSync(newInstance, newSync); + newInstance.fireLayoutReady(); + vi.advanceTimersByTime(60_000); + + expect(oldSync).not.toHaveBeenCalled(); + expect(newSync).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ai/startupModelSync.ts b/src/ai/startupModelSync.ts new file mode 100644 index 000000000..27eba2376 --- /dev/null +++ b/src/ai/startupModelSync.ts @@ -0,0 +1,35 @@ +import type { App } from "obsidian"; + +/** The slice of an Obsidian `Plugin` the scheduler needs. */ +export interface StartupSyncHost { + app: Pick; + /** Obsidian `Component.register`: runs the callback when the plugin unloads. */ + register(cleanup: () => void): void; +} + +export const STARTUP_MODEL_SYNC_DELAY_MS = 5_000; + +/** + * Run the background model sync shortly after layout-ready, so it never + * competes with startup work. The pending run belongs to this plugin instance: + * unloading it (disable, reload, update) cancels the run, including before + * layout-ready. Otherwise an unloaded instance would still sync against the + * network seconds after the user turned QuickAdd off. + */ +export function scheduleStartupModelSync( + host: StartupSyncHost, + sync: () => void, + delayMs = STARTUP_MODEL_SYNC_DELAY_MS, +): void { + let unloaded = false; + let timer: number | undefined; + host.register(() => { + unloaded = true; + window.clearTimeout(timer); + }); + + host.app.workspace.onLayoutReady(() => { + if (unloaded) return; + timer = window.setTimeout(sync, delayMs); + }); +} diff --git a/src/main.ts b/src/main.ts index 10e96047d..0cfaad203 100644 --- a/src/main.ts +++ b/src/main.ts @@ -59,6 +59,7 @@ import { ingestImagesIntoActivePrompt as ingestPromptImages } from "./gui/imageP import { setQuickAddInstance } from "./quickAddInstance"; import { registerQuickAddUri } from "./uri/registerQuickAddUri"; import { registerCoreCommands } from "./plugin/registerCoreCommands"; +import { scheduleStartupModelSync } from "./ai/startupModelSync"; // The settingsStore subscriber fires on every store change — including high-frequency // ones like folder collapse toggles. Coalesce those full-settings disk writes into one @@ -214,11 +215,10 @@ export default class QuickAdd extends Plugin { // Keep AI provider model lists current without plugin releases: a quiet, // daily-throttled background sync for providers that opted in. Deferred - // past layout-ready so it never competes with startup work. - this.app.workspace.onLayoutReady(() => { - window.setTimeout(() => { - void autoSyncEnabledProviders(this.app); - }, 5_000); + // past layout-ready so it never competes with startup work, and cancelled + // if this instance unloads first. + scheduleStartupModelSync(this, () => { + void autoSyncEnabledProviders(this.app); }); this.announceUpdate();