fix(context-engine): share registry Map across bundled chunks via globalThis#41513
fix(context-engine): share registry Map across bundled chunks via globalThis#41513thalesfsp wants to merge 3 commits into
Conversation
Greptile SummaryThis PR fixes a real bundling problem in the context-engine registry by anchoring the Two issues were found in the accompanying test file that need attention before merging:
Confidence Score: 3/5
Last reviewed commit: 7bc0be0 |
| const testEngines = [...(globalThis.__openclawContextEngines?.keys() ?? [])].filter( | ||
| (id) => id.startsWith("test-") || id.startsWith("cross-module-"), | ||
| ); | ||
| testEngines.forEach((id) => globalThis.__openclawContextEngines.delete(id)); |
There was a problem hiding this comment.
Missing type assertion on globalThis accesses
globalThis.__openclawContextEngines is accessed here (and on lines 103, 106, 123, and 126) without a type cast. Since __openclawContextEngines is not declared on the global type and the project uses strict: true in tsconfig.json, TypeScript will raise a "Property __openclawContextEngines does not exist on type Window & typeof globalThis" error on all these lines when running tsc --noEmit.
The registry.ts source file correctly wraps every such access in (globalThis as unknown as { __openclawContextEngines?: ... }), but the test file omits those casts.
Either add a declare global augmentation in a .d.ts file (recommended, as it makes the intent explicit and avoids repetition), or apply the same cast pattern used in the source file to every direct access in the tests:
| const testEngines = [...(globalThis.__openclawContextEngines?.keys() ?? [])].filter( | |
| (id) => id.startsWith("test-") || id.startsWith("cross-module-"), | |
| ); | |
| testEngines.forEach((id) => globalThis.__openclawContextEngines.delete(id)); | |
| const testEngines = [ | |
| ...( | |
| (globalThis as unknown as { __openclawContextEngines?: Map<string, unknown> }) | |
| .__openclawContextEngines?.keys() ?? [] | |
| ), | |
| ].filter( | |
| (id) => id.startsWith("test-") || id.startsWith("cross-module-"), | |
| ); | |
| testEngines.forEach((id) => | |
| (globalThis as unknown as { __openclawContextEngines: Map<string, unknown> }) | |
| .__openclawContextEngines.delete(id), | |
| ); |
The same cast is also needed at lines 103, 106, 123, and 126.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/context-engine/registry.test.ts
Line: 14-17
Comment:
**Missing type assertion on `globalThis` accesses**
`globalThis.__openclawContextEngines` is accessed here (and on lines 103, 106, 123, and 126) without a type cast. Since `__openclawContextEngines` is not declared on the global type and the project uses `strict: true` in `tsconfig.json`, TypeScript will raise a "Property `__openclawContextEngines` does not exist on type `Window & typeof globalThis`" error on all these lines when running `tsc --noEmit`.
The `registry.ts` source file correctly wraps every such access in `(globalThis as unknown as { __openclawContextEngines?: ... })`, but the test file omits those casts.
Either add a `declare global` augmentation in a `.d.ts` file (recommended, as it makes the intent explicit and avoids repetition), or apply the same cast pattern used in the source file to every direct access in the tests:
```suggestion
const testEngines = [
...(
(globalThis as unknown as { __openclawContextEngines?: Map<string, unknown> })
.__openclawContextEngines?.keys() ?? []
),
].filter(
(id) => id.startsWith("test-") || id.startsWith("cross-module-"),
);
testEngines.forEach((id) =>
(globalThis as unknown as { __openclawContextEngines: Map<string, unknown> })
.__openclawContextEngines.delete(id),
);
```
The same cast is also needed at lines 103, 106, 123, and 126.
How can I resolve this? If you propose a fix, please make it concise.| afterEach(() => { | ||
| // Clean up test engines to prevent state leakage | ||
| const testEngines = [...(globalThis.__openclawContextEngines?.keys() ?? [])].filter( | ||
| (id) => id.startsWith("test-") || id.startsWith("cross-module-"), | ||
| ); | ||
| testEngines.forEach((id) => globalThis.__openclawContextEngines.delete(id)); | ||
| }); |
There was a problem hiding this comment.
afterEach cleanup does not cover all registered IDs
The afterEach hook only removes engine IDs that start with "test-" or "cross-module-". However, many tests in this file register engines with IDs that don't match either prefix and are therefore never cleaned up:
"plugin-a-engine"/"plugin-b-engine"(cross-context visibility suite)"context-a-engine"/"context-b-engine"(cross-context visibility suite)"duplicate-engine"/"type-safe-engine"(edge cases suite)"custom-slot-engine"/"async-factory-engine"/"trim-test-engine"/"available-engine"(resolveContextEngine suite)"list-test-engine-1"/"list-test-engine-2"(listContextEngineIds suite)
Because globalThis.__openclawContextEngines is intentionally shared, these registrations accumulate for the lifetime of the test file's process. Tests that check the full set of IDs (e.g. the "should provide helpful error message listing available engines" test) currently happen to pass, but the leaked state makes future tests brittle.
Consider either:
- Renaming all test IDs to use the
"test-"prefix so the current filter covers them, or - Clearing the entire Map in
afterEachand re-populating only the built-in default engines before each test.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/context-engine/registry.test.ts
Line: 12-18
Comment:
**`afterEach` cleanup does not cover all registered IDs**
The `afterEach` hook only removes engine IDs that start with `"test-"` or `"cross-module-"`. However, many tests in this file register engines with IDs that don't match either prefix and are therefore never cleaned up:
- `"plugin-a-engine"` / `"plugin-b-engine"` (cross-context visibility suite)
- `"context-a-engine"` / `"context-b-engine"` (cross-context visibility suite)
- `"duplicate-engine"` / `"type-safe-engine"` (edge cases suite)
- `"custom-slot-engine"` / `"async-factory-engine"` / `"trim-test-engine"` / `"available-engine"` (resolveContextEngine suite)
- `"list-test-engine-1"` / `"list-test-engine-2"` (listContextEngineIds suite)
Because `globalThis.__openclawContextEngines` is intentionally shared, these registrations accumulate for the lifetime of the test file's process. Tests that check the full set of IDs (e.g. the `"should provide helpful error message listing available engines"` test) currently happen to pass, but the leaked state makes future tests brittle.
Consider either:
1. Renaming all test IDs to use the `"test-"` prefix so the current filter covers them, or
2. Clearing the entire Map in `afterEach` and re-populating only the built-in default engines before each test.
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: 663306b293
ℹ️ 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".
| const _engines: Map<string, ContextEngineFactory> = | ||
| (globalThis as unknown as { __openclawContextEngines?: Map<string, ContextEngineFactory> }) | ||
| .__openclawContextEngines || | ||
| (( | ||
| globalThis as unknown as { __openclawContextEngines: Map<string, ContextEngineFactory> } | ||
| ).__openclawContextEngines = new Map<string, ContextEngineFactory>()); |
There was a problem hiding this comment.
Clear shared engine registry on plugin reload
Once a context-engine plugin has been loaded at least once, reloading the gateway with that plugin disabled or uninstalled will still leave its factory reachable because this map now lives on globalThis for the lifetime of the process. loadOpenClawPlugins() only clears commands during reload (src/plugins/loader.ts:524-526), and resolveContextEngine() accepts any factory still present in the registry, so plugins.slots.contextEngine can keep instantiating stale plugin code instead of failing fast after the plugin is removed. In the bundled build that is new behavior: before the host and plugin-sdk copies did not share a registry, so disabled plugin engines were not visible to the host bundle.
Useful? React with 👍 / 👎.
| const _engines: Map<string, ContextEngineFactory> = | ||
| (globalThis as unknown as { __openclawContextEngines?: Map<string, ContextEngineFactory> }) | ||
| .__openclawContextEngines || | ||
| (( | ||
| globalThis as unknown as { __openclawContextEngines: Map<string, ContextEngineFactory> } | ||
| ).__openclawContextEngines = new Map<string, ContextEngineFactory>()); |
There was a problem hiding this comment.
Guard the shared registry against duplicate engine IDs
Sharing one process-wide map here also widens registerContextEngine()'s existing last-wins behavior to every bundled entry point. Because context-engine plugins are not slot-gated during load the way memory plugins are (src/plugins/loader.ts:782-800 only filters kind: "memory"), any enabled plugin that accidentally registers "legacy" or another existing engine id can overwrite the host's entry for the whole process after a reload, changing what resolveContextEngine() instantiates even when the user did not intend to switch engines.
Useful? React with 👍 / 👎.
|
Thanks for the review! I've addressed the TypeScript type errors in commit Fixed:
Verified:
Re: afterEach cleanup — the current filter ( |
|
Hey team 👋 — just checking in on this PR. I've addressed the TypeScript type cast feedback from the initial review. All CI checks are green and tests pass. Would love a maintainer review when you have a moment. Happy to make any additional changes if needed! Thanks! 🙏 |
|
Thanks for the contribution. Closing this because the bundled-chunk registry singleton fix already landed on Rebasing this branch now would conflict with and regress current registry behavior, so there is not a clean incremental path to merge this PR as-is. If you want to keep iterating in this area, please start from current |
Fix: Context Engine Registry Shared State Across Bundled Chunks
Title
Fix context engine registry duplicate Map instances in bundled chunks
Problem Description
When OpenClaw bundles the application, the context engine registry creates duplicate
Mapinstances across chunks. This causes context engines registered in one chunk to be invisible in another chunk, resulting in "not registered" errors at runtime.Reproduction Steps
graphiti-context-engine)openclaw.json:{ "plugins": { "allow": ["graphiti-context-engine"], "slots": { "contextEngine": "graphiti-context-engine" } } }Context engine "graphiti-context-engine" is not registered. Available engines: (none)Expected: The plugin registers successfully and is available for use.
Actual: The plugin registers in one chunk but is not visible when resolved in another chunk.
Root Cause Analysis
File:
src/context-engine/registry.ts(line 15)Original Code:
When bundlers (esbuild, webpack, etc.) split code into chunks, each chunk that imports
registry.tsgets its own instance of the_enginesMap. This violates the singleton pattern and breaks the registry.Why This Happens:
Proposed Fix
Use
globalThisto share the Map across all chunks:Why This Works:
globalThisis shared across all JavaScript execution contexts in Node.jsTesting Done
npx tsc --noEmitdist/registry-*.jsfilesAlternative Considered
Could use a shared module cache or singleton factory pattern, but
globalThisis the simplest solution that works across all bundler configurations without requiring build-time changes.Impact
Patch Diff: