1616import atexit
1717import socket
1818import threading
19+ from collections import deque
1920from collections .abc import Iterator
2021from contextlib import contextmanager
2122from typing import Optional
2223
24+ # Maximum number of recently released ports to track before reuse
25+ _RECENTLY_RELEASED_PORTS_MAXLEN = 256
26+
2327
2428class PortManager :
2529 """Thread-safe port manager to prevent EADDRINUSE errors.
@@ -33,10 +37,14 @@ class PortManager:
3337 def __init__ (self ) -> None :
3438 self ._lock = threading .Lock ()
3539 self ._allocated_ports : set [int ] = set ()
40+ # Recently released ports are kept in a queue to avoid immediate reuse
41+ self ._recently_released : deque [int ] = deque (maxlen = _RECENTLY_RELEASED_PORTS_MAXLEN )
42+ # Counter to vary starting position on each allocation
43+ self ._allocation_counter = 0
3644 # Register cleanup to release all ports on exit
3745 atexit .register (self .release_all )
3846
39- def allocate_port (self , preferred_port : Optional [int ] = None , max_attempts : int = 100 ) -> int :
47+ def allocate_port (self , preferred_port : Optional [int ] = None , max_attempts : int = 1000 ) -> int :
4048 """Allocate a free port, ensuring it's not already reserved.
4149
4250 Args:
@@ -55,23 +63,42 @@ def allocate_port(self, preferred_port: Optional[int] = None, max_attempts: int
5563 if (
5664 preferred_port is not None
5765 and preferred_port not in self ._allocated_ports
66+ and preferred_port not in self ._recently_released
5867 and self ._is_port_free (preferred_port )
5968 ):
6069 self ._allocated_ports .add (preferred_port )
6170 return preferred_port
6271
63- # Try to find a free port
72+ # Ephemeral port range (49152-65535)
73+ # We'll search through this range to find a port that's:
74+ # 1. Not in our allocated set
75+ # 2. Not in our recently_released queue
76+ # 3. Actually free according to the OS
77+
78+ # Use combination of thread ID and allocation counter to vary starting point
79+ # This distributes allocation across the port range and avoids hotspots
80+ port_range_size = 65535 - 49152 + 1
81+ thread_id = threading .get_ident ()
82+ self ._allocation_counter += 1
83+ start_offset = (hash (thread_id ) + self ._allocation_counter ) % port_range_size
84+
6485 for attempt in range (max_attempts ):
65- port = self ._find_free_port ()
86+ # Cycle through port range with varied offset
87+ port = 49152 + ((start_offset + attempt ) % port_range_size )
6688
67- # Double-check it's not in our reserved set (shouldn't happen, but be safe)
68- if port not in self ._allocated_ports :
89+ # Skip if in our tracking structures
90+ if port in self ._allocated_ports or port in self ._recently_released :
91+ continue
92+
93+ # Check if actually free
94+ if self ._is_port_free (port ):
6995 self ._allocated_ports .add (port )
7096 return port
7197
7298 raise RuntimeError (
7399 f"Failed to allocate a free port after { max_attempts } attempts. "
74- f"Currently allocated ports: { len (self ._allocated_ports )} "
100+ f"Currently allocated: { len (self ._allocated_ports )} , "
101+ f"recently released: { len (self ._recently_released )} "
75102 )
76103
77104 def release_port (self , port : int ) -> None :
@@ -82,12 +109,43 @@ def release_port(self, port: int) -> None:
82109
83110 """
84111 with self ._lock :
85- self ._allocated_ports .discard (port )
112+ if port in self ._allocated_ports :
113+ self ._allocated_ports .remove (port )
114+ # Add to the back of the queue; oldest will be evicted when queue is full
115+ self ._recently_released .append (port )
86116
87117 def release_all (self ) -> None :
88118 """Release all allocated ports."""
89119 with self ._lock :
90120 self ._allocated_ports .clear ()
121+ self ._recently_released .clear ()
122+
123+ def reserve_existing_port (self , port : int ) -> bool :
124+ """Reserve a port that was allocated externally.
125+
126+ Args:
127+ port: The externally assigned port to reserve.
128+
129+ Returns:
130+ True if the port was reserved (or already reserved), False if the port value is invalid.
131+
132+ """
133+ if port <= 0 or port > 65535 :
134+ return False
135+
136+ with self ._lock :
137+ if port in self ._allocated_ports :
138+ return True
139+
140+ # Remove from recently released queue if present (we're explicitly reserving it)
141+ if port in self ._recently_released :
142+ # Create a new deque without this port
143+ self ._recently_released = deque (
144+ (p for p in self ._recently_released if p != port ), maxlen = _RECENTLY_RELEASED_PORTS_MAXLEN
145+ )
146+
147+ self ._allocated_ports .add (port )
148+ return True
91149
92150 @contextmanager
93151 def allocated_port (self , preferred_port : Optional [int ] = None ) -> Iterator [int ]:
@@ -121,7 +179,8 @@ def _find_free_port() -> int:
121179
122180 """
123181 s = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
124- s .setsockopt (socket .SOL_SOCKET , socket .SO_REUSEADDR , 1 )
182+ # Don't use SO_REUSEADDR - we need to match the behavior of TCPStore
183+ # which binds without it, so ports in TIME_WAIT will be rejected
125184 s .bind (("" , 0 ))
126185 port = s .getsockname ()[1 ]
127186 s .close ()
@@ -140,7 +199,8 @@ def _is_port_free(port: int) -> bool:
140199 """
141200 try :
142201 s = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
143- s .setsockopt (socket .SOL_SOCKET , socket .SO_REUSEADDR , 1 )
202+ # Don't use SO_REUSEADDR - we need to match the behavior of TCPStore
203+ # which binds without it, so ports in TIME_WAIT will be rejected
144204 s .bind (("" , port ))
145205 s .close ()
146206 return True
0 commit comments