fix(hooks): use globalThis singleton for internal hooks handlers Map#23019
fix(hooks): use globalThis singleton for internal hooks handlers Map#23019karmafeast wants to merge 2 commits intoopenclaw:mainfrom
Conversation
src/hooks/internal-hooks.ts
Outdated
| 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[]>()); |
There was a problem hiding this comment.
repeated type assertion as Record<string, unknown> could be extracted to a variable for better readability
| 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.
src/hooks/internal-hooks.test.ts
Outdated
|
|
||
| describe("globalThis singleton", () => { | ||
| it("handlers Map is stored on globalThis for cross-chunk sharing", () => { | ||
| const GLOBAL_KEY = "__openclaw_internal_hooks__"; |
There was a problem hiding this 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
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.|
Both Greptile suggestions addressed in
|
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
b364f89 to
c4a3530
Compare
|
+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. |
|
Closing — the same fix landed upstream independently (the globalThis singleton pattern for internal hooks handlers Map is now in main, using The bug class was real — confirmed by @pk197197 in #26293. Glad it got fixed regardless of which PR merged. |
Summary
message:received(and other message events) silently never fire. The hook loader logs successful registration, buttriggerInternalHookat dispatch time finds an empty handlers Map.message:receivedinternal hook bridge (backported inf07bb8e8f, 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.handlersMap insrc/hooks/internal-hooks.tsis now stored onglobalThisso all bundler-split chunk duplicates share a single instance. Two tests added documenting the singleton contract.internal-hooks.ts.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
Workspace hooks registered for
message:receivedandmessage:sentevents now fire as documented. No config or default changes.Security Impact (required)
No)No)No)No)No)Repro + Verification
Environment
hooks.internal.enabled: true, workspace hook in~/.openclaw/hooks/watchdog-timestamp/Steps
metadata.openclaw.events: ["message:received"]in HOOK.mdmessage:receivedeventopenclaw hooks enable watchdog-timestampExpected
Handler fires on every inbound message; file is written.
Actual
Handler never fires. Hook loader logs
Registered hook: watchdog-timestamp -> message:receivedbut the handler is never called despite messages being processed.Evidence
Root cause: bundler code-splitting duplicates module state
tsdown(Rolldown) code-splitssrc/hooks/internal-hooks.tsinto multiple chunks. In OpenClaw 2026.2.19-2, three separate registry chunks exist:Import chain (before fix):
gateway-cli-*.js):import { registerInternalHook } from "./registry-B-j4DRfe.js"→ writes to Map Api-embedded-*.js):import { triggerInternalHook } from "./registry-BmY4gNy6.js"→ reads from Map BDiagnostic patch output:
Confirms
triggerInternalHookIS 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 emitglobalThis[GLOBAL_KEY] ?? (globalThis[GLOBAL_KEY] = new Map()).Test results
main)pnpm check: 0 warnings, 0 errorspnpm build: succeeds, no regressionsHuman Verification (required)
grepon compiled output. ConfirmedtriggerInternalHookis called but finds empty Map via diagnosticconsole.errorpatch 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.clearInternalHooks()callshandlers.clear()(in-place), nothandlers = new Map()— globalThis reference remains valid. Multiple gateway restarts tested.Compatibility / Migration
Yes)No)No)Failure Recovery (if this breaks)
src/hooks/internal-hooks.tsis the only changed source fileglobalThis.__openclaw_internal_hooks__collides with another module (extremely unlikely given the namespaced key)Risks and Mitigations
globalThispollution with a well-namespaced key.__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:receivedandmessage:sentevents by storing the internal hooks handlers Map onglobalThisto prevent bundler code-splitting from creating separate Map instances across chunks. The fix ensuresregisterInternalHook(hook loader) andtriggerInternalHook(message dispatcher) share the same Map when they're split into different bundler chunks.Confidence Score: 5/5
clearInternalHooks()using.clear(). Two new tests document the singleton behavior. Only minor style suggestions for code readability.Last reviewed commit: 2a2ec82
(2/5) Greptile learns from your feedback when you react with thumbs up/down!