perf(plugins): share host code identity for bundled TypeScript plugins (#163639)

Bundled plugins only shared the host loader when their entry was compiled
JavaScript, so a source checkout captured all 87 bundled packages (copy, hash,
and dependency materialization) on every prepared model runtime publication:
79.5 s of plugin discovery per agent turn, which exceeded the 120 s budget on
a loaded host. Bundled plugins now take the shared host loader regardless of
entry extension, the edited-code warning keys on the canonical export so it
survives loader interop wrappers, and the docs describe source and compiled
bundled identity together.

Discovery for the live embedded-runner case fell from 79.5 s to 12-13 s and the
case from ~100 s to ~25 s on the same host. Editing a bundled plugin's
TypeScript source now needs a Gateway restart, matching compiled bundled code;
installed, external, workspace-path, and standalone plugins keep captured
source reload semantics.
This commit is contained in:
Peter Steinberger 2026-10-02 10:05:16 -07:00 • committed by GitHub
parent f8015dfe3f
commit b5a41c2001
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 192 additions and 172 deletions

View file

@ -185,11 +185,13 @@ The receipt can also include cleanup warnings. Modules and native libraries may
remain loaded after their registrations are removed.
Bundled plugins can reload while preserving their enabled or disabled policy.
Compiled bundled plugins reuse their process-loaded code when their registrations
reload. If the plugin's files changed while its original module remains loaded,
Bundled plugins, including TypeScript source entries, reuse their process-loaded
code when their registrations reload. If the plugin's files changed while its
original module remains loaded,
the result reports `restartRequired: true` with a warning, and CLI and tool
output explain that a Gateway restart is needed to load edited code. Reload does
not rebuild compiled bundled code; source installations also need a build.
not rebuild bundled code. Rebuild before restarting when the installation loads
compiled output.
External captured sources return `restartRequired: false` after replacement.
Reloading unchanged bundled files also returns `restartRequired: false`; channel
and service registrations can be replaced without restarting the Gateway.

View file

@ -216,13 +216,14 @@ unchanged plugin preserves that proof; changing its selected runtime files
invalidates it.
A managed runtime instance owns its module results, registered callables, and
runtime-store slots. With Node's synchronous module hooks, it also owns a captured
source artifact. Package plugins capture their package inputs when the instance
is created. Standalone files capture their entry and statically known inputs
without copying the surrounding workspace. Compiled bundled runtime and setup
modules share the host's code identity; each inventory still owns its registered
callbacks and cleanup. Replacing that compiled code requires a build and Gateway
restart. Conditional package aliases retain their package metadata, and native
runtime-store slots. Non-bundled instances also own a captured source artifact.
Package plugins capture their package inputs when the instance is created.
Standalone files capture their entry and statically known inputs
without copying the surrounding workspace. Bundled runtime and setup modules,
including TypeScript source entries, share the host's code identity; each inventory
still owns its registered callbacks and cleanup. Loading edited bundled code requires
a Gateway restart; rebuild first when the installation loads compiled output.
Conditional package aliases retain their package metadata, and native
Node conditions, including `module-sync`, select the target from that captured metadata.
Source inspection uses the same synchronous-module condition without evaluating plugin code.
Captured source retains the difference between authored imports and require calls, so Bun's

View file

@ -486,7 +486,7 @@ describe("capability loading from a Gateway generation", () => {
});
});
it("retains catalog factories within a cache generation and replaces them on source reload", () => {
it("recreates bundled catalog providers while retaining process-loaded factory code", () => {
withSpeechFixture((fixture) => {
fixture.config.plugins = { enabled: true };
const { pluginDir, runtimeImported } = declareCapabilityCatalog(fixture);
@ -518,7 +518,7 @@ describe("capability loading from a Gateway generation", () => {
withPluginRuntimeRegistryScope(current, () => {
const replacement = speechProviders(fixture.config)[0];
expect(replacement).not.toBe(first);
expect(replacement?.label).toBe("replacement:catalog");
expect(replacement?.label).toBe("source:catalog");
});
expect(fs.existsSync(runtimeImported)).toBe(false);
});

View file

@ -6,7 +6,6 @@ import type { JitiOptions, JitiResolveOptions } from "jiti";
import { isPathInside } from "../infra/path-guards.js";
import { createJiti } from "./jiti-factory.js";
import {
isJavaScriptModulePath,
resolvePluginLoaderTryNative,
isPluginSourceModulePath,
supportsBunRuntimeOnResolveTargets,
@ -35,12 +34,11 @@ import { preparePluginLoaderAliases, isPluginSdkAliasSpecifier } from "./sdk-ali
/** Runtime and setup share code identity policy while keeping separate instance authority. */
export function bindPluginInstanceModuleLoader(params: PluginInstanceModuleLoaderParams): void {
const cache = getPluginCache();
if (params.origin === "bundled" && isJavaScriptModulePath(params.source)) {
if (params.origin === "bundled") {
if (params.expectedSourceDigest !== undefined) {
throw new Error("Source digest validation is not applicable to core-bundled runtime modules");
}
// Core-shipped code keeps process identity. Recapturing it creates native ESM
// module jobs that Node retains after the inventory and its callbacks retire.
// Recaptured bundled code leaves native ESM jobs alive after its inventory retires.
let loader: PluginModuleLoader;
if (params.createHostModuleLoader) {
loader = params.createHostModuleLoader();

View file

@ -251,8 +251,46 @@ it("captures quiesced code without admitting calls and refuses capture after dis
expect((restored.loadModule(entry) as FixtureModule).late()).toBe("original late import");
});
it("shares bundled TypeScript code without captures and warns when edited code needs a restart", () => {
const root = temp.make("bundled-source-identity-");
const captures = temp.make("bundled-source-captures-");
const entry = path.join(root, "index.ts");
const writeEntry = (value: number) =>
fs.writeFileSync(
entry,
`import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/plugin-entry";
export const value: number = ${value};
export const schema = emptyPluginConfigSchema().jsonSchema;`,
);
const load = () => {
const instance = new PluginInstance("bundled-source");
instances.push(instance);
const value = withPluginSourceCaptureDirectory(captures, () => {
bindPluginInstanceModuleLoader({
instance,
origin: "bundled",
source: entry,
rootDir: root,
});
return instance.loadModule(entry);
});
expect(instance.sourceDigest).toBeUndefined();
expect(fs.readdirSync(captures)).toEqual([]);
expect(value).toMatchObject({ value: 1, schema: { type: "object" } });
return instance;
};
writeEntry(1);
expect(getSharedPluginCodeReloadWarning(load())).toBeUndefined();
resetPluginCache();
writeEntry(2);
expect(getSharedPluginCodeReloadWarning(load())).toBe(
"Bundled plugin code remains loaded. Restart the Gateway to load edited code.",
);
});
it.each(["cjs", "mjs"])(
"reuses compiled bundled %s code while giving recovery fresh callback authority",
"reuses bundled %s code while giving recovery fresh callback authority",
async (extension) => {
const root = temp.make("bundled-recovery-identity-");
const entry = path.join(root, `index.${extension}`);

View file

@ -18,7 +18,7 @@ const state = resolveGlobalSingleton(Symbol.for("openclaw.sharedPluginModuleIden
}));
const log = createSubsystemLogger("plugins");
/** The loaded module owns its original artifact identity across registry replacements. */
/** Canonical exports retain file identity across Jiti wrappers and registry replacements. */
export function bindSharedPluginModuleLoader(params: {
instance: PluginModuleLoaderOwner;
rootDir: string;
@ -57,11 +57,13 @@ function createSharedModuleBinding(
if ((typeof value !== "object" || value === null) && typeof value !== "function") {
return value;
}
let identities = state.sources.get(value);
if (!identities) {
identities = new Map();
state.sources.set(value, identities);
}
const exported: unknown = Reflect.get(value, "default");
const identity =
exported && (typeof exported === "object" || typeof exported === "function")
? exported
: value;
const identities = state.sources.get(identity) ?? new Map<string, string | undefined>();
state.sources.set(identity, identities);
if (!identities.has(root)) {
identities.set(root, currentIdentity);
} else {
@ -70,8 +72,8 @@ function createSharedModuleBinding(
state.warnings.set(
target,
!loadedIdentity || !currentIdentity
? "Compiled bundled plugin code remains loaded and its files could not be verified. Restart the Gateway to load edited code."
: "Compiled bundled plugin code remains loaded. Restart the Gateway to load edited code.",
? "Bundled plugin code remains loaded and its files could not be verified. Restart the Gateway to load edited code."
: "Bundled plugin code remains loaded. Restart the Gateway to load edited code.",
);
}
}

View file

@ -56,11 +56,11 @@ function preparePolicy(root: string, version: string) {
const canonicalize = () => canonicalizeProviderModelId("policy-fixture", "authored-id");
describe("provider policy generations", () => {
it("reuses published policies and resolves replacement and retained generations through their owners", () => {
it("reuses bundled policy libraries across registry selection and retirement", () => {
const root = tempDirs.make("openclaw-policy-generation-");
const firstCache = createPluginCache();
const { registry: first, record: firstRecord } = withPluginCache(firstCache, () =>
preparePolicy(root, "first"),
preparePolicy(path.join(root, "first"), "first"),
);
stageActivePluginRegistry(first, "first", "default");
const candidates = vi.spyOn(
@ -74,7 +74,9 @@ describe("provider policy generations", () => {
expect(candidates).toHaveBeenCalledTimes(1);
const nextCache = createPluginCache();
const { registry: next } = withPluginCache(nextCache, () => preparePolicy(root, "replacement"));
const { registry: next } = withPluginCache(nextCache, () =>
preparePolicy(path.join(root, "replacement"), "replacement"),
);
stageActivePluginRegistry(next, "replacement", "default");
const readNext = () =>
withPluginCache(nextCache, () => withPluginRuntimeRegistryScope(next, canonicalize));
@ -97,13 +99,7 @@ describe("provider policy generations", () => {
expect(candidates).toHaveBeenCalledTimes(6);
getPluginInstance(firstRecord)!.quiesce();
expect(() =>
withPluginCache(firstCache, () =>
withPluginRuntimeRegistryScope(first, () =>
resolveDirectBundledProviderPolicySurface("policy-fixture"),
),
),
).toThrow("Plugin policy-fixture was reloaded or disabled");
expect(readFirst()).toBe("first");
expect(candidates).toHaveBeenCalledTimes(6);
});
@ -116,7 +112,7 @@ describe("provider policy generations", () => {
withPluginRuntimeRegistryScope(registry, () => {
expect(resolveDirectBundledProviderPolicySurface("policy-fixture")).toBeNull();
const { registry: prepared } = preparePolicy(
path.join(root, "policy-fixture"),
path.join(root, "selected", "policy-fixture"),
"new-owner",
);
registry.plugins.push(...prepared.plugins);

View file

@ -117,7 +117,7 @@ function prepare(
}
describe("managed plugin public surfaces", () => {
it("keeps a prepared source overlay's registered web provider under a merged environment", async () => {
it("keeps a prepared source overlay's setup web provider under a merged environment", async () => {
const parent = temp.make("openclaw-web-provider-overlay-");
const pluginId = "overlay-web-provider";
const bundledDir = path.join(parent, "stock");
@ -146,7 +146,6 @@ describe("managed plugin public surfaces", () => {
vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_PLUGINS", "0");
// Secret refresh copies env while retaining the selected manifest and live source generation.
const env = { ...process.env };
fs.rmSync(overlayArtifact);
const providers = resolvePluginWebSearchProviders({
env,
mode: "setup",
@ -166,7 +165,7 @@ describe("managed plugin public surfaces", () => {
const provider = expectDefined(providers[0], "retained overlay web provider");
expect(provider.getCredentialValue()).toBe("registered-overlay");
await active.instance.dispose();
expect(() => provider.getCredentialValue()).toThrow(/reloaded|disabled|retiring/);
expect(provider.getCredentialValue()).toBe("registered-overlay");
});
it.each(["id", "folder", "channel"] as const)(
@ -216,136 +215,127 @@ describe("managed plugin public surfaces", () => {
},
);
it.each(["global", "bundled"] as const)(
"resolves native filesystem dist APIs without losing their managed owner (%s)",
(origin) => {
const hooks = Reflect.get(Module, "registerHooks");
Reflect.set(Module, "registerHooks", undefined);
try {
const root = fs.realpathSync(temp.make("openclaw-native-public-dist-"));
writeSource(root, "library");
fs.rmSync(path.join(root, "api.ts"));
fs.rmSync(path.join(root, "runtime-api.ts"));
fs.mkdirSync(path.join(root, "dist"));
fs.writeFileSync(path.join(root, "dist/api.js"), 'export { read } from "../state.js";');
const active = prepare(root, "native-dist-fixture", origin);
active.entry.default.register("registered");
active.publish();
const api = loadPluginPublicArtifactModuleSync<{ read(): string }>({
pluginRoot: root,
artifactBasename: "api.js",
origin,
});
expect(api.read()).toBe("registered");
expect(getPluginValueInstance(api)).toBe(active.instance);
} finally {
Reflect.set(Module, "registerHooks", hooks);
}
},
);
it.each(["global", "bundled"] as const)(
"shares registration state with captured APIs, refreshes a retained facade, and fences old exports (%s)",
async (origin) => {
const root = fs.realpathSync(temp.make("openclaw-public-generation-"));
writeSource(root, "source-one");
const first = prepare(root, "surface-fixture", origin);
first.entry.default.register("registered-one");
first.publish();
const loadApi = () =>
loadPluginPublicArtifactModuleSync<PublicApi>({
pluginRoot: root,
artifactBasename: "api.js",
});
const loadFacade = () =>
loadBundledPluginPublicSurfaceModuleSyncCore<PublicApi>({
dirName: first.record.id,
artifactBasename: "runtime-api.js",
});
const lazy = createLazyFacadeObjectValue(() => loadFacade().view);
const frozen = createLazyFacadeObjectValue(() => loadFacade().view.frozen);
// Neither public entry has executed yet. The captured graph survives source deletion.
it("resolves installed native filesystem dist APIs without losing their managed owner", () => {
const hooks = Reflect.get(Module, "registerHooks");
Reflect.set(Module, "registerHooks", undefined);
try {
const root = fs.realpathSync(temp.make("openclaw-native-public-dist-"));
writeSource(root, "library");
fs.rmSync(path.join(root, "api.ts"));
fs.rmSync(path.join(root, "runtime-api.ts"));
fs.writeFileSync(
path.join(root, "state.ts"),
'export const read = () => "uncommitted edit";',
);
const originalApi = loadApi();
const originalRead = originalApi.read;
expect(originalRead()).toBe("registered-one");
expect(loadFacade().read()).toBe("registered-one");
expect(lazy.read()).toBe("registered-one");
const retainedLazyRead = lazy.read;
for (const [property, descriptor] of [
["read", { configurable: false, writable: false }],
["added", { value: "fixed" }],
[Symbol("fixed"), { get: () => "fixed", configurable: false }],
] as const) {
const before = Object.getOwnPropertyDescriptor(originalApi.view, property);
expect(Reflect.defineProperty(lazy, property, descriptor)).toBe(false);
expect(Object.getOwnPropertyDescriptor(originalApi.view, property)).toEqual(before);
}
expect(Reflect.set(lazy, "note", "first")).toBe(true);
expect(Reflect.defineProperty(lazy, "note", { value: "updated" })).toBe(true);
expect(Reflect.get(originalApi.view, "note")).toBe("updated");
const configurable = Symbol("configurable");
expect(
Reflect.defineProperty(lazy, configurable, { value: "allowed", configurable: true }),
).toBe(true);
expect(Reflect.get(originalApi.view, configurable)).toBe("allowed");
expect(Object.getOwnPropertyDescriptor(frozen, "length")).toMatchObject({
value: 1,
configurable: true,
fs.mkdirSync(path.join(root, "dist"));
fs.writeFileSync(path.join(root, "dist/api.js"), 'export { read } from "../state.js";');
const active = prepare(root, "native-dist-fixture");
active.entry.default.register("registered");
active.publish();
const api = loadPluginPublicArtifactModuleSync<{ read(): string }>({
pluginRoot: root,
artifactBasename: "api.js",
origin: "global",
});
expect(Object.keys(frozen)).toEqual(["0"]);
for (const operation of ["preventExtensions", "seal", "freeze"] as const) {
const applyIntegrity: (value: object) => object = Object[operation];
expect(() => applyIntegrity(lazy)).toThrow(TypeError);
expect(Object.isExtensible(lazy)).toBe(true);
expect(lazy.read()).toBe("registered-one");
}
const probes = [
vi.spyOn(fs, "existsSync"),
vi.spyOn(fs, "realpathSync"),
vi.spyOn(fs, "statSync"),
vi.spyOn(fs, "openSync"),
];
expect(loadApi()).toBe(originalApi);
expect(api.read()).toBe("registered");
expect(getPluginValueInstance(api)).toBe(active.instance);
} finally {
Reflect.set(Module, "registerHooks", hooks);
}
});
it("shares installed registration state with captured APIs, refreshes a retained facade, and fences old exports", async () => {
const root = fs.realpathSync(temp.make("openclaw-public-generation-"));
writeSource(root, "source-one");
const first = prepare(root);
first.entry.default.register("registered-one");
first.publish();
const loadApi = () =>
loadPluginPublicArtifactModuleSync<PublicApi>({
pluginRoot: root,
artifactBasename: "api.js",
});
const loadFacade = () =>
loadBundledPluginPublicSurfaceModuleSyncCore<PublicApi>({
dirName: first.record.id,
artifactBasename: "runtime-api.js",
});
const lazy = createLazyFacadeObjectValue(() => loadFacade().view);
const frozen = createLazyFacadeObjectValue(() => loadFacade().view.frozen);
// Neither public entry has executed yet. The captured graph survives source deletion.
fs.rmSync(path.join(root, "api.ts"));
fs.rmSync(path.join(root, "runtime-api.ts"));
fs.writeFileSync(path.join(root, "state.ts"), 'export const read = () => "uncommitted edit";');
const originalApi = loadApi();
const originalRead = originalApi.read;
expect(originalRead()).toBe("registered-one");
expect(loadFacade().read()).toBe("registered-one");
expect(lazy.read()).toBe("registered-one");
const retainedLazyRead = lazy.read;
for (const [property, descriptor] of [
["read", { configurable: false, writable: false }],
["added", { value: "fixed" }],
[Symbol("fixed"), { get: () => "fixed", configurable: false }],
] as const) {
const before = Object.getOwnPropertyDescriptor(originalApi.view, property);
expect(Reflect.defineProperty(lazy, property, descriptor)).toBe(false);
expect(Object.getOwnPropertyDescriptor(originalApi.view, property)).toEqual(before);
}
expect(Reflect.set(lazy, "note", "first")).toBe(true);
expect(Reflect.defineProperty(lazy, "note", { value: "updated" })).toBe(true);
expect(Reflect.get(originalApi.view, "note")).toBe("updated");
const configurable = Symbol("configurable");
expect(
Reflect.defineProperty(lazy, configurable, { value: "allowed", configurable: true }),
).toBe(true);
expect(Reflect.get(originalApi.view, configurable)).toBe("allowed");
expect(Object.getOwnPropertyDescriptor(frozen, "length")).toMatchObject({
value: 1,
configurable: true,
});
expect(Object.keys(frozen)).toEqual(["0"]);
for (const operation of ["preventExtensions", "seal", "freeze"] as const) {
const applyIntegrity: (value: object) => object = Object[operation];
expect(() => applyIntegrity(lazy)).toThrow(TypeError);
expect(Object.isExtensible(lazy)).toBe(true);
expect(lazy.read()).toBe("registered-one");
for (const probe of probes) {
expect(probe).not.toHaveBeenCalled();
probe.mockRestore();
}
}
const probes = [
vi.spyOn(fs, "existsSync"),
vi.spyOn(fs, "realpathSync"),
vi.spyOn(fs, "statSync"),
vi.spyOn(fs, "openSync"),
];
expect(loadApi()).toBe(originalApi);
expect(lazy.read()).toBe("registered-one");
for (const probe of probes) {
expect(probe).not.toHaveBeenCalled();
probe.mockRestore();
}
await first.instance.dispose();
writeSource(root, "source-two", "ts", 2);
const second = prepare(root, "surface-fixture", origin);
second.entry.default.register("registered-two");
second.publish();
expect(loadApi().read()).toBe("registered-two");
expect(lazy.read()).toBe("registered-two");
expect(frozen.length).toBe(2);
expect(Object.keys(frozen)).toEqual(["0", "1"]);
expect(frozen[1]?.()).toBe("registered-two");
expect(originalRead).toThrow(/reloaded|disabled|retiring/);
expect(retainedLazyRead).toThrow(/reloaded|disabled|retiring/);
expect(() =>
withPluginRuntimeGatewayRequestScope(
{ pluginRegistry: first.registry, isWebchatConnect: () => false },
loadApi,
),
).toThrow(MissingPublicSurfaceError);
await first.instance.dispose();
writeSource(root, "source-two", "ts", 2);
const second = prepare(root);
second.entry.default.register("registered-two");
second.publish();
expect(loadApi().read()).toBe("registered-two");
expect(lazy.read()).toBe("registered-two");
expect(frozen.length).toBe(2);
expect(Object.keys(frozen)).toEqual(["0", "1"]);
expect(frozen[1]?.()).toBe("registered-two");
expect(originalRead).toThrow(/reloaded|disabled|retiring/);
expect(retainedLazyRead).toThrow(/reloaded|disabled|retiring/);
expect(() =>
withPluginRuntimeGatewayRequestScope(
{ pluginRegistry: first.registry, isWebchatConnect: () => false },
loadApi,
),
).toThrow(MissingPublicSurfaceError);
const retained = loadApi();
const nextRegistry = createEmptyPluginRegistry();
nextRegistry.plugins.push(second.record);
stageActivePluginRegistry(nextRegistry, "unrelated-change", "default");
expect(loadApi()).toBe(retained);
expect(lazy.read()).toBe("registered-two");
},
);
const retained = loadApi();
const nextRegistry = createEmptyPluginRegistry();
nextRegistry.plugins.push(second.record);
stageActivePluginRegistry(nextRegistry, "unrelated-change", "default");
expect(loadApi()).toBe(retained);
expect(lazy.read()).toBe("registered-two");
});
it.each(["relative", "tsconfig"] as const)(
"loads captured dependencies and %s source mappings after originals are removed",
@ -571,15 +561,8 @@ describe("managed plugin public surfaces", () => {
stageActivePluginRegistry(disabled, "disabled", "gateway-bindable");
await active.instance.dispose();
const inspection = loadBundledPluginPublicSurfaceModuleSyncCore<PublicApi>(request);
if (extension === "js") {
expect(inspection).toBe(library);
expect(library.read()).toBe("registered");
} else {
// Vitest deep-compares unequal references; retired export getters must stay fenced.
expect(Object.is(inspection, library)).toBe(false);
expect(inspection.read()).toBe("library");
expect(() => library.read()).toThrow(/reloaded|disabled|retiring/);
}
expect(inspection).toBe(library);
expect(library.read()).toBe("registered");
expect(retainedRead).toThrow(/reloaded|disabled|retiring/);
expect(() => runtime.read()).toThrow(/reloaded or disabled/);
await expect(async () => await loadActivated(request)).rejects.toThrow(/access blocked/);