Summary
CreateNodeFromAcceptedSocket() enforces the inbound connection limit by calling AttemptToEvictConnection() before accepting a replacement peer. Previously, the selected peer was only marked with fDisconnect and remained in m_nodes until the socket handler later ran DisconnectNodes().
That is enough for inbound TCP accepts, which run in the socket handler thread, but not for accept loops outside the socket handler. In particular, ThreadI2PAcceptIncoming() can continue accepting replacement peers before the socket handler drains the evicted peers, temporarily exceeding -maxconnections.
This removes an evicted inbound peer from m_nodes immediately, closes/releases it into m_nodes_disconnected, and guards m_nodes_disconnected with m_nodes_mutex because it can now be updated from the eviction path as well as the socket handler path.
The regression test fills the inbound slots, accepts one more socket, and checks that the live node set remains capped while one original node is marked disconnected and replaced. As a regression check, transplanting the new test onto unpatched origin/master fails with:
nodes.size() == static_cast<size_t>(max_inbound) has failed [30 != 29]
Local I2P Repro
As supporting evidence, I also reproduced the behavior with a local i2pd SAM router using -maxconnections=40 and 120 SAM STREAM CONNECT attempts:
origin/master d406cffafdd1: 120/120 streams succeeded, peak connections_in=35
patched branch (same patch): 120/120 streams succeeded, peak connections_in=29
Test
cmake --build build --target test_bitcoin
build/bin/test_bitcoin --run_test=net_peer_connection_tests
Fixes #27843.