Skip to content

fix(whatsapp): use globalThis singleton for active-listener Map#47433

Merged
mcaxtr merged 2 commits intoopenclaw:mainfrom
clawdia67:fix/whatsapp-active-listener-singleton
Mar 19, 2026
Merged

fix(whatsapp): use globalThis singleton for active-listener Map#47433
mcaxtr merged 2 commits intoopenclaw:mainfrom
clawdia67:fix/whatsapp-active-listener-singleton

Conversation

@clawdia67
Copy link
Copy Markdown
Contributor

Summary

  • The listeners Map in extensions/whatsapp/src/active-listener.ts is duplicated across 7+ output chunks by Rolldown code-splitting
  • setActiveWebListener() populates the Map in one chunk, but requireActiveWebListener() reads from a different chunk's copy
  • Outbound sends via the message tool always fail with "No active WhatsApp Web listener" while auto-replies (which bypass the Map via direct socket references) work fine
  • Pin both the listeners Map and _currentListener to globalThis so all chunk-local copies resolve to the same shared instance

Test plan

  • Verify existing send.test.ts passes
  • Start gateway with WhatsApp linked
  • Send media via message tool — should succeed instead of throwing "No active WhatsApp Web listener"
  • Verify auto-reply path still works
  • Verify getActiveWebListener() / requireActiveWebListener() return the listener registered by setActiveWebListener() even when called from a different chunk

Fixes #14406

🤖 Generated with Claude Code

@openclaw-barnacle openclaw-barnacle bot added channel: whatsapp-web Channel integration: whatsapp-web size: XS labels Mar 15, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 15, 2026

Greptile Summary

This PR correctly fixes the "No active WhatsApp Web listener" error caused by Rolldown code-splitting duplicating the active-listener.ts module across multiple output chunks, each holding an isolated copy of the listeners Map. By pinning both listeners and _currentListener to globalThis under stable, namespaced keys, all chunk-local module instances now share a single Map, so setActiveWebListener() and requireActiveWebListener() always operate on the same state regardless of which chunk they execute in.

Key observations:

  • The ??= initialization pattern is correct: the first chunk to load creates the Map/null value; every subsequent chunk reuses the existing one, preserving any already-registered listeners.
  • Capturing const listeners = _global[GLOBAL_KEY] at module init time is safe — since all chunks reference the same underlying Map object, mutations via .set()/.delete() are shared.
  • getCurrentListener() (and the GLOBAL_CURRENT_KEY global property) are introduced but never called within the file. The original _currentListener variable was also set but never read, so this preserves the existing dead-code pattern rather than introducing a new bug — but the dead state on globalThis is unnecessary.

Confidence Score: 4/5

  • The core fix is sound and correctly resolves the chunk-isolation bug; the only issue is harmless dead code.
  • The globalThis singleton approach is the established pattern for surviving bundler code-splitting, the ??= initialization correctly handles both first-chunk and subsequent-chunk scenarios, and the Map reference capture is safe. The one minor concern — getCurrentListener() being defined but never called — is inherited dead code that doesn't affect the correctness of the fix.
  • No files require special attention beyond the single noted style issue in extensions/whatsapp/src/active-listener.ts.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/whatsapp/src/active-listener.ts
Line: 53-55

Comment:
**`getCurrentListener()` is dead code**

`getCurrentListener()` is defined here and `setCurrentListener()` faithfully writes to `_global[GLOBAL_CURRENT_KEY]`, but `getCurrentListener()` is never called anywhere in the file — neither `getActiveWebListener` nor `requireActiveWebListener` reads from it. Both functions exclusively use `listeners.get(id)` (the `GLOBAL_KEY`-backed Map).

This was true of the old `_currentListener` variable too (it was set but apparently never read), so the PR faithfully preserves the existing pattern — but it does mean `GLOBAL_CURRENT_KEY` is maintained as dead state on `globalThis`.

