LLM report LUP-30432 verbatim:
ReconnectionInfo retains a live CountingSemaphoreGrant while waiting in m_reconnections. DisconnectNodes() moves a disconnected V2 peer's grant into this queue when ShouldReconnectV1() is true (src/net.cpp:1976-1984), so removing the connection does not free its slot. ThreadOpenAddedConnections() then blocks acquiring semAddnode at the top of its loop (net.cpp:3075), before reaching PerformReconnections() (3096). When all eight manual permits are held by pending fallbacks, the worker cannot process the queue that owns the permits it needs.
A concrete affected deployment uses -connect=0 and eight configured -addnode endpoints. Those endpoints, or an interceptor causing equivalent handshake failures, can accept the V2 connections, receive at least 24 bytes, send nothing, and close after the worker's initial connection pass. All grants remain queued. Once the 60-second retry sleep ends, the only reconnection worker blocks indefinitely; -connect=0 prevents ThreadOpenConnections from being started. The node remains disconnected even after the endpoints become healthy, and replacing the configured addnodes cannot free the retained grants. Restarting clears the state. This requires influence over selected outbound connections; arbitrary inbound peers alone cannot trigger it. Other configurations may recover through another worker or a released permit.
The regression extends the existing functional test with eight local listeners, requires actual V1 retries, and times out on HEAD's blocked accept. Python syntax was checked and validate_poc accepted the diff; execution was unavailable because no built bitcoind exists in the worktree. Searches for reconnections/semaphore, ThreadOpenAddedConnections, and fallback/deadlock found no matching prior report.
PoC:
diff --git a/test/functional/p2p_v2_transport.py b/test/functional/p2p_v2_transport.py
--- a/test/functional/p2p_v2_transport.py
+++ b/test/functional/p2p_v2_transport.py
@@ -5,7 +5,9 @@
"""
Test v2 transport
"""
+from contextlib import ExitStack
import socket
+import time
from test_framework.messages import MAGIC_BYTES, NODE_P2P_V2
from test_framework.test_framework import BitcoinTestFramework
@@ -168,5 +170,62 @@
self.wait_until(lambda: peer_id not in [p["id"] for p in self.nodes[0].getpeerinfo()])
+ self.test_addnode_fallback_at_capacity()
+
+ def test_addnode_fallback_at_capacity(self):
+ self.log.info("V1 fallback must progress when all addnode permits are held")
+ with ExitStack() as stack:
+ listeners = []
+ addnodes = []
+ for _ in range(8):
+ listener = stack.enter_context(socket.socket(socket.AF_INET, socket.SOCK_STREAM))
+ listener.bind(("127.0.0.1", 0))
+ listener.listen(2)
+ listener.settimeout(20 * self.options.timeout_factor)
+ listeners.append(listener)
+ addnodes.append(f"-addnode=127.0.0.1:{listener.getsockname()[1]}")
+
+ # Disable the other reconnection worker and keep handshakes alive
+ # until all eight manual connection permits have been consumed.
+ self.restart_node(0, extra_args=[
+ "-v2transport=1", "-connect=0", "-peertimeout=9999",
+ *addnodes,
+ ])
+ initial = []
+ for listener in listeners:
+ peer = stack.enter_context(listener.accept()[0])
+ peer.settimeout(20 * self.options.timeout_factor)
+ initial.append(peer)
+ received = b""
+ while len(received) < 24:
+ chunk = peer.recv(24 - len(received))
+ assert chunk, "V2 initiator closed before sending its handshake"
+ received += chunk
+
+ assert_equal(len(self.nodes[0].getpeerinfo()), 8)
+ # The worker waits 500ms after its last initial connect, processes
+ # pending fallbacks, then sleeps 60s. Close only after that first
+ # pass, so our fallbacks are queued during the retry sleep.
+ time.sleep(2 * self.options.timeout_factor)
+ for peer in initial:
+ # Send no bytes: this makes ShouldReconnectV1() return true.
+ peer.close()
+
+ expected_prefix = MAGIC_BYTES["regtest"] + b"version" + bytes(5)
+ for listener in listeners:
+ listener.settimeout(75 * self.options.timeout_factor)
+ # HEAD times out here: every permit is retained in
+ # m_reconnections, and the only worker waits for a new permit
+ # before it can process that queue. A fix must resume fallback.
+ retry = stack.enter_context(listener.accept()[0])
+ retry.settimeout(20 * self.options.timeout_factor)
+ prefix = b""
+ while len(prefix) < len(expected_prefix):
+ chunk = retry.recv(len(expected_prefix) - len(prefix))
+ assert chunk, "Fallback connection closed before VERSION"
+ prefix += chunk
+ assert_equal(prefix, expected_prefix)
+
+
if __name__ == '__main__':
V2TransportTest(__file__).main()