From a9ec09b4f883dc45717ee8dc859aa36e809a78c7 Mon Sep 17 00:00:00 2001 From: Forge Date: Sun, 15 Mar 2026 11:56:33 +0800 Subject: [PATCH 1/3] blog: add WhatsApp listener post --- blog/2026/03-15-whatsapp-no-listener/index.md | 197 +++++++++++++++++ .../2026/03-15-whatsapp-no-listener/index.md | 201 +++++++++++++++++ .../2026/03-15-whatsapp-no-listener/index.md | 203 ++++++++++++++++++ static/img/2026/0315-whatsapp-no-listener.svg | 38 ++++ 4 files changed, 639 insertions(+) create mode 100644 blog/2026/03-15-whatsapp-no-listener/index.md create mode 100644 i18n/en/docusaurus-plugin-content-blog/2026/03-15-whatsapp-no-listener/index.md create mode 100644 i18n/ja/docusaurus-plugin-content-blog/2026/03-15-whatsapp-no-listener/index.md create mode 100644 static/img/2026/0315-whatsapp-no-listener.svg 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..dd3dbb7e2f7 --- /dev/null +++ b/blog/2026/03-15-whatsapp-no-listener/index.md @@ -0,0 +1,197 @@ +--- +slug: whatsapp-online-but-no-listener +title: WhatsApp 明明在線,為什麼 OpenClaw 一發送就說沒有 listener? +authors: Z. Yuan +tags: [openclaw, whatsapp, debugging, javascript, bundling] +image: /img/2026/0315-whatsapp-no-listener.svg +description: Gateway 明明顯示 WhatsApp 已在監聽,主動發送卻回 No active WhatsApp Web listener。問題不是連線斷了,而是 bundle chunk 把共享狀態拆成兩份。 +--- + +它看起來在線。 + +log 也說它在聽。 + +你按下發送。 + +它回你一句: + +```text +No active WhatsApp Web listener +``` + +這種錯法很適合讓人懷疑人生,因為每一邊都像在講真話。 + +monitor 端說自己已經掛上 listener。 + +send 端說自己這裡沒有 listener。 + +兩邊都沒說謊。 + +只是它們活在不同宇宙。 + +> **問題不是 WhatsApp 沒連上,而是同一份 runtime state 被 bundler 切成了兩份。** + +這次把問題、修法、還有為什麼最後要搬回 `~/openclaw` 一次寫清楚。 + + + +## 先看症狀:看起來通了,實際上送不出去 + +當時看到的現象很矛盾: + +- gateway log 會印出正在監聽 WhatsApp inbound +- dashboard 也能正常打開 +- 但一走主動發送,系統就回 `No active WhatsApp Web listener` + +如果只看表面,你很容易往錯方向走: + +- 懷疑 WhatsApp session 斷了 +- 懷疑 QR pairing 壞了 +- 懷疑 gateway service 沒起來 +- 懷疑是某一段重啟 timing 不對 + +這些方向都合理。 + +只是這次都不是主因。 + +## 真正的根因:listener 被拆成兩份,各自以為自己是唯一真相 + +OpenClaw 的 WhatsApp 路徑裡,有一個「目前活著的 web listener」共享狀態。 + +理論上: + +- monitor 路徑把 listener 註冊進去 +- send 路徑把它讀出來,拿來做主動發送 + +問題出在 bundle 之後。 + +monitor 和 send 被打進不同 chunk。 + +兩邊都 import 了 `active-listener`,但在輸出結果裡,它們拿到的是兩份彼此不共享的模組狀態。 + +結果就變成: + +- A chunk 裡真的有 listener +- B chunk 裡的 registry 還是空的 +- log 與 send 錯誤訊息都各自合理 +- 合在一起就很欠揍 + +這不是「誰蓋掉誰」那種普通 bug。 + +比較像一間辦公室複印了兩份白板,每個人都認真在自己的白板上更新進度,然後彼此很困惑為什麼對方永遠看不到。 + +## 為什麼先前在 Homebrew 那套修了,還是不夠安心 + +前一輪的止血是在 Homebrew 安裝的系統副本裡處理。 + +它可以暫時驗證方向是對的。 + +但那個位置有一個很現實的問題: + +- 你改的是安裝產物 +- 套件一更新,修補就可能被覆蓋 +- 下次再壞,還要重新追一次 + +所以這次真正該做的,不只是「讓它能送」。 + +而是把主系統搬回 `~/openclaw` 這份 repo,自行 build、自行跑 service,讓修法落在真正可維護的來源碼上。 + +這樣之後: + +- 修補不會被 Homebrew 更新順手抹掉 +- regression test 可以一起留在 repo +- gateway 真的跑的是你眼前這份程式 + +## 修法很直接:把共享狀態從模組層搬到 `globalThis` + +既然問題是 chunk 之間沒有共用模組狀態,修法就不要再賭 bundler 會替你共享。 + +這次的做法是把 active listener registry 掛到 `globalThis`,再用 `Symbol.for(...)` 拿一個穩定 key。 + +意思很簡單: + +- 不管 monitor 從哪個 chunk 進來 +- 不管 send 從哪個 chunk 進來 +- 只要還在同一個 JavaScript runtime +- 它們就會碰到同一份 store + +核心方向像這樣: + +```ts +const STORE_KEY = Symbol.for("openclaw.whatsapp.active-web-listener-store"); +``` + +然後把原本模組內的單例,改成從 `globalThis` 上初始化或讀取。 + +這種修法沒有很浪漫。 + +但它有效,而且有效得很老實。 + +## 只修程式不夠,還要補一個會咬人的測試 + +這類問題最麻煩的地方,在於它很容易「現在好了」,但下次 bundling 或 lazy load 稍微一調整,又悄悄回來。 + +所以這次不只修 `active-listener.ts`,也補了 regression test。 + +測試重點不是單純驗證 set/get。 + +而是驗證: + +- 模組重新載入之後 +- 共享 listener 仍然指向同一份 store +- send 路徑不會因為 module boundary 重新長出一個平行宇宙 + +簡單講,就是把這次真正踩到的坑,釘成一顆之後會叫的地雷。 + +## 驗證也有陷阱:不要拿 `message send` 當主系統證明 + +這次還順手釐清了一個很容易誤判的點。 + +`openclaw message send` 不是驗證主系統 WhatsApp 主動推送是否恢復的最好方法。 + +原因不是它不能送。 + +而是它走的是 CLI 自己那個 process 的 lazy-loaded send path。 + +你測到的,有可能只是: + +- 這次 CLI process 自己可以送 +- 但真正常駐的 gateway service 還沒恢復共享 listener + +如果你要驗證的是「主系統 service 已經修好」,比較準的做法是直接打 gateway 的 `send` RPC。 + +這次就是用那條路重新做 smoke test,確認主系統的送訊息路徑已經真的通了。 + +## 這次整理出來的結論,其實不只適用於 WhatsApp + +這次 bug 很像傳輸層壞掉。 + +其實不是。 + +它更接近一種 runtime 邊界問題: + +- process boundary +- lazy loading boundary +- bundle chunk boundary +- global state boundary + +這類 bug 的特點是: + +- log 常常不是假的 +- 只是每條 log 只代表它自己看到的世界 +- 你把它們拼起來,才發現世界其實裂成兩塊 + +所以如果你以後再看到某個系統表現出這種症狀: + +- 「明明在線」 +- 「明明有 monitor」 +- 「明明 service 活著」 +- 「但某條主動操作就是說找不到共享物件」 + +先不要急著怪 transport。 + +先看看是不是狀態根本沒有跨邊界共享。 + +很多時候,鬼不在網路上。 + +鬼在你自己的 runtime 裡。 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..ad767354e0b --- /dev/null +++ b/i18n/en/docusaurus-plugin-content-blog/2026/03-15-whatsapp-no-listener/index.md @@ -0,0 +1,201 @@ +--- +slug: whatsapp-online-but-no-listener +title: WhatsApp Looks Online. Why Does OpenClaw Still Say There Is No Listener? +authors: Z. Yuan +tags: [openclaw, whatsapp, debugging, javascript, bundling] +image: /img/2026/0315-whatsapp-no-listener.svg +description: The gateway said WhatsApp was listening, yet outbound send returned No active WhatsApp Web listener. The connection was not dead. Shared state had been split across bundle chunks. +--- + +It looked online. + +The log said it was listening. + +You hit send. + +It replied: + +```text +No active WhatsApp Web listener +``` + +This kind of bug is annoying because every side appears to be telling the truth. + +The monitor says it registered a listener. + +The send path says it cannot find one. + +Neither side is lying. + +They just live in different universes. + +> **The problem was not a dead WhatsApp connection. The same runtime state had been split into two copies by the bundler.** + +This is the cleaned-up version of the issue, the fix, and why the final repair had to move back into `~/openclaw`. + + + +## Start with the symptom: it looked alive, but outbound send still failed + +The behavior was contradictory: + +- the gateway log said WhatsApp inbound monitoring was active +- the dashboard still opened normally +- outbound send still returned `No active WhatsApp Web listener` + +If you stop at the surface, you end up chasing the usual suspects: + +- maybe the WhatsApp session died +- maybe QR pairing broke +- maybe the gateway service was only half alive +- maybe a restart race left something in a bad state + +Those are all reasonable guesses. + +They were just wrong this time. + +## The real cause: the listener got split in two + +There is a shared piece of state in the WhatsApp path: the currently active web listener. + +In theory: + +- the monitor path registers the listener +- the send path reads it back and uses it for outbound send + +The trouble started after bundling. + +The monitor path and send path landed in different chunks. + +Both imported `active-listener`, but after build they no longer shared the same module state. + +So the runtime ended up like this: + +- chunk A had the real listener +- chunk B had an empty registry +- the log message was true in its world +- the send error was also true in its world + +This was not a normal overwrite bug. + +It was closer to making two copies of the same office whiteboard, then wondering why both teams kept reporting different reality with a straight face. + +## Why the earlier Homebrew-side fix was not enough + +The first round of mitigation happened inside the Homebrew-installed copy. + +That was useful for proving the diagnosis. + +It was not a stable place to stop. + +The reason is boring and important: + +- you are patching installed output +- package updates can wipe the fix +- the next regression sends you back to the same chase + +So the real job was not just "make it send again." + +It was to move the main system back into the `~/openclaw` repo, build from source, and run the service from the maintained codebase. + +That way: + +- the fix does not disappear during package upgrades +- the regression test lives next to the code +- the gateway is actually running the code you just inspected + +## The fix was simple: move shared state from the module to `globalThis` + +Once the problem became "chunk boundaries do not share module state," the fix stopped being mysterious. + +We moved the active-listener registry onto `globalThis`, keyed by `Symbol.for(...)`. + +The idea is plain: + +- no matter which chunk the monitor path comes from +- no matter which chunk the send path comes from +- if they are in the same JavaScript runtime +- they reach the same store + +The core direction looked like this: + +```ts +const STORE_KEY = Symbol.for("openclaw.whatsapp.active-web-listener-store"); +``` + +Then the old module-local singleton became a shared store initialized off `globalThis`. + +Not glamorous. + +Just effective. + +## The code fix needed a test with teeth + +Bugs like this are slippery because they can look solved until the next bundling change or lazy-load adjustment quietly reintroduces them. + +So this was not only a code patch in `active-listener.ts`. + +It also got a regression test. + +The test does more than verify set/get behavior. + +It checks that: + +- the store survives module reload boundaries +- the shared listener still points at the same backing state +- the send path does not accidentally grow a second universe later + +In other words, the exact hole we fell into now has a tripwire. + +## Validation has a trap too: `message send` is not the right proof + +One more detail turned out to matter. + +`openclaw message send` is not the best way to prove that the main-system WhatsApp push path is fixed. + +Not because it never works. + +Because it exercises the CLI process and its own lazy-loaded outbound path. + +That means you can accidentally prove only this: + +- the CLI process can send +- the long-running gateway service is still wrong + +If the question is "is the main service fixed," the better check is the gateway `send` RPC. + +That is the path we used for the final smoke test, and that is the path that confirmed the repo-based main system was actually healthy again. + +## The broader lesson is not really about WhatsApp + +This bug looked like transport failure. + +It was not. + +It was a runtime-boundary bug: + +- process boundary +- lazy-loading boundary +- bundle chunk boundary +- global-state boundary + +These bugs have a particular personality: + +- the logs are often not false +- they are just reporting from different worlds +- only when you line them up do you realize the state was never shared + +So the next time a system looks like this: + +- "it is online" +- "the monitor is running" +- "the service is alive" +- "but this one active operation says the shared object does not exist" + +do not blame the transport first. + +Check whether the state actually crosses the boundary you think it does. + +Quite often the ghost is not in the network. + +It is in your own runtime. 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..35659d70a22 --- /dev/null +++ b/i18n/ja/docusaurus-plugin-content-blog/2026/03-15-whatsapp-no-listener/index.md @@ -0,0 +1,203 @@ +--- +slug: whatsapp-online-but-no-listener +title: WhatsApp はオンラインに見えるのに、なぜ OpenClaw は listener がないと言うのか +authors: Z. Yuan +tags: [openclaw, whatsapp, debugging, javascript, bundling] +image: /img/2026/0315-whatsapp-no-listener.svg +description: Gateway は WhatsApp を監視中だと言っているのに、送信すると No active WhatsApp Web listener が返る。接続が死んでいたのではなく、共有状態が bundle chunk の境界で分裂していました。 +--- + +オンラインには見えていました。 + +log も監視中だと言っていました。 + +送信すると、 + +こう返ってきます。 + +```text +No active WhatsApp Web listener +``` + +この手のバグが腹立たしいのは、どちらも真実を話しているように見えるからです。 + +monitor 側は listener を登録したと言う。 + +send 側はここには listener がないと言う。 + +どちらも嘘ではありません。 + +ただ、別の宇宙に住んでいました。 + +> **問題は WhatsApp の接続断ではなく、同じ runtime state が bundler によって二重化されていたことでした。** + +今回はその症状、修正、そして最終的に `~/openclaw` 側へ戻した理由をまとめておきます。 + + + +## まず症状:生きているように見えるのに送れない + +見えていた現象はかなり矛盾していました。 + +- gateway log には WhatsApp inbound を監視中と出る +- dashboard も普通に開く +- それでも送信すると `No active WhatsApp Web listener` が返る + +表面だけ見ると、疑う先はいくつもあります。 + +- WhatsApp session が切れたのかもしれない +- QR pairing が壊れたのかもしれない +- gateway service が半分だけ死んでいるのかもしれない +- 再起動のタイミングが悪かったのかもしれない + +どれも自然な推測です。 + +ただし今回は全部違いました。 + +## 本当の原因:listener が二つに割れた + +OpenClaw の WhatsApp 経路には、「今生きている web listener」を持つ共有状態があります。 + +本来は、 + +- monitor 側が listener を登録し +- send 側がそれを読んで送信に使う + +という流れです。 + +問題は bundle 後に起きました。 + +monitor と send が別の chunk に入りました。 + +どちらも `active-listener` を import しているのに、build 後は同じ module state を共有していませんでした。 + +結果はこうです。 + +- chunk A には本物の listener がいる +- chunk B の registry は空のまま +- log はその世界では正しい +- send のエラーもその世界では正しい + +普通の「どこかで上書きされた」系のバグではありません。 + +同じオフィスに白板を二枚置いて、それぞれが真面目に更新しているのに、なぜか話が噛み合わない感じです。 + +## 以前 Homebrew 側で直しただけでは足りなかった理由 + +最初の応急処置は Homebrew で入れたシステム側に当てました。 + +診断を確かめるには役立ちました。 + +ただ、そこに留まるのは危ない。 + +理由は単純です。 + +- 直しているのはインストール済みの成果物 +- 更新が入れば修正が消えるかもしれない +- 次に再発したとき、また同じ追跡をやり直す + +なので本当にやるべきことは、「とりあえず送れるようにする」だけではありませんでした。 + +主系統を `~/openclaw` の repo に戻し、source から build し、そのコードで service を動かすことです。 + +そうすれば: + +- 修正がパッケージ更新で消えない +- regression test を source と一緒に残せる +- gateway が本当に今見ているコードを動かす + +## 修正は素直です:共有状態を `globalThis` に出す + +問題が「chunk 境界では module state が共有されない」なら、bundler の気分に期待しない方が早いです。 + +active listener registry を `globalThis` に載せ、`Symbol.for(...)` で安定した key を使う形に変えました。 + +考え方は単純です。 + +- monitor がどの chunk から来ても +- send がどの chunk から来ても +- 同じ JavaScript runtime にいる限り +- 同じ store に着地する + +核になる方向はこうです。 + +```ts +const STORE_KEY = Symbol.for("openclaw.whatsapp.active-web-listener-store"); +``` + +もともと module 内に閉じていた singleton を、`globalThis` 上の共有 store に置き換えたわけです。 + +派手ではありません。 + +でも効きます。 + +## コードだけでなく、ちゃんと再発防止のテストも置く + +この種のバグは厄介です。 + +今は直って見えても、次の bundling 変更や lazy load の調整で静かに戻ってきます。 + +だから今回は `active-listener.ts` の修正だけでは終わらせませんでした。 + +regression test も追加しました。 + +見るポイントは単なる set/get ではなく、 + +- module reload 境界をまたいでも store が残るか +- 共有 listener が同じ backing state を見続けるか +- send 側が後から別宇宙を増やさないか + +要するに、今回落ちた穴にベルを付けました。 + +## 検証にも罠がある:`message send` を主系統の証拠にしない + +もう一つ、見落としやすい点がありました。 + +`openclaw message send` は、主系統の WhatsApp push が直った証明としては最適ではありません。 + +使えないからではありません。 + +CLI process 側の lazy-loaded send path を通るからです。 + +つまり、うっかり次のことだけを証明してしまいます。 + +- CLI process 単体では送れる +- でも常駐している gateway service はまだ壊れている + +本当に確認したいのが「主系統 service が直ったか」なら、見るべきは gateway の `send` RPC です。 + +最終 smoke test はその経路で行い、repo ベースの主系統が本当に回復したことを確認しました。 + +## この教訓は、実は WhatsApp に限らない + +見た目は transport の障害に見えました。 + +でも違いました。 + +runtime 境界の問題です。 + +- process boundary +- lazy-loading boundary +- bundle chunk boundary +- global-state boundary + +この手のバグには特徴があります。 + +- log は別に嘘ではない +- ただし各 log は自分の世界しか見ていない +- 並べて初めて、状態が共有されていなかったと分かる + +なので今後、こんな症状を見たら: + +- 「オンラインに見える」 +- 「monitor は動いている」 +- 「service も生きている」 +- 「でも能動操作だけ共有物がないと言う」 + +最初から transport を疑わない方がいいです。 + +まず、本当にその境界を越えて状態が共有されているかを見る。 + +たいてい、幽霊はネットワークの中ではありません。 + +自分の runtime の中にいます。 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 + + + + From c4c3518481b2a3648053cd6094b788aa23316878 Mon Sep 17 00:00:00 2001 From: Forge Date: Sun, 15 Mar 2026 12:14:01 +0800 Subject: [PATCH 2/3] blog: rewrite WhatsApp post as incident analysis --- blog/2026/03-15-whatsapp-no-listener/index.md | 181 +++++------------ blog/authors.json | 2 +- blog/authors.yml | 2 +- .../2026/03-15-whatsapp-no-listener/index.md | 184 +++++------------ .../2026/03-15-whatsapp-no-listener/index.md | 186 +++++------------- 5 files changed, 137 insertions(+), 418 deletions(-) diff --git a/blog/2026/03-15-whatsapp-no-listener/index.md b/blog/2026/03-15-whatsapp-no-listener/index.md index dd3dbb7e2f7..4d84ca89018 100644 --- a/blog/2026/03-15-whatsapp-no-listener/index.md +++ b/blog/2026/03-15-whatsapp-no-listener/index.md @@ -1,197 +1,108 @@ --- slug: whatsapp-online-but-no-listener -title: WhatsApp 明明在線,為什麼 OpenClaw 一發送就說沒有 listener? +title: 從 `No active WhatsApp Web listener` 到 Runtime State Split 的定位與修復 authors: Z. Yuan tags: [openclaw, whatsapp, debugging, javascript, bundling] image: /img/2026/0315-whatsapp-no-listener.svg -description: Gateway 明明顯示 WhatsApp 已在監聽,主動發送卻回 No active WhatsApp Web listener。問題不是連線斷了,而是 bundle chunk 把共享狀態拆成兩份。 +description: 分析 OpenClaw WhatsApp 路徑中 monitor 已成功註冊 listener,但 send 仍回報 `No active WhatsApp Web listener` 的狀態一致性問題,根因是 bundling 後產生分裂的 module-scoped runtime state。 --- -它看起來在線。 +## Summary -log 也說它在聽。 +本文分析一個發生在 OpenClaw WhatsApp 路徑中的狀態一致性問題:monitor 端已成功掛載 listener,但主動發送路徑仍回報 `No active WhatsApp Web listener`。根因不是 session 失效,也不是 gateway 未啟動,而是 bundling 後產生了分裂的 module-scoped runtime state,導致 monitor 與 send 讀寫的並非同一份 listener registry。 -你按下發送。 +最終修復包含兩個部分:將共享狀態從模組層移到 `globalThis`,並以 `Symbol.for(...)` 提供穩定 key;同時補上跨模組重載情境的 regression test,避免後續 bundling 或 lazy-load 調整再次導致狀態分裂。 -它回你一句: - -```text -No active WhatsApp Web listener -``` - -這種錯法很適合讓人懷疑人生,因為每一邊都像在講真話。 - -monitor 端說自己已經掛上 listener。 - -send 端說自己這裡沒有 listener。 - -兩邊都沒說謊。 - -只是它們活在不同宇宙。 - -> **問題不是 WhatsApp 沒連上,而是同一份 runtime state 被 bundler 切成了兩份。** - -這次把問題、修法、還有為什麼最後要搬回 `~/openclaw` 一次寫清楚。 +- monitor 端的監聽流程已經成功註冊 listener +- 主動發送路徑仍回報 listener 不存在 +- 問題核心不在 transport layer,而在 runtime state 的共享邊界 +- 最終維護點需要回到 repo source tree,而不是停留在 Homebrew 安裝產物 -## 先看症狀:看起來通了,實際上送不出去 +## Observed Symptoms -當時看到的現象很矛盾: +實際症狀具有明顯的不一致性: - gateway log 會印出正在監聽 WhatsApp inbound - dashboard 也能正常打開 - 但一走主動發送,系統就回 `No active WhatsApp Web listener` -如果只看表面,你很容易往錯方向走: - -- 懷疑 WhatsApp session 斷了 -- 懷疑 QR pairing 壞了 -- 懷疑 gateway service 沒起來 -- 懷疑是某一段重啟 timing 不對 +這組訊號表示 monitor path 與 send path 對 listener 狀態的觀察並不一致。從表面症狀看,系統像是局部可用,但在真正需要共享 listener 的主動發送操作上失敗。 -這些方向都合理。 +## Initial Misleading Hypotheses -只是這次都不是主因。 +初步排查時,最容易被優先懷疑的方向包括: -## 真正的根因:listener 被拆成兩份,各自以為自己是唯一真相 +- WhatsApp session 狀態失效 +- QR pairing 流程異常 +- gateway service 未正確啟動 +- listener 初始化與 send path 之間存在 lifecycle timing 問題 -OpenClaw 的 WhatsApp 路徑裡,有一個「目前活著的 web listener」共享狀態。 +這些方向都合理,但不足以解釋「monitor 已成功記錄 listener 存在,而 send 仍回報 listener 缺失」這組訊號。 -理論上: +## Root Cause -- monitor 路徑把 listener 註冊進去 -- send 路徑把它讀出來,拿來做主動發送 +OpenClaw 的 WhatsApp 路徑內存在一份「目前活躍中的 web listener」共享狀態。理論上,monitor path 會註冊 listener,而 send path 會讀取同一份 registry 以完成主動發送。 -問題出在 bundle 之後。 +實際問題發生在 bundling 之後。monitor 與 send 雖然都引用 `active-listener`,但 build 產物中它們位於不同 chunk,最終並未共享同一份 module-scoped runtime store。 -monitor 和 send 被打進不同 chunk。 +這代表問題本質不是 registry 被覆寫,而是產生了兩份彼此隔離的狀態: -兩邊都 import 了 `active-listener`,但在輸出結果裡,它們拿到的是兩份彼此不共享的模組狀態。 +- monitor chunk 寫入的是 store A +- send chunk 讀取的是 store B +- 兩端 individually 都能輸出合理訊號 +- 組合後則呈現出 listener state 不一致問題 -結果就變成: +這也是為什麼 log 與錯誤訊息各自成立,但無法共同描述真實執行狀態。 -- A chunk 裡真的有 listener -- B chunk 裡的 registry 還是空的 -- log 與 send 錯誤訊息都各自合理 -- 合在一起就很欠揍 +## Why the Existing Patch Was Not Sufficient -這不是「誰蓋掉誰」那種普通 bug。 - -比較像一間辦公室複印了兩份白板,每個人都認真在自己的白板上更新進度,然後彼此很困惑為什麼對方永遠看不到。 - -## 為什麼先前在 Homebrew 那套修了,還是不夠安心 - -前一輪的止血是在 Homebrew 安裝的系統副本裡處理。 - -它可以暫時驗證方向是對的。 - -但那個位置有一個很現實的問題: +前一輪修補是在 Homebrew 安裝的系統副本內完成。這對驗證 diagnosis 有幫助,但不適合作為最終維護點,原因很直接: - 你改的是安裝產物 - 套件一更新,修補就可能被覆蓋 - 下次再壞,還要重新追一次 -所以這次真正該做的,不只是「讓它能送」。 - -而是把主系統搬回 `~/openclaw` 這份 repo,自行 build、自行跑 service,讓修法落在真正可維護的來源碼上。 - -這樣之後: - -- 修補不會被 Homebrew 更新順手抹掉 -- regression test 可以一起留在 repo -- gateway 真的跑的是你眼前這份程式 - -## 修法很直接:把共享狀態從模組層搬到 `globalThis` - -既然問題是 chunk 之間沒有共用模組狀態,修法就不要再賭 bundler 會替你共享。 +因此,最終修復需要回到 `~/openclaw` 的 source tree,自行 build 並讓 service 直接執行 repo 版本,才能讓 source、測試與 runtime 行為維持同一個維護面。 -這次的做法是把 active listener registry 掛到 `globalThis`,再用 `Symbol.for(...)` 拿一個穩定 key。 +## Final Remediation -意思很簡單: +修復策略不追求抽象優雅,而是優先保證 runtime 一致性。做法是將 active listener registry 從模組內單例改成 `globalThis` 上的共享 store,並使用 `Symbol.for(...)` 確保不同 chunk 會命中同一個 key。 -- 不管 monitor 從哪個 chunk 進來 -- 不管 send 從哪個 chunk 進來 -- 只要還在同一個 JavaScript runtime -- 它們就會碰到同一份 store - -核心方向像這樣: +核心識別方式如下: ```ts const STORE_KEY = Symbol.for("openclaw.whatsapp.active-web-listener-store"); ``` -然後把原本模組內的單例,改成從 `globalThis` 上初始化或讀取。 - -這種修法沒有很浪漫。 - -但它有效,而且有效得很老實。 - -## 只修程式不夠,還要補一個會咬人的測試 - -這類問題最麻煩的地方,在於它很容易「現在好了」,但下次 bundling 或 lazy load 稍微一調整,又悄悄回來。 +這個調整的目的很單純:只要 monitor 與 send 還在同一個 JavaScript runtime 內,就必須讀寫同一份 listener store,而不是各自持有 chunk-local state。 -所以這次不只修 `active-listener.ts`,也補了 regression test。 +## Regression Test Strategy -測試重點不是單純驗證 set/get。 +修復之後,單靠人工驗證並不足以覆蓋後續 bundling 變化。因此這次同步補上 regression test,確認狀態共享不會在模組重載或 lazy-load 邊界重新失效。 -而是驗證: +測試關注點包括: - 模組重新載入之後 - 共享 listener 仍然指向同一份 store -- send 路徑不會因為 module boundary 重新長出一個平行宇宙 - -簡單講,就是把這次真正踩到的坑,釘成一顆之後會叫的地雷。 - -## 驗證也有陷阱:不要拿 `message send` 當主系統證明 - -這次還順手釐清了一個很容易誤判的點。 - -`openclaw message send` 不是驗證主系統 WhatsApp 主動推送是否恢復的最好方法。 - -原因不是它不能送。 +- send path 不會因為 module boundary 重新取得另一份 registry -而是它走的是 CLI 自己那個 process 的 lazy-loaded send path。 +## Verification Caveats -你測到的,有可能只是: +驗證策略本身也存在一個容易誤判的點:`openclaw message send` 並不是確認主系統 WhatsApp 推送已恢復的最佳證據。 -- 這次 CLI process 自己可以送 -- 但真正常駐的 gateway service 還沒恢復共享 listener +原因在於它走的是 CLI process 自身的 lazy-loaded send path。這能證明 CLI process 可用,但未必能證明常駐中的 gateway service 已恢復共享 listener。 -如果你要驗證的是「主系統 service 已經修好」,比較準的做法是直接打 gateway 的 `send` RPC。 +若驗證目標是「repo-based main system 是否已修復」,較準確的方式是直接打 gateway 的 `send` RPC。這次最終 smoke test 也是沿用這條路徑。 -這次就是用那條路重新做 smoke test,確認主系統的送訊息路徑已經真的通了。 +## Generalized Engineering Lessons -## 這次整理出來的結論,其實不只適用於 WhatsApp - -這次 bug 很像傳輸層壞掉。 - -其實不是。 - -它更接近一種 runtime 邊界問題: +這次事件的核心教訓是:當系統同時出現「monitor 可見、service 存活、主動操作失敗、共享物件缺失」這組症狀時,應優先檢查 runtime state 是否跨越了 process、bundle 或 lazy-load 邊界而失去一致性。 - process boundary - lazy loading boundary - bundle chunk boundary - global state boundary -這類 bug 的特點是: - -- log 常常不是假的 -- 只是每條 log 只代表它自己看到的世界 -- 你把它們拼起來,才發現世界其實裂成兩塊 - -所以如果你以後再看到某個系統表現出這種症狀: - -- 「明明在線」 -- 「明明有 monitor」 -- 「明明 service 活著」 -- 「但某條主動操作就是說找不到共享物件」 - -先不要急著怪 transport。 - -先看看是不是狀態根本沒有跨邊界共享。 - -很多時候,鬼不在網路上。 - -鬼在你自己的 runtime 裡。 +這類問題在表面上容易被誤判為 transport failure,但真正失效的往往是狀態共享模型本身。只要排查順序能優先落在 state ownership 與 runtime 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 index ad767354e0b..7e2de7ffecf 100644 --- 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 @@ -1,201 +1,105 @@ --- slug: whatsapp-online-but-no-listener -title: WhatsApp Looks Online. Why Does OpenClaw Still Say There Is No Listener? +title: Why OpenClaw Reported `No active WhatsApp Web listener` Despite an Active Monitor authors: Z. Yuan tags: [openclaw, whatsapp, debugging, javascript, bundling] image: /img/2026/0315-whatsapp-no-listener.svg -description: The gateway said WhatsApp was listening, yet outbound send returned No active WhatsApp Web listener. The connection was not dead. Shared state had been split across bundle chunks. +description: An analysis of a state-consistency failure in OpenClaw's WhatsApp path, where monitor successfully registered a listener but send still returned `No active WhatsApp Web listener` because bundling split module-scoped runtime state. --- -It looked online. +## Summary -The log said it was listening. +This article analyzes a state-consistency failure in OpenClaw's WhatsApp path: the monitor side successfully registered a listener, yet outbound send still returned `No active WhatsApp Web listener`. The root cause was neither session invalidation nor a missing gateway process. It was a bundling-induced split in module-scoped runtime state, which meant the monitor path and send path were not reading and writing the same listener registry. -You hit send. +The final remediation had two parts: move the shared registry onto `globalThis` with a stable `Symbol.for(...)` key, and add regression coverage for module reload boundaries so later bundling or lazy-load changes would not silently reintroduce the same failure mode. -It replied: - -```text -No active WhatsApp Web listener -``` - -This kind of bug is annoying because every side appears to be telling the truth. - -The monitor says it registered a listener. - -The send path says it cannot find one. - -Neither side is lying. - -They just live in different universes. - -> **The problem was not a dead WhatsApp connection. The same runtime state had been split into two copies by the bundler.** - -This is the cleaned-up version of the issue, the fix, and why the final repair had to move back into `~/openclaw`. +- the monitor path had already registered a listener +- the outbound send path still reported that no listener existed +- the failure was about runtime-state boundaries, not the transport layer +- the durable maintenance point had to move back into the repo source tree -## Start with the symptom: it looked alive, but outbound send still failed +## Observed Symptoms -The behavior was contradictory: +The observed behavior was internally inconsistent: - the gateway log said WhatsApp inbound monitoring was active - the dashboard still opened normally - outbound send still returned `No active WhatsApp Web listener` -If you stop at the surface, you end up chasing the usual suspects: - -- maybe the WhatsApp session died -- maybe QR pairing broke -- maybe the gateway service was only half alive -- maybe a restart race left something in a bad state - -Those are all reasonable guesses. +Taken together, these signals showed that the monitor path and send path were not observing the same listener state. The system looked partially healthy, but failed at the exact point where shared listener ownership was required. -They were just wrong this time. +## Initial Misleading Hypotheses -## The real cause: the listener got split in two +During initial triage, the most plausible explanations were: -There is a shared piece of state in the WhatsApp path: the currently active web listener. +- WhatsApp session invalidation +- QR pairing failure +- gateway service startup failure +- lifecycle timing issues between listener initialization and the send path -In theory: +Those hypotheses were reasonable, but they did not explain why the monitor could confirm listener registration while the send path still reported listener absence. -- the monitor path registers the listener -- the send path reads it back and uses it for outbound send +## Root Cause -The trouble started after bundling. +The WhatsApp path in OpenClaw maintains a shared piece of state: the currently active web listener. Under normal conditions, the monitor path registers the listener, and the send path reads the same registry for outbound delivery. -The monitor path and send path landed in different chunks. +The failure emerged after bundling. The monitor and send paths both imported `active-listener`, but after build they landed in different chunks and no longer shared the same module-scoped runtime store. -Both imported `active-listener`, but after build they no longer shared the same module state. - -So the runtime ended up like this: +This means the problem was not an overwritten registry. It was a split state model: - chunk A had the real listener - chunk B had an empty registry -- the log message was true in its world -- the send error was also true in its world - -This was not a normal overwrite bug. +- each side emitted locally consistent signals +- the combined outcome was a state-consistency failure -It was closer to making two copies of the same office whiteboard, then wondering why both teams kept reporting different reality with a straight face. +That is why the logs and the send error were not individually false, yet still failed to describe a coherent global runtime state. -## Why the earlier Homebrew-side fix was not enough +## Why the Existing Patch Was Not Sufficient The first round of mitigation happened inside the Homebrew-installed copy. -That was useful for proving the diagnosis. - -It was not a stable place to stop. - -The reason is boring and important: +That was useful for confirming the diagnosis, but it was not a stable maintenance point: - you are patching installed output - package updates can wipe the fix - the next regression sends you back to the same chase -So the real job was not just "make it send again." - -It was to move the main system back into the `~/openclaw` repo, build from source, and run the service from the maintained codebase. - -That way: - -- the fix does not disappear during package upgrades -- the regression test lives next to the code -- the gateway is actually running the code you just inspected - -## The fix was simple: move shared state from the module to `globalThis` - -Once the problem became "chunk boundaries do not share module state," the fix stopped being mysterious. - -We moved the active-listener registry onto `globalThis`, keyed by `Symbol.for(...)`. +The long-term fix therefore had to move back into the `~/openclaw` source tree, where source, tests, and runtime behavior remain aligned. -The idea is plain: +## Final Remediation -- no matter which chunk the monitor path comes from -- no matter which chunk the send path comes from -- if they are in the same JavaScript runtime -- they reach the same store +The remediation prioritized runtime consistency over abstraction. The active-listener registry was moved from module-local singleton state to a shared store on `globalThis`, keyed by `Symbol.for(...)`. -The core direction looked like this: +The core identifier looked like this: ```ts const STORE_KEY = Symbol.for("openclaw.whatsapp.active-web-listener-store"); ``` -Then the old module-local singleton became a shared store initialized off `globalThis`. +The goal was direct: if the monitor path and send path are still inside the same JavaScript runtime, they must resolve the same listener store rather than separate chunk-local state. -Not glamorous. +## Regression Test Strategy -Just effective. +The code change alone was not enough. This type of failure can reappear when bundling changes or lazy-load boundaries move, so regression coverage was added for the exact state-sharing behavior that failed here. -## The code fix needed a test with teeth - -Bugs like this are slippery because they can look solved until the next bundling change or lazy-load adjustment quietly reintroduces them. - -So this was not only a code patch in `active-listener.ts`. - -It also got a regression test. - -The test does more than verify set/get behavior. - -It checks that: +The test specifically verifies that: - the store survives module reload boundaries - the shared listener still points at the same backing state -- the send path does not accidentally grow a second universe later - -In other words, the exact hole we fell into now has a tripwire. - -## Validation has a trap too: `message send` is not the right proof - -One more detail turned out to matter. - -`openclaw message send` is not the best way to prove that the main-system WhatsApp push path is fixed. - -Not because it never works. - -Because it exercises the CLI process and its own lazy-loaded outbound path. - -That means you can accidentally prove only this: - -- the CLI process can send -- the long-running gateway service is still wrong - -If the question is "is the main service fixed," the better check is the gateway `send` RPC. - -That is the path we used for the final smoke test, and that is the path that confirmed the repo-based main system was actually healthy again. - -## The broader lesson is not really about WhatsApp - -This bug looked like transport failure. - -It was not. - -It was a runtime-boundary bug: - -- process boundary -- lazy-loading boundary -- bundle chunk boundary -- global-state boundary - -These bugs have a particular personality: +- the send path does not silently acquire a different registry after reloads -- the logs are often not false -- they are just reporting from different worlds -- only when you line them up do you realize the state was never shared +## Verification Caveats -So the next time a system looks like this: +Verification strategy also had an important caveat. `openclaw message send` is not the best proof that the main-system WhatsApp push path has recovered. -- "it is online" -- "the monitor is running" -- "the service is alive" -- "but this one active operation says the shared object does not exist" +The reason is not that the command never works. The reason is that it exercises the CLI process and its own lazy-loaded outbound path. -do not blame the transport first. +That can prove that the CLI process can send while leaving the long-running gateway service unverified. If the question is whether the repo-based main system is fixed, the more accurate check is the gateway `send` RPC. That is the path used for the final smoke test. -Check whether the state actually crosses the boundary you think it does. +## Generalized Engineering Lessons -Quite often the ghost is not in the network. +The main lesson from this incident is operational: when a system simultaneously shows "monitor visible, service alive, active operation failing, shared object missing," the first check should be whether runtime state still remains consistent across process, bundle, or lazy-load boundaries. -It is in your own runtime. +On the surface, these failures are easy to misclassify as transport problems. In practice, what often fails first is the state-sharing model itself. Prioritizing state ownership and runtime-boundary analysis shortens diagnosis substantially. 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 index 35659d70a22..24eaf244147 100644 --- 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 @@ -1,203 +1,107 @@ --- slug: whatsapp-online-but-no-listener -title: WhatsApp はオンラインに見えるのに、なぜ OpenClaw は listener がないと言うのか +title: Active Monitor 存在下で OpenClaw が `No active WhatsApp Web listener` を返した理由 authors: Z. Yuan tags: [openclaw, whatsapp, debugging, javascript, bundling] image: /img/2026/0315-whatsapp-no-listener.svg -description: Gateway は WhatsApp を監視中だと言っているのに、送信すると No active WhatsApp Web listener が返る。接続が死んでいたのではなく、共有状態が bundle chunk の境界で分裂していました。 +description: monitor が listener を正常に登録しているにもかかわらず send が `No active WhatsApp Web listener` を返した OpenClaw WhatsApp 経路の状態整合性問題を分析する。根因は bundling 後に発生した module-scoped runtime state の分裂でした。 --- -オンラインには見えていました。 +## Summary -log も監視中だと言っていました。 +本稿では、OpenClaw の WhatsApp 経路で発生した状態整合性問題を扱います。monitor 側は listener の登録に成功しているのに、主動送信経路では `No active WhatsApp Web listener` が返るという現象です。根因は session 失効でも gateway 未起動でもなく、bundling 後に module-scoped runtime state が分裂し、monitor と send が同一の listener registry を共有していなかったことにあります。 -送信すると、 +最終的な remediation は二段構えでした。共有 registry を `globalThis` に移し、`Symbol.for(...)` で安定した key を与えること。さらに、module reload 境界で同じ失敗が再発しないよう regression coverage を追加することです。 -こう返ってきます。 - -```text -No active WhatsApp Web listener -``` - -この手のバグが腹立たしいのは、どちらも真実を話しているように見えるからです。 - -monitor 側は listener を登録したと言う。 - -send 側はここには listener がないと言う。 - -どちらも嘘ではありません。 - -ただ、別の宇宙に住んでいました。 - -> **問題は WhatsApp の接続断ではなく、同じ runtime state が bundler によって二重化されていたことでした。** - -今回はその症状、修正、そして最終的に `~/openclaw` 側へ戻した理由をまとめておきます。 +- monitor 経路では listener 登録が完了していた +- send 経路では listener 不在が報告された +- 問題の中心は transport layer ではなく runtime state の共有境界にあった +- 恒久対応には repo source tree への回帰が必要だった -## まず症状:生きているように見えるのに送れない +## Observed Symptoms -見えていた現象はかなり矛盾していました。 +観測された症状は内部的に整合していませんでした。 - gateway log には WhatsApp inbound を監視中と出る - dashboard も普通に開く - それでも送信すると `No active WhatsApp Web listener` が返る -表面だけ見ると、疑う先はいくつもあります。 - -- WhatsApp session が切れたのかもしれない -- QR pairing が壊れたのかもしれない -- gateway service が半分だけ死んでいるのかもしれない -- 再起動のタイミングが悪かったのかもしれない - -どれも自然な推測です。 +これらの信号から分かるのは、monitor path と send path が同じ listener state を見ていないということです。表面的には部分的に正常に見えますが、共有 listener が必要な主動送信で失敗しています。 -ただし今回は全部違いました。 +## Initial Misleading Hypotheses -## 本当の原因:listener が二つに割れた +初期切り分けで優先候補になりやすいのは次の方向です。 -OpenClaw の WhatsApp 経路には、「今生きている web listener」を持つ共有状態があります。 +- WhatsApp session の失効 +- QR pairing の異常 +- gateway service の起動不全 +- listener 初期化と send path の間にある lifecycle timing 問題 -本来は、 +これらは妥当な仮説ですが、「monitor では listener 存在が確認できるのに send では欠落している」という症状全体は説明しきれません。 -- monitor 側が listener を登録し -- send 側がそれを読んで送信に使う +## Root Cause -という流れです。 +OpenClaw の WhatsApp 経路には、現在有効な web listener を保持する共有状態があります。通常は monitor path が listener を登録し、send path が同じ registry を参照して主動送信を行います。 -問題は bundle 後に起きました。 +実際の失敗点は bundling 後にありました。monitor と send はどちらも `active-listener` を import していましたが、build 産物では別 chunk に入り、同じ module-scoped runtime store を共有しなくなっていました。 -monitor と send が別の chunk に入りました。 - -どちらも `active-listener` を import しているのに、build 後は同じ module state を共有していませんでした。 - -結果はこうです。 +つまり本質は registry の上書きではありません。状態が二つに分裂していました。 - chunk A には本物の listener がいる - chunk B の registry は空のまま -- log はその世界では正しい -- send のエラーもその世界では正しい - -普通の「どこかで上書きされた」系のバグではありません。 +- それぞれの側は局所的には正しい信号を出す +- 合成すると state consistency failure として現れる -同じオフィスに白板を二枚置いて、それぞれが真面目に更新しているのに、なぜか話が噛み合わない感じです。 +このため、log と send error は individually には成立していても、全体として一つの整合した runtime state を表していませんでした。 -## 以前 Homebrew 側で直しただけでは足りなかった理由 +## Why the Existing Patch Was Not Sufficient 最初の応急処置は Homebrew で入れたシステム側に当てました。 -診断を確かめるには役立ちました。 - -ただ、そこに留まるのは危ない。 - -理由は単純です。 +診断の妥当性を確認するには十分でしたが、恒久的な保守点としては不十分です。 - 直しているのはインストール済みの成果物 - 更新が入れば修正が消えるかもしれない - 次に再発したとき、また同じ追跡をやり直す -なので本当にやるべきことは、「とりあえず送れるようにする」だけではありませんでした。 - -主系統を `~/openclaw` の repo に戻し、source から build し、そのコードで service を動かすことです。 - -そうすれば: - -- 修正がパッケージ更新で消えない -- regression test を source と一緒に残せる -- gateway が本当に今見ているコードを動かす - -## 修正は素直です:共有状態を `globalThis` に出す - -問題が「chunk 境界では module state が共有されない」なら、bundler の気分に期待しない方が早いです。 +そのため、最終的な修復は `~/openclaw` の source tree に戻し、source・test・runtime を同じ保守面に揃える必要がありました。 -active listener registry を `globalThis` に載せ、`Symbol.for(...)` で安定した key を使う形に変えました。 +## Final Remediation -考え方は単純です。 +修復方針は抽象性より runtime 一貫性を優先しました。active listener registry を module-local singleton から `globalThis` 上の共有 store に移し、`Symbol.for(...)` で安定した key を与えています。 -- monitor がどの chunk から来ても -- send がどの chunk から来ても -- 同じ JavaScript runtime にいる限り -- 同じ store に着地する - -核になる方向はこうです。 +識別子の中心は次の形です。 ```ts const STORE_KEY = Symbol.for("openclaw.whatsapp.active-web-listener-store"); ``` -もともと module 内に閉じていた singleton を、`globalThis` 上の共有 store に置き換えたわけです。 - -派手ではありません。 - -でも効きます。 - -## コードだけでなく、ちゃんと再発防止のテストも置く - -この種のバグは厄介です。 - -今は直って見えても、次の bundling 変更や lazy load の調整で静かに戻ってきます。 +意図は明確です。monitor と send が同じ JavaScript runtime に存在する限り、参照先は常に同じ listener store でなければなりません。 -だから今回は `active-listener.ts` の修正だけでは終わらせませんでした。 +## Regression Test Strategy -regression test も追加しました。 +コード修正だけでは bundling 変更に対して脆弱です。そのため、今回の failure mode を直接再現・監視する regression coverage も追加しました。 -見るポイントは単なる set/get ではなく、 +確認しているのは単純な set/get ではありません。 - module reload 境界をまたいでも store が残るか - 共有 listener が同じ backing state を見続けるか -- send 側が後から別宇宙を増やさないか - -要するに、今回落ちた穴にベルを付けました。 - -## 検証にも罠がある:`message send` を主系統の証拠にしない - -もう一つ、見落としやすい点がありました。 - -`openclaw message send` は、主系統の WhatsApp push が直った証明としては最適ではありません。 - -使えないからではありません。 - -CLI process 側の lazy-loaded send path を通るからです。 - -つまり、うっかり次のことだけを証明してしまいます。 - -- CLI process 単体では送れる -- でも常駐している gateway service はまだ壊れている - -本当に確認したいのが「主系統 service が直ったか」なら、見るべきは gateway の `send` RPC です。 - -最終 smoke test はその経路で行い、repo ベースの主系統が本当に回復したことを確認しました。 - -## この教訓は、実は WhatsApp に限らない - -見た目は transport の障害に見えました。 - -でも違いました。 - -runtime 境界の問題です。 - -- process boundary -- lazy-loading boundary -- bundle chunk boundary -- global-state boundary +- send path が別の registry を取得しないか -この手のバグには特徴があります。 +## Verification Caveats -- log は別に嘘ではない -- ただし各 log は自分の世界しか見ていない -- 並べて初めて、状態が共有されていなかったと分かる +検証手順にも注意点があります。`openclaw message send` は主系統の WhatsApp push が回復したことを証明する最適な方法ではありません。 -なので今後、こんな症状を見たら: +理由は、そのコマンドが CLI process 自身の lazy-loaded send path を通るためです。 -- 「オンラインに見える」 -- 「monitor は動いている」 -- 「service も生きている」 -- 「でも能動操作だけ共有物がないと言う」 +つまり確認できるのは「CLI process は送れる」ことであり、「常駐 gateway service が listener を共有できている」ことではありません。 -最初から transport を疑わない方がいいです。 +repo-based main system の修復を検証するには、gateway の `send` RPC を直接叩く方が適切です。最終 smoke test もこの経路で実施しました。 -まず、本当にその境界を越えて状態が共有されているかを見る。 +## Generalized Engineering Lessons -たいてい、幽霊はネットワークの中ではありません。 +この事例の教訓は再利用可能です。システムが同時に「monitor は見えている」「service は生きている」「能動操作だけ失敗する」「共有オブジェクトだけ欠落する」という症状を示した場合、最初に確認すべきなのは runtime state が process・bundle・lazy-load 境界をまたいで一貫しているかどうかです。 -自分の runtime の中にいます。 +表面的には transport failure に見えやすい問題でも、実際に先に壊れているのは state-sharing model であることがあります。state ownership と runtime boundary を優先して点検する方が、診断は速く安定します。 From f98b5eb65147e3322b3b7a8bb955594d3b849244 Mon Sep 17 00:00:00 2001 From: Forge Date: Sun, 15 Mar 2026 12:37:45 +0800 Subject: [PATCH 3/3] blog: update WhatsApp listener analysis to clarify runtime state split issue --- blog/2026/03-15-whatsapp-no-listener/index.md | 237 +++++++++++++----- .../2026/03-15-whatsapp-no-listener/index.md | 224 +++++++++++++---- .../2026/03-15-whatsapp-no-listener/index.md | 226 ++++++++++++----- 3 files changed, 520 insertions(+), 167 deletions(-) diff --git a/blog/2026/03-15-whatsapp-no-listener/index.md b/blog/2026/03-15-whatsapp-no-listener/index.md index 4d84ca89018..38da470ea04 100644 --- a/blog/2026/03-15-whatsapp-no-listener/index.md +++ b/blog/2026/03-15-whatsapp-no-listener/index.md @@ -1,108 +1,233 @@ --- slug: whatsapp-online-but-no-listener -title: 從 `No active WhatsApp Web listener` 到 Runtime State Split 的定位與修復 +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 路徑中 monitor 已成功註冊 listener,但 send 仍回報 `No active WhatsApp Web listener` 的狀態一致性問題,根因是 bundling 後產生分裂的 module-scoped runtime state。 +description: 分析 OpenClaw WhatsApp 路徑中的狀態一致性問題。 --- -## Summary +難得有空,我決定嘗試把 OpenClaw 串接到 WhatsApp。 -本文分析一個發生在 OpenClaw WhatsApp 路徑中的狀態一致性問題:monitor 端已成功掛載 listener,但主動發送路徑仍回報 `No active WhatsApp Web listener`。根因不是 session 失效,也不是 gateway 未啟動,而是 bundling 後產生了分裂的 module-scoped runtime state,導致 monitor 與 send 讀寫的並非同一份 listener registry。 +基本流程其實很順利: +當使用者從 WhatsApp 傳訊息進來時,AI Agent 可以正常回覆。 -最終修復包含兩個部分:將共享狀態從模組層移到 `globalThis`,並以 `Symbol.for(...)` 提供穩定 key;同時補上跨模組重載情境的 regression test,避免後續 bundling 或 lazy-load 調整再次導致狀態分裂。 +但當我嘗試 **從 OpenClaw 主動推送訊息到 WhatsApp** 時,系統卻始終回覆: -- monitor 端的監聽流程已經成功註冊 listener -- 主動發送路徑仍回報 listener 不存在 -- 問題核心不在 transport layer,而在 runtime state 的共享邊界 -- 最終維護點需要回到 repo source tree,而不是停留在 Homebrew 安裝產物 +``` +No active WhatsApp Web listener +``` + +奇怪的是,系統的其他訊號卻顯示一切正常。 -## Observed Symptoms +## 問題現象 + +整體症狀呈現出一種不一致的狀態: + +- 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 +``` -- gateway log 會印出正在監聽 WhatsApp inbound -- dashboard 也能正常打開 -- 但一走主動發送,系統就回 `No active WhatsApp Web listener` +設計上: -這組訊號表示 monitor path 與 send path 對 listener 狀態的觀察並不一致。從表面症狀看,系統像是局部可用,但在真正需要共享 listener 的主動發送操作上失敗。 +- monitor path 會 **註冊 listener** +- send path 會 **讀取 listener** -## Initial Misleading Hypotheses +兩者理論上應該共享同一份 module state。 -初步排查時,最容易被優先懷疑的方向包括: +但在 bundling 之後,事情變得不同。 -- WhatsApp session 狀態失效 -- QR pairing 流程異常 -- gateway service 未正確啟動 -- listener 初始化與 send path 之間存在 lifecycle timing 問題 +在 build 產物中: -這些方向都合理,但不足以解釋「monitor 已成功記錄 listener 存在,而 send 仍回報 listener 缺失」這組訊號。 +- monitor code 與 send code 落在 **不同 bundle chunk** +- module-scoped store 因此被 **各自初始化** -## Root Cause +結果就是: -OpenClaw 的 WhatsApp 路徑內存在一份「目前活躍中的 web listener」共享狀態。理論上,monitor path 會註冊 listener,而 send path 會讀取同一份 registry 以完成主動發送。 +``` +monitor chunk -> store A +send chunk -> store B +``` -實際問題發生在 bundling 之後。monitor 與 send 雖然都引用 `active-listener`,但 build 產物中它們位於不同 chunk,最終並未共享同一份 module-scoped runtime store。 +兩邊 individually 都是合理的: -這代表問題本質不是 registry 被覆寫,而是產生了兩份彼此隔離的狀態: +- monitor 確實寫入 listener +- send 確實找不到 listener -- monitor chunk 寫入的是 store A -- send chunk 讀取的是 store B -- 兩端 individually 都能輸出合理訊號 -- 組合後則呈現出 listener state 不一致問題 +但它們操作的是 **兩份完全不同的 runtime state**。 -這也是為什麼 log 與錯誤訊息各自成立,但無法共同描述真實執行狀態。 +這也是為什麼: -## Why the Existing Patch Was Not Sufficient +- log 看起來正確 +- error message 也正確 +- 但整體行為卻完全不一致 + +--- -前一輪修補是在 Homebrew 安裝的系統副本內完成。這對驗證 diagnosis 有幫助,但不適合作為最終維護點,原因很直接: +## 為什麼第一版 patch 不夠 -- 你改的是安裝產物 -- 套件一更新,修補就可能被覆蓋 -- 下次再壞,還要重新追一次 +最初的修補其實是在 **Homebrew 安裝的 OpenClaw 副本**裡完成的。 -因此,最終修復需要回到 `~/openclaw` 的 source tree,自行 build 並讓 service 直接執行 repo 版本,才能讓 source、測試與 runtime 行為維持同一個維護面。 +這對於驗證 diagnosis 有幫助,但不是一個可維護的解法。 + +原因很簡單: + +- 你修改的是 **安裝產物** +- 套件更新就會被覆蓋 +- 下次壞掉又要重新 patch + +因此最終修復必須回到: + +``` +~/openclaw +``` + +在 **source tree 層級完成修改並重新 build**,讓 runtime、source 與測試維持同一個維護面。 + +--- -## Final Remediation +## 最終修復策略 -修復策略不追求抽象優雅,而是優先保證 runtime 一致性。做法是將 active listener registry 從模組內單例改成 `globalThis` 上的共享 store,並使用 `Symbol.for(...)` 確保不同 chunk 會命中同一個 key。 +修復的目標很單純: -核心識別方式如下: +> 確保 monitor 與 send 永遠共享同一份 listener store。 -```ts +做法是把 module-scoped state 移到 **global runtime store**: + +```javascript const STORE_KEY = Symbol.for("openclaw.whatsapp.active-web-listener-store"); ``` -這個調整的目的很單純:只要 monitor 與 send 還在同一個 JavaScript runtime 內,就必須讀寫同一份 listener store,而不是各自持有 chunk-local state。 +然後將 listener registry 掛在: + +``` +globalThis[STORE_KEY] +``` + +這樣做的好處是: -## Regression Test Strategy +- 不同 bundle chunk 仍會命中同一個 Symbol key +- module reload 不會重新初始化 state +- 只要還在同一個 JavaScript runtime 內,就一定共享同一份 store -修復之後,單靠人工驗證並不足以覆蓋後續 bundling 變化。因此這次同步補上 regression test,確認狀態共享不會在模組重載或 lazy-load 邊界重新失效。 +換句話說: -測試關注點包括: +``` +module state -> unreliable +global runtime -> stable +``` -- 模組重新載入之後 -- 共享 listener 仍然指向同一份 store -- send path 不會因為 module boundary 重新取得另一份 registry +--- -## Verification Caveats +## Regression Test -驗證策略本身也存在一個容易誤判的點:`openclaw message send` 並不是確認主系統 WhatsApp 推送已恢復的最佳證據。 +修復 runtime state 的 bug,如果沒有測試,很容易在未來的 build 調整中重新出現。 -原因在於它走的是 CLI process 自身的 lazy-loaded send path。這能證明 CLI process 可用,但未必能證明常駐中的 gateway service 已恢復共享 listener。 +因此這次同步加入 regression test,覆蓋以下情境: -若驗證目標是「repo-based main system 是否已修復」,較準確的方式是直接打 gateway 的 `send` RPC。這次最終 smoke test 也是沿用這條路徑。 +- module reload +- lazy load boundary +- bundle chunk boundary -## Generalized Engineering Lessons +測試確保: -這次事件的核心教訓是:當系統同時出現「monitor 可見、service 存活、主動操作失敗、共享物件缺失」這組症狀時,應優先檢查 runtime state 是否跨越了 process、bundle 或 lazy-load 邊界而失去一致性。 +- 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 +- lazy-loading boundary - bundle chunk boundary - global state boundary -這類問題在表面上容易被誤判為 transport failure,但真正失效的往往是狀態共享模型本身。只要排查順序能優先落在 state ownership 與 runtime boundary,定位速度通常會明顯改善。 +這類問題在表面上很像 network 或 session failure,但實際上通常是 **state 在不同 runtime context 中被複製或重新初始化**。 + +只要排查順序能優先落在 runtime state 與 module boundary,定位速度通常會快非常多。 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 index 7e2de7ffecf..77b495896d4 100644 --- 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 @@ -1,105 +1,219 @@ --- slug: whatsapp-online-but-no-listener -title: Why OpenClaw Reported `No active WhatsApp Web listener` Despite an Active Monitor +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 a state-consistency failure in OpenClaw's WhatsApp path, where monitor successfully registered a listener but send still returned `No active WhatsApp Web listener` because bundling split module-scoped runtime state. +description: An analysis of the state-consistency problem in OpenClaw's WhatsApp path. --- -## Summary +I finally had a bit of free time, so I decided to try wiring OpenClaw into WhatsApp. -This article analyzes a state-consistency failure in OpenClaw's WhatsApp path: the monitor side successfully registered a listener, yet outbound send still returned `No active WhatsApp Web listener`. The root cause was neither session invalidation nor a missing gateway process. It was a bundling-induced split in module-scoped runtime state, which meant the monitor path and send path were not reading and writing the same listener registry. +The basic flow went smoothly: +when a user sent a message from WhatsApp, the AI agent replied normally. -The final remediation had two parts: move the shared registry onto `globalThis` with a stable `Symbol.for(...)` key, and add regression coverage for module reload boundaries so later bundling or lazy-load changes would not silently reintroduce the same failure mode. +But when I tried to proactively push a message from OpenClaw to WhatsApp, the system kept responding with: -- the monitor path had already registered a listener -- the outbound send path still reported that no listener existed -- the failure was about runtime-state boundaries, not the transport layer -- the durable maintenance point had to move back into the repo source tree +``` +No active WhatsApp Web listener +``` + +What made it strange was that every other signal still looked healthy. -## Observed Symptoms +## 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 +``` -The observed behavior was internally inconsistent: +In other words: -- the gateway log said WhatsApp inbound monitoring was active -- the dashboard still opened normally -- outbound send still returned `No active WhatsApp Web listener` +- the monitor path could see the listener +- the send path believed the listener did not exist -Taken together, these signals showed that the monitor path and send path were not observing the same listener state. The system looked partially healthy, but failed at the exact point where shared listener ownership was required. +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 Misleading Hypotheses +## Initial Triage, But Not the Cause -During initial triage, the most plausible explanations were: +At first, the most reasonable suspects were these: - WhatsApp session invalidation -- QR pairing failure -- gateway service startup failure -- lifecycle timing issues between listener initialization and the send path +- 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. -Those hypotheses were reasonable, but they did not explain why the monitor could confirm listener registration while the send path still reported listener absence. +But after bundling, that assumption stopped being true. -## Root Cause +In the build output: -The WhatsApp path in OpenClaw maintains a shared piece of state: the currently active web listener. Under normal conditions, the monitor path registers the listener, and the send path reads the same registry for outbound delivery. +- 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 +``` -The failure emerged after bundling. The monitor and send paths both imported `active-listener`, but after build they landed in different chunks and no longer shared the same module-scoped runtime store. +Each side was locally reasonable: -This means the problem was not an overwritten registry. It was a split state model: +- the monitor really did write the listener +- the send path really could not find the listener -- chunk A had the real listener -- chunk B had an empty registry -- each side emitted locally consistent signals -- the combined outcome was a state-consistency failure +But they were operating on two completely different copies of runtime state. -That is why the logs and the send error were not individually false, yet still failed to describe a coherent global runtime state. +That is also why: -## Why the Existing Patch Was Not Sufficient +- the logs looked correct +- the error message looked correct too +- but the overall behavior was still inconsistent -The first round of mitigation happened inside the Homebrew-installed copy. +## Why the First Patch Was Not Enough -That was useful for confirming the diagnosis, but it was not a stable maintenance point: +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 +``` -- you are patching installed output -- package updates can wipe the fix -- the next regression sends you back to the same chase +The change needed to be made in the source tree and rebuilt there, so runtime behavior, source, and tests would stay aligned. -The long-term fix therefore had to move back into the `~/openclaw` source tree, where source, tests, and runtime behavior remain aligned. +## Final Remediation Strategy -## Final Remediation +The remediation goal was straightforward: -The remediation prioritized runtime consistency over abstraction. The active-listener registry was moved from module-local singleton state to a shared store on `globalThis`, keyed by `Symbol.for(...)`. +> ensure that the monitor path and the send path always share the same listener store. -The core identifier looked like this: +The fix was to move the module-scoped state into a global runtime store: -```ts +```javascript const STORE_KEY = Symbol.for("openclaw.whatsapp.active-web-listener-store"); ``` -The goal was direct: if the monitor path and send path are still inside the same JavaScript runtime, they must resolve the same listener store rather than separate chunk-local state. +Then mount the listener registry on: -## Regression Test Strategy +``` +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 -The code change alone was not enough. This type of failure can reappear when bundling changes or lazy-load boundaries move, so regression coverage was added for the exact state-sharing behavior that failed here. +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 +``` -The test specifically verifies that: +That was the path used for the final smoke test in this case. -- the store survives module reload boundaries -- the shared listener still points at the same backing state -- the send path does not silently acquire a different registry after reloads +## A Broader Engineering Lesson -## Verification Caveats +When a system shows all of the following at the same time: -Verification strategy also had an important caveat. `openclaw message send` is not the best proof that the main-system WhatsApp push path has recovered. +- the monitor is visible +- the service is alive +- the active operation fails +- the shared object is missing -The reason is not that the command never works. The reason is that it exercises the CLI process and its own lazy-loaded outbound path. +the problem is often not in the transport layer, but in runtime state ownership. -That can prove that the CLI process can send while leaving the long-running gateway service unverified. If the question is whether the repo-based main system is fixed, the more accurate check is the gateway `send` RPC. That is the path used for the final smoke test. +The boundaries worth checking first are: -## Generalized Engineering Lessons +- process boundaries +- lazy-loading boundaries +- bundle chunk boundaries +- global state boundaries -The main lesson from this incident is operational: when a system simultaneously shows "monitor visible, service alive, active operation failing, shared object missing," the first check should be whether runtime state still remains consistent across process, bundle, or lazy-load 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. -On the surface, these failures are easy to misclassify as transport problems. In practice, what often fails first is the state-sharing model itself. Prioritizing state ownership and runtime-boundary analysis shortens diagnosis substantially. +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 index 24eaf244147..738d7b552f9 100644 --- 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 @@ -1,107 +1,221 @@ --- slug: whatsapp-online-but-no-listener -title: Active Monitor 存在下で OpenClaw が `No active WhatsApp Web 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: monitor が listener を正常に登録しているにもかかわらず send が `No active WhatsApp Web listener` を返した OpenClaw WhatsApp 経路の状態整合性問題を分析する。根因は bundling 後に発生した module-scoped runtime state の分裂でした。 +description: OpenClaw の WhatsApp 経路で起きた state consistency 問題を分析する。 --- -## Summary +ようやく少し時間ができたので、OpenClaw を WhatsApp につないでみることにしました。 -本稿では、OpenClaw の WhatsApp 経路で発生した状態整合性問題を扱います。monitor 側は listener の登録に成功しているのに、主動送信経路では `No active WhatsApp Web listener` が返るという現象です。根因は session 失効でも gateway 未起動でもなく、bundling 後に module-scoped runtime state が分裂し、monitor と send が同一の listener registry を共有していなかったことにあります。 +基本の流れ自体はかなり順調でした。 +ユーザーが WhatsApp からメッセージを送ると、AI Agent は普通に返答できます。 -最終的な remediation は二段構えでした。共有 registry を `globalThis` に移し、`Symbol.for(...)` で安定した key を与えること。さらに、module reload 境界で同じ失敗が再発しないよう regression coverage を追加することです。 +ところが、OpenClaw から WhatsApp へ能動的にメッセージを push しようとすると、システムはずっとこう返してきました。 -- monitor 経路では listener 登録が完了していた -- send 経路では listener 不在が報告された -- 問題の中心は transport layer ではなく runtime state の共有境界にあった -- 恒久対応には repo source tree への回帰が必要だった +``` +No active WhatsApp Web listener +``` + +不思議なのは、ほかのシグナルはどれも正常に見えたことです。 -## Observed Symptoms +## 問題の現れ方 + +全体の症状は、かなり不整合に見えました。 + +- gateway log では WhatsApp inbound listener が起動済みになっている +- dashboard は普通に開く +- inbound message で agent reply も発火する +- それでも能動 send path に入ると、必ず次が返る + +``` +No active WhatsApp Web listener +``` -観測された症状は内部的に整合していませんでした。 +言い換えると: -- gateway log には WhatsApp inbound を監視中と出る -- dashboard も普通に開く -- それでも送信すると `No active WhatsApp Web listener` が返る +- monitor path からは listener が見えている +- send path からは listener が存在しないように見える -これらの信号から分かるのは、monitor path と send path が同じ listener state を見ていないということです。表面的には部分的に正常に見えますが、共有 listener が必要な主動送信で失敗しています。 +つまり問題は WhatsApp 接続そのものでも、gateway service そのものでもありません。listener state がシステム内部で一貫しない形で観測されていたのです。 -## Initial Misleading Hypotheses +## 初期の切り分け、でも実際は違った -初期切り分けで優先候補になりやすいのは次の方向です。 +最初に疑う方向として自然だったのは、次のようなものです。 - WhatsApp session の失効 -- QR pairing の異常 -- gateway service の起動不全 -- listener 初期化と send path の間にある lifecycle timing 問題 +- 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 を共有しているはずです。 -これらは妥当な仮説ですが、「monitor では listener 存在が確認できるのに send では欠落している」という症状全体は説明しきれません。 +しかし bundling 後は事情が変わりました。 -## Root Cause +build 産物では: -OpenClaw の WhatsApp 経路には、現在有効な web listener を保持する共有状態があります。通常は monitor path が listener を登録し、send path が同じ registry を参照して主動送信を行います。 +- monitor code と send code が別々の bundle chunk に分かれた +- module-scoped store がそれぞれで初期化された + +結果はこうです。 + +``` +monitor chunk -> store A +send chunk -> store B +``` -実際の失敗点は bundling 後にありました。monitor と send はどちらも `active-listener` を import していましたが、build 産物では別 chunk に入り、同じ module-scoped runtime store を共有しなくなっていました。 +それぞれの側だけを見ると、動きは筋が通っています。 -つまり本質は registry の上書きではありません。状態が二つに分裂していました。 +- monitor は確かに listener を書き込んでいた +- send は確かに listener を見つけられなかった -- chunk A には本物の listener がいる -- chunk B の registry は空のまま -- それぞれの側は局所的には正しい信号を出す -- 合成すると state consistency failure として現れる +ただし、操作していたのは完全に別々の runtime state でした。 -このため、log と send error は individually には成立していても、全体として一つの整合した runtime state を表していませんでした。 +だからこそ: -## Why the Existing Patch Was Not Sufficient +- log は正しく見える +- error message も正しく見える +- それでも全体の振る舞いは整合しない -最初の応急処置は Homebrew で入れたシステム側に当てました。 +## 最初の patch では足りなかった理由 -診断の妥当性を確認するには十分でしたが、恒久的な保守点としては不十分です。 +最初の patch は Homebrew で入れた OpenClaw のコピーに当てていました。 + +診断の正しさを確かめるには役立ちましたが、保守可能な解法ではありません。 + +理由は単純です。 + +- 触っているのはインストール済み成果物 +- package update で上書きされる +- 次に壊れたらまた patch を当て直すことになる + +そのため、最終的な修復は次へ戻す必要がありました。 + +``` +~/openclaw +``` -- 直しているのはインストール済みの成果物 -- 更新が入れば修正が消えるかもしれない -- 次に再発したとき、また同じ追跡をやり直す +source tree で修正して再 build し、runtime と source と test を同じ保守面に揃える必要がありました。 -そのため、最終的な修復は `~/openclaw` の source tree に戻し、source・test・runtime を同じ保守面に揃える必要がありました。 +## 最終的な修復方針 -## Final Remediation +目標は単純です。 -修復方針は抽象性より runtime 一貫性を優先しました。active listener registry を module-local singleton から `globalThis` 上の共有 store に移し、`Symbol.for(...)` で安定した key を与えています。 +> monitor path と send path が、常に同じ listener store を共有すること。 -識別子の中心は次の形です。 +そのために module-scoped state を global runtime store へ移しました。 -```ts +```javascript const STORE_KEY = Symbol.for("openclaw.whatsapp.active-web-listener-store"); ``` -意図は明確です。monitor と send が同じ JavaScript runtime に存在する限り、参照先は常に同じ listener store でなければなりません。 +そして listener registry を次にぶら下げます。 -## Regression Test Strategy +``` +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 を取り直さないこと + +## 検証時の落とし穴 -コード修正だけでは bundling 変更に対して脆弱です。そのため、今回の failure mode を直接再現・監視する regression coverage も追加しました。 +この種の問題を検証するときは、誤判定しやすいポイントがあります。 -確認しているのは単純な set/get ではありません。 +`openclaw message send` は最適な smoke test ではありません。 + +理由は: + +- CLI command が独自の process を起動する +- send path が lazy-loaded である + +つまり証明できるのは: + +``` +CLI process は listener を見つけられる +``` + +ということだけで、必ずしも: + +``` +常駐 gateway service が listener 共有を回復した +``` + +ことまでは言えません。 + +より正確な検証方法は、次を直接叩くことです。 + +``` +gateway send RPC +``` -- module reload 境界をまたいでも store が残るか -- 共有 listener が同じ backing state を見続けるか -- send path が別の registry を取得しないか +今回の最終 smoke test でも、この経路を使いました。 -## Verification Caveats +## 工学的に広く使える教訓 -検証手順にも注意点があります。`openclaw message send` は主系統の WhatsApp push が回復したことを証明する最適な方法ではありません。 +システムが同時に次のような信号を出しているとき: -理由は、そのコマンドが CLI process 自身の lazy-loaded send path を通るためです。 +- monitor は見えている +- service は生きている +- 能動操作だけ失敗する +- 共有オブジェクトだけ見つからない -つまり確認できるのは「CLI process は送れる」ことであり、「常駐 gateway service が listener を共有できている」ことではありません。 +疑うべきなのは transport layer ではなく、runtime state ownership であることが多いです。 -repo-based main system の修復を検証するには、gateway の `send` RPC を直接叩く方が適切です。最終 smoke test もこの経路で実施しました。 +特に先に確認したい境界は次です。 -## Generalized Engineering Lessons +- process boundary +- lazy-loading boundary +- bundle chunk boundary +- global state boundary -この事例の教訓は再利用可能です。システムが同時に「monitor は見えている」「service は生きている」「能動操作だけ失敗する」「共有オブジェクトだけ欠落する」という症状を示した場合、最初に確認すべきなのは runtime state が process・bundle・lazy-load 境界をまたいで一貫しているかどうかです。 +表面上は network failure や session failure に見える問題でも、実際には state が別 runtime context で複製されたり再初期化されたりしていることがよくあります。 -表面的には transport failure に見えやすい問題でも、実際に先に壊れているのは state-sharing model であることがあります。state ownership と runtime boundary を優先して点検する方が、診断は速く安定します。 +runtime state と module boundary を早めに疑うようにすると、診断速度はかなり上がります。