Files
openclaw/src/plugins/plugin-scan-existence-cache.ts
ml12580 1585ec54f1 perf(plugins): cache existence probes within bundle manifest scan [AI-assisted] (#93919)
* perf(plugins): cache existence probes within bundle manifest scan

Bundle plugin discovery re-probes the same marker paths (skills/, commands/,
agents/, .mcp.json, .lsp.json, settings.json, hooks/hooks.json) once in
detectBundleManifestFormat and again in loadBundleManifest's capability
builders. Across the bundled plugin tree this is thousands of redundant
synchronous fs.existsSync calls; #76209 reports 25.4s of self-time on a
Windows cold start.

Add a scan-scoped existence cache (plugin-scan-existence-cache.ts) entered
only around discoverBundleInRoot. pluginScanExistsSync memoizes inside the
active scan and falls back to plain fs.existsSync outside it, so install,
hooks, and doctor flows stay uncached. The cache is push/pop per
discoverBundleInRoot call (try/finally), so a later install/repair pass
re-reads the filesystem — no process-global staleness.

Measured on Windows over a 25-plugin fixture: 550 -> 325 fs.existsSync
calls (41% fewer), 294.75ms -> 208.49ms. Discovery results unchanged.

Closes #76209

* fix(plugins): drop unused test reset helper and satisfy oxlint

Remove __resetPluginScanExistenceCacheForTest: the scan cache is push/pop
balanced by try/finally in withPluginScanExistenceCache, so the stack never
leaks between tests and the helper was dead code. It also tripped oxlint
no-underscore-dangle. Refactor the integration test to count existsSync calls
via a const-returning helper so there is no useless assignment.
2026-06-22 18:27:36 +00:00

52 lines
2.2 KiB
TypeScript

/** Scan-scoped existence cache for plugin discovery hot paths.
*
* Plugin metadata is process-stable: installs, manifests, and catalogs change
* only on restart or an explicit owner reload/install/doctor flow (see
* AGENTS.md). A single cold-start discovery scan still re-probes the same paths
* many times — `detectBundleManifestFormat` checks `skills/`, `.mcp.json`,
* `settings.json`, ... and `loadBundleManifest`'s capability builders check
* them again. Across bundled plugins that is thousands of synchronous
* `fs.existsSync` calls; the issue reports 25.4s of self-time on Windows cold
* start.
*
* This memoizes existence results for the lifetime of ONE scan pass only. A
* later install/repair pass runs without an active cache (or under a fresh
* cache), so marker files that appear mid-process are never served stale — the
* freshness bug a process-global cache would reintroduce. Outside a scan,
* `pluginScanExistsSync` falls back to plain `fs.existsSync`, so one-off
* callers (install, hooks, doctor) stay correct and uncached. */
import fs from "node:fs";
// Stack so nested wrapped scans get isolated caches and always pop on exit.
// Discovery scans are synchronous, so a single active cache is safe; an async
// scan would need its own scope rather than sharing this module state.
const scanExistenceCacheStack: Map<string, boolean>[] = [];
/** Runs `fn` with a scan-scoped existence cache active. Sync-only. */
export function withPluginScanExistenceCache<T>(fn: () => T): T {
scanExistenceCacheStack.push(new Map());
try {
return fn();
} finally {
scanExistenceCacheStack.pop();
}
}
/** `fs.existsSync` memoized for the active scan pass, if any.
*
* Outside `withPluginScanExistenceCache` this is plain `fs.existsSync`, so
* callers that are not part of a scan pay no caching cost or staleness. */
export function pluginScanExistsSync(targetPath: string): boolean {
const cache = scanExistenceCacheStack[scanExistenceCacheStack.length - 1];
if (!cache) {
return fs.existsSync(targetPath);
}
const cached = cache.get(targetPath);
if (cached !== undefined) {
return cached;
}
const result = fs.existsSync(targetPath);
cache.set(targetPath, result);
return result;
}