refactor(plugins): isolate loader cache state

This commit is contained in:
Vincent Koc
2026-04-26 14:09:05 -07:00
parent b28de9a7d9
commit 2edbdc42ae
3 changed files with 150 additions and 49 deletions

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { PluginLoaderCacheState, PluginLoadReentryError } from "./loader-cache-state.js";
describe("PluginLoaderCacheState", () => {
it("evicts the least recently used registry cache entry", () => {
const cache = new PluginLoaderCacheState<string>(2);
cache.set("", "empty");
cache.set("a", "alpha");
cache.set("b", "bravo");
expect(cache.get("a")).toBe("alpha");
cache.set("c", "charlie");
expect(cache.get("b")).toBeUndefined();
expect(cache.get("a")).toBe("alpha");
expect(cache.get("c")).toBe("charlie");
});
it("tracks in-flight loads and reports reentry by cache key", () => {
const cache = new PluginLoaderCacheState<string>(2);
cache.beginLoad("demo");
expect(cache.isLoadInFlight("demo")).toBe(true);
expect(() => cache.beginLoad("demo")).toThrow(PluginLoadReentryError);
cache.finishLoad("demo");
expect(cache.isLoadInFlight("demo")).toBe(false);
});
it("clears registry, in-flight, and warning state together", () => {
const cache = new PluginLoaderCacheState<string>(2);
cache.set("demo", "registry");
cache.beginLoad("demo");
cache.recordOpenAllowlistWarning("demo-warning");
cache.clear();
expect(cache.get("demo")).toBeUndefined();
expect(cache.isLoadInFlight("demo")).toBe(false);
expect(cache.hasOpenAllowlistWarning("demo-warning")).toBe(false);
});
});

View File

@@ -0,0 +1,91 @@
export class PluginLoadReentryError extends Error {
readonly cacheKey: string;
constructor(cacheKey: string) {
super(`plugin load reentry detected for cache key: ${cacheKey}`);
this.name = "PluginLoadReentryError";
this.cacheKey = cacheKey;
}
}
export class PluginLoaderCacheState<T> {
readonly #defaultMaxEntries: number;
#maxEntries: number;
readonly #registryCache = new Map<string, T>();
readonly #inFlightLoads = new Set<string>();
readonly #openAllowlistWarningCache = new Set<string>();
constructor(defaultMaxEntries: number) {
this.#defaultMaxEntries = Math.max(1, Math.floor(defaultMaxEntries));
this.#maxEntries = this.#defaultMaxEntries;
}
get maxEntries(): number {
return this.#maxEntries;
}
setMaxEntriesForTest(value?: number): void {
this.#maxEntries =
typeof value === "number" && Number.isFinite(value) && value > 0
? Math.max(1, Math.floor(value))
: this.#defaultMaxEntries;
this.#evictOldestEntries();
}
clear(): void {
this.#registryCache.clear();
this.#inFlightLoads.clear();
this.#openAllowlistWarningCache.clear();
}
get(cacheKey: string): T | undefined {
const cached = this.#registryCache.get(cacheKey);
if (!cached) {
return undefined;
}
this.#registryCache.delete(cacheKey);
this.#registryCache.set(cacheKey, cached);
return cached;
}
set(cacheKey: string, state: T): void {
if (this.#registryCache.has(cacheKey)) {
this.#registryCache.delete(cacheKey);
}
this.#registryCache.set(cacheKey, state);
this.#evictOldestEntries();
}
isLoadInFlight(cacheKey: string): boolean {
return this.#inFlightLoads.has(cacheKey);
}
beginLoad(cacheKey: string): void {
if (this.#inFlightLoads.has(cacheKey)) {
throw new PluginLoadReentryError(cacheKey);
}
this.#inFlightLoads.add(cacheKey);
}
finishLoad(cacheKey: string): void {
this.#inFlightLoads.delete(cacheKey);
}
hasOpenAllowlistWarning(cacheKey: string): boolean {
return this.#openAllowlistWarningCache.has(cacheKey);
}
recordOpenAllowlistWarning(cacheKey: string): void {
this.#openAllowlistWarningCache.add(cacheKey);
}
#evictOldestEntries(): void {
while (this.#registryCache.size > this.#maxEntries) {
const oldestEntry = this.#registryCache.keys().next();
if (oldestEntry.done) {
break;
}
this.#registryCache.delete(oldestEntry.value);
}
}
}

View File

