diff --git a/blog/2026/03-15-whatsapp-no-listener/index.md b/blog/2026/03-15-whatsapp-no-listener/index.md new file mode 100644 index 00000000000..38da470ea04 --- /dev/null +++ b/blog/2026/03-15-whatsapp-no-listener/index.md @@ -0,0 +1,233 @@ +--- +slug: whatsapp-online-but-no-listener +title: OpenClaw × WhatsApp:一次由 bundler 引發的 runtime state 分裂 +authors: Z. Yuan +tags: [openclaw, whatsapp, debugging, javascript, bundling] +image: /img/2026/0315-whatsapp-no-listener.svg +description: 分析 OpenClaw WhatsApp 路徑中的狀態一致性問題。 +--- + +難得有空,我決定嘗試把 OpenClaw 串接到 WhatsApp。 + +基本流程其實很順利: +當使用者從 WhatsApp 傳訊息進來時,AI Agent 可以正常回覆。 + +但當我嘗試 **從 OpenClaw 主動推送訊息到 WhatsApp** 時,系統卻始終回覆: + +``` +No active WhatsApp Web listener +``` + +奇怪的是,系統的其他訊號卻顯示一切正常。 + + + +## 問題現象 + +整體症狀呈現出一種不一致的狀態: + +- gateway log 顯示 WhatsApp inbound listener 已啟動 +- dashboard 可以正常打開 +- inbound message 可以觸發 agent 回覆 +- 但只要走 **主動 send path**,就會得到: + +``` +No active WhatsApp Web listener +``` + +換句話說: + +- **monitor path** 可以觀察到 listener +- **send path** 卻認為 listener 不存在 + +這表示問題不在 WhatsApp 連線,也不在 gateway service 本身,而是 **listener 狀態在系統內部出現了不一致的觀察結果**。 + +--- + +## 初步排查(但其實都不是) + +一開始最合理的懷疑方向其實是這些: + +- WhatsApp session 失效 +- QR pairing 流程問題 +- gateway service 沒有正確啟動 +- listener lifecycle timing race condition + +這些方向都很合理,但它們都無法解釋一個關鍵訊號: + +> monitor 明確記錄 listener 已存在,但 send path 卻仍然回報不存在。 + +這意味著 **listener 狀態不是消失,而是「被不同模組看成不同版本」**。 + +--- + +## Root Cause:bundler 造成 runtime state 分裂 + +OpenClaw 的 WhatsApp integration 內部有一份共享狀態: + +``` +active web listener registry +``` + +設計上: + +- monitor path 會 **註冊 listener** +- send path 會 **讀取 listener** + +兩者理論上應該共享同一份 module state。 + +但在 bundling 之後,事情變得不同。 + +在 build 產物中: + +- monitor code 與 send code 落在 **不同 bundle chunk** +- module-scoped store 因此被 **各自初始化** + +結果就是: + +``` +monitor chunk -> store A +send chunk -> store B +``` + +兩邊 individually 都是合理的: + +- monitor 確實寫入 listener +- send 確實找不到 listener + +但它們操作的是 **兩份完全不同的 runtime state**。 + +這也是為什麼: + +- log 看起來正確 +- error message 也正確 +- 但整體行為卻完全不一致 + +--- + +## 為什麼第一版 patch 不夠 + +最初的修補其實是在 **Homebrew 安裝的 OpenClaw 副本**裡完成的。 + +這對於驗證 diagnosis 有幫助,但不是一個可維護的解法。 + +原因很簡單: + +- 你修改的是 **安裝產物** +- 套件更新就會被覆蓋 +- 下次壞掉又要重新 patch + +因此最終修復必須回到: + +``` +~/openclaw +``` + +在 **source tree 層級完成修改並重新 build**,讓 runtime、source 與測試維持同一個維護面。 + +--- + +## 最終修復策略 + +修復的目標很單純: + +> 確保 monitor 與 send 永遠共享同一份 listener store。 + +做法是把 module-scoped state 移到 **global runtime store**: + +```javascript +const STORE_KEY = Symbol.for("openclaw.whatsapp.active-web-listener-store"); +``` + +然後將 listener registry 掛在: + +``` +globalThis[STORE_KEY] +``` + +這樣做的好處是: + +- 不同 bundle chunk 仍會命中同一個 Symbol key +- module reload 不會重新初始化 state +- 只要還在同一個 JavaScript runtime 內,就一定共享同一份 store + +換句話說: + +``` +module state -> unreliable +global runtime -> stable +``` + +--- + +## Regression Test + +修復 runtime state 的 bug,如果沒有測試,很容易在未來的 build 調整中重新出現。 + +因此這次同步加入 regression test,覆蓋以下情境: + +- module reload +- lazy load boundary +- bundle chunk boundary + +測試確保: + +- listener registry 始終指向同一份 store +- send path 不會重新取得新的 registry + +--- + +## 驗證時的一個陷阱 + +驗證這類問題時,很容易出現一個誤判來源。 + +`openclaw message send` 其實不是最佳 smoke test。 + +原因是: + +- CLI command 會啟動 **自己的 process** +- send path 是 **lazy-loaded** + +因此它只能證明: + +``` +CLI process 可以找到 listener +``` + +但不一定代表: + +``` +常駐 gateway service 的 listener 已恢復 +``` + +比較準確的驗證方式是直接呼叫: + +``` +gateway send RPC +``` + +這次的最終 smoke test 也是使用這條路徑。 + +--- + +## 工程上的通用教訓 + +當系統同時出現以下訊號時: + +- monitor 可見 +- service 存活 +- 主動操作失敗 +- 共享物件缺失 + +問題往往不在 transport layer,而是在 **runtime state ownership**。 + +特別需要檢查以下邊界: + +- process boundary +- lazy-loading boundary +- bundle chunk boundary +- global state boundary + +這類問題在表面上很像 network 或 session failure,但實際上通常是 **state 在不同 runtime context 中被複製或重新初始化**。 + +只要排查順序能優先落在 runtime state 與 module boundary,定位速度通常會快非常多。 diff --git a/blog/authors.json b/blog/authors.json index ae2fbcf851e..806d4399b6a 100644 --- a/blog/authors.json +++ b/blog/authors.json @@ -1,7 +1,7 @@ { "Z. Yuan": { "name": "Z. Yuan", - "title": "Dosaid maintainer, Full-Stack AI Engineer", + "title": "Maintainer", "url": "https://github.com/zephyr-sh", "image_url": "https://github.com/zephyr-sh.png", "socials": { diff --git a/blog/authors.yml b/blog/authors.yml index 61ad5c7127a..4b20770a734 100644 --- a/blog/authors.yml +++ b/blog/authors.yml @@ -1,6 +1,6 @@ Z. Yuan: name: Z. Yuan - title: Dosaid maintainer, Full-Stack AI Engineer + title: Maintainer url: https://github.com/zephyr-sh image_url: https://github.com/zephyr-sh.png socials: diff --git a/i18n/en/docusaurus-plugin-content-blog/2026/03-15-whatsapp-no-listener/index.md b/i18n/en/docusaurus-plugin-content-blog/2026/03-15-whatsapp-no-listener/index.md new file mode 100644 index 00000000000..77b495896d4 --- /dev/null +++ b/i18n/en/docusaurus-plugin-content-blog/2026/03-15-whatsapp-no-listener/index.md @@ -0,0 +1,219 @@ +--- +slug: whatsapp-online-but-no-listener +title: OpenClaw × WhatsApp: A Runtime State Split Triggered by the Bundler +authors: Z. Yuan +tags: [openclaw, whatsapp, debugging, javascript, bundling] +image: /img/2026/0315-whatsapp-no-listener.svg +description: An analysis of the state-consistency problem in OpenClaw's WhatsApp path. +--- + +I finally had a bit of free time, so I decided to try wiring OpenClaw into WhatsApp. + +The basic flow went smoothly: +when a user sent a message from WhatsApp, the AI agent replied normally. + +But when I tried to proactively push a message from OpenClaw to WhatsApp, the system kept responding with: + +``` +No active WhatsApp Web listener +``` + +What made it strange was that every other signal still looked healthy. + + + +## The Problem + +The overall symptom looked inconsistent: + +- the gateway log showed that the WhatsApp inbound listener had started +- the dashboard opened normally +- inbound messages could still trigger agent replies +- but the active send path always returned: + +``` +No active WhatsApp Web listener +``` + +In other words: + +- the monitor path could see the listener +- the send path believed the listener did not exist + +That suggested the issue was not the WhatsApp connection, and not the gateway service itself. The problem was that listener state was being observed inconsistently inside the system. + +## Initial Triage, But Not the Cause + +At first, the most reasonable suspects were these: + +- WhatsApp session invalidation +- a broken QR pairing flow +- the gateway service not starting correctly +- a lifecycle timing race condition around listener initialization + +All of those were plausible, but they could not explain one key signal: + +> the monitor explicitly recorded that a listener existed, yet the send path still reported that none existed. + +That meant the listener state had not disappeared. It was being seen as different versions by different modules. + +## Root Cause: Bundling Split the Runtime State + +Inside OpenClaw's WhatsApp integration, there is a piece of shared state: + +``` +active web listener registry +``` + +By design: + +- the monitor path registers the listener +- the send path reads the listener + +In theory, both sides should share the same module state. + +But after bundling, that assumption stopped being true. + +In the build output: + +- the monitor code and the send code landed in different bundle chunks +- the module-scoped store was initialized separately in each chunk + +The result looked like this: + +``` +monitor chunk -> store A +send chunk -> store B +``` + +Each side was locally reasonable: + +- the monitor really did write the listener +- the send path really could not find the listener + +But they were operating on two completely different copies of runtime state. + +That is also why: + +- the logs looked correct +- the error message looked correct too +- but the overall behavior was still inconsistent + +## Why the First Patch Was Not Enough + +The first patch was applied inside the Homebrew-installed copy of OpenClaw. + +That was useful for validating the diagnosis, but it was not a maintainable fix. + +The reason was simple: + +- you are modifying installed artifacts +- package updates can overwrite the patch +- the next failure sends you back to patching it again + +So the final fix had to move back into: + +``` +~/openclaw +``` + +The change needed to be made in the source tree and rebuilt there, so runtime behavior, source, and tests would stay aligned. + +## Final Remediation Strategy + +The remediation goal was straightforward: + +> ensure that the monitor path and the send path always share the same listener store. + +The fix was to move the module-scoped state into a global runtime store: + +```javascript +const STORE_KEY = Symbol.for("openclaw.whatsapp.active-web-listener-store"); +``` + +Then mount the listener registry on: + +``` +globalThis[STORE_KEY] +``` + +The benefits are: + +- different bundle chunks still resolve the same Symbol key +- module reload does not reinitialize the state +- as long as execution stays inside the same JavaScript runtime, it shares the same store + +In other words: + +``` +module state -> unreliable +global runtime -> stable +``` + +## Regression Test + +When a runtime-state bug is fixed without tests, it is easy for it to come back during a later build change. + +So this change also added regression coverage for: + +- module reload +- lazy-load boundaries +- bundle chunk boundaries + +The tests ensure that: + +- the listener registry always resolves to the same store +- the send path does not silently obtain a fresh registry + +## A Verification Trap + +There is an easy source of false confidence when verifying this kind of bug. + +`openclaw message send` is not the best smoke test. + +The reason is: + +- the CLI command starts its own process +- the send path is lazy-loaded + +So it can only prove: + +``` +the CLI process can find a listener +``` + +It does not necessarily prove: + +``` +the long-running gateway service has recovered its listener sharing +``` + +A more accurate verification path is to call: + +``` +gateway send RPC +``` + +That was the path used for the final smoke test in this case. + +## A Broader Engineering Lesson + +When a system shows all of the following at the same time: + +- the monitor is visible +- the service is alive +- the active operation fails +- the shared object is missing + +the problem is often not in the transport layer, but in runtime state ownership. + +The boundaries worth checking first are: + +- process boundaries +- lazy-loading boundaries +- bundle chunk boundaries +- global state boundaries + +On the surface, this kind of failure looks like a network problem or a session problem. In practice, it is often a case where state got duplicated or reinitialized across different runtime contexts. + +If runtime state and module boundaries move earlier in the debugging order, diagnosis usually gets much faster. diff --git a/i18n/ja/docusaurus-plugin-content-blog/2026/03-15-whatsapp-no-listener/index.md b/i18n/ja/docusaurus-plugin-content-blog/2026/03-15-whatsapp-no-listener/index.md new file mode 100644 index 00000000000..738d7b552f9 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-blog/2026/03-15-whatsapp-no-listener/index.md @@ -0,0 +1,221 @@ +--- +slug: whatsapp-online-but-no-listener +title: OpenClaw × WhatsApp:bundler が引き起こした runtime state の分裂 +authors: Z. Yuan +tags: [openclaw, whatsapp, debugging, javascript, bundling] +image: /img/2026/0315-whatsapp-no-listener.svg +description: OpenClaw の WhatsApp 経路で起きた state consistency 問題を分析する。 +--- + +ようやく少し時間ができたので、OpenClaw を WhatsApp につないでみることにしました。 + +基本の流れ自体はかなり順調でした。 +ユーザーが WhatsApp からメッセージを送ると、AI Agent は普通に返答できます。 + +ところが、OpenClaw から WhatsApp へ能動的にメッセージを push しようとすると、システムはずっとこう返してきました。 + +``` +No active WhatsApp Web listener +``` + +不思議なのは、ほかのシグナルはどれも正常に見えたことです。 + + + +## 問題の現れ方 + +全体の症状は、かなり不整合に見えました。 + +- gateway log では WhatsApp inbound listener が起動済みになっている +- dashboard は普通に開く +- inbound message で agent reply も発火する +- それでも能動 send path に入ると、必ず次が返る + +``` +No active WhatsApp Web listener +``` + +言い換えると: + +- monitor path からは listener が見えている +- send path からは listener が存在しないように見える + +つまり問題は WhatsApp 接続そのものでも、gateway service そのものでもありません。listener state がシステム内部で一貫しない形で観測されていたのです。 + +## 初期の切り分け、でも実際は違った + +最初に疑う方向として自然だったのは、次のようなものです。 + +- WhatsApp session の失効 +- QR pairing flow の問題 +- gateway service が正しく起動していない +- listener lifecycle の timing race condition + +どれももっともらしい仮説ですが、次の重要なシグナルを説明できません。 + +> monitor には listener が存在すると明確に記録されているのに、send path はそれでも listener 不在を返していた。 + +これは listener state が消えたのではなく、別の module から別バージョンのものとして見えていたことを意味します。 + +## Root Cause:bundler による runtime state の分裂 + +OpenClaw の WhatsApp integration には、共有状態が一つあります。 + +``` +active web listener registry +``` + +設計上は: + +- monitor path が listener を登録する +- send path が listener を読む + +理論上、この二つは同じ module state を共有しているはずです。 + +しかし bundling 後は事情が変わりました。 + +build 産物では: + +- monitor code と send code が別々の bundle chunk に分かれた +- module-scoped store がそれぞれで初期化された + +結果はこうです。 + +``` +monitor chunk -> store A +send chunk -> store B +``` + +それぞれの側だけを見ると、動きは筋が通っています。 + +- monitor は確かに listener を書き込んでいた +- send は確かに listener を見つけられなかった + +ただし、操作していたのは完全に別々の runtime state でした。 + +だからこそ: + +- log は正しく見える +- error message も正しく見える +- それでも全体の振る舞いは整合しない + +## 最初の patch では足りなかった理由 + +最初の patch は Homebrew で入れた OpenClaw のコピーに当てていました。 + +診断の正しさを確かめるには役立ちましたが、保守可能な解法ではありません。 + +理由は単純です。 + +- 触っているのはインストール済み成果物 +- package update で上書きされる +- 次に壊れたらまた patch を当て直すことになる + +そのため、最終的な修復は次へ戻す必要がありました。 + +``` +~/openclaw +``` + +source tree で修正して再 build し、runtime と source と test を同じ保守面に揃える必要がありました。 + +## 最終的な修復方針 + +目標は単純です。 + +> monitor path と send path が、常に同じ listener store を共有すること。 + +そのために module-scoped state を global runtime store へ移しました。 + +```javascript +const STORE_KEY = Symbol.for("openclaw.whatsapp.active-web-listener-store"); +``` + +そして listener registry を次にぶら下げます。 + +``` +globalThis[STORE_KEY] +``` + +この方法の利点は: + +- 別 bundle chunk でも同じ Symbol key に到達できる +- module reload で state が再初期化されない +- 同じ JavaScript runtime の中にいる限り、必ず同じ store を共有できる + +言い換えると: + +``` +module state -> unreliable +global runtime -> stable +``` + +## Regression Test + +runtime state の bug は、修正だけして test がないと、後の build 変更で簡単に戻ってきます。 + +そこで今回は次の境界に対する regression coverage も追加しました。 + +- module reload +- lazy-load boundary +- bundle chunk boundary + +test が保証しているのは次の点です。 + +- listener registry が常に同じ store を指すこと +- send path が新しい registry を取り直さないこと + +## 検証時の落とし穴 + +この種の問題を検証するときは、誤判定しやすいポイントがあります。 + +`openclaw message send` は最適な smoke test ではありません。 + +理由は: + +- CLI command が独自の process を起動する +- send path が lazy-loaded である + +つまり証明できるのは: + +``` +CLI process は listener を見つけられる +``` + +ということだけで、必ずしも: + +``` +常駐 gateway service が listener 共有を回復した +``` + +ことまでは言えません。 + +より正確な検証方法は、次を直接叩くことです。 + +``` +gateway send RPC +``` + +今回の最終 smoke test でも、この経路を使いました。 + +## 工学的に広く使える教訓 + +システムが同時に次のような信号を出しているとき: + +- monitor は見えている +- service は生きている +- 能動操作だけ失敗する +- 共有オブジェクトだけ見つからない + +疑うべきなのは transport layer ではなく、runtime state ownership であることが多いです。 + +特に先に確認したい境界は次です。 + +- process boundary +- lazy-loading boundary +- bundle chunk boundary +- global state boundary + +表面上は network failure や session failure に見える問題でも、実際には state が別 runtime context で複製されたり再初期化されたりしていることがよくあります。 + +runtime state と module boundary を早めに疑うようにすると、診断速度はかなり上がります。 diff --git a/static/img/2026/0315-whatsapp-no-listener.svg b/static/img/2026/0315-whatsapp-no-listener.svg new file mode 100644 index 00000000000..305840a0772 --- /dev/null +++ b/static/img/2026/0315-whatsapp-no-listener.svg @@ -0,0 +1,38 @@ + + + + + + + + + + OpenClaw × WhatsApp + When bundle chunks stop sharing state + + + monitor chunk + + listener registered + + + send chunk + + listener missing + + + store A + + store B + + + + + + + Fix: globalThis + Symbol.for(...) + one runtime, one listener store + + + +