fix(whatsapp): kill bridge process tree on Windows disconnect

This commit is contained in:
Es1la 2026-04-21 05:47:21 +03:00 committed by Teknium
parent 735996d2ad
commit 3821921ef7
2 changed files with 60 additions and 10 deletions

View file

@ -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:

View file

@ -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."""