@@ -71,6 +71,7 @@ import {
restorePluginInteractiveHandlers,
} from "./interactive-registry.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import { PluginLoaderCacheState } from "./loader-cache-state.js";
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js";
import type { PluginManifestContracts } from "./manifest.js";
@@ -134,6 +135,7 @@ import type {
} from "./types.js";
export type PluginLoadResult = PluginRegistry;
export { PluginLoadReentryError } from "./loader-cache-state.js";
export type PluginLoadOptions = {
config?: OpenClawConfig;
@@ -207,16 +209,6 @@ export class PluginLoadFailureError extends Error {
}
}
export class PluginLoadReentryError extends Error {
readonly cacheKey: string;
constructor(cacheKey: string) {
super(`plugin load reentry detected for cache key: ${cacheKey}`);
this.name = "PluginLoadReentryError";
this.cacheKey = cacheKey;
}
}
type CachedPluginState = {
registry: PluginRegistry;
detachedTaskRuntimeRegistration: ReturnType<typeof getDetachedTaskLifecycleRuntimeRegistration>;
@@ -234,10 +226,9 @@ type CachedPluginState = {
};
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128;
let pluginRegistryCacheEntryCap = MAX_PLUGIN_REGISTRY_CACHE_ENTRIES;
const registryCache = new Map<string, CachedPluginState>();
const inFlightPluginRegistryLoads = new Set<string>();
const openAllowlistWarningCache = new Set<string>();
const pluginLoaderCacheState = new PluginLoaderCacheState<CachedPluginState>(
MAX_PLUGIN_REGISTRY_CACHE_ENTRIES,
);
const LAZY_RUNTIME_REFLECTION_KEYS = [
"version",
"config",
@@ -255,9 +246,7 @@ const LAZY_RUNTIME_REFLECTION_KEYS = [
] as const satisfies readonly (keyof PluginRuntime)[];
export function clearPluginLoaderCache(): void {
registryCache.clear();
inFlightPluginRegistryLoads.clear();
openAllowlistWarningCache.clear();
pluginLoaderCacheState.clear();
clearBundledRuntimeDependencyNodePaths();
bundledRuntimeDependencyJitiAliases.clear();
clearAgentHarnesses();
@@ -949,39 +938,19 @@ export const __testing = {
getCompatibleActivePluginRegistry,
resolvePluginLoadCacheContext,
get maxPluginRegistryCacheEntries() {
return pluginRegistryCacheEntryCap;
return pluginLoaderCacheState.maxEntries;
},
setMaxPluginRegistryCacheEntriesForTest(value?: number) {
pluginRegistryCacheEntryCap =
typeof value === "number" && Number.isFinite(value) && value > 0
? Math.max(1, Math.floor(value))
: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES;
pluginLoaderCacheState.setMaxEntriesForTest(value);
},
};
function getCachedPluginRegistry(cacheKey: string): CachedPluginState | undefined {
const cached = registryCache.get(cacheKey);
if (!cached) {
return undefined;
}
// Refresh insertion order so frequently reused registries survive eviction.
registryCache.delete(cacheKey);
registryCache.set(cacheKey, cached);
return cached;
return pluginLoaderCacheState.get(cacheKey);
}
function setCachedPluginRegistry(cacheKey: string, state: CachedPluginState): void {
if (registryCache.has(cacheKey)) {
registryCache.delete(cacheKey);
}
registryCache.set(cacheKey, state);
while (registryCache.size > pluginRegistryCacheEntryCap) {
const oldestKey = registryCache.keys().next().value;
if (!oldestKey) {
break;
}
registryCache.delete(oldestKey);
}
pluginLoaderCacheState.set(cacheKey, state);
}
function buildCacheKey(params: {
@@ -1398,7 +1367,7 @@ export function resolvePluginRegistryLoadCacheKey(options: PluginLoadOptions = {
}
export function isPluginRegistryLoadInFlight(options: PluginLoadOptions = {}): boolean {
return inFlightPluginRegistryLoads.has(resolvePluginRegistryLoadCacheKey(options));
return pluginLoaderCacheState.isLoadInFlight(resolvePluginRegistryLoadCacheKey(options));
}
export function resolveCompatibleRuntimePluginRegistry(
@@ -2089,7 +2058,7 @@ function warnWhenAllowlistIsOpen(params: {
if (autoDiscoverable.length === 0) {
return;
}
if (openAllowlistWarningCache.has(params.warningCacheKey)) {
if (pluginLoaderCacheState.hasOpenAllowlistWarning(params.warningCacheKey)) {
return;
}
const preview = autoDiscoverable
@@ -2097,7 +2066,7 @@ function warnWhenAllowlistIsOpen(params: {
.map((entry) => `${entry.id} (${entry.source})`)
.join(", ");
const extra = autoDiscoverable.length > 6 ? ` (+${autoDiscoverable.length - 6} more)` : "";
openAllowlistWarningCache.add(params.warningCacheKey);
pluginLoaderCacheState.recordOpenAllowlistWarning(params.warningCacheKey);
params.logger.warn(
`[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load: ${preview}${extra}. Set plugins.allow to explicit trusted ids.`,
);
@@ -2210,10 +2179,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
return cached.registry;
}
}
if (inFlightPluginRegistryLoads.has(cacheKey)) {
throw new PluginLoadReentryError(cacheKey);
}
inFlightPluginRegistryLoads.add(cacheKey);
pluginLoaderCacheState.beginLoad(cacheKey);
try {
// Clear previously registered plugin state before reloading.
// Skip for non-activating (snapshot) loads to avoid wiping commands from other plugins.
@@ -3195,7 +3161,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
return registry;
} finally {
inFlightPluginRegistryLoads.delete(cacheKey);
pluginLoaderCacheState.finishLoad(cacheKey);
}
}