Repository navigation
fix: resolve all waiters for plugin loads #2955
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import fsOperation from "fileSystem"; | ||
| import sidebarApps from "sidebarApps"; | ||
| import * as cmAutocomplete from "@codemirror/autocomplete"; | ||
| import * as cmCommands from "@codemirror/commands"; | ||
|
|
@@ -61,12 +61,16 @@ | |
| 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 @@ | |
| #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 @@ | |
| } | ||
|
|
||
| [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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the initial load has already completed ( Since this function is being rewritten anyway, it'd be good to reject immediately in that case. Related: with the new |
||
| if (LOADED_PLUGINS.has(pluginId)) return Promise.resolve(true); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: the fast path resolves with
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also keep this in mind that, this fast reject only checks |
||
| if (isInitialPluginLoadComplete() && !isPluginLoading(pluginId)) { | ||
| return Promise.reject(new Error(`Plugin '${pluginId}' failed to load.`)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: |
||
| } | ||
| return this.#pluginWatchers.waitFor(pluginId); | ||
| } | ||
|
|
||
| get exitAppMessage() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,18 +26,24 @@ const THEME_IDENTIFIERS = new Set([ | |
| ]); | ||
|
|
||
| export const onPluginLoadCallback = Symbol("onPluginLoadCallback"); | ||
| export const onPluginLoadFailedCallback = Symbol("onPluginLoadFailedCallback"); | ||
| export const onPluginsLoadCompleteCallback = Symbol( | ||
| "onPluginsLoadCompleteCallback", | ||
| ); | ||
|
|
||
| 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]?.( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: if anything above throws (e.g. |
||
| (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); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These still cover only |
||
| 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")); | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this file isn't Biome-formatted (several lines exceed the 80-col width). It slips through because |
||
| await expect(pending).rejects.toThrow("Syntax error"); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This callback also fires at the end of the theme-only pass (
loadPlugins(true)inmain.js:766), which runs before the mainloadPlugins()pass. Any waiter registered by then (e.g. by a theme plugin waiting on a regular plugin) gets rejected with "failed to load" even though that plugin hasn't been attempted yet.Could we only reject when
!loadOnlyTheme, or pass that flag through to the callback?