mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
refactor(plugins): isolate loader cache state
This commit is contained in:
44
src/plugins/loader-cache-state.test.ts
Normal file
44
src/plugins/loader-cache-state.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
91
src/plugins/loader-cache-state.ts
Normal file
91
src/plugins/loader-cache-state.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user