Skip to content

fix: resolve all waiters for plugin loads - #2955

Open
mikamikasuki wants to merge 1 commit into
Acode-Foundation:mainfrom
mikamikasuki:fix/2936-multiple-plugin-waiters
Open

mikamikasuki wants to merge 1 commit into
Acode-Foundation:mainfrom
mikamikasuki:fix/2936-multiple-plugin-waiters

Conversation

@mikamikasuki

Copy link
Copy Markdown

Fixes #2936

waitForPlugin() stored one resolver per plugin ID. When multiple callers waited for the same plugin, later calls replaced earlier resolvers and left those promises pending.

This change keeps all pending callers for each plugin ID, resolves them when that plugin loads, and rejects them all with the existing plugin-specific error when initial plugin loading completes. The already-loaded fast path remains unchanged.

Regression coverage verifies that multiple callers resolve together and that all waiters receive the correct error when loading fails.

Validation:

  • vitest run tests/unit/pluginWaiters.test.js
  • biome check src/lib/acode.js src/lib/pluginWaiters.js tests/unit/pluginWaiters.test.js
  • git diff --check

@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Refactors plugin load waiting mechanism.

This PR appears safe to merge; no actionable issues were found.

What we checked:

  • Earlier callers stay waiting: waitFor() adds each caller to a shared Set. resolve() settles every caller in that set instead of only the latest caller.

Summary

This PR fixes waitForPlugin() losing earlier callers when several callers wait for the same plugin.

  • PluginWaiters keeps every pending caller for each plugin ID.
  • A successful load resolves that plugin’s callers. Load completion rejects remaining callers with the existing plugin-specific message.
  • New tests cover multiple callers resolving together and rejection across multiple plugin IDs.

Tests were inspected, not run.

Reviews (1) · Last reviewed commit: "fix: resolve all waiters for plugin load..."

Comment thread src/lib/acode.js
);
}
this.#pluginWatchers = {};
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?

Comment thread src/lib/acode.js
this.#pluginWatchers.resolve(pluginId);
}

[onPluginsLoadCompleteCallback]() {

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.

Timed-out plugins: loadPluginWithTimeout returns false after 15s but keeps loading in the background. loadPlugins then completes and rejects its waiters here. When the plugin finishes later, markPluginLoaded → resolve(pluginId) finds nothing, so dependents never initialize even though the plugin is running.

Maybe skip rejecting plugins that timed out but haven't settled, or defer their rejection until PLUGIN_DISABLE_TIMEOUT.

Comment thread src/lib/acode.js
);
}

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.

Comment thread src/lib/acode.js
reject,
};
});
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.

Comment thread src/lib/pluginWaiters.js
waiters = new Set();
this.#waiters.set(pluginId, waiters);
}
waiters.add({ resolve, reject });

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.

Suggestion: rather than a Set of separate { resolve, reject } objects, store one deferred per plugin ID and hand every caller the same promise:

waitFor(pluginId) {
	let deferred = this.#waiters.get(pluginId);
	if (!deferred) {
		deferred = Promise.withResolvers();
		this.#waiters.set(pluginId, deferred);
	}
	return deferred.promise;
}

resolve/rejectAll then become single calls with no inner loops. (Check Promise.withResolvers support on the minimum WebView; a small manual deferred works too.)

Comment thread src/lib/pluginWaiters.js
rejectAll(getError) {
for (const [pluginId, waiters] of this.#waiters) {
this.#waiters.delete(pluginId);
for (const waiter of waiters) waiter.reject(getError(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.

getError(pluginId) is called per waiter inside the loop, after the entry has already been deleted. If it ever throws, the remaining waiters for this plugin (and all later plugins) are stranded, and the exception propagates into loadPlugins. Building the error once per plugin before the inner loop avoids that and avoids redundant allocations.

@@ -0,0 +1,31 @@
import { expect, it } from "vitest";

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.

These tests cover PluginWaiters in isolation, but none of the interesting failure modes are here — they're in how acode.waitForPlugin is wired to loadPlugins (theme-only pass rejection, timed-out plugins that load later, calls after initial load). Would be worth adding at least one test exercising waitForPlugin + the load callbacks.

const pending = Promise.allSettled([first, second, third]);

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

Race Condition in acode.waitForPlugin

2 participants