fix: stop repeated static catalog manifest scans (#125090)

This commit is contained in:
Peter Steinberger
2026-08-16 23:26:11 -07:00
committed by GitHub
parent 35695cb3bc
commit f77d2ec3a8
9 changed files with 254 additions and 55 deletions
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
const manifestMocks = vi.hoisted(() => ({
@@ -108,6 +109,7 @@ function setManifestPlugins(plugins: unknown[]) {
}
beforeEach(() => {
clearPluginMetadataLifecycleCaches();
for (const mock of Object.values(manifestMocks)) {
mock.mockReset();
}
@@ -284,6 +286,56 @@ describe("bundled static model catalog snapshot cache", () => {
expect(manifestMocks.loadPluginManifest).toHaveBeenCalledTimes(1);
});
it("refreshes the no-snapshot memo only at the plugin metadata lifecycle boundary", () => {
const env = { HOME: "/custom-home" };
const plugin = createMistralManifestPlugin();
setManifestPlugins([
{ ...plugin, modelCatalog: { ...plugin.modelCatalog, runtimeAugment: true } },
]);
expect(bundledStaticCatalogProviderUsesRuntimeAugment({ provider: "mistral", env })).toBe(true);
expect(bundledStaticCatalogProviderUsesRuntimeAugment({ provider: "mistral", env })).toBe(true);
expect(manifestMocks.listOpenClawPluginManifestMetadata).toHaveBeenCalledTimes(1);
setManifestPlugins([
{ ...plugin, modelCatalog: { ...plugin.modelCatalog, runtimeAugment: false } },
]);
expect(bundledStaticCatalogProviderUsesRuntimeAugment({ provider: "mistral", env })).toBe(true);
clearPluginMetadataLifecycleCaches();
expect(bundledStaticCatalogProviderUsesRuntimeAugment({ provider: "mistral", env })).toBe(
false,
);
expect(manifestMocks.listOpenClawPluginManifestMetadata).toHaveBeenCalledTimes(2);
});
it("refreshes a retained no-snapshot resolver at the plugin metadata lifecycle boundary", () => {
const env = { HOME: "/custom-home" };
const firstPlugin = createMistralManifestPlugin();
setManifestPlugins([firstPlugin]);
const resolveModel = createBundledStaticCatalogModelResolver({ env });
expect(resolveModel({ provider: "mistral", modelId: "mistral-medium-3-5" })?.id).toBe(
"mistral-medium-3-5",
);
const replacementPlugin = createMistralManifestPlugin();
replacementPlugin.modelCatalog.providers.mistral.models =
replacementPlugin.modelCatalog.providers.mistral.models.map((model) => ({
...model,
id: "mistral-medium-next",
name: "Mistral Medium Next",
}));
setManifestPlugins([replacementPlugin]);
clearPluginMetadataLifecycleCaches();
expect(resolveModel({ provider: "mistral", modelId: "mistral-medium-3-5" })).toBeUndefined();
expect(resolveModel({ provider: "mistral", modelId: "mistral-medium-next" })?.name).toBe(
"Mistral Medium Next",
);
expect(manifestMocks.listOpenClawPluginManifestMetadata).toHaveBeenCalledTimes(2);
});
it("preserves plugin enablement policy for current snapshot catalog rows", () => {
setCurrentManifestPlugins([createMistralManifestPlugin()]);
@@ -48,6 +48,7 @@ vi.mock("../../plugins/provider-discovery.js", async (importOriginal) => ({
runProviderStaticCatalog: providerMocks.runProviderStaticCatalog,
}));
import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js";
import { getModelProviderRequestTransport } from "../provider-request-config.js";
import {
createBundledProviderStaticCatalogContextResolver,
@@ -192,6 +193,7 @@ function expectManifestAliasResolution(
}
beforeEach(() => {
clearPluginMetadataLifecycleCaches();
manifestMocks.getCurrentPluginMetadataSnapshot.mockReset();
manifestMocks.listOpenClawPluginManifestMetadata.mockReset();
manifestMocks.loadPluginManifest.mockReset();
@@ -12,6 +12,7 @@ import { listOpenClawPluginManifestMetadata } from "../../plugins/manifest-metad
import { passesManifestOwnerBasePolicy } from "../../plugins/manifest-owner-policy.js";
import { loadPluginManifestRegistryCore } from "../../plugins/manifest-registry.js";
import { loadPluginManifest } from "../../plugins/manifest.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "../../plugins/plugin-metadata-lifecycle.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import {
normalizePluginDiscoveryResult,
@@ -145,14 +146,20 @@ type BundledStaticCatalogState = {
plans: Map<string, ReturnType<typeof planEffectiveModelCatalogRows>>;
};
// Snapshot identity changes at the Gateway reload commit, so old provider plans
// cannot survive into a replacement plugin generation.
const bundledStaticCatalogStatesBySnapshot = new WeakMap<
PluginMetadataSnapshot,
let bundledStaticCatalogStatesByOwner = new WeakMap<
object,
WeakMap<OpenClawConfig, BundledStaticCatalogState>
>();
const defaultBundledStaticCatalogConfig: OpenClawConfig = {};
function clearBundledStaticCatalogStates(): void {
bundledStaticCatalogStatesByOwner = new WeakMap();
}
// Snapshot or environment identity pins one plugin generation; install/reload
// owners replace this map so retained resolvers cannot keep stale provider plans.
registerPluginMetadataProcessMemoLifecycleClear(clearBundledStaticCatalogStates);
function resolveBundledStaticCatalogMetadataSnapshot(
params: BundledStaticCatalogParams,
): PluginMetadataSnapshot | undefined {
@@ -203,14 +210,15 @@ function listBundledStaticCatalogPlugins(
);
}
function resolveSnapshotBundledStaticCatalogState(
function resolveBundledStaticCatalogState(
params: BundledStaticCatalogParams,
metadataSnapshot: PluginMetadataSnapshot,
metadataSnapshot?: PluginMetadataSnapshot,
): BundledStaticCatalogState {
let states = bundledStaticCatalogStatesBySnapshot.get(metadataSnapshot);
const owner = metadataSnapshot ?? params.env;
let states = bundledStaticCatalogStatesByOwner.get(owner);
if (!states) {
states = new WeakMap();
bundledStaticCatalogStatesBySnapshot.set(metadataSnapshot, states);
bundledStaticCatalogStatesByOwner.set(owner, states);
}
const config = params.cfg ?? defaultBundledStaticCatalogConfig;
const cached = states.get(config);
@@ -230,6 +238,7 @@ export function bundledStaticCatalogProviderUsesRuntimeAugment(params: {
provider: string;
cfg?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
metadataSnapshot?: PluginMetadataSnapshot;
workspaceDir?: string;
}): boolean {
const provider = normalizeProviderId(params.provider);
@@ -239,12 +248,11 @@ export function bundledStaticCatalogProviderUsesRuntimeAugment(params: {
const catalogParams = {
cfg: params.cfg,
env: params.env ?? process.env,
...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}),
workspaceDir: params.workspaceDir,
};
const metadataSnapshot = resolveBundledStaticCatalogMetadataSnapshot(catalogParams);
const plugins = metadataSnapshot
? resolveSnapshotBundledStaticCatalogState(catalogParams, metadataSnapshot).plugins
: listBundledStaticCatalogPlugins(catalogParams);
const plugins = resolveBundledStaticCatalogState(catalogParams, metadataSnapshot).plugins;
return plugins.some((plugin) => {
const catalog = plugin.modelCatalog;
if (catalog?.runtimeAugment !== true) {
@@ -305,19 +313,13 @@ export function createBundledStaticCatalogModelResolver(params?: {
const matchesStaticModelId = params?.metadataSnapshot
? createStaticModelIdMatcher({ manifestPlugins: params.metadataSnapshot.plugins })
: staticModelIdMatches;
let standaloneState: BundledStaticCatalogState | undefined;
return (lookup) => {
const provider = normalizeProviderId(lookup.provider);
if (!provider || !lookup.modelId.trim()) {
return undefined;
}
const metadataSnapshot = resolveBundledStaticCatalogMetadataSnapshot(catalogParams);
const state = metadataSnapshot
? resolveSnapshotBundledStaticCatalogState(catalogParams, metadataSnapshot)
: (standaloneState ??= {
plugins: listBundledStaticCatalogPlugins(catalogParams),
plans: new Map(),
});
const state = resolveBundledStaticCatalogState(catalogParams, metadataSnapshot);
if (state.plugins.length === 0) {
return undefined;
}
@@ -13,6 +13,7 @@ import type {
describeImagesWithModel,
MediaUnderstandingProvider,
} from "../../plugin-sdk/media-understanding.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import type { AuthProfileStore } from "../auth-profiles/types.js";
import type { resolveBundledStaticCatalogModel } from "../embedded-agent-runner/model.static-catalog.js";
import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js";
@@ -51,6 +52,7 @@ type ResolveImageCompressionPolicy = (params: {
imageCount: number;
agentDir?: string;
workspaceDir?: string;
metadataSnapshot?: PluginMetadataSnapshot;
}) => Promise<ImageCompressionPolicy>;
type ImageToolProviderDeps = {
+100
View File
@@ -8,6 +8,7 @@ import { isInboundPathAllowed } from "@openclaw/media-core/inbound-path-policy";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { createPluginMetadataSnapshot } from "../../config/plugin-auto-enable.test-helpers.js";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
import { encodePngRgba, fillPixel } from "../../media/png-encode.js";
import type {
@@ -15,6 +16,8 @@ import type {
ImagesDescriptionRequest,
MediaUnderstandingProvider,
} from "../../plugin-sdk/media-understanding.js";
import { installTemporaryCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js";
@@ -3149,6 +3152,103 @@ describe("image compression policy", () => {
testing.setProviderDepsForTest();
});
it("keeps runtime augmentation pinned to the prepared plugin generation", async () => {
const provider = "prepared-image-provider";
const model = "prepared-image-model";
const cfg = {
models: {
providers: {
[provider]: {
baseUrl: "https://prepared-image.example.test/v1",
api: "openai-completions",
models: [
{
id: model,
name: "Prepared image model",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_192,
},
],
},
},
},
} satisfies OpenClawConfig;
const createSnapshot = (runtimeAugment: boolean) => {
const plugin = {
id: "prepared-image-plugin",
enabledByDefault: true,
channels: [],
providers: [provider],
cliBackends: [],
skills: [],
hooks: [],
origin: "bundled",
rootDir: "/fake/prepared-image-plugin",
source: "/fake/prepared-image-plugin/index.js",
manifestPath: "/fake/prepared-image-plugin/openclaw.plugin.json",
modelCatalog: {
runtimeAugment,
providers: {
[provider]: {
baseUrl: "https://prepared-image.example.test/v1",
api: "openai-completions",
models: [{ id: model, name: "Prepared image model" }],
},
},
},
} satisfies PluginManifestRecord;
return createPluginMetadataSnapshot({
config: cfg,
manifestRegistry: { plugins: [plugin], diagnostics: [] },
});
};
const preparedSnapshot = createSnapshot(false);
const currentSnapshot = createSnapshot(true);
const hookModes: Array<boolean | undefined> = [];
installImageUnderstandingProviderDeps([], {
resolveModelAsync: async (resolvedProvider, resolvedModel, _agentDir, _cfg, options) => {
hookModes.push(options?.skipProviderRuntimeHooks);
return {
model: {
id: resolvedModel,
provider: resolvedProvider,
input: ["text", "image"],
mediaInput: {
image: options?.skipProviderRuntimeHooks
? { maxBytes: 1_000_000 }
: { maxSidePx: 4096 },
},
} as never,
authStorage: {} as never,
modelRegistry: {} as never,
};
},
});
const currentLease = installTemporaryCurrentPluginMetadataSnapshot(currentSnapshot, {
config: cfg,
});
try {
await expect(
testing.resolveImageCompressionPolicy({
cfg,
imageModelConfig: { primary: `${provider}/${model}` },
imageCount: 1,
metadataSnapshot: preparedSnapshot,
}),
).resolves.toEqual({
imageCount: 1,
models: [{ maxBytes: 1_000_000 }],
});
expect(hookModes).toEqual([true]);
} finally {
currentLease.release();
}
});
it("derives model metadata, quality preference, and image count from config", async () => {
const cfg = {
...cfgWithImageModelMetadata,
+10 -3
View File
@@ -496,9 +496,6 @@ function providerUsesRuntimeModelAugment(params: {
if (!provider) {
return false;
}
if (bundledStaticCatalogProviderUsesRuntimeAugment({ provider })) {
return true;
}
const config = params.cfg ?? {};
const preparedSnapshot =
params.metadataSnapshot &&
@@ -518,6 +515,16 @@ function providerUsesRuntimeModelAugment(params: {
env: process.env,
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
});
if (
bundledStaticCatalogProviderUsesRuntimeAugment({
provider,
cfg: params.cfg,
...(snapshot ? { metadataSnapshot: snapshot } : {}),
workspaceDir: params.workspaceDir,
})
) {
return true;
}
if (!snapshot) {
return false;
}
+51 -1
View File
@@ -2,10 +2,11 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js";
import { listOpenClawPluginManifestMetadata } from "./manifest-metadata-scan.js";
import { loadPluginManifest } from "./manifest.js";
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
const tempRoots: string[] = [];
@@ -22,11 +23,60 @@ function writeJson(filePath: string, value: unknown): void {
describe("listOpenClawPluginManifestMetadata", () => {
afterEach(() => {
vi.restoreAllMocks();
clearPluginMetadataLifecycleCaches();
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("keeps manifest metadata stable until explicit lifecycle invalidation", () => {
const root = createTempRoot();
const home = path.join(root, "home");
const bundledRoot = path.join(root, "extensions");
const pluginDir = path.join(bundledRoot, "lifecycle-catalog");
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
const env = {
HOME: home,
OPENCLAW_HOME: home,
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
};
const writeManifest = (generation: string) =>
writeJson(manifestPath, { id: "lifecycle-catalog", generation });
writeManifest("first");
clearPluginMetadataLifecycleCaches();
const statSpy = vi.spyOn(fs, "statSync");
const readdirSpy = vi.spyOn(fs, "readdirSync");
expect(
listOpenClawPluginManifestMetadata(env).find(
(record) => record.manifest.id === "lifecycle-catalog",
)?.manifest.generation,
).toBe("first");
const firstStatCalls = statSpy.mock.calls.length;
const firstReaddirCalls = readdirSpy.mock.calls.length;
expect(firstReaddirCalls).toBeGreaterThan(0);
writeManifest("second");
expect(
listOpenClawPluginManifestMetadata(env).find(
(record) => record.manifest.id === "lifecycle-catalog",
)?.manifest.generation,
).toBe("first");
expect(statSpy).toHaveBeenCalledTimes(firstStatCalls);
expect(readdirSpy).toHaveBeenCalledTimes(firstReaddirCalls);
clearPluginMetadataLifecycleCaches();
expect(
listOpenClawPluginManifestMetadata(env).find(
(record) => record.manifest.id === "lifecycle-catalog",
)?.manifest.generation,
).toBe("second");
expect(statSpy).toHaveBeenCalledTimes(firstStatCalls);
expect(readdirSpy.mock.calls.length).toBeGreaterThan(firstReaddirCalls);
});
it("prefers the active bundled manifest over stale persisted bundled installs", () => {
const root = createTempRoot();
const home = path.join(root, "home");
+16 -32
View File
@@ -12,6 +12,7 @@ import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import { resolveDefaultPluginExtensionsDir } from "./install-paths.js";
import { readPersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
// Plugin manifest files are small metadata descriptors. Bound reads to prevent
// a corrupted or hostile manifest from exhausting memory during metadata scan.
@@ -33,12 +34,15 @@ type CandidateDir = {
};
const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json";
let manifestMetadataCache:
| {
key: string;
records: PluginManifestMetadataRecord[];
}
| undefined;
let manifestMetadataCache = new WeakMap<NodeJS.ProcessEnv, PluginManifestMetadataRecord[]>();
function clearManifestMetadataCache(): void {
manifestMetadataCache = new WeakMap();
}
// Manifest metadata is process-stable; install/reload owners refresh it only
// through the shared plugin metadata lifecycle boundary.
registerPluginMetadataProcessMemoLifecycleClear(clearManifestMetadataCache);
function listChildPluginDirs(
root: string | undefined,
@@ -88,16 +92,6 @@ function readManifestObject(pluginDir: string): Record<string, unknown> | undefi
return readJsonObject(path.join(pluginDir, PLUGIN_MANIFEST_FILENAME));
}
function manifestFileFingerprint(pluginDir: string): string {
const manifestPath = path.join(pluginDir, PLUGIN_MANIFEST_FILENAME);
try {
const stat = fs.statSync(manifestPath);
return `${manifestPath}:${stat.mtimeMs}:${stat.size}`;
} catch {
return `${manifestPath}:missing`;
}
}
function listPersistedIndexPluginDirs(env: NodeJS.ProcessEnv, startOrder: number): CandidateDir[] {
const index = readPersistedInstalledPluginIndexSync({ env });
if (!index) {
@@ -174,6 +168,10 @@ function uniqueCandidateDirs(candidates: CandidateDir[]): CandidateDir[] {
export function listOpenClawPluginManifestMetadata(
env: NodeJS.ProcessEnv = process.env,
): PluginManifestMetadataRecord[] {
const cached = manifestMetadataCache.get(env);
if (cached) {
return cached.slice();
}
const candidates: CandidateDir[] = [];
let order = 0;
candidates.push(...listPersistedIndexPluginDirs(env, order));
@@ -185,21 +183,7 @@ export function listOpenClawPluginManifestMetadata(
candidates.push(
...listChildPluginDirs(resolveDefaultPluginExtensionsDir(env), 4, order, "global"),
);
const uniqueCandidates = uniqueCandidateDirs(candidates);
const cacheKey = JSON.stringify(
uniqueCandidates.map((candidate) => [
candidate.pluginDir,
candidate.rank,
candidate.order,
candidate.origin ?? "",
manifestFileFingerprint(candidate.pluginDir),
]),
);
if (manifestMetadataCache?.key === cacheKey) {
return manifestMetadataCache.records.slice();
}
const byManifestId = new Map<string, CandidateDir>();
const records: PluginManifestMetadataRecord[] = [];
for (const candidate of uniqueCandidates) {
@@ -217,6 +201,6 @@ export function listOpenClawPluginManifestMetadata(
}
records.push({ pluginDir: candidate.pluginDir, manifest, origin: candidate.origin });
}
manifestMetadataCache = { key: cacheKey, records };
return records;
manifestMetadataCache.set(env, records);
return records.slice();
}
@@ -129,7 +129,7 @@ describe("manifest model id normalization", () => {
expect(normalizeDemoModel()).toBe("charlie/demo-model");
});
it("reuses manifest metadata while file fingerprints are unchanged", () => {
it("reuses manifest metadata for the same environment identity", () => {
const stateDir = tempDirs.make("openclaw-model-id-normalization-");
const pluginDir = path.join(stateDir, "extensions", "normalizer");
writeInstallIndex({ stateDir, pluginDir });