Skip to content
Open
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
45 changes: 23 additions & 22 deletions src/lib/acode.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import fsOperation from "fileSystem";

Check failure on line 1 in src/lib/acode.js

View workflow job for this annotation

GitHub Actions / Linting and formatting

assist/source/organizeImports

The imports and exports are not sorted.
import sidebarApps from "sidebarApps";
import * as cmAutocomplete from "@codemirror/autocomplete";
import * as cmCommands from "@codemirror/commands";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Member

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) in main.js:766), which runs before the main loadPlugins() 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?

(pluginId) => new Error(`Plugin '${pluginId}' failed to load.`),
filter,
);
}

this.#pluginWatchers[pluginId] = {
resolve,
reject,
};
});
waitForPlugin(pluginId) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the initial load has already completed (isInitialPluginLoadComplete()), a call for a plugin that's broken/disabled/not installed registers a waiter that will never settle — no further onPluginsLoadCompleteCallback runs, so the caller hangs silently.

Since this function is being rewritten anyway, it'd be good to reject immediately in that case.

Related: with the new Set, repeated calls for a never-loaded ID now accumulate entries for the rest of the session (previously it was overwritten, so bounded at one per ID). Settling late callers immediately fixes both.

if (LOADED_PLUGINS.has(pluginId)) return Promise.resolve(true);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the fast path resolves with true, but the waiter path resolves with undefined (waiter.resolve() in PluginWaiters). Code like if (await acode.waitForPlugin("dep")) init(); behaves differently depending on load order. Suggest waiter.resolve(true) and updating the test accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also keep this in mind that, this fast reject only checks LOADED_PLUGINS / IN_FLIGHT_PLUGINS, but re-enabling a plugin from the Plugins page (pages/plugins/plugins.js L812) or the sidebar (sidebarApps/extensions/index.js L1043) calls loadPlugin(id) directly, which never touches either set and never fires onPluginLoadCallback. So if B depends on A and the user re-enables A then B, B's waitForPlugin("A") now rejects with "failed to load" even though A is running (earlier it just hung). Maybe route those call sites through loadPluginWithTimeout? Fine as a follow-up too, just flagging since this PR adds the early reject.

if (isInitialPluginLoadComplete() && !isPluginLoading(pluginId)) {
return Promise.reject(new Error(`Plugin '${pluginId}' failed to load.`));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Plugin '${pluginId}' failed to load. is now built in 3 places, a small helper would keep it in one spot. Also ?. is used for the new callbacks but not for onPluginLoadCallback in markPluginLoaded, would be good to keep it consistent.

}
return this.#pluginWatchers.waitFor(pluginId);
}

get exitAppMessage() {
Expand Down
17 changes: 16 additions & 1 deletion src/lib/loadPlugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -113,7 +119,11 @@ export default async function loadPlugins(loadOnlyTheme = false) {

await Promise.allSettled(loadPromises);

acode[onPluginsLoadCompleteCallback]();
if (!loadOnlyTheme) {
acode[onPluginsLoadCompleteCallback]?.(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: if anything above throws (e.g. lsDir on PLUGIN_DIR), this call is skipped but finally still sets initialPluginLoadComplete = true, so already queued waiters never settle. Moving the rejectAll into finally (still behind !loadOnlyTheme) would close that.

(pluginId) => !IN_FLIGHT_PLUGINS.has(pluginId),
);
}
return results.filter(Boolean).length;
} finally {
if (!loadOnlyTheme) {
Expand All @@ -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);
});

Expand Down Expand Up @@ -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),
Expand All @@ -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);
Expand Down
43 changes: 43 additions & 0 deletions src/lib/pluginWaiters.js
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);
}
}
}
75 changes: 75 additions & 0 deletions tests/unit/pluginWaiters.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These still cover only PluginWaiters in isolation. The cases that actually broke are in the wiring (theme-only pass not rejecting, a timed-out in-flight plugin surviving rejectAll and resolving later, the fast reject after load completes). One test that mocks loadPlugin and drives loadPlugins() + acode.waitForPlugin() together would cover it.

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"));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 biome.json files.includes only covers src/** and utils/**, so the biome check mentioned in the PR description doesn't actually check it. Running biome format --write on it explicitly would keep it consistent.

await expect(pending).rejects.toThrow("Syntax error");
});
});
Loading