diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index e1ccd2234c..767908023e 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -66,6 +66,37 @@ def _kill_port_process(port: int) -> None: except Exception: pass + +def _terminate_bridge_process(proc, *, force: bool = False) -> None: + """Terminate the bridge process using process-tree semantics where possible.""" + if _IS_WINDOWS: + cmd = ["taskkill", "/PID", str(proc.pid), "/T"] + if force: + cmd.append("/F") + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10, + ) + except FileNotFoundError: + if force: + proc.kill() + else: + proc.terminate() + return + + if result.returncode != 0: + details = (result.stderr or result.stdout or "").strip() + raise OSError(details or f"taskkill failed for PID {proc.pid}") + return + + import signal + + sig = signal.SIGTERM if not force else signal.SIGKILL + os.killpg(os.getpgid(proc.pid), sig) + import sys sys.path.insert(0, str(Path(__file__).resolve().parents[2])) @@ -537,22 +568,14 @@ class WhatsAppAdapter(BasePlatformAdapter): """Stop the WhatsApp bridge and clean up any orphaned processes.""" if self._bridge_process: try: - # Kill the entire process group so child node processes die too - import signal try: - if _IS_WINDOWS: - self._bridge_process.terminate() - else: - os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGTERM) + _terminate_bridge_process(self._bridge_process, force=False) except (ProcessLookupError, PermissionError): self._bridge_process.terminate() await asyncio.sleep(1) if self._bridge_process.poll() is None: try: - if _IS_WINDOWS: - self._bridge_process.kill() - else: - os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGKILL) + _terminate_bridge_process(self._bridge_process, force=True) except (ProcessLookupError, PermissionError): self._bridge_process.kill() except Exception as e: diff --git a/tests/gateway/test_whatsapp_connect.py b/tests/gateway/test_whatsapp_connect.py index 60fff0bdc1..29f7eee3af 100644 --- a/tests/gateway/test_whatsapp_connect.py +++ b/tests/gateway/test_whatsapp_connect.py @@ -453,6 +453,33 @@ class TestKillPortProcess: class TestHttpSessionLifecycle: """Verify persistent aiohttp.ClientSession is created and cleaned up.""" + @pytest.mark.asyncio + async def test_disconnect_uses_taskkill_tree_on_windows(self): + """Windows disconnect should target the bridge process tree, not just the parent PID.""" + adapter = _make_adapter() + mock_proc = MagicMock() + mock_proc.pid = 12345 + mock_proc.poll.side_effect = [0] + adapter._bridge_process = mock_proc + adapter._poll_task = None + adapter._http_session = None + adapter._running = True + adapter._session_lock_identity = None + + with patch("gateway.platforms.whatsapp._IS_WINDOWS", True), \ + patch("gateway.platforms.whatsapp.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, \ + patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock): + await adapter.disconnect() + + mock_run.assert_called_once_with( + ["taskkill", "/PID", "12345", "/T"], + capture_output=True, + text=True, + timeout=10, + ) + mock_proc.terminate.assert_not_called() + mock_proc.kill.assert_not_called() + @pytest.mark.asyncio async def test_session_closed_on_disconnect(self): """disconnect() should close self._http_session."""