Consider either removing `getCurrentListener()` and `setCurrentListener()` (and the `GLOBAL_CURRENT_KEY` constant and its initializer), or wiring `getCurrentListener()` into `getActiveWebListener` as a fallback for the `DEFAULT_ACCOUNT_ID` case if that was the original intent:

```typescript
export function getActiveWebListener(accountId?: string | null): ActiveWebListener | null {
  const id = resolveWebAccountId(accountId);
  return listeners.get(id) ?? (id === DEFAULT_ACCOUNT_ID ? getCurrentListener() : null);
}
```

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

Last reviewed commit: a298168

Comment on lines +53 to +55
function getCurrentListener(): ActiveWebListener | null {
return _global[GLOBAL_CURRENT_KEY] ?? null;
}
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.

getCurrentListener() is dead code

getCurrentListener() is defined here and setCurrentListener() faithfully writes to _global[GLOBAL_CURRENT_KEY], but getCurrentListener() is never called anywhere in the file — neither getActiveWebListener nor requireActiveWebListener reads from it. Both functions exclusively use listeners.get(id) (the GLOBAL_KEY-backed Map).

This was true of the old _currentListener variable too (it was set but apparently never read), so the PR faithfully preserves the existing pattern — but it does mean GLOBAL_CURRENT_KEY is maintained as dead state on globalThis.

Consider either removing getCurrentListener() and setCurrentListener() (and the GLOBAL_CURRENT_KEY constant and its initializer), or wiring getCurrentListener() into getActiveWebListener as a fallback for the DEFAULT_ACCOUNT_ID case if that was the original intent:

