Skip to content

Commit 2e1876a

Browse files
committed
Fall back to a free port when the dashboard port is unavailable
Starting the dashboard on a port that is already in use or blocked by the OS raised an unhandled error (for example Windows error 10013) and printed a stack trace. It now tries a short list of fallback ports and finally an OS-assigned one, and reports the address it actually bound.
1 parent c0b58e7 commit 2e1876a

4 files changed

Lines changed: 81 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project are documented here. The format is loosely
44
based on [Keep a Changelog](https://keepachangelog.com/), and the project aims
55
to follow semantic versioning once it reaches 1.0.
66

7+
## [2.2.1] - 2026-06-29
8+
9+
### Fixed
10+
11+
- `sentineldeck dashboard` no longer crashes with a stack trace when its port is
12+
already in use or blocked by the OS (for example Windows error 10013). It now
13+
falls back to another free port — and finally an OS-assigned one — and prints
14+
the address it actually bound.
15+
716
## [2.2.0] - 2026-06-29
817

918
### Added

src/sentineldeck/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""SentinelDeck: passive attack-surface visibility for small businesses."""
22

3-
__version__ = "2.2.0"
3+
__version__ = "2.2.1"

src/sentineldeck/dashboard.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from __future__ import annotations
1313

1414
import json
15+
import sys
1516
import webbrowser
1617
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
1718
from pathlib import Path
@@ -98,10 +99,39 @@ def _scan(self) -> None:
9899
return DashboardHandler
99100

100101

102+
def _bind(host: str, port: int, timeout: int) -> ThreadingHTTPServer | None:
103+
"""Try to bind the dashboard server to ``port``; return None if unavailable."""
104+
try:
105+
return ThreadingHTTPServer((host, port), _make_handler(timeout))
106+
except OSError:
107+
# Port in use by another app, or blocked/reserved by the OS (e.g. Windows
108+
# WinError 10013 / Hyper-V reserved ranges). Caller falls back.
109+
return None
110+
111+
101112
def serve(host: str = "127.0.0.1", port: int = 8765, open_browser: bool = True, timeout: int = 10) -> int:
102113
"""Run the dashboard server until interrupted. Returns a process exit code."""
103-
httpd = ThreadingHTTPServer((host, port), _make_handler(timeout))
104-
url = f"http://{host}:{port}"
114+
# The requested port may be taken or blocked, so fall back to other ports and
115+
# finally an OS-assigned one (0) instead of crashing with a stack trace.
116+
candidates: list[int] = []
117+
for candidate in (port, 8765, 8780, 8800, 8888, 9000, 0):
118+
if candidate not in candidates:
119+
candidates.append(candidate)
120+
121+
httpd = next((s for s in (_bind(host, c, timeout) for c in candidates) if s is not None), None)
122+
if httpd is None:
123+
print(
124+
f"Could not start the dashboard: no free port was available "
125+
f"(tried {', '.join(str(c) for c in candidates if c)}). "
126+
f"Free a port or pass --port.",
127+
file=sys.stderr,
128+
)
129+
return 1
130+
131+
actual = httpd.server_address[1]
132+
if actual != port:
133+
print(f"Port {port} was unavailable, so the dashboard is using port {actual} instead.")
134+
url = f"http://{host}:{actual}"
105135
print(f"SentinelDeck dashboard running at {url}")
106136
print("Press Ctrl+C to stop.")
107137
if open_browser:

tests/test_dashboard.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,45 @@ def _get(port, path):
1717
return response.status, response.read().decode("utf-8")
1818

1919

20+
def test_bind_returns_none_when_port_is_forbidden(monkeypatch):
21+
real = dashboard.ThreadingHTTPServer
22+
23+
def fake(addr, handler):
24+
if addr[1] == 8765:
25+
raise PermissionError("WinError 10013") # simulate a blocked port
26+
return real(addr, handler)
27+
28+
monkeypatch.setattr(dashboard, "ThreadingHTTPServer", fake)
29+
30+
assert dashboard._bind("127.0.0.1", 8765, 5) is None
31+
httpd = dashboard._bind("127.0.0.1", 0, 5)
32+
assert httpd is not None
33+
httpd.server_close()
34+
35+
36+
def test_serve_falls_back_to_a_free_port(monkeypatch):
37+
real = dashboard.ThreadingHTTPServer
38+
bound = {}
39+
40+
def stop(self): # so serve() returns instead of blocking on serve_forever
41+
raise KeyboardInterrupt
42+
43+
monkeypatch.setattr(real, "serve_forever", stop)
44+
45+
def fake(addr, handler):
46+
if addr[1] == 8765:
47+
raise PermissionError("WinError 10013") # default port blocked
48+
server = real(addr, handler)
49+
bound["port"] = server.server_address[1]
50+
return server
51+
52+
monkeypatch.setattr(dashboard, "ThreadingHTTPServer", fake)
53+
54+
rc = dashboard.serve(port=8765, open_browser=False)
55+
assert rc == 0
56+
assert bound["port"] != 8765 # fell back to a working port
57+
58+
2059
def test_dashboard_serves_static_assets():
2160
httpd, port = _start_server()
2261
try:

0 commit comments

Comments
 (0)