From fa866d562ed40d321838b860f424782202267be3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 16:42:55 +0100 Subject: [PATCH] perf(gateway): trim startup imports and sentinel checks --- scripts/bench-gateway-startup.ts | 5 +- src/gateway/server-plugins.ts | 21 +++++ .../server-startup-post-attach.test.ts | 62 +++++++++++++- src/gateway/server-startup-post-attach.ts | 84 ++++++++++++++++++- src/gateway/server.impl.ts | 50 ++++++----- .../plugin-module-loader-cache.test.ts | 29 ++++++- src/plugins/plugin-module-loader-cache.ts | 74 +++++++++++++++- 7 files changed, 292 insertions(+), 33 deletions(-) diff --git a/scripts/bench-gateway-startup.ts b/scripts/bench-gateway-startup.ts index 84d7445fed5..1ceb918a87a 100644 --- a/scripts/bench-gateway-startup.ts +++ b/scripts/bench-gateway-startup.ts @@ -580,7 +580,10 @@ function parseStartupTraceMetrics(raw: string): Array<{ key: string; value: numb const value = Number(metricMatch[2]); if ( !Number.isFinite(value) || - (key !== "eventLoopMax" && !key.endsWith("Ms") && !key.endsWith("Mb")) + (key !== "eventLoopMax" && + !key.endsWith("Ms") && + !key.endsWith("Mb") && + !key.endsWith("Count")) ) { continue; } diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index c8cf807dbf4..25785ccd51f 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { clearActivatedPluginRuntimeState, loadOpenClawPlugins } from "../plugins/loader.js"; import { loadPluginLookUpTable, type PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; +import { getPluginModuleLoaderStats } from "../plugins/plugin-module-loader-cache.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; @@ -599,6 +600,7 @@ export function loadGatewayPlugins(params: { }; } const beforeLoad = performance.now(); + const loaderStatsBefore = getPluginModuleLoaderStats(); const pluginRegistry = loadOpenClawPlugins({ config: resolvedConfig, activationSourceConfig: params.activationSourceConfig ?? params.cfg, @@ -624,6 +626,7 @@ export function loadGatewayPlugins(params: { : {}), }); const loadMs = performance.now() - beforeLoad; + const loaderStatsAfter = getPluginModuleLoaderStats(); const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods])); params.startupTrace?.detail("plugins.gateway-load", [ @@ -633,6 +636,24 @@ export function loadGatewayPlugins(params: { ["loadMs", loadMs], ["pluginIds", String(pluginIds.length)], ["gatewayHandlers", String(pluginMethods.length)], + ["loaderCallsCount", loaderStatsAfter.calls - loaderStatsBefore.calls], + ["loaderNativeHitsCount", loaderStatsAfter.nativeHits - loaderStatsBefore.nativeHits], + ["loaderNativeMissesCount", loaderStatsAfter.nativeMisses - loaderStatsBefore.nativeMisses], + [ + "loaderSourceTransformForcedCount", + loaderStatsAfter.sourceTransformForced - loaderStatsBefore.sourceTransformForced, + ], + [ + "loaderSourceTransformFallbacksCount", + loaderStatsAfter.sourceTransformFallbacks - loaderStatsBefore.sourceTransformFallbacks, + ], + [ + "loaderTopSourceTransformTargets", + loaderStatsAfter.topSourceTransformTargets + .slice(0, 3) + .map((entry) => `${entry.count}:${entry.target}`) + .join(","), + ], ]); return { pluginRegistry, gatewayMethods }; } diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 0afd7622e69..e7efc7b1359 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginHookGatewayContext, @@ -21,7 +24,9 @@ const hoisted = vi.hoisted(() => { const scheduleSubagentOrphanRecovery = vi.fn(); const shouldWakeFromRestartSentinel = vi.fn(() => false); const scheduleRestartSentinelWake = vi.fn(); - const refreshLatestUpdateRestartSentinel = vi.fn(async () => null); + const refreshLatestUpdateRestartSentinel = vi.fn< + typeof import("./server-restart-sentinel.js").refreshLatestUpdateRestartSentinel + >(async () => null); const getAcpRuntimeBackend = vi.fn<(id?: string) => unknown>(() => null); const reconcilePendingSessionIdentities = vi.fn(async () => ({ checked: 0, @@ -281,6 +286,61 @@ describe("startGatewayPostAttachRuntime", () => { expect(events).toEqual(["sidecars", "returned", "sentinel"]); }); + it("skips heavy restart sentinel refresh when no sentinel file exists", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-no-sentinel-")); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + + const result = await __testing.refreshLatestUpdateRestartSentinelIfPresent(); + + expect(result).toBeNull(); + expect(hoisted.refreshLatestUpdateRestartSentinel).not.toHaveBeenCalled(); + fs.rmSync(stateDir, { recursive: true, force: true }); + }); + + it("refreshes the restart sentinel when the sentinel file exists", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sentinel-")); + fs.writeFileSync(path.join(stateDir, "restart-sentinel.json"), "{}\n"); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + const sentinel = { kind: "update", status: "ok", ts: 1 } as const; + hoisted.refreshLatestUpdateRestartSentinel.mockResolvedValue(sentinel); + + const result = await __testing.refreshLatestUpdateRestartSentinelIfPresent(); + + expect(result).toBe(sentinel); + expect(hoisted.refreshLatestUpdateRestartSentinel).toHaveBeenCalledOnce(); + fs.rmSync(stateDir, { recursive: true, force: true }); + }); + + it("expands tilde-based restart sentinel state paths", () => { + const osHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-home-")); + try { + const openclawHome = path.join(osHome, "openclaw-home"); + const stateDirFromHome = path.join(openclawHome, ".openclaw"); + fs.mkdirSync(stateDirFromHome, { recursive: true }); + fs.writeFileSync(path.join(stateDirFromHome, "restart-sentinel.json"), "{}\n"); + + expect( + __testing.hasRestartSentinelFileFast({ + HOME: osHome, + OPENCLAW_HOME: "~/openclaw-home", + } as NodeJS.ProcessEnv), + ).toBe(true); + + const backslashStateDir = path.resolve(`${osHome}\\openclaw-state`); + fs.mkdirSync(backslashStateDir, { recursive: true }); + fs.writeFileSync(path.join(backslashStateDir, "restart-sentinel.json"), "{}\n"); + + expect( + __testing.hasRestartSentinelFileFast({ + HOME: osHome, + OPENCLAW_STATE_DIR: "~\\openclaw-state", + } as NodeJS.ProcessEnv), + ).toBe(true); + } finally { + fs.rmSync(osHome, { recursive: true, force: true }); + } + }); + it("loads deferred startup plugins before channel sidecars", async () => { const events: string[] = []; const loadedPluginRegistry = { diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index a5738f47f0b..a273e27866a 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import type { CliDeps } from "../cli/deps.types.js"; import type { GatewayTailscaleMode } from "../config/types.gateway.js"; @@ -8,6 +11,7 @@ import type { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; import type { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import type { PluginHookGatewayCronService } from "../plugins/hook-types.js"; import type { loadOpenClawPlugins } from "../plugins/loader.js"; +import { getPluginModuleLoaderStats } from "../plugins/plugin-module-loader-cache.js"; import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginServicesHandle } from "../plugins/services.js"; import { @@ -26,10 +30,12 @@ const PRIMARY_MODEL_PREWARM_TIMEOUT_MS = 5_000; const STARTUP_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000; const SKIP_STARTUP_MODEL_PREWARM_ENV = "OPENCLAW_SKIP_STARTUP_MODEL_PREWARM"; const QMD_STARTUP_IDLE_DELAY_MS = 120_000; +const RESTART_SENTINEL_FILENAME = "restart-sentinel.json"; type Awaitable = T | Promise; type GatewayStartupTrace = { + detail: (name: string, metrics: ReadonlyArray) => void; mark: (name: string) => void; measure: (name: string, run: () => Awaitable) => Promise; }; @@ -124,6 +130,61 @@ function schedulePostAttachUpdateSentinelRefresh(params: { handle.unref?.(); } +function resolveRestartSentinelPathFast(env: NodeJS.ProcessEnv = process.env): string { + const normalizePathEnv = (value: string | undefined) => { + const trimmed = value?.trim(); + return trimmed && trimmed !== "undefined" && trimmed !== "null" ? trimmed : undefined; + }; + const resolveRawOsHome = () => normalizePathEnv(env.HOME) ?? normalizePathEnv(env.USERPROFILE); + const expandHomePrefix = (input: string, home: string) => input.replace(/^~(?=$|[\\/])/, home); + const resolveHome = () => { + const explicitHome = normalizePathEnv(env.OPENCLAW_HOME); + if (explicitHome) { + const osHome = resolveRawOsHome() ?? os.homedir(); + return path.resolve(expandHomePrefix(explicitHome, osHome)); + } + return path.resolve(resolveRawOsHome() ?? os.homedir()); + }; + const resolveUserPath = (input: string) => { + const trimmed = input.trim(); + if (trimmed.startsWith("~")) { + return path.resolve(expandHomePrefix(trimmed, resolveHome())); + } + return path.resolve(trimmed); + }; + const override = normalizePathEnv(env.OPENCLAW_STATE_DIR); + if (override) { + return path.join(resolveUserPath(override), RESTART_SENTINEL_FILENAME); + } + const home = resolveHome(); + const newStateDir = path.join(home, ".openclaw"); + if (env.OPENCLAW_TEST_FAST === "1" || fs.existsSync(newStateDir)) { + return path.join(newStateDir, RESTART_SENTINEL_FILENAME); + } + const legacyStateDir = path.join(home, ".clawdbot"); + if (fs.existsSync(legacyStateDir)) { + return path.join(legacyStateDir, RESTART_SENTINEL_FILENAME); + } + return path.join(newStateDir, RESTART_SENTINEL_FILENAME); +} + +function hasRestartSentinelFileFast(env: NodeJS.ProcessEnv = process.env): boolean { + try { + return fs.existsSync(resolveRestartSentinelPathFast(env)); + } catch { + return false; + } +} + +async function refreshLatestUpdateRestartSentinelIfPresent(): Promise +> | null> { + if (!hasRestartSentinelFileFast()) { + return null; + } + return await (await import("./server-restart-sentinel.js")).refreshLatestUpdateRestartSentinel(); +} + function hasGatewayStartHooks(pluginRegistry: ReturnType): boolean { return pluginRegistry.typedHooks.some((hook) => hook.hookName === "gateway_start"); } @@ -507,8 +568,7 @@ export async function startGatewaySidecars(params: { if (!shouldCheckRestartSentinel()) { return; } - const { hasRestartSentinel } = await import("../infra/restart-sentinel.js"); - if (!(await hasRestartSentinel())) { + if (!hasRestartSentinelFileFast()) { return; } setTimeout(() => { @@ -556,8 +616,7 @@ const defaultGatewayPostAttachRuntimeDeps: GatewayPostAttachRuntimeDeps = { (await import("../plugins/hook-runner-global.js")).getGlobalHookRunner(), logGatewayStartup: async (params) => (await import("./server-startup-log.js")).logGatewayStartup(params), - refreshLatestUpdateRestartSentinel: async () => - (await import("./server-restart-sentinel.js")).refreshLatestUpdateRestartSentinel(), + refreshLatestUpdateRestartSentinel: refreshLatestUpdateRestartSentinelIfPresent, scheduleGatewayUpdateCheck: async (...args) => (await import("../infra/update-startup.js")).scheduleGatewayUpdateCheck(...args), startGatewaySidecars, @@ -676,6 +735,7 @@ export async function startGatewayPostAttachRuntime( ? Promise.resolve({ pluginServices: null, pluginRegistry }) : new Promise((resolve) => setImmediate(resolve)).then(async () => { params.log.info("starting channels and sidecars..."); + const loaderStatsBefore = getPluginModuleLoaderStats(); const result = await measureStartup(params.startupTrace, "sidecars.total", () => runtimeDeps.startGatewaySidecars({ cfg: params.gatewayPluginConfigAtStart, @@ -689,6 +749,20 @@ export async function startGatewayPostAttachRuntime( startupTrace: params.startupTrace, }), ); + const loaderStatsAfter = getPluginModuleLoaderStats(); + params.startupTrace?.detail("sidecars.plugin-loader", [ + ["callsCount", loaderStatsAfter.calls - loaderStatsBefore.calls], + ["nativeHitsCount", loaderStatsAfter.nativeHits - loaderStatsBefore.nativeHits], + ["nativeMissesCount", loaderStatsAfter.nativeMisses - loaderStatsBefore.nativeMisses], + [ + "sourceTransformForcedCount", + loaderStatsAfter.sourceTransformForced - loaderStatsBefore.sourceTransformForced, + ], + [ + "sourceTransformFallbacksCount", + loaderStatsAfter.sourceTransformFallbacks - loaderStatsBefore.sourceTransformFallbacks, + ], + ]); for (const method of STARTUP_UNAVAILABLE_GATEWAY_METHODS) { params.unavailableGatewayMethods.delete(method); } @@ -758,8 +832,10 @@ export async function startGatewayPostAttachRuntime( } export const __testing = { + hasRestartSentinelFileFast, prewarmConfiguredPrimaryModel, prewarmConfiguredPrimaryModelWithTimeout, + refreshLatestUpdateRestartSentinelIfPresent, resolveGatewayMemoryStartupPolicy, schedulePrimaryModelPrewarm, shouldSkipStartupModelPrewarm, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 52e4de73200..711233444e0 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -60,28 +60,15 @@ import { listChannelPluginConfigTargetIds, pluginConfigTargetsChanged, } from "./plugin-channel-reload-targets.js"; -import { createGatewayAuxHandlers } from "./server-aux-handlers.js"; -import { createChannelManager } from "./server-channels.js"; import { resolveGatewayControlUiRootState } from "./server-control-ui-root.js"; import { createLazyGatewayCronState } from "./server-cron-lazy.js"; import { applyGatewayLaneConcurrency } from "./server-lanes.js"; import { createGatewayServerLiveState, type GatewayServerLiveState } from "./server-live-state.js"; import { GATEWAY_EVENTS } from "./server-methods-list.js"; import type { GatewayRequestHandlers } from "./server-methods/types.js"; -import { loadGatewayModelCatalog } from "./server-model-catalog.js"; -import { bootstrapGatewayNetworkRuntime } from "./server-network-runtime.js"; -import { createGatewayNodeSessionRuntime } from "./server-node-session-runtime.js"; import { setFallbackGatewayContextResolver } from "./server-plugins.js"; import type { GatewayPluginReloadResult } from "./server-reload-handlers.js"; -import { createGatewayRequestContext } from "./server-request-context.js"; -import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; -import { - activateGatewayScheduledServices, - startGatewayCronWithLogging, - startGatewayRuntimeServices, -} from "./server-runtime-services.js"; import { createGatewayRuntimeState } from "./server-runtime-state.js"; -import { startGatewayEventSubscriptions } from "./server-runtime-subscriptions.js"; import { resolveSessionKeyForRun } from "./server-session-key.js"; import { enforceSharedGatewaySessionGenerationForConfigWrite, @@ -104,7 +91,6 @@ import { startGatewayPostAttachRuntime, } from "./server-startup.js"; import { createWizardSessionTracker } from "./server-wizard-sessions.js"; -import { attachGatewayWsHandlers } from "./server-ws-runtime.js"; import { createGatewayEventLoopHealthMonitor } from "./server/event-loop-health.js"; import { getHealthCache, @@ -172,6 +158,17 @@ function loadGatewayCloseModule(): Promise { return gatewayCloseModulePromise; } +type LoadGatewayModelCatalog = typeof import("./server-model-catalog.js").loadGatewayModelCatalog; + +let gatewayModelCatalogModulePromise: Promise | null = + null; + +const loadGatewayModelCatalog: LoadGatewayModelCatalog = async (...args) => { + gatewayModelCatalogModulePromise ??= import("./server-model-catalog.js"); + const mod = await gatewayModelCatalogModulePromise; + return mod.loadGatewayModelCatalog(...args); +}; + const logHealth = log.child("health"); const logCron = log.child("cron"); const logReload = log.child("reload"); @@ -490,6 +487,7 @@ export async function startGatewayServer( port = 18789, opts: GatewayServerOptions = {}, ): Promise { + const { bootstrapGatewayNetworkRuntime } = await import("./server-network-runtime.js"); bootstrapGatewayNetworkRuntime(); const minimalTestGateway = @@ -660,8 +658,9 @@ export async function startGatewayServer( ...listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []), ]), ); - const runtimeConfig = await startupTrace.measure("runtime.config", () => - resolveGatewayRuntimeConfig({ + const runtimeConfig = await startupTrace.measure("runtime.config", async () => { + const { resolveGatewayRuntimeConfig } = await import("./server-runtime-config.js"); + return resolveGatewayRuntimeConfig({ cfg: cfgAtStart, port, bind: opts.bind, @@ -671,8 +670,8 @@ export async function startGatewayServer( openResponsesEnabled: opts.openResponsesEnabled, auth: opts.auth, tailscale: opts.tailscale, - }), - ); + }); + }); const { bindHost, controlUiEnabled, @@ -755,6 +754,7 @@ export async function startGatewayServer( const readinessEventLoopHealth = createGatewayEventLoopHealthMonitor(); let startupSidecarsReady = minimalTestGateway; let startupPendingReason = "startup-sidecars"; + const { createChannelManager } = await import("./server-channels.js"); const channelManager = createChannelManager({ getRuntimeConfig: () => applyPluginAutoEnable({ @@ -832,6 +832,7 @@ export async function startGatewayServer( getReadiness, }), ); + const { createGatewayNodeSessionRuntime } = await import("./server-node-session-runtime.js"); const { nodeRegistry, nodePresenceTimers, @@ -980,6 +981,10 @@ export async function startGatewayServer( getActiveTaskCount = earlyRuntime.getActiveTaskCount; runtimeState.skillsChangeUnsub = earlyRuntime.skillsChangeUnsub; + const [{ startGatewayEventSubscriptions }, gatewayRuntimeServices] = await Promise.all([ + import("./server-runtime-subscriptions.js"), + import("./server-runtime-services.js"), + ]); Object.assign( runtimeState, startGatewayEventSubscriptions({ @@ -999,7 +1004,7 @@ export async function startGatewayServer( Object.assign( runtimeState, - startGatewayRuntimeServices({ + gatewayRuntimeServices.startGatewayRuntimeServices({ minimalTestGateway, cfgAtStart, channelManager, @@ -1007,6 +1012,7 @@ export async function startGatewayServer( }), ); + const { createGatewayAuxHandlers } = await import("./server-aux-handlers.js"); const { execApprovalManager, pluginApprovalManager, extraHandlers } = createGatewayAuxHandlers({ log, activateRuntimeSecrets, @@ -1179,6 +1185,7 @@ export async function startGatewayServer( const unavailableGatewayMethods = new Set( minimalTestGateway ? [] : STARTUP_UNAVAILABLE_GATEWAY_METHODS, ); + const { createGatewayRequestContext } = await import("./server-request-context.js"); const gatewayRequestContext = createGatewayRequestContext({ deps, runtimeState, @@ -1272,6 +1279,7 @@ export async function startGatewayServer( } } + const { attachGatewayWsHandlers } = await import("./server-ws-runtime.js"); attachGatewayWsHandlers({ wss, clients, @@ -1311,7 +1319,7 @@ export async function startGatewayServer( ) { return; } - const activated = activateGatewayScheduledServices({ + const activated = gatewayRuntimeServices.activateGatewayScheduledServices({ minimalTestGateway, cfgAtStart, deps, @@ -1445,7 +1453,7 @@ export async function startGatewayServer( runtimeState.dedupeCleanup = maintenance.dedupeCleanup; runtimeState.mediaCleanup = maintenance.mediaCleanup; } - startGatewayCronWithLogging({ + gatewayRuntimeServices.startGatewayCronWithLogging({ cron: runtimeState.cronState.cron, logCron, }); diff --git a/src/plugins/plugin-module-loader-cache.test.ts b/src/plugins/plugin-module-loader-cache.test.ts index acc35634c00..7bc5419f8a0 100644 --- a/src/plugins/plugin-module-loader-cache.test.ts +++ b/src/plugins/plugin-module-loader-cache.test.ts @@ -371,7 +371,7 @@ describe("getCachedPluginModuleLoader", () => { p.endsWith(".js") || p.endsWith(".mjs") || p.endsWith(".cjs"), tryNativeRequireJavaScriptModule: nativeStub, })); - const { getCachedPluginModuleLoader } = await importFreshModule< + const { getCachedPluginModuleLoader, getPluginModuleLoaderStats } = await importFreshModule< typeof import("./plugin-module-loader-cache.js") >(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-fastpath"); @@ -393,6 +393,13 @@ describe("getCachedPluginModuleLoader", () => { expect(nativeStub).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", { allowWindows: true, }); + expect(getPluginModuleLoaderStats()).toMatchObject({ + calls: 1, + nativeHits: 1, + nativeMisses: 0, + sourceTransformFallbacks: 0, + sourceTransformForced: 0, + }); }); it("falls back to source transform when the native-require helper declines", async () => { @@ -403,7 +410,7 @@ describe("getCachedPluginModuleLoader", () => { isJavaScriptModulePath: () => true, tryNativeRequireJavaScriptModule: () => ({ ok: false }), })); - const { getCachedPluginModuleLoader } = await importFreshModule< + const { getCachedPluginModuleLoader, getPluginModuleLoaderStats } = await importFreshModule< typeof import("./plugin-module-loader-cache.js") >(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-fallback"); @@ -418,6 +425,14 @@ describe("getCachedPluginModuleLoader", () => { const result = loader("/repo/dist/extensions/demo/api.js") as { fromSourceTransform: boolean }; expect(result.fromSourceTransform).toBe(true); expect(fromSourceTransformer).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js"); + expect(getPluginModuleLoaderStats()).toMatchObject({ + calls: 1, + nativeHits: 0, + nativeMisses: 1, + sourceTransformFallbacks: 1, + sourceTransformForced: 0, + topSourceTransformTargets: [{ target: "/repo/dist/extensions/demo/api.js", count: 1 }], + }); }); it("normalizes Windows absolute paths before creating and calling the source transformer", async () => { @@ -462,7 +477,7 @@ describe("getCachedPluginModuleLoader", () => { isJavaScriptModulePath: () => true, tryNativeRequireJavaScriptModule: nativeStub, })); - const { getCachedPluginModuleLoader } = await importFreshModule< + const { getCachedPluginModuleLoader, getPluginModuleLoaderStats } = await importFreshModule< typeof import("./plugin-module-loader-cache.js") >(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-opt-out"); @@ -482,6 +497,14 @@ describe("getCachedPluginModuleLoader", () => { // so its alias rewrites still apply; native require must not be consulted. expect(nativeStub).not.toHaveBeenCalled(); expect(fromSourceTransformer).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js"); + expect(getPluginModuleLoaderStats()).toMatchObject({ + calls: 1, + nativeHits: 0, + nativeMisses: 0, + sourceTransformFallbacks: 0, + sourceTransformForced: 1, + topSourceTransformTargets: [{ target: "/repo/dist/extensions/demo/api.js", count: 1 }], + }); }); it("normalizes Windows absolute paths when native loading is disabled", async () => { diff --git a/src/plugins/plugin-module-loader-cache.ts b/src/plugins/plugin-module-loader-cache.ts index aac07b4ca82..0ffe43000dc 100644 --- a/src/plugins/plugin-module-loader-cache.ts +++ b/src/plugins/plugin-module-loader-cache.ts @@ -34,8 +34,67 @@ export type PluginModuleLoaderCacheEntry = { cacheKey: string; scopedCacheKey: string; }; +export type PluginModuleLoaderStatsSnapshot = { + calls: number; + nativeHits: number; + nativeMisses: number; + sourceTransformForced: number; + sourceTransformFallbacks: number; + topSourceTransformTargets: Array<{ target: string; count: number }>; +}; const DEFAULT_PLUGIN_MODULE_LOADER_CACHE_ENTRIES = 128; +const MAX_TRACKED_SOURCE_TRANSFORM_TARGETS = 24; +const pluginModuleLoaderStats = { + calls: 0, + nativeHits: 0, + nativeMisses: 0, + sourceTransformForced: 0, + sourceTransformFallbacks: 0, + sourceTransformTargets: new Map(), +}; + +function recordSourceTransformTarget(target: string): void { + const current = pluginModuleLoaderStats.sourceTransformTargets.get(target) ?? 0; + pluginModuleLoaderStats.sourceTransformTargets.set(target, current + 1); + if (pluginModuleLoaderStats.sourceTransformTargets.size <= MAX_TRACKED_SOURCE_TRANSFORM_TARGETS) { + return; + } + let leastUsedTarget: string | undefined; + let leastUsedCount = Number.POSITIVE_INFINITY; + for (const [candidate, count] of pluginModuleLoaderStats.sourceTransformTargets) { + if (count < leastUsedCount) { + leastUsedTarget = candidate; + leastUsedCount = count; + } + } + if (leastUsedTarget) { + pluginModuleLoaderStats.sourceTransformTargets.delete(leastUsedTarget); + } +} + +export function getPluginModuleLoaderStats(): PluginModuleLoaderStatsSnapshot { + return { + calls: pluginModuleLoaderStats.calls, + nativeHits: pluginModuleLoaderStats.nativeHits, + nativeMisses: pluginModuleLoaderStats.nativeMisses, + sourceTransformForced: pluginModuleLoaderStats.sourceTransformForced, + sourceTransformFallbacks: pluginModuleLoaderStats.sourceTransformFallbacks, + topSourceTransformTargets: [...pluginModuleLoaderStats.sourceTransformTargets] + .toSorted((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, 8) + .map(([target, count]) => ({ target, count })), + }; +} + +export function resetPluginModuleLoaderStatsForTest(): void { + pluginModuleLoaderStats.calls = 0; + pluginModuleLoaderStats.nativeHits = 0; + pluginModuleLoaderStats.nativeMisses = 0; + pluginModuleLoaderStats.sourceTransformForced = 0; + pluginModuleLoaderStats.sourceTransformFallbacks = 0; + pluginModuleLoaderStats.sourceTransformTargets.clear(); +} export function createPluginModuleLoaderCache( maxEntries = DEFAULT_PLUGIN_MODULE_LOADER_CACHE_ENTRIES, @@ -139,11 +198,15 @@ function createPluginModuleLoader(params: { // jiti's alias rewriting to surface a narrow SDK slice), route every // target through jiti so those alias rewrites still apply. if (!params.tryNative) { - return ((target: string, ...rest: unknown[]) => - (getLoadWithSourceTransform() as (t: string, ...a: unknown[]) => unknown)( + return ((target: string, ...rest: unknown[]) => { + pluginModuleLoaderStats.calls += 1; + pluginModuleLoaderStats.sourceTransformForced += 1; + recordSourceTransformTarget(target); + return (getLoadWithSourceTransform() as (t: string, ...a: unknown[]) => unknown)( target, ...rest, - )) as PluginModuleLoader; + ); + }) as PluginModuleLoader; } // Otherwise prefer native require() for already-compiled JS artifacts // (the bundled plugin public surfaces shipped in dist/). jiti's transform @@ -153,10 +216,15 @@ function createPluginModuleLoader(params: { // async-module fallbacks `tryNativeRequireJavaScriptModule` declines to // handle. return ((target: string, ...rest: unknown[]) => { + pluginModuleLoaderStats.calls += 1; const native = tryNativeRequireJavaScriptModule(target, { allowWindows: true }); if (native.ok) { + pluginModuleLoaderStats.nativeHits += 1; return native.moduleExport; } + pluginModuleLoaderStats.nativeMisses += 1; + pluginModuleLoaderStats.sourceTransformFallbacks += 1; + recordSourceTransformTarget(target); return (getLoadWithSourceTransform() as (t: string, ...a: unknown[]) => unknown)( target, ...rest,