diff --git a/scripts/bench-gateway-startup.ts b/scripts/bench-gateway-startup.ts index f0d0200a2bdc..07093b3841fa 100644 --- a/scripts/bench-gateway-startup.ts +++ b/scripts/bench-gateway-startup.ts @@ -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 Warmup runs per case (default: ${DEFAULT_WARMUP}) --timeout-ms Per-run timeout (default: ${DEFAULT_TIMEOUT_MS}) --cpu-prof-dir Write one V8 CPU profile per run + --heap-prof-dir Write one V8 heap profile per run --output 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 { @@ -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, diff --git a/src/gateway/server-startup-handler-prewarm.test.ts b/src/gateway/server-startup-handler-prewarm.test.ts deleted file mode 100644 index 3be0f52b93e2..000000000000 --- a/src/gateway/server-startup-handler-prewarm.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/gateway/server-startup-handler-prewarm.ts b/src/gateway/server-startup-handler-prewarm.ts deleted file mode 100644 index 84f50a07ac11..000000000000 --- a/src/gateway/server-startup-handler-prewarm.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-work-admission.js"; - -type StartupTrace = { - measure: (name: string, run: () => T | Promise) => Promise; -}; - -type GatewayHandlerPrewarmFamily = { - name: string; - load: () => Promise; -}; - -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 | 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; - } - }, - }; -} diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index bb28f16e236d..439a65b392db 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -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 }> | 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], ], }); }); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index a7d7f5d5bcb1..eddb79ee6c39 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -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); } diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index 292dbb8c71dd..9c4f36f919dd 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -119,6 +119,10 @@ const INTERNAL_CORE_PACKAGE_ALIASES = [ }, ] as const; const pluginSdkNativeAliases = new Map(); +const pluginSdkNativeAliasesByMap = new WeakMap< + Record, + Array +>(); const internalCorePackageHostRoots = new PluginLruCache(128); const registeredInternalCorePackageHosts = new PluginLruCache(128); let installed = false; @@ -290,17 +294,20 @@ function listPluginSdkNativeAliases( options: InstallOpenClawPluginSdkNativeResolverOptions, ): Array { 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; }); + pluginSdkNativeAliasesByMap.set(aliasMap, aliases); + return aliases; } function listInternalCorePackageNativeAliases( diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index 85a255399abd..f8213ae76d7a 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -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", () => { diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index c4988316ab8a..efd9dbdfcb0f 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -412,6 +412,9 @@ const cachedPluginSdkScopedAliasMaps = new PluginLruCache const cachedBundledPluginPublicSurfaceAliasMaps = new PluginLruCache>( MAX_PLUGIN_LOADER_ALIAS_CACHE_ENTRIES, ); +const cachedWorkspacePackageAliasMaps = new PluginLruCache>( + 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 = {}; 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; cacheKey: string; }>(MAX_PLUGIN_LOADER_ALIAS_CACHE_ENTRIES); +const normalizedAliasTargetsByInput = new WeakMap, Record>(); +const mergedAliasMapsByComponent = new WeakMap< + Record, + WeakMap, WeakMap, Record>> +>(); + +function normalizeAliasTargets(aliasMap: Record): Record { + 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, + workspace: Record, + pluginSdk: Record, +): Record { + 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) { return Boolean((aliasMap as Record)[JITI_NORMALIZED_ALIAS_SYMBOL]); @@ -1423,33 +1478,32 @@ export function buildPluginLoaderAliasMap( return cached; } - const result: Record = { - ...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; } diff --git a/test/scripts/bench-gateway-startup.test.ts b/test/scripts/bench-gateway-startup.test.ts index 014e0ef8d1ec..e53412937145 100644 --- a/test/scripts/bench-gateway-startup.test.ts +++ b/test/scripts/bench-gateway-startup.test.ts @@ -47,6 +47,7 @@ describe("gateway startup benchmark script", () => { expect(helpResult.stdout).toContain("OpenClaw Gateway startup benchmark"); expect(helpResult.stdout).toContain("--case "); expect(helpResult.stdout).toContain("--cpu-prof-dir "); + expect(helpResult.stdout).toContain("--heap-prof-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, });