fix: use live plugin registry for HTTP route matching after reload#45606
fix: use live plugin registry for HTTP route matching after reload#45606dubthree wants to merge 1 commit intoopenclaw:mainfrom
Conversation
Greptile SummaryThis PR fixes a stale closure bug in
Confidence Score: 4/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/gateway/server/plugins-http.test.ts
Line: 288-325
Comment:
**Test leaves global registry state dirty**
The test calls `setActivePluginRegistry(registryA)` and `setActivePluginRegistry(registryB)` but never restores the original registry state after the test completes. This also permanently increments the global version counter (each `setActivePluginRegistry` call does `state.version += 1`).
`unregister()` cleans up the late-registered route but leaves `registryB` as the active registry and the version at whatever it was bumped to. While this doesn't break any current tests in the file, it creates a subtle dependency on test execution order. Any future test that creates a handler expecting `creationVersion` to match the `currentVersion` at handler-creation time could silently switch to the live registry if this test has already mutated the global state.
Consider adding an `afterEach` (or a local cleanup via `try/finally`) that restores the original registry reference:
```ts
import { getActivePluginRegistry, setActivePluginRegistry } from "../../plugins/runtime.js";
it("sees routes added to the active registry after a reload replaces it", async () => {
const originalRegistry = getActivePluginRegistry();
try {
// ... test body ...
} finally {
if (originalRegistry) setActivePluginRegistry(originalRegistry);
unregister();
}
});
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: 8c31a08 |
| it("sees routes added to the active registry after a reload replaces it", async () => { | ||
| // Simulate gateway startup: handler is created with registry A. | ||
| const registryA = createTestRegistry({ | ||
| httpRoutes: [createRoute({ path: "/existing", auth: "plugin" })], | ||
| }); | ||
| setActivePluginRegistry(registryA); | ||
| const handler = createGatewayPluginRequestHandler({ | ||
| registry: registryA, | ||
| log: createPluginLog(), | ||
| }); | ||
|
|
||
| // Simulate a config reload that replaces the active registry with a new | ||
| // object (registry B). This is what loadOpenClawPlugins does on reload. | ||
| const registryB = createTestRegistry({ | ||
| httpRoutes: [createRoute({ path: "/existing", auth: "plugin" })], | ||
| }); | ||
| setActivePluginRegistry(registryB); | ||
|
|
||
| // Simulate a channel provider registering a webhook route after reload. | ||
| // registerPluginHttpRoute falls back to requireActivePluginRegistry(), | ||
| // which now returns registry B. The handler still holds registry A. | ||
| const lateHandler = vi.fn(async () => true); | ||
| const unregister = registerPluginHttpRoute({ | ||
| path: "/late-webhook", | ||
| handler: lateHandler, | ||
| auth: "plugin", | ||
| match: "exact", | ||
| pluginId: "late-plugin", | ||
| source: "test", | ||
| }); | ||
|
|
||
| const { res } = makeMockHttpResponse(); | ||
| const handled = await handler({ url: "/late-webhook" } as IncomingMessage, res); | ||
| expect(handled).toBe(true); | ||
| expect(lateHandler).toHaveBeenCalledTimes(1); | ||
|
|
||
| unregister(); | ||
| }); |
There was a problem hiding this comment.
Test leaves global registry state dirty
The test calls setActivePluginRegistry(registryA) and setActivePluginRegistry(registryB) but never restores the original registry state after the test completes. This also permanently increments the global version counter (each setActivePluginRegistry call does state.version += 1).
unregister() cleans up the late-registered route but leaves registryB as the active registry and the version at whatever it was bumped to. While this doesn't break any current tests in the file, it creates a subtle dependency on test execution order. Any future test that creates a handler expecting creationVersion to match the currentVersion at handler-creation time could silently switch to the live registry if this test has already mutated the global state.
Consider adding an afterEach (or a local cleanup via try/finally) that restores the original registry reference:
import { getActivePluginRegistry, setActivePluginRegistry } from "../../plugins/runtime.js";
it("sees routes added to the active registry after a reload replaces it", async () => {
const originalRegistry = getActivePluginRegistry();
try {
// ... test body ...
} finally {
if (originalRegistry) setActivePluginRegistry(originalRegistry);
unregister();
}
});Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server/plugins-http.test.ts
Line: 288-325
Comment:
**Test leaves global registry state dirty**
The test calls `setActivePluginRegistry(registryA)` and `setActivePluginRegistry(registryB)` but never restores the original registry state after the test completes. This also permanently increments the global version counter (each `setActivePluginRegistry` call does `state.version += 1`).
`unregister()` cleans up the late-registered route but leaves `registryB` as the active registry and the version at whatever it was bumped to. While this doesn't break any current tests in the file, it creates a subtle dependency on test execution order. Any future test that creates a handler expecting `creationVersion` to match the `currentVersion` at handler-creation time could silently switch to the live registry if this test has already mutated the global state.
Consider adding an `afterEach` (or a local cleanup via `try/finally`) that restores the original registry reference:
```ts
import { getActivePluginRegistry, setActivePluginRegistry } from "../../plugins/runtime.js";
it("sees routes added to the active registry after a reload replaces it", async () => {
const originalRegistry = getActivePluginRegistry();
try {
// ... test body ...
} finally {
if (originalRegistry) setActivePluginRegistry(originalRegistry);
unregister();
}
});
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8c31a08ff1
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
src/gateway/server/plugins-http.ts
Outdated
| const effectiveRegistry = | ||
| currentVersion !== creationVersion ? (getActivePluginRegistry() ?? registry) : registry; |
There was a problem hiding this comment.
Keep plugin HTTP handler bound to runtime registry
Switching to getActivePluginRegistry() whenever the global version changes lets unrelated registry activations hijack request routing. setActivePluginRegistry() increments the version on every activation (src/plugins/runtime.ts:25-29), and loadOpenClawPlugins() activates a registry even on cache hits (src/plugins/loader.ts:539-543); config.get/config.schema/config.schema.lookup call that path via loadSchemaWithPlugins() (src/gateway/server-methods/config.ts:245-303). After this change, those read-only RPCs can cause the HTTP handler to route against a different registry than the one it was created with, so plugin routes can change before the gateway runtime is actually reloaded.
Useful? React with 👍 / 👎.
Channel plugins that register webhook routes via registerPluginHttpRoute() (called from registerWebhookTargetWithPluginRoute) target the active plugin registry singleton. When the build system duplicates the singleton state across chunk boundaries, the singleton in the plugin-sdk chunk can diverge from the one the gateway handler captured, making webhook routes invisible to the HTTP request dispatcher. Introduce a dedicated globalThis-backed registry bridge (setGatewayPluginRegistry / getGatewayPluginRegistry) so registerPluginHttpRoute always targets the same registry object that the gateway handler checks, regardless of chunk-boundary singleton divergence. Fixes openclaw#45598
8c31a08 to
bf73ba8
Compare
|
We are seeing what looks like the WS/gateway-method analogue of this stale-registry pattern. In our case, a plugin-backed gateway method ( Relevant code paths on 2026.3.23-2:
So the shape looks very similar to the HTTP route issue here:
I opened a dedicated issue for the WS/gateway-method variant here: #54783 If useful, I can also add a minimal repro focused specifically on plugin gateway methods rather than HTTP routes. |
Summary
globalThis-backed registry bridge soregisterPluginHttpRoutealways targets the gateway handler's registryhttp-registry.test.tsandplugins-http.test.tsProblem
createGatewayPluginRequestHandlercaptures aregistryobject by closure at creation time. Channel plugins that register webhook routes later viaregisterPluginHttpRoute()(without an explicitregistryparameter) fall back torequireActivePluginRegistry(). In the source, this is a single module (src/plugins/runtime.ts), so it returns the same object. But in the compiled output, the build system (rolldown) duplicates the registry singleton IIFE across chunk boundaries. Each chunk gets its ownstatevariable. Although all are initialized from the sameglobalThis[Symbol.for(...)]entry, the entry itself can be replaced during startup, causing thestatevariables in different chunks to reference different registry objects.The result:
registerPluginHttpRouteadds the BlueBubbles webhook route to a registry object that the gateway handler never checks. POST requests to/bluebubbles-webhookreturn 404.Confirmed with instrumentation:
Both the handler's captured registry and the global singleton have only 1 route (twilio-whatsapp). The BlueBubbles route was added to a third registry object held by a different chunk's
statevariable.Plugins that register routes during plugin loading via
api.registerHttpRoute()(e.g., twilio-whatsapp) are unaffected because they write directly to the captured registry.Fix
Three files changed:
src/plugins/runtime.ts: AddsetGatewayPluginRegistry()/getGatewayPluginRegistry()backed by a dedicatedglobalThis[Symbol.for("openclaw.gatewayPluginRegistry")]. This bypasses chunk-boundary singleton divergence entirely.src/gateway/server-runtime-state.ts: CallsetGatewayPluginRegistry(params.pluginRegistry)before creating the HTTP request handler, stashing the gateway's registry on the global bridge.src/plugins/http-registry.ts:registerPluginHttpRoutenow checksgetGatewayPluginRegistry()before falling back torequireActivePluginRegistry(), ensuring routes always land in the registry the handler checks.Test plan
src/plugins/http-registry.test.ts: "prefers the gateway plugin registry over the active singleton" (verifies routes go to the gateway registry, not the singleton)src/gateway/server/plugins-http.test.ts: "sees routes registered via registerPluginHttpRoute after handler creation" (end-to-end: handler finds late-registered routes)plugins-http.test.ts(14/14),http-registry.test.ts(11/11),webhook-targets.test.ts(16/16)pnpm buildpasses (type-check + rolldown)Fixes #45598
🤖 Generated with Claude Code