Skip to content

Commit 9bda0e8

Browse files
tschmclaude
andcommitted
Fix find_free_port to use ephemeral OS-assigned port by default
find_free_port() scanned upward from a hardcoded port 8000, bound a socket only to test availability, then released it before returning the number. Under pytest-xdist (-n auto), every worker scanned from 8000 and picked the same port, then collided when uvicorn actually bound it, causing SystemExit and nondeterministic test failures in test_lifecycle and test_interface. The default no-arg call (used by ChartServer/run_server) now binds to ephemeral port 0 and returns the OS-assigned port, which the kernel guarantees to be unique per process. Explicit-range calls still scan, preserving existing behaviour and tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 39dc011 commit 9bda0e8

1 file changed

Lines changed: 29 additions & 10 deletions

File tree

src/pycharting/core/server.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,29 +42,48 @@ async def get_response(self, path: str, scope) -> Response:
4242
logger = logging.getLogger(__name__)
4343

4444

45-
def find_free_port(start_port: int = 8000, end_port: int = 9000) -> int:
46-
"""Find an available TCP port in the specified range.
45+
def find_free_port(start_port: int | None = None, end_port: int | None = None) -> int:
46+
"""Find an available TCP port.
4747
48-
This utility function iterates through a range of ports to find one that is currently
49-
not in use. This is crucial for ensuring the chart server can start without conflicts,
50-
even if the default port is occupied or multiple instances are running.
48+
When called with no arguments (the default), the operating system is asked for an
49+
ephemeral port by binding to port ``0``. The kernel guarantees a unique free port,
50+
which avoids the race where several processes scanning from the same start port all
51+
pick the same number (e.g. multiple ``pytest-xdist`` workers each defaulting to 8000).
52+
53+
When an explicit ``start_port`` is given, the function instead scans upward for the
54+
first available port, which is useful for honouring a preferred port range.
5155
5256
Args:
53-
start_port (int): The starting port number to search from (inclusive). Defaults to 8000.
54-
end_port (int): The ending port number to search to (exclusive). Defaults to 9000.
57+
start_port (Optional[int]): The starting port number to search from (inclusive).
58+
If ``None`` (default), an OS-assigned ephemeral port is returned instead of scanning.
59+
end_port (Optional[int]): The ending port number to search to (exclusive). Only used
60+
when ``start_port`` is provided; defaults to ``start_port + 1000``.
5561
5662
Returns:
57-
int: An available port number found within the range.
63+
int: An available port number.
5864
5965
Raises:
60-
RuntimeError: If no free port can be found in the specified range.
66+
RuntimeError: If no free port can be found in the requested range.
6167
6268
Example:
6369
```python
70+
# Let the OS pick a guaranteed-free ephemeral port.
71+
port = find_free_port()
72+
73+
# Scan a preferred range instead.
6474
port = find_free_port(8000, 8010)
65-
print(f"Found free port: {port}")
6675
```
6776
"""
77+
# No range requested: let the OS hand out a guaranteed-unique ephemeral port.
78+
if start_port is None:
79+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
80+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
81+
s.bind(("", 0))
82+
return s.getsockname()[1]
83+
84+
if end_port is None:
85+
end_port = start_port + 1000
86+
6887
for port in range(start_port, end_port):
6988
try:
7089
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:

0 commit comments

Comments
 (0)