Skip to content

Commit 53b3b40

Browse files
committed
refactor(net): extract port utils to utils/net.py, add GDB port conflict tests
Move check_port_available and get_port_owner from main.py to utils/net.py alongside kill_port_owner and check_and_free_port. Both main.py and gdb_manager.py now import from utils/net. Added test_utils_net.py with 14 tests covering: - is_port_available (free/occupied/after-close) - get_port_owner (no listener/own process) - kill_port_owner (no owner/skip self/mock kill/kill fails) - check_and_free_port (available/freed/fail) - GDB manager integration (uses check/rejects occupied) utils/net.py coverage: 83%. Overall: 86.2% (target 85%). Signed-off-by: VIFEX <vifextech@foxmail.com>
1 parent fafa97f commit 53b3b40

4 files changed

Lines changed: 346 additions & 86 deletions

File tree

Tools/WebServer/core/gdb_manager.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from core.gdb_bridge import GDBRSPBridge
2020
from core.gdb_session import GDBSession
2121
from core.state import ToolLogHandler
22+
from utils.net import check_and_free_port
2223

2324
logger = logging.getLogger(__name__)
2425

@@ -166,11 +167,6 @@ def _apply_elf_memory_regions(bridge, elf_path):
166167
)
167168

168169

169-
# ------------------------------------------------------------------
170-
# External GDB Server (for CLI / IDE connections)
171-
# ------------------------------------------------------------------
172-
173-
174170
def start_external_gdb_server(state, read_memory_fn=None, write_memory_fn=None) -> bool:
175171
"""Start an external-facing GDB RSP Bridge for CLI/IDE connections.
176172
@@ -211,6 +207,10 @@ def start_external_gdb_server(state, read_memory_fn=None, write_memory_fn=None)
211207
logger.info("[ExtGDB] Using provided memory callbacks (may be offline stubs)")
212208

213209
try:
210+
if not check_and_free_port(port):
211+
logger.error(f"Cannot start external GDB server: port {port} is occupied")
212+
return False
213+
214214
bridge = GDBRSPBridge(
215215
read_memory_fn=read_memory_fn,
216216
write_memory_fn=write_memory_fn,

Tools/WebServer/main.py

Lines changed: 2 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
from fpb_inject import serial_open
3838
from services.device_worker import start_worker
3939
from services.file_watcher_manager import restore_file_watcher
40+
from utils.net import is_port_available as check_port_available
41+
from utils.net import get_port_owner
4042
from utils.port_lock import PortLock
4143

4244
# Get the directory where this script is located
@@ -168,87 +170,6 @@ def create_app(auth_token=None):
168170
return app
169171

170172

171-
def check_port_available(host, port):
172-
"""Check if the port is available."""
173-
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
174-
sock.settimeout(1)
175-
try:
176-
result = sock.connect_ex(("127.0.0.1", port))
177-
if result == 0:
178-
return False
179-
return True
180-
except Exception:
181-
return True
182-
finally:
183-
sock.close()
184-
185-
186-
def get_port_owner(port):
187-
"""Get info about the process occupying a port.
188-
189-
Returns dict with pid, name, cmdline, or None if not found.
190-
Uses /proc on Linux, lsof as fallback.
191-
"""
192-
# Try /proc/net/tcp + /proc/<pid>/cmdline (no external tools needed)
193-
try:
194-
hex_port = f"{port:04X}"
195-
with open("/proc/net/tcp", "r") as f:
196-
for line in f:
197-
parts = line.strip().split()
198-
if len(parts) < 10:
199-
continue
200-
local = parts[1]
201-
if local.endswith(f":{hex_port}") and parts[3] == "0A": # LISTEN
202-
inode = parts[9]
203-
# Find PID by scanning /proc/*/fd/
204-
for entry in os.listdir("/proc"):
205-
if not entry.isdigit():
206-
continue
207-
fd_dir = f"/proc/{entry}/fd"
208-
try:
209-
for fd in os.listdir(fd_dir):
210-
link = os.readlink(f"{fd_dir}/{fd}")
211-
if f"socket:[{inode}]" in link:
212-
pid = int(entry)
213-
cmdline = open(f"/proc/{pid}/cmdline", "r").read()
214-
cmdline = cmdline.replace("\x00", " ").strip()
215-
comm = open(f"/proc/{pid}/comm", "r").read().strip()
216-
return {
217-
"pid": pid,
218-
"name": comm,
219-
"cmdline": cmdline,
220-
}
221-
except (PermissionError, FileNotFoundError, OSError):
222-
continue
223-
except Exception:
224-
pass
225-
226-
# Fallback: try lsof
227-
try:
228-
import subprocess
229-
230-
out = subprocess.check_output(
231-
["lsof", "-ti", f":{port}"], stderr=subprocess.DEVNULL, timeout=3
232-
).decode()
233-
for line in out.strip().split("\n"):
234-
pid = int(line.strip())
235-
try:
236-
cmdline = (
237-
open(f"/proc/{pid}/cmdline", "r")
238-
.read()
239-
.replace("\x00", " ")
240-
.strip()
241-
)
242-
comm = open(f"/proc/{pid}/comm", "r").read().strip()
243-
return {"pid": pid, "name": comm, "cmdline": cmdline}
244-
except Exception:
245-
return {"pid": pid, "name": "unknown", "cmdline": "unknown"}
246-
except Exception:
247-
pass
248-
249-
return None
250-
251-
252173
def parse_args():
253174
"""Parse command line arguments."""
254175
parser = argparse.ArgumentParser(description="FPBInject Web Server")
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/usr/bin/env python3
2+
3+
"""
4+
Tests for utils/net.py
5+
"""
6+
7+
import os
8+
import signal
9+
import socket
10+
import sys
11+
import unittest
12+
from unittest.mock import MagicMock, patch
13+
14+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15+
16+
from utils.net import ( # noqa: E402
17+
check_and_free_port,
18+
get_port_owner,
19+
is_port_available,
20+
kill_port_owner,
21+
)
22+
23+
24+
class TestIsPortAvailable(unittest.TestCase):
25+
"""Tests for is_port_available."""
26+
27+
def test_available_port(self):
28+
"""An unused port should be available."""
29+
# Use a random high port unlikely to be in use
30+
self.assertTrue(is_port_available(59123))
31+
32+
def test_occupied_port(self):
33+
"""A port with a listener should not be available."""
34+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
35+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
36+
s.bind(("127.0.0.1", 0))
37+
s.listen(1)
38+
port = s.getsockname()[1]
39+
try:
40+
self.assertFalse(is_port_available(port))
41+
finally:
42+
s.close()
43+
44+
def test_port_after_close(self):
45+
"""Port should be available after listener closes."""
46+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
47+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
48+
s.bind(("127.0.0.1", 0))
49+
s.listen(1)
50+
port = s.getsockname()[1]
51+
s.close()
52+
self.assertTrue(is_port_available(port))
53+
54+
55+
class TestGetPortOwner(unittest.TestCase):
56+
"""Tests for get_port_owner."""
57+
58+
def test_no_listener(self):
59+
"""Should return None for a port with no listener."""
60+
result = get_port_owner(59124)
61+
self.assertIsNone(result)
62+
63+
def test_own_process(self):
64+
"""Should find our own process as the owner."""
65+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
66+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
67+
s.bind(("127.0.0.1", 0))
68+
s.listen(1)
69+
port = s.getsockname()[1]
70+
try:
71+
owner = get_port_owner(port)
72+
if owner: # May fail in some CI environments
73+
self.assertEqual(owner["pid"], os.getpid())
74+
self.assertIn("pid", owner)
75+
self.assertIn("name", owner)
76+
finally:
77+
s.close()
78+
79+
80+
class TestKillPortOwner(unittest.TestCase):
81+
"""Tests for kill_port_owner."""
82+
83+
def test_no_owner(self):
84+
"""Should return False when no process owns the port."""
85+
self.assertFalse(kill_port_owner(59125))
86+
87+
def test_skip_own_pid(self):
88+
"""Should not kill our own process."""
89+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
90+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
91+
s.bind(("127.0.0.1", 0))
92+
s.listen(1)
93+
port = s.getsockname()[1]
94+
try:
95+
# kill_port_owner should skip our own PID
96+
result = kill_port_owner(port)
97+
self.assertFalse(result)
98+
finally:
99+
s.close()
100+
101+
@patch("utils.net.get_port_owner")
102+
@patch("utils.net.os.kill")
103+
def test_kill_stale_process(self, mock_kill, mock_owner):
104+
"""Should kill a stale process and return True."""
105+
mock_owner.return_value = {
106+
"pid": 99999,
107+
"name": "python",
108+
"cmdline": "python main.py",
109+
}
110+
# Simulate process dying after SIGTERM
111+
mock_kill.side_effect = [None, OSError("No such process")]
112+
113+
result = kill_port_owner(12345, timeout=0.5)
114+
self.assertTrue(result)
115+
mock_kill.assert_any_call(99999, signal.SIGTERM)
116+
117+
@patch("utils.net.get_port_owner")
118+
@patch("utils.net.os.kill")
119+
def test_kill_fails(self, mock_kill, mock_owner):
120+
"""Should return False when kill raises OSError."""
121+
mock_owner.return_value = {"pid": 99999, "name": "x", "cmdline": "x"}
122+
mock_kill.side_effect = OSError("Operation not permitted")
123+
124+
result = kill_port_owner(12345)
125+
self.assertFalse(result)
126+
127+
128+
class TestCheckAndFreePort(unittest.TestCase):
129+
"""Tests for check_and_free_port."""
130+
131+
def test_available_port(self):
132+
"""Should return True immediately for a free port."""
133+
self.assertTrue(check_and_free_port(59126))
134+
135+
@patch("utils.net.kill_port_owner", return_value=True)
136+
@patch("utils.net.is_port_available", return_value=False)
137+
def test_occupied_then_freed(self, mock_avail, mock_kill):
138+
"""Should try to kill and return True on success."""
139+
self.assertTrue(check_and_free_port(12345))
140+
mock_kill.assert_called_once_with(12345)
141+
142+
@patch("utils.net.kill_port_owner", return_value=False)
143+
@patch("utils.net.is_port_available", return_value=False)
144+
def test_occupied_kill_fails(self, mock_avail, mock_kill):
145+
"""Should return False when kill fails."""
146+
self.assertFalse(check_and_free_port(12345))
147+
148+
149+
class TestGDBPortConflict(unittest.TestCase):
150+
"""Integration test: GDB server port conflict detection."""
151+
152+
@patch("utils.net.get_port_owner")
153+
def test_gdb_manager_uses_check_and_free_port(self, mock_owner):
154+
"""start_external_gdb_server should call check_and_free_port."""
155+
156+
mock_owner.return_value = None
157+
158+
with patch(
159+
"core.gdb_manager.check_and_free_port", return_value=True
160+
) as mock_check:
161+
with patch("core.gdb_manager.GDBRSPBridge") as mock_bridge_cls:
162+
mock_bridge = MagicMock()
163+
mock_bridge.start.return_value = 3333
164+
mock_bridge.is_running = False
165+
mock_bridge_cls.return_value = mock_bridge
166+
167+
from core.gdb_manager import start_external_gdb_server
168+
169+
state = MagicMock()
170+
state.device.external_gdb_port = 3333
171+
state.device.elf_path = None
172+
state.device.download_chunk_size = 1024
173+
state.external_gdb_bridge = None
174+
175+
result = start_external_gdb_server(state)
176+
self.assertTrue(result)
177+
mock_check.assert_called_once_with(3333)
178+
179+
def test_gdb_manager_rejects_occupied_port(self):
180+
"""start_external_gdb_server should fail if port can't be freed."""
181+
with patch("core.gdb_manager.check_and_free_port", return_value=False):
182+
from core.gdb_manager import start_external_gdb_server
183+
184+
state = MagicMock()
185+
state.device.external_gdb_port = 3333
186+
state.external_gdb_bridge = None
187+
188+
result = start_external_gdb_server(state)
189+
self.assertFalse(result)
190+
191+
192+
if __name__ == "__main__":
193+
unittest.main()

0 commit comments

Comments
 (0)