Skip to content

Commit fa68a2e

Browse files
committed
fix auto port detection
1 parent 4ea32f0 commit fa68a2e

File tree

3 files changed

+113
-12
lines changed

3 files changed

+113
-12
lines changed

sphinx_autobuild/__main__.py

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from sphinx_autobuild.filter import IgnoreFilter
2323
from sphinx_autobuild.middleware import JavascriptInjectorMiddleware
2424
from sphinx_autobuild.server import RebuildServer
25-
from sphinx_autobuild.utils import find_free_port, open_browser, show_message
25+
from sphinx_autobuild.utils import find_free_port, is_port_available, open_browser, show_message
2626

2727

2828
def main(argv=()):
@@ -45,17 +45,26 @@ def main(argv=()):
4545
serve_dir.mkdir(parents=True, exist_ok=True)
4646

4747
host_name = args.host
48-
port_num = args.port or find_free_port()
49-
url_host = f"{host_name}:{port_num}"
48+
49+
# Resolve port:
50+
# - If user specified --port 0, always find a free port
51+
# - If user specified --port N (N > 0), use that port (may fail if unavailable)
52+
# - If user didn't specify --port, use 8000 (or free port if 8000 is taken)
53+
if args.port == 0:
54+
# Auto-find mode
55+
port_num = find_free_port()
56+
port_explicitly_set = False
57+
elif args.port is not None:
58+
# User specified a specific port
59+
port_num = args.port
60+
port_explicitly_set = True
61+
else:
62+
# Default: try 8000, but allow fallback to free port
63+
port_num = 8000
64+
port_explicitly_set = False
5065

5166
pre_build_commands = list(map(shlex.split, args.pre_build))
5267
post_build_commands = list(map(shlex.split, args.post_build))
53-
builder = Builder(
54-
build_args,
55-
url_host=url_host,
56-
pre_build_commands=pre_build_commands,
57-
post_build_commands=post_build_commands,
58-
)
5968

6069
watch_dirs = [src_dir] + args.additional_watched_dirs
6170
ignore_dirs = [
@@ -80,7 +89,57 @@ def main(argv=()):
8089
]
8190
ignore_dirs = list(filter(None, ignore_dirs))
8291
ignore_handler = IgnoreFilter(ignore_dirs, args.re_ignore)
83-
app = _create_app(watch_dirs, ignore_handler, builder, serve_dir, url_host)
92+
93+
_run_with_port_fallback(
94+
host_name,
95+
port_num,
96+
port_explicitly_set,
97+
args,
98+
watch_dirs,
99+
ignore_handler,
100+
build_args,
101+
pre_build_commands,
102+
post_build_commands,
103+
serve_dir,
104+
)
105+
106+
107+
def _run_with_port_fallback(
108+
host_name,
109+
port_num,
110+
port_explicitly_set,
111+
args,
112+
watch_dirs,
113+
ignore_handler,
114+
build_args,
115+
pre_build_commands,
116+
post_build_commands,
117+
serve_dir,
118+
):
119+
"""Run the server with automatic port fallback on binding failure."""
120+
# Check if the port is available BEFORE doing anything else
121+
if not is_port_available(host_name, port_num):
122+
if port_explicitly_set:
123+
show_message(
124+
f"Error: Cannot bind to {host_name}:{port_num}. "
125+
f"The port is already in use. Use --port 0 to automatically find a free port."
126+
)
127+
sys.exit(1)
128+
else:
129+
show_message(
130+
f"Port {port_num} already in use. Attempting to find a free port..."
131+
)
132+
port_num = find_free_port()
133+
show_message(f"Using port {port_num} instead.")
134+
135+
url_host = f"{host_name}:{port_num}"
136+
137+
builder = Builder(
138+
build_args,
139+
url_host=url_host,
140+
pre_build_commands=pre_build_commands,
141+
post_build_commands=post_build_commands,
142+
)
84143

85144
if not args.no_initial_build:
86145
show_message("Starting initial build")
@@ -90,6 +149,8 @@ def main(argv=()):
90149
open_browser(url_host, args.delay)
91150

92151
show_message("Waiting to detect changes...")
152+
app = _create_app(watch_dirs, ignore_handler, builder, serve_dir, url_host)
153+
93154
try:
94155
uvicorn.run(app, host=host_name, port=port_num, log_level="warning")
95156
except KeyboardInterrupt:
@@ -182,8 +243,8 @@ def _add_autobuild_arguments(parser):
182243
group.add_argument(
183244
"--port",
184245
type=int,
185-
default=8000,
186-
help="port to serve documentation on. 0 means find and use a free port",
246+
default=None,
247+
help="port to serve documentation on (defaults to 8000, or a free port if 8000 is in use)",
187248
)
188249
group.add_argument(
189250
"--host",

sphinx_autobuild/utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,20 @@
1111
from colorama import Fore, Style
1212

1313

14+
def is_port_available(host: str, port: int) -> bool:
15+
"""Check if a port is available for binding.
16+
17+
Returns True if the port is available, False otherwise.
18+
"""
19+
try:
20+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
21+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
22+
s.bind((host, port))
23+
return True
24+
except OSError:
25+
return False
26+
27+
1428
def find_free_port():
1529
"""Find and return a free port number.
1630

tests/test_application.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"""A very basic test that the application works."""
22

33
import shutil
4+
import socket
45
from pathlib import Path
56

67
from starlette.testclient import TestClient
78

89
from sphinx_autobuild.__main__ import _create_app
910
from sphinx_autobuild.build import Builder
1011
from sphinx_autobuild.filter import IgnoreFilter
12+
from sphinx_autobuild.utils import find_free_port, is_port_available
1113

1214
ROOT = Path(__file__).parent.parent
1315

@@ -33,3 +35,27 @@ def test_application(tmp_path):
3335

3436
response = client.get("/")
3537
assert response.status_code == 200
38+
39+
40+
def test_is_port_available():
41+
"""Test that is_port_available correctly detects available and unavailable ports."""
42+
# A high port number should generally be available
43+
high_port = find_free_port()
44+
assert is_port_available("127.0.0.1", high_port)
45+
46+
# Bind a port and verify is_port_available detects it as unavailable
47+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
48+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
49+
test_port = find_free_port()
50+
s.bind(("127.0.0.1", test_port))
51+
52+
try:
53+
# Now the port should not be available
54+
assert not is_port_available("127.0.0.1", test_port)
55+
56+
# A different high port should still be available
57+
other_port = find_free_port()
58+
assert other_port != test_port
59+
assert is_port_available("127.0.0.1", other_port)
60+
finally:
61+
s.close()

0 commit comments

Comments
 (0)