Summary
Fixes #22559.
Two gaps in connection deduplication let a node end up with more than one simultaneous connection to the same peer:
ConnectNode()checked "are we already connected to this address?" before performing the (potentially slow, e.g. an I2P SAM handshake) connect itself, and only added the new node tom_nodesonce the connect succeeded. Two concurrent callers (e.g.ThreadOpenConnectionsracing anaddnode/addconnectionRPC) could both pass the check and both spend time connecting to the same address, ending up with duplicate connections to it. This matches the race analyzed in the issue by@vasildand@tryphe, reproducible today via e.g. two near-simultaneousaddnode ... onetrycalls to the same slow-to-connect (I2P) address.CreateNodeFromAcceptedSocket()performed no address-based dedup at all on the accept path, so a peer could open more than one simultaneous inbound connection — the "2 inbound" case reported in the issue.
Fix
- Close the outbound race by having
ConnectNode()atomically reserve the target address (in a newm_connectingset, guarded by the existingm_nodes_mutex) before starting the connect, releasing the reservation on failure or once the caller has added the resulting node tom_nodes.AlreadyConnectedToAddressPort()/AlreadyConnectedToAddress()now also consult this set, so other callers (e.g. the RPC dedup checks) see in-flight attempts too. - For the inbound gap, reject a second connection when the accepted address is already connected/connecting. This is deliberately scoped to I2P only: its inbound source address is the peer's real, non-shareable destination and always uses port 0, so an exact match reliably means the same peer. The same is not true for a Tor hidden-service inbound connection (source masked by the local Tor proxy) or a clearnet IPv4/IPv6 one (source IP can be legitimately shared by distinct peers behind NAT), so both are deliberately left alone — this also avoids affecting the functional test framework, which connects many mock peers from
127.0.0.1.
Test plan
- Added
connecting_addr_claim(unit test for the new reservation primitive and its interaction with the existing dedup checks) - Added
create_node_from_accepted_socket_i2p_dedup(confirms a duplicate I2P inbound connection is dropped, a different I2P peer is unaffected, and clearnet peers sharing a source IP are not deduplicated) - Added
connect_node_rejects_in_flight_address(exercises the realConnectNode()path via the existingConnectNodePublictest helper, confirming the fix is wired into production code, not just the helper in isolation) - Full
test_bitcoinsuite passes locally (763 test cases, no regressions)
🤖 Generated with Claude Code