improve(gateway): reduce plugin startup memory (#115020)

* perf(gateway): reduce plugin startup memory

* test(plugins): cover effective alias order caching
This commit is contained in:
Peter Steinberger
2026-07-28 03:33:03 -04:00
committed by GitHub
parent 0216fbd4e4
commit 6b255f1f74
9 changed files with 137 additions and 177 deletions

View File

@@ -99,6 +99,7 @@ type CliOptions = {
cases: GatewayBenchCase[];
cpuProfDir?: string;
entry: string;
heapProfDir?: string;
json: boolean;
output?: string;
runs: number;
@@ -115,6 +116,7 @@ const VALUE_FLAGS = new Set([
"--case",
"--cpu-prof-dir",
"--entry",
"--heap-prof-dir",
"--output",
"--runs",
"--timeout-ms",
@@ -337,6 +339,7 @@ function parseOptions(argv: string[] = process.argv.slice(2)): CliOptions {
cases: resolveCases(parseRepeatableFlag(argv, "--case")),
cpuProfDir: parseFlagValue(argv, "--cpu-prof-dir"),
entry: resolveEntry(parseFlagValue(argv, "--entry")),
heapProfDir: parseFlagValue(argv, "--heap-prof-dir"),
json: hasFlag(argv, "--json"),
output: resolveOutputPath(parseFlagValue(argv, "--output")),
runs: parsePositiveInt(parseFlagValue(argv, "--runs"), DEFAULT_RUNS, "--runs"),
@@ -363,6 +366,7 @@ Options:
--warmup <n> Warmup runs per case (default: ${DEFAULT_WARMUP})
--timeout-ms <ms> Per-run timeout (default: ${DEFAULT_TIMEOUT_MS})
--cpu-prof-dir <dir> Write one V8 CPU profile per run
--heap-prof-dir <dir> Write one V8 heap profile per run
--output <path> Write machine-readable JSON to a file
--json Emit machine-readable JSON
--help, -h Show this text
@@ -806,6 +810,7 @@ async function runGatewaySample(options: {
benchCase: GatewayBenchCase;
cpuProfDir?: string;
entry: string;
heapProfDir?: string;
sampleIndex: number;
timeoutMs: number;
}): Promise<GatewaySample> {
@@ -835,6 +840,7 @@ async function runGatewaySample(options: {
`openclaw-gateway-${options.benchCase.id}-${options.sampleIndex}-${Date.now()}.cpuprofile`,
]
: []),
...(options.heapProfDir ? ["--heap-prof", "--heap-prof-dir", options.heapProfDir] : []),
options.entry,
"gateway",
"run",
@@ -947,6 +953,7 @@ async function runCase(options: {
benchCase: GatewayBenchCase;
cpuProfDir?: string;
entry: string;
heapProfDir?: string;
runs: number;
timeoutMs: number;
warmup: number;
@@ -958,6 +965,7 @@ async function runCase(options: {
benchCase: options.benchCase,
cpuProfDir: options.cpuProfDir,
entry: options.entry,
heapProfDir: options.heapProfDir,
sampleIndex: index + 1,
timeoutMs: options.timeoutMs,
});
@@ -1013,6 +1021,9 @@ async function main() {
if (options.cpuProfDir) {
mkdirSync(options.cpuProfDir, { recursive: true });
}
if (options.heapProfDir) {
mkdirSync(options.heapProfDir, { recursive: true });
}
const results: CaseResult[] = [];
for (const benchCase of options.cases) {
results.push(
@@ -1020,6 +1031,7 @@ async function main() {
benchCase,
cpuProfDir: options.cpuProfDir,
entry: options.entry,
heapProfDir: options.heapProfDir,
runs: options.runs,
timeoutMs: options.timeoutMs,
warmup: options.warmup,

View File

@@ -1,46 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetGatewayWorkAdmission } from "../process/gateway-work-admission.js";
import { scheduleGatewayHandlerPrewarm } from "./server-startup-handler-prewarm.js";
afterEach(() => {
vi.useRealTimers();
resetGatewayWorkAdmission();
});
describe("scheduleGatewayHandlerPrewarm", () => {
it("loads every scheduled family in order only after the post-ready timer yields", async () => {
vi.useFakeTimers();
const familyNames = ["sessions", "chat", "tasks", "cron"];
const loaded: string[] = [];
const families = familyNames.map((name) => ({
name,
load: vi.fn(async () => {
loaded.push(name);
}),
}));
const sidecar = scheduleGatewayHandlerPrewarm({
families,
log: { warn: vi.fn() },
});
expect(loaded).toEqual([]);
await vi.runAllTimersAsync();
expect(loaded).toEqual(familyNames);
expect(families.every((family) => vi.mocked(family.load).mock.calls.length === 1)).toBe(true);
sidecar.stop();
});
it("stops before importing a scheduled family", async () => {
vi.useFakeTimers();
const load = vi.fn(async () => {});
const sidecar = scheduleGatewayHandlerPrewarm({
families: [{ name: "sessions", load }],
log: { warn: vi.fn() },
});
sidecar.stop();
await vi.runAllTimersAsync();
expect(load).not.toHaveBeenCalled();
});
});

View File

@@ -1,81 +0,0 @@
import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-work-admission.js";
type StartupTrace = {
measure: <T>(name: string, run: () => T | Promise<T>) => Promise<T>;
};
type GatewayHandlerPrewarmFamily = {
name: string;
load: () => Promise<unknown>;
};
type GatewayHandlerPrewarmHandle = {
stop: () => void;
};
// These are the families requested by the Control UI's first dashboard turn.
// Keep the list explicit so adding a cold import is a conscious startup tradeoff.
const DASHBOARD_HANDLER_FAMILIES: readonly GatewayHandlerPrewarmFamily[] = [
{ name: "sessions", load: () => import("./server-methods/sessions.js") },
{ name: "chat", load: () => import("./server-methods/chat.js") },
{ name: "tasks", load: () => import("./server-methods/tasks.js") },
{ name: "cron", load: () => import("./server-methods/cron.js") },
{ name: "models-auth-status", load: () => import("./server-methods/models-auth-status.js") },
{ name: "agent-identity", load: () => import("./server-methods/agent-identity.js") },
{ name: "board", load: () => import("./server-methods/board.js") },
{ name: "channels", load: () => import("./server-methods/channels.js") },
];
export function scheduleGatewayHandlerPrewarm(params: {
startupTrace?: StartupTrace;
log: { warn: (msg: string) => void };
families?: readonly GatewayHandlerPrewarmFamily[];
}): GatewayHandlerPrewarmHandle {
const families = params.families ?? DASHBOARD_HANDLER_FAMILIES;
let stopped = false;
let nextIndex = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const scheduleNext = () => {
if (stopped || nextIndex >= families.length) {
return;
}
timer = setTimeout(() => {
timer = undefined;
if (stopped) {
return;
}
const family = families[nextIndex++];
if (!family) {
return;
}
const load = () => family.load();
void runWithGatewayIndependentRootWorkAdmission(() =>
params.startupTrace
? params.startupTrace.measure(`post-ready.gateway-handler.${family.name}`, load)
: load(),
)
.catch((err: unknown) => {
params.log.warn(
`post-ready gateway handler prewarm failed for ${family.name}: ${String(err)}`,
);
})
.finally(scheduleNext);
}, 0);
timer.unref?.();
};
// One family per event-loop turn keeps this work behind readiness and lets
// immediate client traffic run between imports instead of recreating a startup wall.
scheduleNext();
return {
stop: () => {
stopped = true;
if (timer) {
clearTimeout(timer);
timer = undefined;
}
},
};
}

View File

@@ -65,7 +65,6 @@ const hoisted = vi.hoisted(() => {
const refreshPreparedModelRuntimeSnapshots = vi.fn(async (_cfg?: unknown) => {});
const ensureRuntimePluginsLoaded = vi.fn();
const ensureContextWindowCacheLoaded = vi.fn(async () => {});
const scheduleGatewayHandlerPrewarm = vi.fn(() => ({ stop: vi.fn() }));
const clearCurrentProviderAuthState = vi.fn();
const warmCurrentProviderAuthStateOffMainThread = vi.fn(
async (_cfg?: unknown, _options?: unknown) => {},
@@ -106,7 +105,6 @@ const hoisted = vi.hoisted(() => {
refreshPreparedModelRuntimeSnapshots,
ensureRuntimePluginsLoaded,
ensureContextWindowCacheLoaded,
scheduleGatewayHandlerPrewarm,
clearCurrentProviderAuthState,
warmCurrentProviderAuthStateOffMainThread,
setAuthProfileFailureHook,
@@ -224,10 +222,6 @@ vi.mock("../agents/model-provider-auth.js", () => ({
warmCurrentProviderAuthStateOffMainThread: hoisted.warmCurrentProviderAuthStateOffMainThread,
}));
vi.mock("./server-startup-handler-prewarm.js", () => ({
scheduleGatewayHandlerPrewarm: hoisted.scheduleGatewayHandlerPrewarm,
}));
vi.mock("../agents/model-provider-auth-state.js", () => ({
clearCurrentProviderAuthState: hoisted.clearCurrentProviderAuthState,
}));
@@ -1082,7 +1076,7 @@ describe("startGatewayPostAttachRuntime", () => {
await vi.advanceTimersToNextTimerAsync();
expect(postReadyRequestTurn).toHaveBeenCalledTimes(1);
expect(onPostReadySidecars.mock.calls[0]?.[0]).toHaveLength(0);
expect(onGatewayLifetimeSidecars.mock.calls[0]?.[0]).toHaveLength(4);
expect(onGatewayLifetimeSidecars.mock.calls[0]?.[0]).toHaveLength(3);
await vi.dynamicImportSettled();
await waitForGatewayTestState(() => {
expect(hoisted.setAuthProfileFailureHook).toHaveBeenCalledTimes(1);
@@ -1114,7 +1108,7 @@ describe("startGatewayPostAttachRuntime", () => {
await waitForGatewayTestState(() => {
expect(hoisted.setAuthProfileFailureHook).toHaveBeenCalledTimes(1);
});
expect(onGatewayLifetimeSidecars.mock.calls[0]?.[0]).toHaveLength(4);
expect(onGatewayLifetimeSidecars.mock.calls[0]?.[0]).toHaveLength(3);
await vi.advanceTimersByTimeAsync(10_000);
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).not.toHaveBeenCalled();
@@ -1238,7 +1232,7 @@ describe("startGatewayPostAttachRuntime", () => {
| { stop: () => void }[]
| undefined;
expect(gmailSidecars).toHaveLength(1);
expect(lifetimeSidecars).toHaveLength(4);
expect(lifetimeSidecars).toHaveLength(3);
for (const sidecar of gmailSidecars ?? []) {
sidecar.stop();
@@ -1300,7 +1294,7 @@ describe("startGatewayPostAttachRuntime", () => {
| Array<{ stop: () => Promise<void> | void }>
| undefined;
expect(gmailSidecars).toHaveLength(1);
expect(lifetimeSidecars).toHaveLength(4);
expect(lifetimeSidecars).toHaveLength(3);
await waitForGatewayTestState(() => {
expect(hoisted.transcriptsAutoStartService.start).toHaveBeenCalledTimes(1);
@@ -1795,7 +1789,7 @@ describe("startGatewayPostAttachRuntime", () => {
name: "sidecars.ready",
metrics: [
["loadedPluginCount", 2],
["postReadySidecarCount", 3],
["postReadySidecarCount", 2],
],
});
});

View File

@@ -28,7 +28,6 @@ import type { GatewayRecoveryRuntime } from "./server-instance-runtime.types.js"
import type { refreshLatestUpdateRestartSentinel } from "./server-restart-sentinel.js";
import type { GatewaySidecarStartupMode } from "./server-sidecar-startup-mode.js";
import { scheduleContextCachePrewarm } from "./server-startup-context-cache-prewarm.js";
import { scheduleGatewayHandlerPrewarm } from "./server-startup-handler-prewarm.js";
import type { logGatewayStartup } from "./server-startup-log.js";
import {
createGatewayStartupOutcomeRecorder,
@@ -1231,10 +1230,7 @@ export async function startGatewayPostAttachRuntime(
reportPluginServices(result.pluginServices);
}
const postReadySidecars = [...result.postReadySidecars];
const gatewayLifetimeSidecars = [
scheduleContextCachePrewarm(params),
scheduleGatewayHandlerPrewarm(params),
];
const gatewayLifetimeSidecars = [scheduleContextCachePrewarm(params)];
if (workerEnvironmentSidecar) {
gatewayLifetimeSidecars.push(workerEnvironmentSidecar);
}

View File

@@ -119,6 +119,10 @@ const INTERNAL_CORE_PACKAGE_ALIASES = [
},
] as const;
const pluginSdkNativeAliases = new Map<string, NativeAliasEntry[]>();
const pluginSdkNativeAliasesByMap = new WeakMap<
Record<string, string>,
Array<readonly [string, string]>
>();
const internalCorePackageHostRoots = new PluginLruCache<string>(128);
const registeredInternalCorePackageHosts = new PluginLruCache<true>(128);
let installed = false;
@@ -290,17 +294,20 @@ function listPluginSdkNativeAliases(
options: InstallOpenClawPluginSdkNativeResolverOptions,
): Array<readonly [string, string]> {
const modulePath = options.pluginModulePath ?? resolveLoaderModulePath(options);
return Object.entries(
buildPluginLoaderAliasMap(
modulePath,
options.argv1 ?? process.argv[1],
options.moduleUrl,
// Native require hooks must point at JavaScript artifacts, even when the
// plugin loader itself is configured to prefer source imports.
"dist",
options.devSourceRoot,
),
)
const aliasMap = buildPluginLoaderAliasMap(
modulePath,
options.argv1 ?? process.argv[1],
options.moduleUrl,
// Native require hooks must point at JavaScript artifacts, even when the
// plugin loader itself is configured to prefer source imports.
"dist",
options.devSourceRoot,
);
const cached = pluginSdkNativeAliasesByMap.get(aliasMap);
if (cached) {
return cached;
}
const aliases = Object.entries(aliasMap)
.filter(([specifier]) => isPluginSdkAliasSpecifier(specifier))
.filter(([, target]) => isNativeLoadableSdkTarget(target))
.flatMap(([specifier, target]) => {
@@ -312,6 +319,8 @@ function listPluginSdkNativeAliases(
[`${specifier}.js`, target],
] as Array<readonly [string, string]>;
});
pluginSdkNativeAliasesByMap.set(aliasMap, aliases);
return aliases;
}
function listInternalCorePackageNativeAliases(

View File

@@ -2238,6 +2238,14 @@ describe("buildPluginLoaderAliasMap memoization", () => {
expect(aliasA).not.toBe(aliasB);
});
it("reuses one merged map for plugin entrypoints with the same effective SDK surface", () => {
const fixture = createPluginSdkAliasFixture();
const entryA = writePluginEntry(fixture.root, bundledPluginFile("a", "src/index.ts"));
const entryB = writePluginEntry(fixture.root, bundledPluginFile("b", "src/index.ts"));
expect(buildPluginLoaderAliasMap(entryB)).toBe(buildPluginLoaderAliasMap(entryA));
});
it("returns different references when pluginSdkResolution differs", () => {
const fixture = createPluginSdkAliasFixture();
const entry = writePluginEntry(fixture.root, bundledPluginFile("res", "src/index.ts"));
@@ -2248,14 +2256,24 @@ describe("buildPluginLoaderAliasMap memoization", () => {
expect(auto).not.toBe(dist);
});
it("returns different references when argv1 differs", () => {
it("reuses one merged map when resolution modes have the same effective order", () => {
const fixture = createPluginSdkAliasFixture();
const entry = writePluginEntry(fixture.root, bundledPluginFile("same-order", "src/index.ts"));
const auto = buildPluginLoaderAliasMap(entry, undefined, undefined, "auto");
const source = buildPluginLoaderAliasMap(entry, undefined, undefined, "src");
expect(source).toBe(auto);
});
it("reuses a merged map when different argv hints resolve the same SDK surface", () => {
const fixture = createPluginSdkAliasFixture();
const entry = writePluginEntry(fixture.root, bundledPluginFile("argv", "src/index.ts"));
const a = buildPluginLoaderAliasMap(entry, "/path/to/cli-a.mjs");
const b = buildPluginLoaderAliasMap(entry, "/path/to/cli-b.mjs");
expect(a).not.toBe(b);
expect(a).toBe(b);
});
it("returns different references when an explicit dev source root differs", () => {

View File

@@ -412,6 +412,9 @@ const cachedPluginSdkScopedAliasMaps = new PluginLruCache<Record<string, string>
const cachedBundledPluginPublicSurfaceAliasMaps = new PluginLruCache<Record<string, string>>(
MAX_PLUGIN_LOADER_ALIAS_CACHE_ENTRIES,
);
const cachedWorkspacePackageAliasMaps = new PluginLruCache<Record<string, string>>(
MAX_PLUGIN_LOADER_ALIAS_CACHE_ENTRIES,
);
const PLUGIN_SDK_PACKAGE_NAMES = ["openclaw/plugin-sdk", "@openclaw/plugin-sdk"] as const;
const CODEX_MCP_PROJECTION_PLUGIN_SDK_SUBPATH = "codex-mcp-projection";
const OLLAMA_CONFIGURED_LOCAL_ORIGIN_RUNTIME_PLUGIN_SDK_SUBPATH = "ssrf-runtime-internal";
@@ -896,6 +899,13 @@ function resolveWorkspacePackageAliasMap(params: {
isProduction: process.env.NODE_ENV === "production",
pluginSdkResolution: params.pluginSdkResolution,
});
// Raw modes with the same effective preference order resolve identical targets.
// Key the process-stable cache by that target-affecting order, not the caller spelling.
const cacheKey = `${packageRoot}::${orderedKinds.join(",")}`;
const cached = cachedWorkspacePackageAliasMaps.get(cacheKey);
if (cached) {
return cached;
}
const aliasMap: Record<string, string> = {};
const workspacePackageAliasEntries = [
...WORKSPACE_PACKAGE_ALIAS_ENTRIES,
@@ -933,6 +943,7 @@ function resolveWorkspacePackageAliasMap(params: {
}
}
}
cachedWorkspacePackageAliasMaps.set(cacheKey, aliasMap);
return aliasMap;
}
@@ -1262,6 +1273,50 @@ const pluginLoaderModuleConfigCache = new PluginLruCache<{
aliasMap: Record<string, string>;
cacheKey: string;
}>(MAX_PLUGIN_LOADER_ALIAS_CACHE_ENTRIES);
const normalizedAliasTargetsByInput = new WeakMap<Record<string, string>, Record<string, string>>();
const mergedAliasMapsByComponent = new WeakMap<
Record<string, string>,
WeakMap<Record<string, string>, WeakMap<Record<string, string>, Record<string, string>>>
>();
function normalizeAliasTargets(aliasMap: Record<string, string>): Record<string, string> {
if (process.platform !== "win32") {
return aliasMap;
}
const cached = normalizedAliasTargetsByInput.get(aliasMap);
if (cached) {
return cached;
}
const normalized = Object.fromEntries(
Object.entries(aliasMap).map(([key, value]) => [key, normalizeJitiAliasTargetPath(value)]),
);
normalizedAliasTargetsByInput.set(aliasMap, normalized);
return normalized;
}
function mergeAliasMaps(
bundled: Record<string, string>,
workspace: Record<string, string>,
pluginSdk: Record<string, string>,
): Record<string, string> {
let byWorkspace = mergedAliasMapsByComponent.get(bundled);
if (!byWorkspace) {
byWorkspace = new WeakMap();
mergedAliasMapsByComponent.set(bundled, byWorkspace);
}
let byPluginSdk = byWorkspace.get(workspace);
if (!byPluginSdk) {
byPluginSdk = new WeakMap();
byWorkspace.set(workspace, byPluginSdk);
}
const cached = byPluginSdk.get(pluginSdk);
if (cached) {
return cached;
}
const merged = { ...bundled, ...workspace, ...pluginSdk };
byPluginSdk.set(pluginSdk, merged);
return merged;
}
function hasJitiNormalizedAliasMarker(aliasMap: Record<string, string>) {
return Boolean((aliasMap as Record<symbol, unknown>)[JITI_NORMALIZED_ALIAS_SYMBOL]);
@@ -1423,33 +1478,32 @@ export function buildPluginLoaderAliasMap(
return cached;
}
const result: Record<string, string> = {
...resolveBundledPluginPackagePublicSurfaceAliasMap({
const bundledAliases = resolveBundledPluginPackagePublicSurfaceAliasMap({
modulePath,
argv1,
moduleUrl,
pluginSdkResolution,
devSourceRoot,
});
const workspaceAliases = resolveWorkspacePackageAliasMap({
modulePath,
argv1,
moduleUrl,
pluginSdkResolution,
devSourceRoot,
});
const pluginSdkAliases = normalizeAliasTargets(
resolvePluginSdkScopedAliasMap({
modulePath,
argv1,
moduleUrl,
pluginSdkResolution,
devSourceRoot,
}),
...resolveWorkspacePackageAliasMap({
modulePath,
argv1,
moduleUrl,
pluginSdkResolution,
devSourceRoot,
}),
...Object.fromEntries(
Object.entries(
resolvePluginSdkScopedAliasMap({
modulePath,
argv1,
moduleUrl,
pluginSdkResolution,
devSourceRoot,
}),
).map(([key, value]) => [key, normalizeJitiAliasTargetPath(value)]),
),
};
);
// Different plugin entrypoints commonly resolve the same process-stable SDK surface.
// Reuse one merged map so plugin count does not multiply identical alias objects.
const result = mergeAliasMaps(bundledAliases, workspaceAliases, pluginSdkAliases);
aliasMapCache.set(cacheKey, result);
return result;
}

View File

@@ -47,6 +47,7 @@ describe("gateway startup benchmark script", () => {
expect(helpResult.stdout).toContain("OpenClaw Gateway startup benchmark");
expect(helpResult.stdout).toContain("--case <id>");
expect(helpResult.stdout).toContain("--cpu-prof-dir <dir>");
expect(helpResult.stdout).toContain("--heap-prof-dir <dir>");
expect(helpResult.stdout).toContain("default (gateway default)");
expect(helpResult.stdout).not.toContain("[gateway-startup-bench]");
expect(helpResult.stderr).toBe("");
@@ -63,12 +64,15 @@ describe("gateway startup benchmark script", () => {
"--output",
"startup.json",
"--json",
"--heap-prof-dir",
"profiles",
"--runs",
"2",
]),
).toMatchObject({
cases: [{ id: "default" }],
json: true,
heapProfDir: "profiles",
output: "startup.json",
runs: 2,
});