Skip to content

Commit f80895b

Browse files
committed
pyln-testing: give btcproxy a port and choose better ports
btcproxy was using port 0 by default without going through our port reservation logic, so it may have caused "Address already in use" issues. It was also binding to 0.0.0.0, so on all addresses. For the port reservations to work we use 127.0.0.1 here too. We choose from outside the source-port range of ports in hopes of avoiding more port bind race conditions. Changelog-None
1 parent 3bc430d commit f80895b

2 files changed

Lines changed: 115 additions & 29 deletions

File tree

contrib/pyln-testing/pyln/testing/btcproxy.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
import flask # type: ignore
1111
import json
1212
import logging
13+
import socket
1314
import threading
15+
import time
1416

1517

1618
class DecimalEncoder(json.JSONEncoder):
@@ -81,17 +83,28 @@ def proxy(self):
8183

8284
def start(self):
8385
d = PathInfoDispatcher({'/': self.app})
84-
self.server = Server(('0.0.0.0', self.rpcport), d)
86+
self.server = Server(('127.0.0.1', self.rpcport), d)
8587
self.proxy_thread = threading.Thread(target=self.server.start)
8688
self.proxy_thread.daemon = True
8789
self.proxy_thread.start()
8890

89-
# Now that bitcoind is running on the real rpcport, let's tell all
90-
# future callers to talk to the proxyport. We use the bind_addr as a
91-
# signal that the port is bound and accepting connections.
92-
while self.server.bind_addr[1] == 0:
93-
pass
94-
self.rpcport = self.server.bind_addr[1]
91+
if self.rpcport == 0:
92+
while self.server.bind_addr[1] == 0:
93+
time.sleep(0.01)
94+
self.rpcport = self.server.bind_addr[1]
95+
96+
deadline = time.time() + 10
97+
while time.time() < deadline:
98+
try:
99+
s = socket.create_connection(('127.0.0.1', self.rpcport),
100+
timeout=0.5)
101+
s.close()
102+
break
103+
except OSError:
104+
time.sleep(0.05)
105+
else:
106+
raise RuntimeError("BitcoinRpcProxy failed to bind on "
107+
"127.0.0.1:{}".format(self.rpcport))
95108
logging.debug("BitcoinRpcProxy proxying incoming port {} to {}".format(self.rpcport, self.bitcoind.rpcport))
96109

97110
def stop(self):

contrib/pyln-testing/pyln/testing/utils.py

Lines changed: 95 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from pyln.client import Plugin
1515
from pyln.client.plugin import PluginLogHandler
1616

17-
import ephemeral_port_reserve # type: ignore
1817
import tempfile
1918
import errno
2019
import json
@@ -178,12 +177,96 @@ def get_tx_p2wsh_outnum(bitcoind, tx, amount):
178177
_PORT_LOCK_DIR = Path(tempfile.gettempdir()) / "pyln-testing-ports"
179178
_PORT_LOCK_DIR.mkdir(exist_ok=True)
180179

