{"repo":"NousResearch/hermes-agent","public_id":"432","display_label":"Cross-platform port cleanup for WhatsApp bridge","summary":"These items address the WhatsApp bridge's use of the Linux-only `fuser` command for orphaned process cleanup, with proposed fixes to replace it with a cross-platform alternative.","subsystem":"gateway/platforms","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":433,"merged_at":"2026-03-06T12:54:03+00:00"},"resolution":{"state":"resolved","by":"pr-433"},"freshness":{"state":"stale","last_verified_at":"2026-03-06T12:54:03+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":432,"pr_number":433,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-06T12:54:03+00:00"}],"members":[{"kind":"issue","number":432,"title":"WhatsApp bridge uses Linux-only `fuser` command for orphaned process cleanup","url":null,"state":"closed"},{"kind":"pr","number":433,"title":"fix(whatsapp): replace Linux-only fuser with cross-platform port cleanup","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/whatsapp.py","tests/gateway/test_whatsapp_connect.py"],"fix_hunks":[{"file":"gateway/platforms/whatsapp.py","hunks":["@@ -28,6 +28,41 @@\n \n logger = logging.getLogger(__name__)\n \n+\n+def _kill_port_process(port: int) -> None:\n+    \"\"\"Kill any process listening on the given TCP port.\"\"\"\n+    try:\n+        if _IS_WINDOWS:\n+            # Use netstat to find the PID bound to this port, then taskkill\n+            result = subprocess.run(\n+                [\"netstat\", \"-ano\", \"-p\", \"TCP\"],\n+                capture_output=True, text=True, timeout=5,\n+            )\n+            for line in result.stdout.splitlines():\n+                parts = line.split()\n+                if len(parts) >= 5 and parts[3] == \"LISTENING\":\n+                    local_addr = parts[1]\n+                    if local_addr.endswith(f\":{port}\"):\n+                        try:\n+                            subprocess.run(\n+                                [\"taskkill\", \"/PID\", parts[4], \"/F\"],\n+                                capture_output=True, timeout=5,\n+                            )\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+                    capture_output=True, timeout=5,\n+                )\n+    except Exception:\n+        pass\n+\n import sys\n sys.path.insert(0, str(Path(__file__).resolve().parents[2]))\n ","@@ -145,21 +180,9 @@ async def connect(self) -> bool:\n             self._session_path.mkdir(parents=True, exist_ok=True)\n             \n             # Kill any orphaned bridge from a previous gateway run\n-            try:\n-                result = subprocess.run(\n-                    [\"fuser\", f\"{self._bridge_port}/tcp\"],\n-                    capture_output=True, timeout=5,\n-                )\n-                if result.returncode == 0:\n-                    # Port is in use — kill the process\n-                    subprocess.run(\n-                        [\"fuser\", \"-k\", f\"{self._bridge_port}/tcp\"],\n-                        capture_output=True, timeout=5,\n-                    )\n-                    import time\n-                    time.sleep(2)\n-            except Exception:\n-                pass\n+            _kill_port_process(self._bridge_port)\n+            import time\n+            time.sleep(1)\n             \n             # Start the bridge process in its own process group.\n             # Route output to a log file so QR codes, errors, and reconnection","@@ -293,13 +316,7 @@ async def disconnect(self) -> None:\n                 print(f\"[{self.name}] Error stopping bridge: {e}\")\n         \n         # Also kill any orphaned bridge processes on our port\n-        try:\n-            subprocess.run(\n-                [\"fuser\", \"-k\", f\"{self._bridge_port}/tcp\"],\n-                capture_output=True, timeout=5,\n-            )\n-        except Exception:\n-            pass\n+        _kill_port_process(self._bridge_port)\n         \n         self._running = False\n         self._bridge_process = None"]},{"file":"tests/gateway/test_whatsapp_connect.py","hunks":["@@ -268,3 +268,94 @@ async def test_closed_on_unexpected_exception(self):\n         assert result is False\n         mock_fh.close.assert_called_once()\n         assert adapter._bridge_log_fh is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# _kill_port_process() cross-platform tests\n+# ---------------------------------------------------------------------------\n+\n+class TestKillPortProcess:\n+    \"\"\"Verify _kill_port_process uses platform-appropriate commands.\"\"\"\n+\n+    def test_uses_netstat_and_taskkill_on_windows(self):\n+        from gateway.platforms.whatsapp import _kill_port_process\n+\n+        netstat_output = (\n+            \"  Proto  Local Address          Foreign Address        State           PID\\n\"\n+            \"  TCP    0.0.0.0:3000           0.0.0.0:0              LISTENING       12345\\n\"\n+            \"  TCP    0.0.0.0:3001           0.0.0.0:0              LISTENING       99999\\n\"\n+        )\n+        mock_netstat = MagicMock(stdout=netstat_output)\n+        mock_taskkill = MagicMock()\n+\n+        def run_side_effect(cmd, **kwargs):\n+            if cmd[0] == \"netstat\":\n+                return mock_netstat\n+            if cmd[0] == \"taskkill\":\n+                return mock_taskkill\n+            return MagicMock()\n+\n+        with patch(\"gateway.platforms.whatsapp._IS_WINDOWS\", True), \\\n+             patch(\"gateway.platforms.whatsapp.subprocess.run\", side_effect=run_side_effect) as mock_run:\n+            _kill_port_process(3000)\n+\n+        # netstat called\n+        assert any(\n+            call.args[0][0] == \"netstat\" for call in mock_run.call_args_list\n+        )\n+        # taskkill called with correct PID\n+        assert any(\n+            call.args[0] == [\"taskkill\", \"/PID\", \"12345\", \"/F\"]\n+            for call in mock_run.call_args_list\n+        )\n+\n+    def test_does_not_kill_wrong_port_on_windows(self):\n+        from gateway.platforms.whatsapp import _kill_port_process\n+\n+        netstat_output = (\n+            \"  TCP    0.0.0.0:30000          0.0.0.0:0              LISTENING       55555\\n\"\n+        )\n+        mock_netstat = MagicMock(stdout=netstat_output)\n+\n+        with patch(\"gateway.platforms.whatsapp._IS_WINDOWS\", True), \\\n+             patch(\"gateway.platforms.whatsapp.subprocess.run\", return_value=mock_netstat) as mock_run:\n+            _kill_port_process(3000)\n+\n+        # Should NOT call taskkill because port 30000 != 3000\n+        assert not any(\n+            call.args[0][0] == \"taskkill\"\n+            for call in mock_run.call_args_list\n+        )\n+\n+    def test_uses_fuser_on_linux(self):\n+        from gateway.platforms.whatsapp import _kill_port_process\n+\n+        mock_check = MagicMock(returncode=0)\n+\n+        with patch(\"gateway.platforms.whatsapp._IS_WINDOWS\", False), \\\n+             patch(\"gateway.platforms.whatsapp.subprocess.run\", return_value=mock_check) as mock_run:\n+            _kill_port_process(3000)\n+\n+        calls = [c.args[0] for c in mock_run.call_args_list]\n+        assert [\"fuser\", \"3000/tcp\"] in calls\n+        assert [\"fuser\", \"-k\", \"3000/tcp\"] in calls\n+\n+    def test_skips_fuser_kill_when_port_free(self):\n+        from gateway.platforms.whatsapp import _kill_port_process\n+\n+        mock_check = MagicMock(returncode=1)  # port not in use\n+\n+        with patch(\"gateway.platforms.whatsapp._IS_WINDOWS\", False), \\\n+             patch(\"gateway.platforms.whatsapp.subprocess.run\", return_value=mock_check) as mock_run:\n+            _kill_port_process(3000)\n+\n+        calls = [c.args[0] for c in mock_run.call_args_list]\n+        assert [\"fuser\", \"3000/tcp\"] in calls\n+        assert [\"fuser\", \"-k\", \"3000/tcp\"] not in calls\n+\n+    def test_suppresses_exceptions(self):\n+        from gateway.platforms.whatsapp import _kill_port_process\n+\n+        with patch(\"gateway.platforms.whatsapp._IS_WINDOWS\", True), \\\n+             patch(\"gateway.platforms.whatsapp.subprocess.run\", side_effect=OSError(\"no netstat\")):\n+            _kill_port_process(3000)  # must not raise\n"]}]},{"kind":"pr","number":472,"title":"fix: cross-platform orphaned process cleanup for WhatsApp bridge","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}