openclaw - ✅(Solved) Fix onMessage cleanup closure unconditionally deletes the current handler, not the registered one [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#83888Fetched 2026-05-20 03:47:31
View on GitHub
Comments
1
Participants
2
Timeline
9
Reactions
1
Timeline (top)
labeled ×6commented ×1cross-referenced ×1unsubscribed ×1

Fix Action

Fix / Workaround

Severity: medium / Confidence: high / Category: bug Triage: confirmed-bug Detected against: openclaw v2026.5.18 (latest stable at time of scan, 2026-05-18) Tooling: clawpatch 0.3.0 + acpx/claude-sonnet-4-5 via Brad Mills protocol

Suggested regression test

In a unit test, call manager.onMessage(account, h1) then const off = manager.onMessage(account, h1); const off2 = manager.onMessage(account, h2); off(); // h2 should still be active and verify h2 still receives a dispatched message.


Standardized clawpatch finding. Persistent in v2026.5.18 (not resolved by upgrading from v2026.5.12). Finding ID: fnd_sig-feat-cli-command-0c715f7406-_dbda9034b9.

PR fix notes

PR #84195: fix(twitch): guard onMessage cleanup against newer handler (#83888)

Description (problem / solution / changelog)

Fixes #83888.

TwitchClientManager.onMessage (extensions/twitch/src/twitch-client.ts:197) registers a handler under getAccountKey(account) and returns a cleanup closure that captures key. The closure calls this.messageHandlers.delete(key) unconditionally. If a second onMessage(account, h2) call overwrites the first handler — which messageHandlers.set silently does — and the first caller's cleanup then fires, it evicts the live h2. The account is left with no handler and the next inbound chat message — and every one after — is silently dropped. The bug is invisible to existing tests because none register two handlers for the same account key and then exercise the first cleanup.

Changes

  • extensions/twitch/src/twitch-client.ts: guard the cleanup closure with if (this.messageHandlers.get(key) === handler) { this.messageHandlers.delete(key); }. The lone-handler case is unchanged; the only difference is that a stale cleanup is now a no-op instead of yanking a newer handler out from under a second caller.
  • extensions/twitch/src/twitch-client.test.ts: two new regression cases under the existing describe("onMessage", …) block. (i) Register h1 → register h2 (overwrites) → invoke cleanup1 → assert h2 is still the registered handler. (ii) Register h_solo → invoke cleanup_solo → assert the entry is removed (no regression on the single-handler path).

Diff stat: 2 files, +32 / -1.

Real behavior proof

  • Behavior or issue addressed: messageHandlers is the per-key Map<string, (msg) => void> at twitch-client.ts:~70-80; getAccountKey(account) derives a stable {bot}:{channel} string used for both set and the captured cleanup key. The new identity check makes the cleanup closure idempotent w.r.t. unrelated overwrites — only the exact registered handler is removed.

  • Real environment tested: Local Node 22.x. Probe at /tmp/probe_83888.mjs does both halves of the proof. (a) Parses the patched twitch-client.ts and verifies the cleanup closure contains this.messageHandlers.get(key) === handler and that the set(key, handler) precedes the guarded cleanup. (b) Replays the buggy and patched shapes in pure JS — registers two handlers for the same key, invokes the first cleanup, dispatches a message, and asserts: pre-fix shape silently drops the dispatch (confirms #83888's exact symptom: "Inbound messages are silently dropped"), patched shape preserves the second handler and delivers the dispatch, and the single-handler regression case still removes the lone entry on cleanup.

  • Exact steps or command run after this patch: node /tmp/probe_83888.mjs

  • Evidence after fix:

PASS: cleanup closure guards delete with identity check (this.messageHandlers.get(key) === handler)
PASS: handler is registered via set() before the guarded cleanup closure is returned
PASS: replay (buggy): cleanup1 deleted the live handler — message silently dropped (confirms #83888)
PASS: replay (patched): cleanup1 saw mismatched handler and skipped — h2 still receives dispatch
PASS: replay (patched, sole handler): cleanup of the only registered handler still removes it — no regression

ALL CASES PASS
  • Observed result after fix: Stale cleanups from earlier onMessage registrations are now no-ops when a newer handler is in place. Inbound chat messages for the account continue to reach the newest registered handler. Single-handler unregister behavior is unchanged.

  • What was not tested: Live tmi.js Twitch chat connection — that requires an OAuth token + channel and is outside the scope of a handler-registration race fix. The new vitest cases drive the exact same public boundary the original test ("should replace existing handler for same account") uses, plus the cleanup invocation and a dispatch-equivalent identity assertion.

Audit (per CLAUDE rules — all 5 steps)

  • Existing-helper check: No helper is needed for a Map.get(key) === value identity check — this is the canonical pattern for handler-table deregistration. PASS
  • Shared-helper caller check: onMessage has one production caller (extensions/twitch/src/monitor.ts:266, monitorTwitchProvider's unregisterHandler = clientManager.onMessage(...)) plus six test sites. The new behavior is strictly a superset: same outcome on a single-handler timeline, no eviction of a newer handler on stale cleanup. PASS
  • Broader-fix rival scan: gh pr list --search '83888 in:title,body' and gh pr list --search 'onMessage cleanup in:title,body' return no open PRs. PASS
  • Recent-merge audit: git log --oneline -5 -- extensions/twitch/src/twitch-client.ts shows 4f4d108639 chore(lint): remove underscore-dangle allow list (#83542) and e1061a8b46 test(live): tolerate provider drift in release checks — unrelated to handler registration. PASS
  • Prototype-pollution scan: N/A — single Map.get + Map.delete call.

Changed files

  • extensions/twitch/src/twitch-client.test.ts (modified, +24/-0)
  • extensions/twitch/src/twitch-client.ts (modified, +8/-1)

Code Example

onMessage(
    account: TwitchAccountConfig,
    handler: (message: TwitchChatMessage) => void,
  ): () => void {
    const key = this.getAccountKey(account);
    this.messageHandlers.set(key, handler);
    return () => {
      this.messageHandlers.delete(key);
    };
  }
RAW_BUFFERClick to expand / collapse

Severity: medium / Confidence: high / Category: bug Triage: confirmed-bug Detected against: openclaw v2026.5.18 (latest stable at time of scan, 2026-05-18) Tooling: clawpatch 0.3.0 + acpx/claude-sonnet-4-5 via Brad Mills protocol

Evidence

  • extensions/twitch/src/twitch-client.ts:197-206 (onMessage)
onMessage(
    account: TwitchAccountConfig,
    handler: (message: TwitchChatMessage) => void,
  ): () => void {
    const key = this.getAccountKey(account);
    this.messageHandlers.set(key, handler);
    return () => {
      this.messageHandlers.delete(key);
    };
  }

Reasoning

The returned cleanup function captures key but calls this.messageHandlers.delete(key) unconditionally. If a second caller invokes onMessage for the same account before the first cleanup fires, invoking the first cleanup silently removes the second (active) handler, leaving the account with no handler and silently dropping all subsequent inbound messages.

Reproduction

Call onMessage(account, handlerA) → get cleanupA. Call onMessage(account, handlerB) → handlerB is now active. Call cleanupA() → handlerB is deleted. Inbound messages are silently dropped.

Recommendation

Guard the delete with a referential check: if (this.messageHandlers.get(key) === handler) { this.messageHandlers.delete(key); } so only the exact registered handler is removed.

Why existing tests miss this

No test registers two handlers for the same account key and then exercises the cleanup of the first one; the plugin lifecycle tests mock monitorTwitchProvider at a higher level.

Suggested regression test

In a unit test, call manager.onMessage(account, h1) then const off = manager.onMessage(account, h1); const off2 = manager.onMessage(account, h2); off(); // h2 should still be active and verify h2 still receives a dispatched message.

Minimum fix scope

One-line guard in the cleanup closure in twitch-client.ts:203-205.


Standardized clawpatch finding. Persistent in v2026.5.18 (not resolved by upgrading from v2026.5.12). Finding ID: fnd_sig-feat-cli-command-0c715f7406-_dbda9034b9.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix onMessage cleanup closure unconditionally deletes the current handler, not the registered one [1 pull requests, 1 comments, 2 participants]