diff --git a/src/lib/acode.js b/src/lib/acode.js index e727b447b..59635cf3e 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -61,12 +61,16 @@ import fullscreen from "lib/fullscreen"; import { BROKEN_PLUGINS, LOADED_PLUGINS, + isInitialPluginLoadComplete, + isPluginLoading, onPluginLoadCallback, + onPluginLoadFailedCallback, onPluginsLoadCompleteCallback, } from "lib/loadPlugins"; 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 +101,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 +706,29 @@ class Acode { } [onPluginLoadCallback](pluginId) { - if (this.#pluginWatchers[pluginId]) { - this.#pluginWatchers[pluginId].resolve(); - delete this.#pluginWatchers[pluginId]; - } + this.#pluginWatchers.resolve(pluginId, true); } - [onPluginsLoadCompleteCallback]() { - for (const pluginId in this.#pluginWatchers) { - this.#pluginWatchers[pluginId].reject( - new Error(`Plugin '${pluginId}' failed to load.`), - ); - } - this.#pluginWatchers = {}; + [onPluginLoadFailedCallback](pluginId, error) { + this.#pluginWatchers.reject( + pluginId, + error || new Error(`Plugin '${pluginId}' failed to load.`), + ); } - waitForPlugin(pluginId) { - return new Promise((resolve, reject) => { - if (LOADED_PLUGINS.has(pluginId)) { - return resolve(true); - } + [onPluginsLoadCompleteCallback](filter) { + this.#pluginWatchers.rejectAll( + (pluginId) => new Error(`Plugin '${pluginId}' failed to load.`), + filter, + ); + } - this.#pluginWatchers[pluginId] = { - resolve, - reject, - }; - }); + 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); } get exitAppMessage() { diff --git a/src/lib/loadPlugins.js b/src/lib/loadPlugins.js index c3475fdab..8e21770a5 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 new file mode 100644 index 000000000..75416fc76 --- /dev/null +++ b/src/lib/pluginWaiters.js @@ -0,0 +1,43 @@ +export default class PluginWaiters { + #waiters = new Map(); + + waitFor(pluginId) { + 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); + } + + reject(pluginId, error) { + const deferred = this.#waiters.get(pluginId); + if (!deferred) return; + + this.#waiters.delete(pluginId); + deferred.reject(error); + } + + rejectAll(getError, filter) { + for (const [pluginId, deferred] of this.#waiters) { + if (filter && !filter(pluginId)) continue; + this.#waiters.delete(pluginId); + const error = getError(pluginId); + deferred.reject(error); + } + } +} diff --git a/tests/unit/pluginWaiters.test.js b/tests/unit/pluginWaiters.test.js new file mode 100644 index 000000000..ea5d28625 --- /dev/null +++ b/tests/unit/pluginWaiters.test.js @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import PluginWaiters from "../../src/lib/pluginWaiters"; + +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"); + + 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 an individual plugin via reject()", async () => { + const waiters = new PluginWaiters(); + const pending = waiters.waitFor("broken"); + + waiters.reject("broken", new Error("Syntax error")); + + await expect(pending).rejects.toThrow("Syntax error"); + }); +});