180+
# If we never hand out this many ports, something is wrong with our
181+
# ephemeral-range detection, and allocating from the OS range is a risky
182+
# fallback we'd rather fail loudly about.
183+
_MIN_RESERVED_PORTS = 2048
184+
185+
186+
def ephemeral_port_range():
187+
"""Return the (lo, hi) of the OS ephemeral source-port range, or None.
188+
189+
The kernel only ever assigns *source* ports from this range, so a listen
190+
socket bound to a port *outside* it can never collide with an active
191+
OS-assigned connection. Linux/BSD expose it via /proc; macOS via sysctl.
192+
"""
193+
try:
194+
with open('/proc/sys/net/ipv4/ip_local_port_range') as f:
195+
lo, hi = (int(x) for x in f.read().split())
196+
return lo, hi
197+
except OSError:
198+
pass
199+
try:
200+
import subprocess
201+
lo = int(subprocess.check_output(
202+
['sysctl', '-n', 'net.inet.ip.portrange.first']).strip())
203+
hi = int(subprocess.check_output(
204+
['sysctl', '-n', 'net.inet.ip.portrange.last']).strip())
205+
return lo, hi
206+
except Exception:
207+
# Unknown platform: assume the Linux default.
208+
return None
209+
210+
211+
def _reserved_port_pool():
212+
"""Return (lo, hi) of the port range we may bind.
213+
214+
We prefer the region strictly below the OS ephemeral floor (which is never
215+
used for source ports, so a test's listen port cannot be stolen by a
216+
*binding* foreign process either, except a deliberate one). If a machine
217+
runs with a very low ephemeral floor we fall back to above the ceiling,
218+
and finally to the ephemeral range itself as a best effort.
219+
"""
220+
rng = ephemeral_port_range()
221+
if rng is None:
222+
lo, hi = 32768, 60999
223+
else:
224+
lo, hi = rng
225+
226+
if lo > 1024 and lo - 1024 >= _MIN_RESERVED_PORTS:
227+
return 1024, lo - 1
228+
if hi < 65535 and 65535 - hi >= _MIN_RESERVED_PORTS:
229+
return hi + 1, 65535
230+
# No room anywhere else: e.g. an ephemeral range covering everything.
231+
return lo, hi
232+
233+
234+
def _port_is_free(port):
235+
"""Return True if 127.0.0.1:port can be bound right now.
236+
237+
This is an actual bind() test (like the old ephemeral_port_reserve), so we
238+
never hand out a port that is genuinely claimed *right now*, as opposed to
239+
merely reserved via lockfile. We use SO_REUSEADDR to match the daemons, so
240+
a lingering TIME_WAIT socket doesn't count as "in use".
241+
"""
242+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
243+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
244+
try:
245+
s.bind(('127.0.0.1', port))
246+
return True
247+
except OSError as e:
248+
if e.errno != errno.EADDRINUSE:
249+
raise
250+
return False
251+
finally:
252+
s.close()
253+
181254

182255
def reserve_unused_port():
183-
"""Get an unused port: avoids handing out the same port unless it's been
184-
returned"""
256+
"""Get an unused port from the non-ephemeral pool.
257+
258+
We test-bind the candidate so a port claimed by anything at all right now
259+
is skipped, then take the filesystem lock so our *own* workers never pick
260+
the same genuinely free port concurrently. Note the lockfile only
261+
coordinates our own allocations: the real protection against the OS
262+
stealing a port via source-port assignment is that the pool lies outside
263+
the ephemeral source-port range.
264+
"""
265+
pool_lo, pool_hi = _reserved_port_pool()
185266
while True:
186-
port = ephemeral_port_reserve.reserve()
267+
port = random.randint(pool_lo, pool_hi)
268+
if not _port_is_free(port):
269+
continue
187270

188271
lock_path = _PORT_LOCK_DIR / f"{port}.lock"
189272
try:
@@ -227,23 +310,11 @@ def wait_for_port_released(port, timeout=TIMEOUT):
227310
connectd fail with 'Address already in use'.
228311
"""
229312
start_time = time.time()
230-
while True:
231-
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
232-
# Match connectd's SO_REUSEADDR, so sockets lingering in
233-
# TIME_WAIT don't count as "still in use".
234-
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
235-
try:
236-
s.bind(('127.0.0.1', port))
237-
break
238-
except OSError as e:
239-
if e.errno != errno.EADDRINUSE:
240-
raise
241-
if time.time() - start_time > timeout:
242-
raise TimeoutError(
243-
"Port {} was not released within {} seconds"
244-
.format(port, timeout))
245-
finally:
246-
s.close()
313+
while not _port_is_free(port):
314+
if time.time() - start_time > timeout:
315+
raise TimeoutError(
316+
"Port {} was not released within {} seconds"
317+
.format(port, timeout))
247318
time.sleep(0.1)
248319

249320
waited = time.time() - start_time
@@ -564,6 +635,8 @@ def kill(self):
564635

565636
self.cleanup_files()
566637
drop_unused_port(self.rpcport)
638+
for p in self.proxies:
639+
drop_unused_port(p.rpcport)
567640

568641
def start(self, wallet_file=None):
569642
if not self.port_setup:
@@ -594,7 +667,7 @@ def stop(self):
594667
return TailableProc.stop(self)
595668

596669
def get_proxy(self):
597-
proxy = BitcoinRpcProxy(self)
670+
proxy = BitcoinRpcProxy(self, rpcport=reserve_unused_port())
598671
self.proxies.append(proxy)
599672
proxy.start()
600673
return proxy

0 commit comments

Comments
 (0)