|
11 | 11 | from pyln.client import LightningRpc |
12 | 12 | from pyln.client import Millisatoshi |
13 | 13 | from pyln.client import NodeVersion |
| 14 | +from pyln.client import Plugin |
14 | 15 |
|
15 | 16 | import ephemeral_port_reserve # type: ignore |
| 17 | +import tempfile |
16 | 18 | import json |
17 | 19 | import logging |
18 | 20 | import lzma |
|
22 | 24 | import random |
23 | 25 | import re |
24 | 26 | import shutil |
| 27 | +import socket |
25 | 28 | import sqlite3 |
26 | 29 | import string |
27 | 30 | import struct |
28 | 31 | import subprocess |
29 | 32 | import sys |
30 | 33 | import threading |
31 | 34 | import time |
| 35 | +import types |
32 | 36 | import warnings |
33 | 37 |
|
34 | 38 | BITCOIND_CONFIG = { |
@@ -78,6 +82,8 @@ def env(name, default=None): |
78 | 82 | VALGRIND = env("VALGRIND") == "1" |
79 | 83 | TEST_NETWORK = env("TEST_NETWORK", 'regtest') |
80 | 84 | TEST_DEBUG = env("TEST_DEBUG", "0") == "1" |
| 85 | + |
| 86 | +INLINE_PLUGIN_PATH = os.path.join(os.path.dirname(__file__), 'inline-plugin.py') |
81 | 87 | SLOW_MACHINE = env("SLOW_MACHINE", "0") == "1" |
82 | 88 | DEPRECATED_APIS = env("DEPRECATED_APIS", "0") == "1" |
83 | 89 | TIMEOUT = int(env("TIMEOUT", 180 if SLOW_MACHINE else 60)) |
@@ -166,25 +172,46 @@ def get_tx_p2wsh_outnum(bitcoind, tx, amount): |
166 | 172 | return None |
167 | 173 |
|
168 | 174 |
|
169 | | -unused_port_lock = threading.Lock() |
170 | | -unused_port_set = set() |
| 175 | +_PORT_LOCK_DIR = Path(tempfile.gettempdir()) / "pyln-testing-ports" |
| 176 | +_PORT_LOCK_DIR.mkdir(exist_ok=True) |
171 | 177 |
|
172 | 178 |
|
173 | 179 | def reserve_unused_port(): |
174 | 180 | """Get an unused port: avoids handing out the same port unless it's been |
175 | 181 | returned""" |
176 | | - with unused_port_lock: |
177 | | - while True: |
178 | | - port = ephemeral_port_reserve.reserve() |
179 | | - if port not in unused_port_set: |
180 | | - break |
181 | | - unused_port_set.add(port) |
| 182 | + while True: |
| 183 | + port = ephemeral_port_reserve.reserve() |
182 | 184 |
|
183 | | - return port |
| 185 | + lock_path = _PORT_LOCK_DIR / f"{port}.lock" |
| 186 | + try: |
| 187 | + fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY) |
| 188 | + os.write(fd, str(os.getpid()).encode()) |
| 189 | + os.close(fd) |
| 190 | + return port |
| 191 | + except FileExistsError: |
| 192 | + continue |
184 | 193 |
|
185 | 194 |
|
186 | 195 | def drop_unused_port(port): |
187 | | - unused_port_set.remove(port) |
| 196 | + if port: |
| 197 | + lock_path = _PORT_LOCK_DIR / f"{port}.lock" |
| 198 | + lock_path.unlink(missing_ok=True) |
| 199 | + |
| 200 | + |
| 201 | +def cleanup_stale_port_locks(): |
| 202 | + """Remove lockfiles whose owning process no longer exists.""" |
| 203 | + try: |
| 204 | + for lock_path in _PORT_LOCK_DIR.glob("*.lock"): |
| 205 | + try: |
| 206 | + pid = int(lock_path.read_text()) |
| 207 | + try: |
| 208 | + os.kill(pid, 0) # signal 0 = existence check, no actual signal |
| 209 | + except ProcessLookupError: |
| 210 | + lock_path.unlink(missing_ok=True) |
| 211 | + except (ValueError, PermissionError, FileNotFoundError): |
| 212 | + pass |
| 213 | + except Exception: |
| 214 | + pass # best-effort, never crash the test run over cleanup |
188 | 215 |
|
189 | 216 |
|
190 | 217 | class TailableProc(object): |
@@ -285,6 +312,8 @@ def kill(self): |
285 | 312 |
|
286 | 313 | def cleanup_files(self): |
287 | 314 | """Ensure files are closed.""" |
| 315 | + cleanup_stale_port_locks() |
| 316 | + |
288 | 317 | for f in ["stdout_write", "stderr_write", "stdout_read", "stderr_read"]: |
289 | 318 | try: |
290 | 319 | getattr(self, f).close() |
@@ -454,10 +483,7 @@ def __init__(self, bitcoin_dir="/tmp/bitcoind-test", rpcport=None): |
454 | 483 | TailableProc.__init__(self, bitcoin_dir, verbose=False) |
455 | 484 |
|
456 | 485 | if rpcport is None: |
457 | | - self.reserved_rpcport = reserve_unused_port() |
458 | | - rpcport = self.reserved_rpcport |
459 | | - else: |
460 | | - self.reserved_rpcport = None |
| 486 | + rpcport = reserve_unused_port() |
461 | 487 |
|
462 | 488 | self.bitcoin_dir = bitcoin_dir |
463 | 489 | self.rpcport = rpcport |
@@ -494,9 +520,15 @@ def __init__(self, bitcoin_dir="/tmp/bitcoind-test", rpcport=None): |
494 | 520 | self.rpc = SimpleBitcoinProxy(btc_conf_file=self.conf_file) |
495 | 521 | self.proxies = [] |
496 | 522 |
|
497 | | - def __del__(self): |
498 | | - if self.reserved_rpcport is not None: |
499 | | - drop_unused_port(self.reserved_rpcport) |
| 523 | + def kill(self): |
| 524 | + try: |
| 525 | + self.stop() |
| 526 | + except Exception: |
| 527 | + self.proc.kill() |
| 528 | + self.proc.wait() |
| 529 | + |
| 530 | + self.cleanup_files() |
| 531 | + drop_unused_port(self.rpcport) |
500 | 532 |
|
501 | 533 | def start(self, wallet_file=None): |
502 | 534 | TailableProc.start(self) |
@@ -1069,6 +1101,11 @@ def grpc(self): |
1069 | 1101 | creds, |
1070 | 1102 | options=(('grpc.ssl_target_name_override', 'cln'),) |
1071 | 1103 | ) |
| 1104 | + |
| 1105 | + # Force the connect+handshake to finish now, instead of lazily on |
| 1106 | + # the first RPC the caller happens to make. |
| 1107 | + grpc.channel_ready_future(channel).result(timeout=10) |
| 1108 | + |
1072 | 1109 | from pyln import grpc as clnpb |
1073 | 1110 | return clnpb.NodeStub(channel) |
1074 | 1111 |
|
@@ -1786,7 +1823,8 @@ def get_nodes(self, num_nodes, opts=None): |
1786 | 1823 | def get_node(self, node_id=None, options=None, dbfile=None, |
1787 | 1824 | bkpr_dbfile=None, feerates=(15000, 11000, 7500, 3750), |
1788 | 1825 | start=True, wait_for_bitcoind_sync=True, may_fail=False, |
1789 | | - expect_fail=False, cleandir=True, gossip_store_file=None, unused_grpc_port=True, **kwargs): |
| 1826 | + expect_fail=False, cleandir=True, gossip_store_file=None, unused_grpc_port=True, |
| 1827 | + inline_plugin=None, **kwargs): |
1790 | 1828 | node_id = self.get_node_id() if not node_id else node_id |
1791 | 1829 | port = reserve_unused_port() |
1792 | 1830 | grpc_port = self.get_unused_port() if unused_grpc_port else None |
@@ -1830,6 +1868,11 @@ def get_node(self, node_id=None, options=None, dbfile=None, |
1830 | 1868 | shutil.copy(gossip_store_file, os.path.join(node.daemon.lightning_dir, TEST_NETWORK, |
1831 | 1869 | 'gossip_store')) |
1832 | 1870 |
|
| 1871 | + if inline_plugin is not None: |
| 1872 | + if 'plugin' not in node.daemon.opts: |
| 1873 | + node.daemon.opts['plugin'] = INLINE_PLUGIN_PATH |
| 1874 | + _inline_plugin(node, inline_plugin) |
| 1875 | + |
1833 | 1876 | if start: |
1834 | 1877 | try: |
1835 | 1878 | node.start(wait_for_bitcoind_sync) |
@@ -1944,3 +1987,65 @@ def killall(self, expected_successes): |
1944 | 1987 | drop_unused_port(p) |
1945 | 1988 |
|
1946 | 1989 | return not unexpected_fail, err_msgs |
| 1990 | + |
| 1991 | + |
| 1992 | +def _inline_plugin(node, setup_fn): |
| 1993 | + """Set up an inline plugin serve thread for a not-yet-started node. |
| 1994 | +
|
| 1995 | + Normally called via get_node(inline_plugin=setup_fn). The plugin's cwd |
| 1996 | + (set by lightningd) is node.daemon.lightning_dir/TEST_NETWORK/, which is |
| 1997 | + where the shim looks for inline-plugin.sock. |
| 1998 | +
|
| 1999 | + Example:: |
| 2000 | +
|
| 2001 | + def setup(plugin): |
| 2002 | + @plugin.method('greet') |
| 2003 | + def greet(name, plugin): |
| 2004 | + return {'message': f'hello {name}'} |
| 2005 | +
|
| 2006 | + l1 = node_factory.get_node(inline_plugin=setup) |
| 2007 | + assert l1.rpc.greet('world') == {'message': 'hello world'} |
| 2008 | + """ |
| 2009 | + sock_path = os.path.join(node.daemon.lightning_dir, TEST_NETWORK, 'inline-plugin.sock') |
| 2010 | + srv = socket.socket(socket.AF_UNIX) |
| 2011 | + srv.bind(sock_path) |
| 2012 | + srv.listen(1) |
| 2013 | + |
| 2014 | + plugin = Plugin(autopatch=False) |
| 2015 | + setup_fn(plugin) |
| 2016 | + |
| 2017 | + def serve(): |
| 2018 | + while True: |
| 2019 | + conn, _ = srv.accept() |
| 2020 | + |
| 2021 | + class _SockWriter: |
| 2022 | + def write(self, data): |
| 2023 | + try: |
| 2024 | + conn.sendall(data) |
| 2025 | + except OSError: |
| 2026 | + pass |
| 2027 | + |
| 2028 | + def flush(self): |
| 2029 | + pass |
| 2030 | + |
| 2031 | + writer = _SockWriter() |
| 2032 | + plugin.stdout = types.SimpleNamespace(buffer=writer, flush=writer.flush) |
| 2033 | + |
| 2034 | + partial = b"" |
| 2035 | + while True: |
| 2036 | + try: |
| 2037 | + chunk = conn.recv(4096) |
| 2038 | + except OSError: |
| 2039 | + break |
| 2040 | + if not chunk: |
| 2041 | + break |
| 2042 | + partial += chunk |
| 2043 | + msgs = partial.split(b'\n\n') |
| 2044 | + if len(msgs) < 2: |
| 2045 | + continue |
| 2046 | + try: |
| 2047 | + partial = plugin._multi_dispatch(msgs) |
| 2048 | + except Exception: |
| 2049 | + break |
| 2050 | + |
| 2051 | + threading.Thread(target=serve, daemon=True).start() |
0 commit comments