From 569fb98762d4cf44f29241d6fd1a6bc23578c5d1 Mon Sep 17 00:00:00 2001 From: mikamikasuki <211269698+mikamikasuki@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:28:25 -0700 Subject: [PATCH 1/2] fix: resolve all waiters for plugin loads --- src/lib/acode.js | 29 ++++++++--------------------- src/lib/pluginWaiters.js | 29 +++++++++++++++++++++++++++++ tests/unit/pluginWaiters.test.js | 31 +++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 21 deletions(-) create mode 100644 src/lib/pluginWaiters.js create mode 100644 tests/unit/pluginWaiters.test.js diff --git a/src/lib/acode.js b/src/lib/acode.js index e727b447b6..dd4ffd9a7f 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -67,6 +67,7 @@ import { import notificationManager from "lib/notificationManager"; import openFolder, { addedFolder } from "lib/openFolder"; import orientation from "lib/orientation"; +import PluginWaiters from "lib/pluginWaiters"; import projects from "lib/projects"; import selectionMenu from "lib/selectionMenu"; import appSettings from "lib/settings"; @@ -97,7 +98,7 @@ class Acode { #pluginUnmount = {}; // Registered formatter implementations (populated by plugins) #formatter = []; - #pluginWatchers = {}; + #pluginWatchers = new PluginWaiters(); /** * Clear a plugin's broken mark (so it can be retried) @@ -702,32 +703,18 @@ class Acode { } [onPluginLoadCallback](pluginId) { - if (this.#pluginWatchers[pluginId]) { - this.#pluginWatchers[pluginId].resolve(); - delete this.#pluginWatchers[pluginId]; - } + this.#pluginWatchers.resolve(pluginId); } [onPluginsLoadCompleteCallback]() { - for (const pluginId in this.#pluginWatchers) { - this.#pluginWatchers[pluginId].reject( - new Error(`Plugin '${pluginId}' failed to load.`), - ); - } - this.#pluginWatchers = {}; + this.#pluginWatchers.rejectAll( + (pluginId) => new Error(`Plugin '${pluginId}' failed to load.`), + ); } waitForPlugin(pluginId) { - return new Promise((resolve, reject) => { - if (LOADED_PLUGINS.has(pluginId)) { - return resolve(true); - } - - this.#pluginWatchers[pluginId] = { - resolve, - reject, - }; - }); + if (LOADED_PLUGINS.has(pluginId)) return Promise.resolve(true); + return this.#pluginWatchers.waitFor(pluginId); } get exitAppMessage() { diff --git a/src/lib/pluginWaiters.js b/src/lib/pluginWaiters.js new file mode 100644 index 0000000000..fa2084f6d9 --- /dev/null +++ b/src/lib/pluginWaiters.js @@ -0,0 +1,29 @@ +export default class PluginWaiters { + #waiters = new Map(); + + waitFor(pluginId) { + return new Promise((resolve, reject) => { + let waiters = this.#waiters.get(pluginId); + if (!waiters) { + waiters = new Set(); + this.#waiters.set(pluginId, waiters); + } + waiters.add({ resolve, reject }); + }); + } + + resolve(pluginId) { + const waiters = this.#waiters.get(pluginId); + if (!waiters) return; + + this.#waiters.delete(pluginId); + for (const waiter of waiters) waiter.resolve(); + } + + rejectAll(getError) { + for (const [pluginId, waiters] of this.#waiters) { + this.#waiters.delete(pluginId); + for (const waiter of waiters) waiter.reject(getError(pluginId)); + } + } +} diff --git a/tests/unit/pluginWaiters.test.js b/tests/unit/pluginWaiters.test.js new file mode 100644 index 0000000000..b205a22e01 --- /dev/null +++ b/tests/unit/pluginWaiters.test.js @@ -0,0 +1,31 @@ +import { expect, it } from "vitest"; +import PluginWaiters from "../../src/lib/pluginWaiters"; + +it("resolves every caller waiting for the same plugin", async () => { + const waiters = new PluginWaiters(); + const first = waiters.waitFor("example"); + const second = waiters.waitFor("example"); + + waiters.resolve("example"); + + await expect(Promise.all([first, second])).resolves.toEqual([ + undefined, + undefined, + ]); +}); + +it("rejects every pending caller with its plugin-specific error", async () => { + const waiters = new PluginWaiters(); + const first = waiters.waitFor("first"); + const second = waiters.waitFor("first"); + const third = waiters.waitFor("second"); + const pending = Promise.allSettled([first, second, third]); + + waiters.rejectAll((pluginId) => new Error(`Plugin '${pluginId}' failed to load.`)); + + expect(await pending).toEqual([ + { status: "rejected", reason: new Error("Plugin 'first' failed to load.") }, + { status: "rejected", reason: new Error("Plugin 'first' failed to load.") }, + { status: "rejected", reason: new Error("Plugin 'second' failed to load.") }, + ]); +}); From 184a04c20ef723b0bd318a78faa1c03500447ec3 Mon Sep 17 00:00:00 2001 From: mikamikasuki <211269698+mikamikasuki@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:28:25 -0700 Subject: [PATCH 2/2] fix: address review feedback on plugin waiters and loading lifecycle --- src/lib/acode.js | 18 ++++++- src/lib/loadPlugins.js | 17 +++++- src/lib/pluginWaiters.js | 44 ++++++++++------ tests/unit/pluginWaiters.test.js | 90 ++++++++++++++++++++++++-------- 4 files changed, 128 insertions(+), 41 deletions(-) diff --git a/src/lib/acode.js b/src/lib/acode.js index dd4ffd9a7f..59635cf3e0 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -61,7 +61,10 @@ import fullscreen from "lib/fullscreen"; import { BROKEN_PLUGINS, LOADED_PLUGINS, + isInitialPluginLoadComplete, + isPluginLoading, onPluginLoadCallback, + onPluginLoadFailedCallback, onPluginsLoadCompleteCallback, } from "lib/loadPlugins"; import notificationManager from "lib/notificationManager"; @@ -703,17 +706,28 @@ class Acode { } [onPluginLoadCallback](pluginId) { - this.#pluginWatchers.resolve(pluginId); + this.#pluginWatchers.resolve(pluginId, true); } - [onPluginsLoadCompleteCallback]() { + [onPluginLoadFailedCallback](pluginId, error) { + this.#pluginWatchers.reject( + pluginId, + error || new Error(`Plugin '${pluginId}' failed to load.`), + ); + } + + [onPluginsLoadCompleteCallback](filter) { this.#pluginWatchers.rejectAll( (pluginId) => new Error(`Plugin '${pluginId}' failed to load.`), + filter, ); } waitForPlugin(pluginId) { if (LOADED_PLUGINS.has(pluginId)) return Promise.resolve(true); + if (isInitialPluginLoadComplete() && !isPluginLoading(pluginId)) { + return Promise.reject(new Error(`Plugin '${pluginId}' failed to load.`)); + } return this.#pluginWatchers.waitFor(pluginId); } diff --git a/src/lib/loadPlugins.js b/src/lib/loadPlugins.js index c3475fdaba..8e21770a5a 100644 --- a/src/lib/loadPlugins.js +++ b/src/lib/loadPlugins.js @@ -26,6 +26,7 @@ const THEME_IDENTIFIERS = new Set([ ]); export const onPluginLoadCallback = Symbol("onPluginLoadCallback"); +export const onPluginLoadFailedCallback = Symbol("onPluginLoadFailedCallback"); export const onPluginsLoadCompleteCallback = Symbol( "onPluginsLoadCompleteCallback", ); @@ -33,11 +34,16 @@ export const onPluginsLoadCompleteCallback = Symbol( export const LOADED_PLUGINS = new Set(); export const BROKEN_PLUGINS = new Map(); const AUTO_DISABLED_PLUGINS = new Set(); +const IN_FLIGHT_PLUGINS = new Set(); const PLUGIN_LOAD_TIMEOUT = 15000; const PLUGIN_DISABLE_TIMEOUT = 60000; let pluginDisabledUpdateQueue = Promise.resolve(); let initialPluginLoadComplete = false; +export function isPluginLoading(pluginId) { + return IN_FLIGHT_PLUGINS.has(pluginId); +} + class PluginLoadTimeoutError extends Error { constructor() { super("Plugin load timeout"); @@ -113,7 +119,11 @@ export default async function loadPlugins(loadOnlyTheme = false) { await Promise.allSettled(loadPromises); - acode[onPluginsLoadCompleteCallback](); + if (!loadOnlyTheme) { + acode[onPluginsLoadCompleteCallback]?.( + (pluginId) => !IN_FLIGHT_PLUGINS.has(pluginId), + ); + } return results.filter(Boolean).length; } finally { if (!loadOnlyTheme) { @@ -127,15 +137,18 @@ export function isInitialPluginLoadComplete() { } export async function loadPluginWithTimeout(pluginId, justInstalled = false) { + IN_FLIGHT_PLUGINS.add(pluginId); const pluginState = { settled: false }; const pluginLoadPromise = loadPlugin(pluginId, justInstalled) .catch(async (error) => { pluginState.settled = true; + IN_FLIGHT_PLUGINS.delete(pluginId); await markPluginBroken(pluginId, error); throw error; }) .then(async () => { pluginState.settled = true; + IN_FLIGHT_PLUGINS.delete(pluginId); await markPluginLoaded(pluginId, justInstalled); }); @@ -178,6 +191,7 @@ async function markPluginLoaded(pluginId, justInstalled = false) { } async function markPluginBroken(pluginId, error) { + acode[onPluginLoadFailedCallback]?.(pluginId, error); // mark plugin as broken to avoid repeated attempts until user intervenes BROKEN_PLUGINS.set(pluginId, { error: String(error.message || error), @@ -197,6 +211,7 @@ function markPluginTimedOut(pluginId, pluginState) { setTimeout(async () => { try { if (pluginState.settled || LOADED_PLUGINS.has(pluginId)) return; + IN_FLIGHT_PLUGINS.delete(pluginId); await markPluginBroken(pluginId, new Error("Plugin load timeout")); } catch (error) { console.error(`Failed to disable timed out plugin ${pluginId}:`, error); diff --git a/src/lib/pluginWaiters.js b/src/lib/pluginWaiters.js index fa2084f6d9..75416fc763 100644 --- a/src/lib/pluginWaiters.js +++ b/src/lib/pluginWaiters.js @@ -2,28 +2,42 @@ export default class PluginWaiters { #waiters = new Map(); waitFor(pluginId) { - return new Promise((resolve, reject) => { - let waiters = this.#waiters.get(pluginId); - if (!waiters) { - waiters = new Set(); - this.#waiters.set(pluginId, waiters); - } - waiters.add({ resolve, reject }); - }); + let deferred = this.#waiters.get(pluginId); + if (!deferred) { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + deferred = { promise, resolve, reject }; + this.#waiters.set(pluginId, deferred); + } + return deferred.promise; + } + + resolve(pluginId, value = true) { + const deferred = this.#waiters.get(pluginId); + if (!deferred) return; + + this.#waiters.delete(pluginId); + deferred.resolve(value); } - resolve(pluginId) { - const waiters = this.#waiters.get(pluginId); - if (!waiters) return; + reject(pluginId, error) { + const deferred = this.#waiters.get(pluginId); + if (!deferred) return; this.#waiters.delete(pluginId); - for (const waiter of waiters) waiter.resolve(); + deferred.reject(error); } - rejectAll(getError) { - for (const [pluginId, waiters] of this.#waiters) { + rejectAll(getError, filter) { + for (const [pluginId, deferred] of this.#waiters) { + if (filter && !filter(pluginId)) continue; this.#waiters.delete(pluginId); - for (const waiter of waiters) waiter.reject(getError(pluginId)); + const error = getError(pluginId); + deferred.reject(error); } } } diff --git a/tests/unit/pluginWaiters.test.js b/tests/unit/pluginWaiters.test.js index b205a22e01..ea5d28625f 100644 --- a/tests/unit/pluginWaiters.test.js +++ b/tests/unit/pluginWaiters.test.js @@ -1,31 +1,75 @@ -import { expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import PluginWaiters from "../../src/lib/pluginWaiters"; -it("resolves every caller waiting for the same plugin", async () => { - const waiters = new PluginWaiters(); - const first = waiters.waitFor("example"); - const second = waiters.waitFor("example"); +describe("PluginWaiters", () => { + it("resolves every caller waiting for the same plugin with true by default", async () => { + const waiters = new PluginWaiters(); + const first = waiters.waitFor("example"); + const second = waiters.waitFor("example"); - waiters.resolve("example"); + waiters.resolve("example"); - await expect(Promise.all([first, second])).resolves.toEqual([ - undefined, - undefined, - ]); -}); + await expect(Promise.all([first, second])).resolves.toEqual([true, true]); + }); + + it("resolves caller with a custom value when provided", async () => { + const waiters = new PluginWaiters(); + const first = waiters.waitFor("custom"); + + waiters.resolve("custom", { pluginId: "custom" }); + + await expect(first).resolves.toEqual({ pluginId: "custom" }); + }); + + it("rejects every pending caller with its plugin-specific error", async () => { + const waiters = new PluginWaiters(); + const first = waiters.waitFor("first"); + const second = waiters.waitFor("first"); + const third = waiters.waitFor("second"); + const pending = Promise.allSettled([first, second, third]); + + waiters.rejectAll( + (pluginId) => new Error(`Plugin '${pluginId}' failed to load.`), + ); + + expect(await pending).toEqual([ + { + status: "rejected", + reason: new Error("Plugin 'first' failed to load."), + }, + { + status: "rejected", + reason: new Error("Plugin 'first' failed to load."), + }, + { + status: "rejected", + reason: new Error("Plugin 'second' failed to load."), + }, + ]); + }); + + it("skips filtered plugins during rejectAll", async () => { + const waiters = new PluginWaiters(); + const inflight = waiters.waitFor("inflight"); + const failed = waiters.waitFor("failed"); + + waiters.rejectAll( + (pluginId) => new Error(`Plugin '${pluginId}' failed to load.`), + (pluginId) => pluginId !== "inflight", + ); + + waiters.resolve("inflight", true); + + await expect(inflight).resolves.toBe(true); + await expect(failed).rejects.toThrow("Plugin 'failed' failed to load."); + }); -it("rejects every pending caller with its plugin-specific error", async () => { - const waiters = new PluginWaiters(); - const first = waiters.waitFor("first"); - const second = waiters.waitFor("first"); - const third = waiters.waitFor("second"); - const pending = Promise.allSettled([first, second, third]); + it("rejects an individual plugin via reject()", async () => { + const waiters = new PluginWaiters(); + const pending = waiters.waitFor("broken"); - waiters.rejectAll((pluginId) => new Error(`Plugin '${pluginId}' failed to load.`)); + waiters.reject("broken", new Error("Syntax error")); - expect(await pending).toEqual([ - { status: "rejected", reason: new Error("Plugin 'first' failed to load.") }, - { status: "rejected", reason: new Error("Plugin 'first' failed to load.") }, - { status: "rejected", reason: new Error("Plugin 'second' failed to load.") }, - ]); + await expect(pending).rejects.toThrow("Syntax error"); + }); });