{"repo":"NousResearch/hermes-agent","public_id":"19124","display_label":"WhatsApp disable config and bridge process leak","summary":"These issues and fixes address two related WhatsApp bugs: the inability to properly disable the WhatsApp platform via UI or CLI, and a bridge process leak caused by asymmetry in the disable configuration handling.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21229,"merged_at":"2026-05-07T12:38:09+00:00"},"resolution":{"state":"resolved","by":"pr-21229"},"freshness":{"state":"verified","last_verified_at":"2026-05-07T12:38:09+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":19124,"pr_number":21229,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-07T12:38:09+00:00"}],"members":[{"kind":"issue","number":19124,"title":"WhatsApp platform cannot be disabled via UI/CLI + bridge process leak","url":null,"state":"closed"},{"kind":"pr","number":19138,"title":"fix: WhatsApp bridge process leak and disable config asymmetry","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":19999,"title":"fix(gateway): honor WhatsApp config disable","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":21229,"title":"fix(whatsapp): bridge process leak + disable config asymmetry (salvage #19138)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/whatsapp.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -1152,10 +1152,17 @@ def _apply_env_overrides(config: GatewayConfig) -> None:\n     \n     # WhatsApp (typically uses different auth mechanism)\n     whatsapp_enabled = os.getenv(\"WHATSAPP_ENABLED\", \"\").lower() in (\"true\", \"1\", \"yes\")\n-    if whatsapp_enabled:\n-        if Platform.WHATSAPP not in config.platforms:\n-            config.platforms[Platform.WHATSAPP] = PlatformConfig()\n-        config.platforms[Platform.WHATSAPP].enabled = True\n+    whatsapp_disabled_explicitly = os.getenv(\"WHATSAPP_ENABLED\", \"\").lower() in (\"false\", \"0\", \"no\")\n+    if Platform.WHATSAPP in config.platforms:\n+        # YAML config exists — respect explicit disable\n+        wa_cfg = config.platforms[Platform.WHATSAPP]\n+        if whatsapp_disabled_explicitly:\n+            wa_cfg.enabled = False\n+        elif whatsapp_enabled:\n+            wa_cfg.enabled = True\n+        # else: keep whatever the YAML set\n+    elif whatsapp_enabled:\n+        config.platforms[Platform.WHATSAPP] = PlatformConfig(enabled=True)\n     whatsapp_home = os.getenv(\"WHATSAPP_HOME_CHANNEL\")\n     if whatsapp_home and Platform.WHATSAPP in config.platforms:\n         config.platforms[Platform.WHATSAPP].home_channel = HomeChannel("]},{"file":"gateway/platforms/whatsapp.py","hunks":["@@ -21,6 +21,7 @@\n import os\n import platform\n import re\n+import signal\n import subprocess\n \n _IS_WINDOWS = platform.system() == \"Windows\"","@@ -54,19 +55,77 @@ def _kill_port_process(port: int) -> None:\n                         except subprocess.SubprocessError:\n                             pass\n         else:\n-            result = subprocess.run(\n-                [\"fuser\", f\"{port}/tcp\"],\n-                capture_output=True, timeout=5,\n-            )\n-            if result.returncode == 0:\n-                subprocess.run(\n-                    [\"fuser\", \"-k\", f\"{port}/tcp\"],\n+            # Try fuser first (Linux), fall back to lsof (macOS / WSL2)\n+            killed = False\n+            try:\n+                result = subprocess.run(\n+                    [\"fuser\", f\"{port}/tcp\"],\n                     capture_output=True, timeout=5,\n                 )\n+                if result.returncode == 0:\n+                    subprocess.run(\n+                        [\"fuser\", \"-k\", f\"{port}/tcp\"],\n+                        capture_output=True, timeout=5,\n+                    )\n+                    killed = True\n+            except FileNotFoundError:\n+                pass  # fuser not installed\n+\n+            if not killed:\n+                try:\n+                    result = subprocess.run(\n+                        [\"lsof\", \"-ti\", f\":{port}\"],\n+                        capture_output=True, text=True, timeout=5,\n+                    )\n+                    for pid_str in result.stdout.strip().splitlines():\n+                        try:\n+                            os.kill(int(pid_str), signal.SIGTERM)\n+                        except (ValueError, ProcessLookupError, PermissionError):\n+                            pass\n+                except FileNotFoundError:\n+                    pass  # lsof not installed either\n     except Exception:\n         pass\n \n \n+def _kill_stale_bridge_by_pidfile(session_path: Path) -> None:\n+    \"\"\"Kill a bridge process recorded in a PID file from a previous run.\n+\n+    The bridge writes ``bridge.pid`` into the session directory when it\n+    starts.  If the gateway crashed without a clean shutdown the old bridge\n+    process becomes orphaned — this helper finds and kills it.\n+    \"\"\"\n+    pid_file = session_path / \"bridge.pid\"\n+    if not pid_file.exists():\n+        return\n+    try:\n+        pid = int(pid_file.read_text().strip())\n+    except (ValueError, OSError, TypeError):\n+        try:\n+            pid_file.unlink()\n+        except OSError:\n+            pass\n+        return\n+    try:\n+        os.kill(pid, 0)  # check existence\n+        os.kill(pid, signal.SIGTERM)\n+        logger.info(\"[whatsapp] Killed stale bridge PID %d from pidfile\", pid)\n+    except (ProcessLookupError, PermissionError, OSError):\n+        pass\n+    try:\n+        pid_file.unlink()\n+    except OSError:\n+        pass\n+\n+\n+def _write_bridge_pidfile(session_path: Path, pid: int) -> None:\n+    \"\"\"Write the bridge PID to a file for later cleanup.\"\"\"\n+    try:\n+        (session_path / \"bridge.pid\").write_text(str(pid))\n+    except OSError:\n+        pass\n+\n+\n def _terminate_bridge_process(proc, *, force: bool = False) -> None:\n     \"\"\"Terminate the bridge process using process-tree semantics where possible.\"\"\"\n     if _IS_WINDOWS:","@@ -428,6 +487,7 @@ async def connect(self) -> bool:\n                 pass  # Bridge not running, start a new one\n             \n             # Kill any orphaned bridge from a previous gateway run\n+            _kill_stale_bridge_by_pidfile(self._session_path)\n             _kill_port_process(self._bridge_port)\n             await asyncio.sleep(1)\n             ","@@ -459,6 +519,7 @@ async def connect(self) -> bool:\n                 preexec_fn=None if _IS_WINDOWS else os.setsid,\n                 env=bridge_env,\n             )\n+            _write_bridge_pidfile(self._session_path, self._bridge_process.pid)\n             \n             # Wait for the bridge to connect to WhatsApp.\n             # Phase 1: wait for the HTTP server to come up (up to 15s).","@@ -609,6 +670,12 @@ async def disconnect(self) -> None:\n             # Bridge was not started by us, don't kill it\n             print(f\"[{self.name}] Disconnecting (external bridge left running)\")\n \n+        # Clean up PID file\n+        try:\n+            (self._session_path / \"bridge.pid\").unlink(missing_ok=True)\n+        except OSError:\n+            pass\n+\n         # Cancel the poll task explicitly\n         if self._poll_task and not self._poll_task.done():\n             self._poll_task.cancel()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}