From 705bde459471fced7946e8b95e0ac10191f09fff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 06:02:13 +0100 Subject: [PATCH] perf(gateway): avoid jiti on native plugin loads --- CHANGELOG.md | 1 + scripts/load-channel-config-surface.ts | 22 ++++++++++- .../plugin-module-loader-cache.test.ts | 23 +++++++++++- src/plugins/plugin-module-loader-cache.ts | 37 ++++++++++++++++--- .../test-helpers/registry-jiti-mocks.ts | 8 ++++ 5 files changed, 82 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 954728f1dbe..1e3bcc562f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Gateway/config: stop Gateway startup and hot reload from auto-restoring invalid config; invalid config now fails closed and `openclaw doctor --fix` owns last-known-good repair. - Gateway/performance: lazy-load early runtime discovery and shutdown-hook helpers, defer maintenance timers until after readiness, and trim duplicate plugin auto-enable work during Gateway startup. - Gateway/performance: defer non-readiness sidecars until after the ready signal, avoid hot-path channel plugin barrel imports, and fast-path trusted bundled plugin metadata during Gateway startup. +- Gateway/performance: avoid importing `jiti` on native-loadable plugin startup paths, so compiled bundled plugin surfaces do not pay source-transform loader cost unless fallback loading is actually needed. - QA/Mantis: add a `pnpm openclaw qa mantis discord-smoke` runner and manual GitHub workflow that verify the Mantis Discord bot can see the configured guild/channel, post a smoke message, add a reaction, and upload artifacts. - QA/Mantis: add `pnpm openclaw qa mantis slack-desktop-smoke` to run Slack live QA inside a Crabbox VNC desktop, open Slack Web, and capture desktop screenshots beside the Slack QA artifacts. - QA/Mantis: pass the runtime env through desktop-browser Crabbox and artifact-copy child commands, so embedded Mantis callers can provide Crabbox credentials without mutating the parent process. Thanks @vincentkoc. diff --git a/scripts/load-channel-config-surface.ts b/scripts/load-channel-config-surface.ts index bf2ac3534c3..28a7985430d 100644 --- a/scripts/load-channel-config-surface.ts +++ b/scripts/load-channel-config-surface.ts @@ -1,7 +1,8 @@ import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { createJiti } from "jiti"; +import type { createJiti } from "jiti"; import { buildChannelConfigSchema } from "../src/channels/plugins/config-schema.js"; import { buildPluginLoaderJitiOptions, @@ -9,6 +10,23 @@ import { resolvePluginSdkScopedAliasMap, } from "../src/plugins/sdk-alias.js"; +type CreateJiti = typeof createJiti; + +const requireForJiti = createRequire(import.meta.url); +let createJitiLoaderFactory: CreateJiti | undefined; + +function loadCreateJitiLoaderFactory(): CreateJiti { + if (createJitiLoaderFactory) { + return createJitiLoaderFactory; + } + const loaded = requireForJiti("jiti") as { createJiti?: CreateJiti }; + if (typeof loaded.createJiti !== "function") { + throw new Error("jiti module did not export createJiti"); + } + createJitiLoaderFactory = loaded.createJiti; + return createJitiLoaderFactory; +} + function isBuiltChannelConfigSchema( value: unknown, ): value is { schema: Record; uiHints?: Record } { @@ -137,7 +155,7 @@ export async function loadChannelConfigSurfaceModule( pluginSdkResolution: "src", }), }; - const jiti = createJiti(import.meta.url, { + const jiti = loadCreateJitiLoaderFactory()(import.meta.url, { ...buildPluginLoaderJitiOptions(aliasMap), interopDefault: true, tryNative: false, diff --git a/src/plugins/plugin-module-loader-cache.test.ts b/src/plugins/plugin-module-loader-cache.test.ts index 7bc5419f8a0..4b29754583a 100644 --- a/src/plugins/plugin-module-loader-cache.test.ts +++ b/src/plugins/plugin-module-loader-cache.test.ts @@ -1,5 +1,6 @@ import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginModuleLoaderFactory } from "./plugin-module-loader-cache.js"; afterEach(() => { vi.restoreAllMocks(); @@ -22,7 +23,17 @@ async function loadCachedPluginModuleLoader(scope: string) { typeof import("./plugin-module-loader-cache.js") >(import.meta.url, `./plugin-module-loader-cache.js?scope=${scope}`); - return { createJiti, getCachedPluginModuleLoader }; + const getCachedPluginModuleLoaderWithMock: typeof getCachedPluginModuleLoader = (params) => + getCachedPluginModuleLoader({ + ...params, + createLoader: params.createLoader ?? asPluginModuleLoaderFactory(createJiti), + }); + + return { createJiti, getCachedPluginModuleLoader: getCachedPluginModuleLoaderWithMock }; +} + +function asPluginModuleLoaderFactory(factory: unknown): PluginModuleLoaderFactory { + return factory as PluginModuleLoaderFactory; } describe("getCachedPluginModuleLoader", () => { @@ -361,7 +372,8 @@ describe("getCachedPluginModuleLoader", () => { it("serves compiled .js targets from native require without invoking the module loader", async () => { const fromSourceTransformer = vi.fn(); const createJiti = vi.fn(() => fromSourceTransformer); - vi.doMock("jiti", () => ({ createJiti })); + const jitiModuleFactory = vi.fn(() => ({ createJiti })); + vi.doMock("jiti", jitiModuleFactory); const nativeStub = vi.fn((target: string) => ({ ok: true as const, moduleExport: { loadedFrom: target }, @@ -381,12 +393,14 @@ describe("getCachedPluginModuleLoader", () => { modulePath: "/repo/dist/extensions/demo/api.js", importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", + createLoader: asPluginModuleLoaderFactory(createJiti), }); const result = loader("/repo/dist/extensions/demo/api.js") as { loadedFrom: string }; expect(result.loadedFrom).toBe("/repo/dist/extensions/demo/api.js"); // Jiti should not be constructed or invoked for .js targets that // `tryNativeRequireJavaScriptModule` resolves. + expect(jitiModuleFactory).not.toHaveBeenCalled(); expect(createJiti).not.toHaveBeenCalled(); expect(fromSourceTransformer).not.toHaveBeenCalled(); // allowWindows must be passed so the native fast path works on Windows too. @@ -420,6 +434,7 @@ describe("getCachedPluginModuleLoader", () => { modulePath: "/repo/dist/extensions/demo/api.js", importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", + createLoader: asPluginModuleLoaderFactory(createJiti), }); const result = loader("/repo/dist/extensions/demo/api.js") as { fromSourceTransform: boolean }; @@ -455,6 +470,7 @@ describe("getCachedPluginModuleLoader", () => { importerUrl: "file:///C:/Users/alice/openclaw/dist/src/plugins/public-surface-loader.js", loaderFilename: "C:\\Users\\alice\\openclaw\\dist\\extensions\\feishu\\api.js", tryNative: true, + createLoader: asPluginModuleLoaderFactory(createJiti), }); loader("C:\\Users\\alice\\openclaw\\dist\\extensions\\feishu\\api.js"); @@ -489,6 +505,7 @@ describe("getCachedPluginModuleLoader", () => { loaderFilename: "file:///repo/src/plugins/bundled-capability-runtime.ts", aliasMap: { "openclaw/plugin-sdk": "/repo/shim.js" }, tryNative: false, + createLoader: asPluginModuleLoaderFactory(createJiti), }); const result = loader("/repo/dist/extensions/demo/api.js") as { fromSourceTransform: boolean }; @@ -528,6 +545,7 @@ describe("getCachedPluginModuleLoader", () => { importerUrl: "file:///C:/Users/alice/openclaw/src/plugins/loader.ts", loaderFilename: "C:\\Users\\alice\\openclaw\\extensions\\feishu\\api.ts", tryNative: false, + createLoader: asPluginModuleLoaderFactory(createJiti), }); loader("C:\\Users\\alice\\openclaw\\extensions\\feishu\\api.ts"); @@ -560,6 +578,7 @@ describe("getCachedPluginModuleLoader", () => { modulePath: "/repo/dist/extensions/demo/api.js", importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", + createLoader: asPluginModuleLoaderFactory(createJiti), }); const loose = loader as unknown as (t: string, ...a: unknown[]) => unknown; diff --git a/src/plugins/plugin-module-loader-cache.ts b/src/plugins/plugin-module-loader-cache.ts index 0ffe43000dc..823465526d0 100644 --- a/src/plugins/plugin-module-loader-cache.ts +++ b/src/plugins/plugin-module-loader-cache.ts @@ -1,4 +1,5 @@ -import { createJiti } from "jiti"; +import { createRequire } from "node:module"; +import type { createJiti } from "jiti"; import { toSafeImportPath } from "../shared/import-specifier.js"; import { tryNativeRequireJavaScriptModule } from "./native-module-require.js"; import { PluginLruCache } from "./plugin-cache-primitives.js"; @@ -45,6 +46,9 @@ export type PluginModuleLoaderStatsSnapshot = { const DEFAULT_PLUGIN_MODULE_LOADER_CACHE_ENTRIES = 128; const MAX_TRACKED_SOURCE_TRANSFORM_TARGETS = 24; +const JITI_FACTORY_OVERRIDE_KEY = Symbol.for("openclaw.pluginModuleLoaderJitiFactoryOverride"); +const requireForJiti = createRequire(import.meta.url); +let createJitiLoaderFactory: PluginModuleLoaderFactory | undefined; const pluginModuleLoaderStats = { calls: 0, nativeHits: 0, @@ -96,6 +100,26 @@ export function resetPluginModuleLoaderStatsForTest(): void { pluginModuleLoaderStats.sourceTransformTargets.clear(); } +function loadCreateJitiLoaderFactory(): PluginModuleLoaderFactory { + const override = ( + globalThis as typeof globalThis & { + [JITI_FACTORY_OVERRIDE_KEY]?: PluginModuleLoaderFactory; + } + )[JITI_FACTORY_OVERRIDE_KEY]; + if (override) { + return override; + } + if (createJitiLoaderFactory) { + return createJitiLoaderFactory; + } + const loaded = requireForJiti("jiti") as { createJiti?: PluginModuleLoaderFactory }; + if (typeof loaded.createJiti !== "function") { + throw new Error("jiti module did not export createJiti"); + } + createJitiLoaderFactory = loaded.createJiti; + return createJitiLoaderFactory; +} + export function createPluginModuleLoaderCache( maxEntries = DEFAULT_PLUGIN_MODULE_LOADER_CACHE_ENTRIES, ): PluginModuleLoaderCache { @@ -166,10 +190,13 @@ function createLazySourceTransformLoader(params: { if (loadWithSourceTransform) { return loadWithSourceTransform; } - const jitiLoader = (params.createLoader ?? createJiti)(params.loaderFilename, { - ...buildPluginLoaderJitiOptions(params.aliasMap), - tryNative: params.tryNative, - }); + const jitiLoader = (params.createLoader ?? loadCreateJitiLoaderFactory())( + params.loaderFilename, + { + ...buildPluginLoaderJitiOptions(params.aliasMap), + tryNative: params.tryNative, + }, + ); loadWithSourceTransform = new Proxy(jitiLoader, { apply(target, thisArg, argArray) { const [first, ...rest] = argArray as [unknown, ...unknown[]]; diff --git a/src/plugins/test-helpers/registry-jiti-mocks.ts b/src/plugins/test-helpers/registry-jiti-mocks.ts index 7608460fe5e..07f0e5a4878 100644 --- a/src/plugins/test-helpers/registry-jiti-mocks.ts +++ b/src/plugins/test-helpers/registry-jiti-mocks.ts @@ -6,6 +6,9 @@ const registryJitiMocks = vi.hoisted(() => ({ loadPluginManifestRegistry: vi.fn(), loadPluginRegistrySnapshot: vi.fn(), })); +const pluginModuleLoaderJitiFactoryOverrideKey = Symbol.for( + "openclaw.pluginModuleLoaderJitiFactoryOverride", +); vi.mock("jiti", () => ({ createJiti: (...args: Parameters) => @@ -43,6 +46,11 @@ vi.mock("../plugin-registry.js", async (importOriginal) => { }; }); export function resetRegistryJitiMocks(): void { + ( + globalThis as typeof globalThis & { + [pluginModuleLoaderJitiFactoryOverrideKey]?: typeof registryJitiMocks.createJiti; + } + )[pluginModuleLoaderJitiFactoryOverrideKey] = registryJitiMocks.createJiti; registryJitiMocks.createJiti.mockReset(); registryJitiMocks.discoverOpenClawPlugins.mockReset(); registryJitiMocks.loadPluginManifestRegistry.mockReset();