Skip to content

Commit 4a63737

Browse files
committed
Merge PR #433: fix(whatsapp): replace Linux-only fuser with cross-platform port cleanup
Authored by Farukest. Fixes #432. Extracts _kill_port_process() helper that uses netstat+taskkill on Windows and fuser on Linux. Previously, fuser calls were inline with bare except-pass, so on Windows orphaned bridge processes were never cleaned up — causing 'address already in use' errors on reconnect. Includes 5 tests covering both platforms, port matching edge cases, and exception suppression.
2 parents 3e93db1 + 82cb175 commit 4a63737

File tree

2 files changed

+130
-22
lines changed

2 files changed

+130
-22
lines changed

gateway/platforms/whatsapp.py

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,41 @@
2828

2929
logger = logging.getLogger(__name__)
3030

31+
32+
def _kill_port_process(port: int) -> None:
33+
"""Kill any process listening on the given TCP port."""
34+
try:
35+
if _IS_WINDOWS:
36+
# Use netstat to find the PID bound to this port, then taskkill
37+
result = subprocess.run(
38+
["netstat", "-ano", "-p", "TCP"],
39+
capture_output=True, text=True, timeout=5,
40+
)
41+
for line in result.stdout.splitlines():
42+
parts = line.split()
43+
if len(parts) >= 5 and parts[3] == "LISTENING":
44+
local_addr = parts[1]
45+
if local_addr.endswith(f":{port}"):
46+
try:
47+
subprocess.run(
48+
["taskkill", "/PID", parts[4], "/F"],
49+
capture_output=True, timeout=5,
50+
)
51+
except subprocess.SubprocessError:
52+
pass
53+
else:
54+
result = subprocess.run(
55+
["fuser", f"{port}/tcp"],
56+
capture_output=True, timeout=5,
57+
)
58+
if result.returncode == 0:
59+
subprocess.run(
60+
["fuser", "-k", f"{port}/tcp"],
61+
capture_output=True, timeout=5,
62+
)
63+
except Exception:
64+
pass
65+
3166
import sys
3267
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
3368

@@ -145,21 +180,9 @@ async def connect(self) -> bool:
145180
self._session_path.mkdir(parents=True, exist_ok=True)
146181

147182
# Kill any orphaned bridge from a previous gateway run
148-
try:
149-
result = subprocess.run(
150-
["fuser", f"{self._bridge_port}/tcp"],
151-
capture_output=True, timeout=5,
152-
)
153-
if result.returncode == 0:
154-
# Port is in use — kill the process
155-
subprocess.run(
156-
["fuser", "-k", f"{self._bridge_port}/tcp"],
157-
capture_output=True, timeout=5,
158-
)
159-
import time
160-
time.sleep(2)
161-
except Exception:
162-
pass
183+
_kill_port_process(self._bridge_port)
184+
import time
185+
time.sleep(1)
163186

164187
# Start the bridge process in its own process group.
165188
# Route output to a log file so QR codes, errors, and reconnection
@@ -293,13 +316,7 @@ async def disconnect(self) -> None:
293316
print(f"[{self.name}] Error stopping bridge: {e}")
294317

295318
# Also kill any orphaned bridge processes on our port
296-
try:
297-
subprocess.run(
298-
["fuser", "-k", f"{self._bridge_port}/tcp"],
299-
capture_output=True, timeout=5,
300-
)
301-
except Exception:
302-
pass
319+
_kill_port_process(self._bridge_port)
303320

304321
self._running = False
305322
self._bridge_process = None

