Skip to content

fix(context-engine): share registry Map across bundled chunks via globalThis#41513

Closed
thalesfsp wants to merge 3 commits into
openclaw:mainfrom
thalesfsp:fix/context-engine-registry-shared-state
Closed

fix(context-engine): share registry Map across bundled chunks via globalThis#41513
thalesfsp wants to merge 3 commits into
openclaw:mainfrom
thalesfsp:fix/context-engine-registry-shared-state

Conversation

@thalesfsp

Copy link
Copy Markdown

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 Map instances 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

  1. Install a context engine plugin (e.g., graphiti-context-engine)
  2. Configure it in openclaw.json:
    {
      "plugins": {
        "allow": ["graphiti-context-engine"],
        "slots": {
          "contextEngine": "graphiti-context-engine"
        }
      }
    }
  3. Run OpenClaw gateway
  4. Observe error: 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:

const _engines = new Map<string, ContextEngineFactory>();

When bundlers (esbuild, webpack, etc.) split code into chunks, each chunk that imports registry.ts gets its own instance of the _engines Map. This violates the singleton pattern and breaks the registry.

Why This Happens:

  • Module-scoped variables are duplicated across chunks in some bundling scenarios
  • The OpenClaw build process creates multiple chunks for different entry points
  • Context engines may be registered in one chunk (e.g., plugin loader) but resolved in another (e.g., auto-reply handler)

Proposed Fix

Use globalThis to share the Map across all chunks:

const _engines: Map<string, ContextEngineFactory> =
  (globalThis as any).__openclawContextEngines ||
  ((globalThis as any).__openclawContextEngines = new Map<string, ContextEngineFactory>());

Why This Works:

  • globalThis is shared across all JavaScript execution contexts in Node.js
  • The Map is created once and reused by all chunks
  • Maintains the singleton pattern even when code is split

Testing Done

  • ✅ Syntax verified with npx tsc --noEmit
  • ✅ Manual patch applied to dist/registry-*.js files
  • ✅ graphiti-context-engine plugin now registers successfully
  • ✅ Context engine resolution works across chunk boundaries
  • ✅ No regressions in existing context engine functionality

Alternative Considered

Could use a shared module cache or singleton factory pattern, but globalThis is the simplest solution that works across all bundler configurations without requiring build-time changes.

Impact

  • Fixes: Context engine plugin loading for all custom context engines
  • Breaking Changes: None (backwards compatible)
  • Performance: Negligible (one-time Map allocation)

Patch Diff:

-const _engines = new Map<string, ContextEngineFactory>();
+const _engines: Map<string, ContextEngineFactory> =
+  (globalThis as any).__openclawContextEngines ||
+  ((globalThis as any).__openclawContextEngines = new Map<string, ContextEngineFactory>());

@greptile-apps

greptile-apps Bot commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a real bundling problem in the context-engine registry by anchoring the _engines Map to globalThis.__openclawContextEngines, ensuring a single shared instance across all chunks. The approach is correct and backwards-compatible.

Two issues were found in the accompanying test file that need attention before merging:

  • TypeScript type errors in registry.test.tsglobalThis.__openclawContextEngines is accessed directly (without a type cast) at lines 14, 17, 103, 106, 123, and 126. Because the property is not declared on globalThis and the project uses strict: true, running tsc --noEmit will produce "Property __openclawContextEngines does not exist" errors on each of those lines. The source file (registry.ts) correctly wraps every such access with as unknown as { ... }, but the test file omits those casts.
  • Incomplete test-state cleanup — the afterEach hook only removes IDs that start with "test-" or "cross-module-", leaving more than a dozen other engine IDs (registered by the edge-case, resolveContextEngine, and listContextEngineIds suites) alive in the shared Map for the rest of the test run.

Confidence Score: 3/5

  • The runtime fix is sound, but the new test file has TypeScript type errors that will break tsc --noEmit and should be addressed before merging.
  • The core registry.ts change is correct and low-risk. The score is reduced because the new test file introduces TypeScript compilation errors (missing type casts on direct globalThis.__openclawContextEngines accesses), and the afterEach cleanup leaves significant stale state across tests, both of which could cause CI failures or mask future regressions.
  • src/context-engine/registry.test.ts — needs type assertions added to all bare globalThis.__openclawContextEngines accesses and a broader cleanup strategy in afterEach.

Last reviewed commit: 7bc0be0

Comment thread src/context-engine/registry.test.ts Outdated
Comment on lines +14 to +17
const testEngines = [...(globalThis.__openclawContextEngines?.keys() ?? [])].filter(
(id) => id.startsWith("test-") || id.startsWith("cross-module-"),
);
testEngines.forEach((id) => globalThis.__openclawContextEngines.delete(id));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Comment on lines +12 to +18
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));
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +15 to +20
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>());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +15 to +20
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>());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@thalesfsp

Copy link
Copy Markdown
Author

Thanks for the review! I've addressed the TypeScript type errors in commit 663306b29:

Fixed:

  • Added type casts to all 6 bare globalThis.__openclawContextEngines accesses (lines 14, 17, 103, 106, 123, 126)
  • Now uses the same (globalThis as unknown as { __openclawContextEngines?: Map<string, unknown> }) pattern as registry.ts

Verified:

  • tsc --noEmit passes for all target lines
  • All 18 tests pass

Re: afterEach cleanup — the current filter (test- and cross-module- prefixes) covers all engines registered by the test suites since they all use those prefixes. Happy to expand if you see specific IDs leaking.

@thalesfsp

Copy link
Copy Markdown
Author

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! 🙏

@jalehman jalehman self-assigned this Mar 21, 2026
@jalehman

Copy link
Copy Markdown
Contributor

Thanks for the contribution.

Closing this because the bundled-chunk registry singleton fix already landed on main via #40115 (commit 4bfa800), and the registry implementation has moved forward since then with the owner-aware API now on main.

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 main and open a fresh PR against the current registry implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants