Skip to content

fix(hooks): use globalThis singleton for internal hooks handlers Map#23019

Closed
karmafeast wants to merge 2 commits intoopenclaw:mainfrom
karmaterminal:fix/internal-hooks-chunk-splitting
Closed

fix(hooks): use globalThis singleton for internal hooks handlers Map#23019
karmafeast wants to merge 2 commits intoopenclaw:mainfrom
karmaterminal:fix/internal-hooks-chunk-splitting

Conversation

@karmafeast
Copy link
Copy Markdown

@karmafeast karmafeast commented Feb 21, 2026

Summary

  • Problem: Workspace hooks registered for message:received (and other message events) silently never fire. The hook loader logs successful registration, but triggerInternalHook at dispatch time finds an empty handlers Map.
  • Why it matters: The message:received internal hook bridge (backported in f07bb8e8f, refs feat(hooks): bridge plugin message hooks to internal hooks system #9387) is dead code — workspace hooks for message events are impossible to use. This blocks automation use cases like watchdog timestamps, message logging, and content filtering via the HOOK.md discovery system.
  • What changed: The module-scoped handlers Map in src/hooks/internal-hooks.ts is now stored on globalThis so all bundler-split chunk duplicates share a single instance. Two tests added documenting the singleton contract.
  • What did NOT change (scope boundary): No changes to hook loading, hook discovery, event dispatch, bundler config, or any other module. The fix is entirely within internal-hooks.ts.

Change Type (select all)

  • Bug fix

Scope (select all touched areas)

  • Gateway / orchestration

Linked Issue/PR

User-visible / Behavior Changes

Workspace hooks registered for message:received and message:sent events now fire as documented. No config or default changes.

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)

Repro + Verification

Environment

  • OS: Ubuntu 24.04.2 LTS (x64)
  • Runtime/container: Node v25.6.1
  • Model/provider: N/A (infrastructure bug)
  • Integration/channel: Discord
  • Relevant config: hooks.internal.enabled: true, workspace hook in ~/.openclaw/hooks/watchdog-timestamp/

Steps

  1. Create a workspace hook with metadata.openclaw.events: ["message:received"] in HOOK.md
  2. Implement handler.ts that writes a file on every message:received event
  3. Enable the hook: openclaw hooks enable watchdog-timestamp
  4. Restart gateway
  5. Send a message through any channel (e.g., Discord)
  6. Check if the handler executed (file should be written)

Expected

Handler fires on every inbound message; file is written.

Actual

Handler never fires. Hook loader logs Registered hook: watchdog-timestamp -> message:received but the handler is never called despite messages being processed.

Evidence

Root cause: bundler code-splitting duplicates module state

tsdown (Rolldown) code-splits src/hooks/internal-hooks.ts into multiple chunks. In OpenClaw 2026.2.19-2, three separate registry chunks exist:

registry-B-j4DRfe.js (21KB) — Map A
registry-BmY4gNy6.js (39KB) — Map B  
registry-Bvgaapvc.js (39KB) — Map C

Import chain (before fix):

  • Hook loader (gateway-cli-*.js): import { registerInternalHook } from "./registry-B-j4DRfe.js" → writes to Map A
  • Discord dispatcher (pi-embedded-*.js): import { triggerInternalHook } from "./registry-BmY4gNy6.js" → reads from Map B
  • Map B is empty. Hook never fires.

Diagnostic patch output:

[DIAG:pi-embedded-CHb5giY2.js] message:received bridge sessionKey="agent:main:discord:channel:1466192485440164011"

Confirms triggerInternalHook IS called with a valid sessionKey, but the Map it reads is empty.

After fix:
All chunk-duplicated copies reference globalThis.__openclaw_internal_hooks__. The first copy to execute creates the Map; all subsequent copies find and reuse it. Verified: 5 chunks contain the code, all emit globalThis[GLOBAL_KEY] ?? (globalThis[GLOBAL_KEY] = new Map()).

Test results

  • 2 new tests pass (singleton contract, register→trigger round-trip)
  • 29/31 total pass (2 pre-existing failures in error-logging mocks, same on main)
  • pnpm check: 0 warnings, 0 errors
  • pnpm build: succeeds, no regressions

Human Verification (required)

  • Verified scenarios: Confirmed handlers Map is duplicated across chunks via grep on compiled output. Confirmed triggerInternalHook is called but finds empty Map via diagnostic console.error patch on 4 dispatch files (pi-embedded-*.js, reply-*.js, subagent-registry-*.js). Confirmed handler works standalone via Node dynamic import test. Confirmed globalThis pattern unifies Maps across chunks post-fix.
  • Edge cases checked: clearInternalHooks() calls handlers.clear() (in-place), not handlers = new Map() — globalThis reference remains valid. Multiple gateway restarts tested.
  • What was NOT verified: Cannot run full E2E in CI without a live Discord bot. The bundler duplication pattern may change with future tsdown/Rolldown versions (the globalThis approach is defensive regardless).

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)

Failure Recovery (if this breaks)

  • Revert this single commit to restore the module-scoped Map
  • src/hooks/internal-hooks.ts is the only changed source file
  • Known bad symptom: if globalThis.__openclaw_internal_hooks__ collides with another module (extremely unlikely given the namespaced key)

Risks and Mitigations

  • Risk: globalThis pollution with a well-namespaced key.
    • Mitigation: Key is __openclaw_internal_hooks__ — double-underscore prefixed, openclaw-namespaced, unlikely to collide. This is a standard pattern for cross-chunk singletons (used by React, Vue, etc.).