tests/gateway/test_whatsapp_connect.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,94 @@ async def test_closed_on_unexpected_exception(self):
268268
assert result is False
269269
mock_fh.close.assert_called_once()
270270
assert adapter._bridge_log_fh is None
271+
272+
273+
# ---------------------------------------------------------------------------
274+
# _kill_port_process() cross-platform tests
275+
# ---------------------------------------------------------------------------
276+
277+
class TestKillPortProcess:
278+
"""Verify _kill_port_process uses platform-appropriate commands."""
279+
280+
def test_uses_netstat_and_taskkill_on_windows(self):
281+
from gateway.platforms.whatsapp import _kill_port_process
282+
283+
netstat_output = (
284+
" Proto Local Address Foreign Address State PID\n"
285+
" TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345\n"
286+
" TCP 0.0.0.0:3001 0.0.0.0:0 LISTENING 99999\n"
287+
)
288+
mock_netstat = MagicMock(stdout=netstat_output)
289+
mock_taskkill = MagicMock()
290+
291+
def run_side_effect(cmd, **kwargs):
292+
if cmd[0] == "netstat":
293+
return mock_netstat
294+
if cmd[0] == "taskkill":
295+
return mock_taskkill
296+
return MagicMock()
297+
298+
with patch("gateway.platforms.whatsapp._IS_WINDOWS", True), \
299+
patch("gateway.platforms.whatsapp.subprocess.run", side_effect=run_side_effect) as mock_run:
300+
_kill_port_process(3000)
301+
302+
# netstat called
303+
assert any(
304+
call.args[0][0] == "netstat" for call in mock_run.call_args_list
305+
)
306+
# taskkill called with correct PID
307+
assert any(
308+
call.args[0] == ["taskkill", "/PID", "12345", "/F"]
309+
for call in mock_run.call_args_list
310+
)
311+
312+
def test_does_not_kill_wrong_port_on_windows(self):
313+
from gateway.platforms.whatsapp import _kill_port_process
314+
315+
netstat_output = (
316+
" TCP 0.0.0.0:30000 0.0.0.0:0 LISTENING 55555\n"
317+
)
318+
mock_netstat = MagicMock(stdout=netstat_output)
319+
320+
with patch("gateway.platforms.whatsapp._IS_WINDOWS", True), \
321+
patch("gateway.platforms.whatsapp.subprocess.run", return_value=mock_netstat) as mock_run:
322+
_kill_port_process(3000)
323+
324+
# Should NOT call taskkill because port 30000 != 3000
325+
assert not any(
326+
call.args[0][0] == "taskkill"
327+
for call in mock_run.call_args_list
328+
)
329+
330+
def test_uses_fuser_on_linux(self):
331+
from gateway.platforms.whatsapp import _kill_port_process
332+
333+
mock_check = MagicMock(returncode=0)
334+
335+
with patch("gateway.platforms.whatsapp._IS_WINDOWS", False), \
336+
patch("gateway.platforms.whatsapp.subprocess.run", return_value=mock_check) as mock_run:
337+
_kill_port_process(3000)
338+
339+
calls = [c.args[0] for c in mock_run.call_args_list]
340+
assert ["fuser", "3000/tcp"] in calls
341+
assert ["fuser", "-k", "3000/tcp"] in calls
342+
343+
def test_skips_fuser_kill_when_port_free(self):
344+
from gateway.platforms.whatsapp import _kill_port_process
345+
346+
mock_check = MagicMock(returncode=1) # port not in use
347+
348+
with patch("gateway.platforms.whatsapp._IS_WINDOWS", False), \
349+
patch("gateway.platforms.whatsapp.subprocess.run", return_value=mock_check) as mock_run:
350+
_kill_port_process(3000)
351+
352+
calls = [c.args[0] for c in mock_run.call_args_list]
353+
assert ["fuser", "3000/tcp"] in calls
354+
assert ["fuser", "-k", "3000/tcp"] not in calls
355+
356+
def test_suppresses_exceptions(self):
357+
from gateway.platforms.whatsapp import _kill_port_process
358+
359+
with patch("gateway.platforms.whatsapp._IS_WINDOWS", True), \
360+
patch("gateway.platforms.whatsapp.subprocess.run", side_effect=OSError("no netstat")):
361+
_kill_port_process(3000) # must not raise

0 commit comments

Comments
 (0)