export function getActiveWebListener(accountId?: string | null): ActiveWebListener | null {
  const id = resolveWebAccountId(accountId);
  return listeners.get(id) ?? (id === DEFAULT_ACCOUNT_ID ? getCurrentListener() : null);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/whatsapp/src/active-listener.ts
Line: 53-55

Comment:
**`getCurrentListener()` is dead code**

`getCurrentListener()` is defined here and `setCurrentListener()` faithfully writes to `_global[GLOBAL_CURRENT_KEY]`, but `getCurrentListener()` is never called anywhere in the file — neither `getActiveWebListener` nor `requireActiveWebListener` reads from it. Both functions exclusively use `listeners.get(id)` (the `GLOBAL_KEY`-backed Map).

This was true of the old `_currentListener` variable too (it was set but apparently never read), so the PR faithfully preserves the existing pattern — but it does mean `GLOBAL_CURRENT_KEY` is maintained as dead state on `globalThis`.

Consider either removing `getCurrentListener()` and `setCurrentListener()` (and the `GLOBAL_CURRENT_KEY` constant and its initializer), or wiring `getCurrentListener()` into `getActiveWebListener` as a fallback for the `DEFAULT_ACCOUNT_ID` case if that was the original intent:

```typescript
export function getActiveWebListener(accountId?: string | null): ActiveWebListener | null {
  const id = resolveWebAccountId(accountId);
  return listeners.get(id) ?? (id === DEFAULT_ACCOUNT_ID ? getCurrentListener() : null);
}
```

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

@fintlp
Copy link
Copy Markdown

fintlp commented Mar 15, 2026

Confirmed working as a manual patch on 2026.3.13 (macOS arm64)

We hit this exact bug today — outbound message tool fails with "No active WhatsApp Web listener" while auto-replies work perfectly. Reproduced after a status 440 session conflict caused the listener Map to go stale across chunks.

Applied the globalThis singleton fix manually by patching all 8 affected dist files:

sed -i '' 's|const listeners = /\* @__PURE__ \*/ new Map();|globalThis.__openclaw_wa_listeners = globalThis.__openclaw_wa_listeners ?? new Map(); const listeners = globalThis.__openclaw_wa_listeners;|g' <file>

After a gateway restart, outbound message tool sends work correctly again. Fix confirmed on 8 chunk files: reply-Bm8VrLQh.js, auth-profiles-DRjqKE3G.js, model-selection-46xMp11W.js, plugin-sdk/thread-bindings-SYAnWHuW.js, auth-profiles-DDVivXkv.js, model-selection-CU2b7bN6.js, discord-CcCLMjHw.js, entry.js.

Please merge/release ASAP — this is a painful bug that's hard to diagnose. 🙏 Thank you!!!

@inem26726
Copy link
Copy Markdown

inem26726 commented Mar 16, 2026

Confirming that this approach works. I manually patched my local installation (v2026.3.13) by forcing the ⁠ listeners ⁠ Map into a ⁠ globalThis ⁠ singleton across the 7 affected chunks in the ⁠ dist ⁠ folder.

Environment Details:
•⁠ ⁠OS: Debian GNU/Linux 13 (trixie)
•⁠ ⁠Kernel: 6.12.69+deb13-cloud-amd64
•⁠ ⁠Node.js: v25.6.1
•⁠ ⁠OpenClaw Version: 2026.3.13 (61d171a)

Before the patch, I was consistently hitting the 'No active WhatsApp Web listener' error when using the ⁠ message ⁠ tool, despite auto-replies working fine. After applying the singleton pattern and restarting the gateway, the ⁠ message ⁠ tool is back to normal and can successfully send outbound messages again.

Thanks for the fix, looking forward to the official merge!

@bisdom-cell
Copy link
Copy Markdown

We've been hit hard by this in production. Our setup runs OpenClaw v2026.3.13 as a WhatsApp automation hub on macOS (launchd-managed), with ~10 cron jobs that send notifications via openclaw message send.

Since upgrading to v2026.3.13, every programmatic send fails with "No active WhatsApp Web listener (account: default)" while auto-replies continue working — classic symptom of the split-Map issue described here.

Impact on our end:

  • All cron-driven WhatsApp notifications dead (ArXiv alerts, HN digests, freight monitoring, health reports)
  • Messages silently queue up with no delivery
  • Had to fall back to direct HTTP calls bypassing the CLI entirely

The globalThis singleton approach in this PR looks like the cleanest fix. Would love to see this merged — happy to test on our production instance if that helps move things along.

(ref: #48126, #38734)

@mcaxtr mcaxtr force-pushed the fix/whatsapp-active-listener-singleton branch from 1bd307d to 1c43dbf Compare March 19, 2026 01:13
@mcaxtr mcaxtr merged commit 6ae68fa into openclaw:main Mar 19, 2026
32 of 41 checks passed
@mcaxtr
Copy link
Copy Markdown
Contributor

mcaxtr commented Mar 19, 2026

Merged via squash.

Thanks @clawdia67!

cxa pushed a commit to cxa/openclaw that referenced this pull request Mar 19, 2026
…claw#47433)

Merged via squash.

Prepared head SHA: 1c43dbf
Co-authored-by: clawdia67 <[email protected]>
Co-authored-by: mcaxtr <[email protected]>
Reviewed-by: @mcaxtr
This was referenced Mar 19, 2026
@wrayclark91
Copy link
Copy Markdown

I manually (with codex) backported the already-merged fix from PR #47433 into my local 2026.3.13 install rather than waiting for the
next release.

With Codex helping map the compiled bundles, I confirmed my install had the same split-state problem described in that PR: the WhatsApp active listener registry had been duplicated across multiple dist
chunks, so one chunk registered the active listener while another chunk later read an empty listeners Map and threw:

No active WhatsApp Web listener (account: default)

The local hot patch was just a manual backport of #47433's globalThis singleton approach. In the affected compiled dist bundles, I changed the WhatsApp listener registry from:

const listeners = /* @__PURE__ */ new Map();

to:

globalThis.__openclaw_wa_listeners = globalThis.__openclaw_wa_listeners ?? /* @__PURE__ */ new Map();
const listeners = globalThis.__openclaw_wa_listeners;

In my install, the patched files were:

- discord-CcCLMjHw.js
- model-selection-46xMp11W.js
- model-selection-CU2b7bN6.js
- auth-profiles-DDVivXkv.js
- auth-profiles-DRjqKE3G.js
- reply-Bm8VrLQh.js
- plugin-sdk/thread-bindings-SYAnWHuW.js

After restarting the gateway, WhatsApp re-registered normally and delivery is working again on my system.

So this appears to confirm that PR #47433 addresses the issue; I just applied it manually to my installed 2026.3.13 bundles before an official release containing that commit was available.

udftd pushed a commit to udftd/openclaw that referenced this pull request Mar 20, 2026
…claw#47433)

Merged via squash.

Prepared head SHA: 1c43dbf
Co-authored-by: clawdia67 <[email protected]>
Co-authored-by: mcaxtr <[email protected]>
Reviewed-by: @mcaxtr
fuller-stack-dev pushed a commit to fuller-stack-dev/openclaw that referenced this pull request Mar 20, 2026
…claw#47433)

Merged via squash.

Prepared head SHA: 1c43dbf
Co-authored-by: clawdia67 <[email protected]>
Co-authored-by: mcaxtr <[email protected]>
Reviewed-by: @mcaxtr
fuller-stack-dev pushed a commit to fuller-stack-dev/openclaw that referenced this pull request Mar 20, 2026
…claw#47433)

Merged via squash.

Prepared head SHA: 1c43dbf
Co-authored-by: clawdia67 <[email protected]>
Co-authored-by: mcaxtr <[email protected]>
Reviewed-by: @mcaxtr
pholpaphankorn pushed a commit to pholpaphankorn/openclaw that referenced this pull request Mar 22, 2026
…claw#47433)

Merged via squash.

Prepared head SHA: 1c43dbf
Co-authored-by: clawdia67 <[email protected]>
Co-authored-by: mcaxtr <[email protected]>
Reviewed-by: @mcaxtr
@caddytan
Copy link
Copy Markdown

I manually backported the merged fix from PR #47433 into my local OpenClaw 2026.3.13 install rather than waiting for the next release.

The issue matched the split-state problem described in that PR: the WhatsApp active-listener registry had been duplicated across multiple compiled dist chunks, so one chunk registered the active listener while another later read a different empty Map and threw:

No active WhatsApp Web listener (account: default)

The local hotfix was to replace the per-bundle listener registry:

const listeners = /* @PURE */ new Map();

with the shared global singleton approach:

globalThis.__openclaw_wa_listeners =
globalThis.__openclaw_wa_listeners ?? /* @PURE */ new Map();
const listeners = globalThis.__openclaw_wa_listeners;

How to locate the build files
The build location can differ depending on how OpenClaw was installed. On my Raspberry Pi npm-global install, the main compiled build folder was:

/home/picoclaw/.npm-global/lib/node_modules/openclaw/dist

To find the correct folder on your own system:

find ~ -type d -name "dist" 2>/dev/null | grep openclaw

Then verify the correct folder with:

grep -R "const listeners = /* @PURE */ new Map()" /path/to/openclaw/dist

Patch only the main OpenClaw dist folder, not nested dependency dist folders under node_modules.

Patch script
Save this as patch_openclaw_wa_listener.sh:

#!/usr/bin/env bash
set -euo pipefail

TARGET_DIR="${1:-.}"
BACKUP_ROOT="${2:-./backup_openclaw_wa_patch_$(date +%Y%m%d_%H%M%S)}"

SEARCH='const listeners = /* @PURE / new Map();'
REPLACEMENT=$'globalThis.__openclaw_wa_listeners = globalThis.__openclaw_wa_listeners ?? /
@PURE */ new Map();\nconst listeners = globalThis.__openclaw_wa_listeners;'

echo "== OpenClaw WhatsApp listener hot patch =="
echo "Target dir : $TARGET_DIR"
echo "Backup dir : $BACKUP_ROOT"
echo

if [[ ! -d "$TARGET_DIR" ]]; then
echo "ERROR: target directory does not exist: $TARGET_DIR" >&2
exit 1
fi

mkdir -p "$BACKUP_ROOT"

mapfile -t files < <(grep -R -l --fixed-strings "$SEARCH" "$TARGET_DIR" 2>/dev/null || true)

if [[ ${#files[@]} -eq 0 ]]; then
echo "No matching files found."
echo "This usually means either:"
echo "1. wrong dist/build folder"
echo "2. build already includes the fix"
echo "3. compiled bundle text differs in your version"
exit 0
fi

echo "Found ${#files[@]} candidate file(s):"
for f in "${files[@]}"; do
echo " - $f"
done
echo

for f in "${files[@]}"; do
rel="${f#"$TARGET_DIR"/}"
backup_path="$BACKUP_ROOT/$rel"
mkdir -p "$(dirname "$backup_path")"
cp "$f" "$backup_path"

python3 - "$f" "$SEARCH" "$REPLACEMENT" <<'PY'
import sys
from pathlib import Path

file_path = Path(sys.argv[1])
search = sys.argv[2]
replacement = sys.argv[3]

text = file_path.read_text(encoding="utf-8")

if "globalThis.__openclaw_wa_listeners" in text:
print(f"SKIP already patched: {file_path}")
sys.exit(0)

count = text.count(search)
if count == 0:
print(f"SKIP no exact match: {file_path}")
sys.exit(0)

new_text = text.replace(search, replacement)

if new_text == text:
print(f"SKIP unchanged: {file_path}")
sys.exit(0)

file_path.write_text(new_text, encoding="utf-8")
print(f"PATCHED {file_path} ({count} occurrence(s))")
PY
done

echo
echo "Patch run complete."
echo "Backups saved under: $BACKUP_ROOT"
echo

echo "Files now containing the singleton:"
grep -R -n --fixed-strings 'globalThis.__openclaw_wa_listeners' "$TARGET_DIR" 2>/dev/null || true

echo
echo "Next step: restart your OpenClaw gateway/service."

How to use

  1. Save the script as patch_openclaw_wa_listener.sh
  2. Make it executable:
    chmod +x patch_openclaw_wa_listener.sh
  3. Run it against your main OpenClaw dist folder:
    ./patch_openclaw_wa_listener.sh /path/to/openclaw/dist
  4. Restart OpenClaw
  5. Re-test WhatsApp outbound delivery

Example from my Raspberry Pi:
./patch_openclaw_wa_listener.sh /home/picoclaw/.npm-global/lib/node_modules/openclaw/dist

How to reverse the hotfix
The script creates a timestamped backup folder before changing any files, for example:

./backup_openclaw_wa_patch_YYYYMMDD_HHMMSS

To undo the patch:

  1. Stop OpenClaw
  2. Restore the original files from the backup folder
  3. Restart OpenClaw

Safer restore command:

cd ./backup_openclaw_wa_patch_YYYYMMDD_HHMMSS
find . -type f -print0 | while IFS= read -r -d '' f; do
cp "$f" "/path/to/openclaw/dist/$f"
done

You can also find patched files with:

grep -R "globalThis.__openclaw_wa_listeners" /path/to/openclaw/dist

and manually change:

globalThis.__openclaw_wa_listeners =
globalThis.__openclaw_wa_listeners ?? /* @PURE */ new Map();
const listeners = globalThis.__openclaw_wa_listeners;

back to:

const listeners = /* @PURE */ new Map();

Notes
This is only a temporary local hotfix for installs that do not yet include PR #47433. Reinstalling or upgrading OpenClaw will usually overwrite the patched compiled bundles. On my system, after restarting the gateway, WhatsApp re-registered normally and delivery started working again.

Reference:
PR #47433
#47433

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

Labels

channel: whatsapp-web Channel integration: whatsapp-web size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WhatsApp message tool fails with 'No active WhatsApp Web listener' while auto-reply works — duplicate listener Maps across bundled chunks

9 participants