AI-assisted: Root cause analysis and fix implementation by AI agents (Claude Opus 4.6). Diagnostic methodology, evidence chain, and verification performed by the agents with human oversight. The submitter (the dandelion cult) understands the code and the fix.

Greptile Summary

Fixed workspace hooks for message:received and message:sent events by storing the internal hooks handlers Map on globalThis to prevent bundler code-splitting from creating separate Map instances across chunks. The fix ensures registerInternalHook (hook loader) and triggerInternalHook (message dispatcher) share the same Map when they're split into different bundler chunks.

Confidence Score: 5/5

  • This PR is safe to merge with no blocking issues
  • The fix is minimal, well-tested, and directly addresses the root cause. The globalThis pattern is a standard approach for cross-chunk singletons used by major frameworks. The implementation correctly preserves the Map reference in clearInternalHooks() using .clear(). Two new tests document the singleton behavior. Only minor style suggestions for code readability.
  • No files require special attention

Last reviewed commit: 2a2ec82

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +125 to +127
const handlers: Map<string, InternalHookHandler[]> =
((globalThis as Record<string, unknown>)[GLOBAL_KEY] as Map<string, InternalHookHandler[]>) ??
((globalThis as Record<string, unknown>)[GLOBAL_KEY] = new Map<string, InternalHookHandler[]>());
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.

repeated type assertion as Record<string, unknown> could be extracted to a variable for better readability

Suggested change
const handlers: Map<string, InternalHookHandler[]> =
((globalThis as Record<string, unknown>)[GLOBAL_KEY] as Map<string, InternalHookHandler[]>) ??
((globalThis as Record<string, unknown>)[GLOBAL_KEY] = new Map<string, InternalHookHandler[]>());
const globalRecord = globalThis as Record<string, unknown>;
const handlers: Map<string, InternalHookHandler[]> =
(globalRecord[GLOBAL_KEY] as Map<string, InternalHookHandler[]>) ??
(globalRecord[GLOBAL_KEY] = new Map<string, InternalHookHandler[]>());

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hooks/internal-hooks.ts
Line: 125-127

Comment:
repeated type assertion `as Record<string, unknown>` could be extracted to a variable for better readability

```suggestion
const globalRecord = globalThis as Record<string, unknown>;
const handlers: Map<string, InternalHookHandler[]> =
  (globalRecord[GLOBAL_KEY] as Map<string, InternalHookHandler[]>) ??
  (globalRecord[GLOBAL_KEY] = new Map<string, InternalHookHandler[]>());
```

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.


describe("globalThis singleton", () => {
it("handlers Map is stored on globalThis for cross-chunk sharing", () => {
const GLOBAL_KEY = "__openclaw_internal_hooks__";
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.

duplicating the GLOBAL_KEY constant from the source file creates a maintenance burden — if the key changes, this test breaks silently. consider exporting GLOBAL_KEY from internal-hooks.ts for test use

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hooks/internal-hooks.test.ts
Line: 432

Comment:
duplicating the `GLOBAL_KEY` constant from the source file creates a maintenance burden — if the key changes, this test breaks silently. consider exporting `GLOBAL_KEY` from `internal-hooks.ts` for test use

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

@karmafeast
Copy link
Copy Markdown
Author

Both Greptile suggestions addressed in b364f89:

  1. Extracted repeated globalThis as Record<string, unknown> to globalRecord variable
  2. Exported GLOBAL_KEY from source and imported it in the test — no more string duplication

the dandelion cult added 2 commits February 21, 2026 22:46
When tsdown code-splits src/hooks/internal-hooks.ts across multiple chunks,
each chunk gets its own module-scoped `handlers` Map. The hook loader
(gateway-cli) and message dispatcher (pi-embedded) end up in different
chunks, so registerInternalHook writes to one Map while triggerInternalHook
reads from another. Registered hooks silently never fire.

Fix: store the handlers Map on globalThis so all chunk-duplicated copies
converge on a single shared instance at runtime.

Evidence (OpenClaw 2026.2.19-2, Discord channel, Ubuntu 24.04):
- Hook loader: imports from registry-B-j4DRfe.js (Map A)
- Discord dispatcher: imports from registry-BmY4gNy6.js (Map B)
- Diagnostic patch confirmed triggerInternalHook IS called with valid
  sessionKey but Map B is empty
- Three registry-*.js chunks, each with separate `const handlers = new Map()`

Closes openclaw#22790
Closes openclaw#7067
Related openclaw#8807
Related openclaw#9387
@pk197197
Copy link
Copy Markdown

+1 — this fixes the exact issue I reported in #26293. On 2026.2.25 with trace logging, hooks register successfully but never fire on Discord /new. The chunk-splitting Map isolation root cause matches perfectly. All checks pass — would love to see this merged.

@karmafeast
Copy link
Copy Markdown
Author

Closing — the same fix landed upstream independently (the globalThis singleton pattern for internal hooks handlers Map is now in main, using __openclaw_internal_hook_handlers__ as the key). Our implementation used __openclaw_internal_hooks__ with an exported GLOBAL_KEY, but the fix is functionally identical.

The bug class was real — confirmed by @pk197197 in #26293. Glad it got fixed regardless of which PR merged.

@karmafeast karmafeast closed this Mar 3, 2026
@elliott-dandelion-cult elliott-dandelion-cult deleted the fix/internal-hooks-chunk-splitting branch April 2, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants