{
 "number": 33954,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/33954",
 "title": "test: add functional test for outbound connection management",
 "author": "mzumsande",
 "author_association": "MEMBER",
 "created_at": "2025-11-26T19:01:42Z",
 "updated_at": "2026-08-29T22:10:13Z",
 "age_days": 294,
 "draft": true,
 "labels": [
  "Tests",
  "CI failed"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "abf8fc4f7a2afaab86c26d94901ae78fe74f27a9",
 "head_ref": "202511_test_outbound_mgmt",
 "head_repo": "mzumsande/bitcoin",
 "head_history": [
  {
   "t": "2025-11-26T19:11:04Z",
   "sha": "1158564303b2e73d5254f258899b63007e3f52a1"
  },
  {
   "t": "2025-12-11T21:00:35Z",
   "sha": "b961ad4e7e3d8694237428cc53e32ba04be4ff56"
  },
  {
   "t": "2026-01-02T00:06:20Z",
   "sha": "8c6151044c9315e6af9c10dbbc9951fa89e430b0"
  },
  {
   "t": "2026-01-02T00:11:56Z",
   "sha": "53457d847cf2f7d2fe15eeaf7d4ab877bd4b4f69"
  },
  {
   "t": "2026-01-02T00:17:53Z",
   "sha": "0cb08573f38b7aa8a18147eef1a775b6c24bcb7c"
  },
  {
   "t": "2026-02-24T15:44:06Z",
   "sha": "1562496a56eae31099020d45695c6d3e5391410b"
  },
  {
   "t": "2026-02-24T16:17:19Z",
   "sha": "b5e9e381381c9ff5f52f14001acfd442c5704c26"
  },
  {
   "t": "2026-02-24T17:25:50Z",
   "sha": "c733d112309321e3671d31cdca92e7aaa9274975"
  },
  {
   "t": "2026-08-28T15:42:45Z",
   "sha": "abf8fc4f7a2afaab86c26d94901ae78fe74f27a9"
  }
 ],
 "additions": 398,
 "deletions": 102,
 "changed_files": 4,
 "commit_count": 3,
 "size_bucket": "L",
 "mergeable_state": "unstable",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "stale_ack": [
     {
      "login": "vasild",
      "url": "https://github.com/bitcoin/bitcoin/pull/33954#pullrequestreview-3852588129"
     },
     {
      "login": "Bortlesboat",
      "url": "https://github.com/bitcoin/bitcoin/pull/33954#pullrequestreview-3950707606"
     }
    ]
   },
   "conflicts": []
  }
 },
 "acks_parsed": {
  "vasild": {
   "kind": "ack",
   "hash": "c733d112309321e3671d31cdca92e7aaa9274975",
   "t": "2026-02-25T08:27:07Z",
   "stale": true
  },
  "Bortlesboat": {
   "kind": "ack",
   "hash": "c733d112309321e3671d31cdca92e7aaa9274975",
   "t": "2026-03-15T22:15:22Z",
   "stale": true
  }
 },
 "acks_tally": {
  "ack": 0,
  "stale_ack": 2,
  "concept_ack": 0,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 2,
  "changes_requested": 0,
  "distinct_reviewers": [
   "Bortlesboat",
   "maflcko",
   "vasild"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": true,
  "mergeable_state": "unstable",
  "last_author_activity": "2026-08-28T15:45:45Z",
  "last_reviewer_activity": "2026-03-15T22:15:32Z",
  "last_reviewer": "Bortlesboat",
  "author_silent_days": 20,
  "waiting_on_author_days": 0,
  "days_since_update": 18
 },
 "refs": {
  "mentioned": [
   11560,
   19858,
   27213,
   29415,
   34186
  ],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [
   {
    "number": 29415,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2026-01-12",
    "title": "Broadcast own transactions only via short-lived Tor or I2P connections"
   },
   {
    "number": 11560,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2017-11-02",
    "title": "Connect to a new outbound peer if our tip is stale"
   },
   {
    "number": 19858,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2020-12-11",
    "title": "Periodically make block-relay connections and sync headers"
   },
   {
    "number": 27213,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2023-08-06",
    "title": "p2p: Diversify automatic outbound connections with respect to networks"
   },
   {
    "number": 34186,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2026-01-14",
    "title": "test: use dynamic port allocation in proxy tests"
   }
  ],
  "conflicts": []
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "test/functional/p2p_outbound_management.py",
  "test/functional/test_framework/test_framework.py"
 ],
 "body": "For #29415, vasild added the possibility to have outbound connections made \"naturally\" by the node using addrman entries, by redirecting them via a SOCKS5 proxy to python p2ps or other full nodes, so that the node under test behaves as if it is connected to normal non-local peers from arbitrary networks, while we have full control over the peers.\n\nI think this feature is really cool and want to suggest to integrate it more prominently into the test framework (\"`auto_outbound_mode`\") in the first commit because it allows to test the code in which the node decides to make connections - in contrast to the existing `add_outbound_p2p_connection` where the functional test intiates the connections.\n\nThe second commit uses it to add functional test coverage for the following features of outbound connection management:\n- extra full outbound connections due to a stale tip (#11560)\n- periodic block-relay-only connections (#19858)\n- full outbound connection management with respect to networks (#27213)\n\nThis should work well with the test from #29415 (which would keep its own `destinations_factory` but can call `_create_auto_outbound_listener` to avoid much of code duplication).\n\nOther possible uses for `auto_outbound_mode` I could think of are more detailed testing of anchors (`feature_anchors.py`), feeler connections, or any other p2p logic that uses the network of peers.",
 "commits": [
  {
   "sha": "6bcb43b4cd4a2f0088aecc79d0cea8e4f8445f51",
   "date": "2026-08-27T15:23:51Z",
   "message": "test: add auto_outbound_mode to test framework\n\nThis new option allows tests to make use of automatic outbound connections\n(made from addrman).\nA SOCKS5 proxy is used to redirect the connection, by default to a P2PInterface.\n\nFor advanced customization, tests can provide auto_outbound_factory() to\noverwrite where each outbound connection made by a node should be redirected\nto (for example to another node). It is passed the index of the connection\nand its connection type, which is determined by matching the proxy client's\naddress against the nodes' getpeerinfo addrbind.\n\nThe generic pieces are taken from p2p_private_broadcast.py, which is\nconverted to use them in the next commit.\n\nCo-authored-by: Vasil Dimov <vd@FreeBSD.org>"
  },
  {
   "sha": "fdb3bdb4ad9930fc62bb3fab71b6bc96a80be064",
   "date": "2026-08-27T15:23:51Z",
   "message": "test: use auto_outbound_mode for p2p_private_broadcast.py\n\nUsing the test framework avoids some duplicated code."
  },
  {
   "sha": "abf8fc4f7a2afaab86c26d94901ae78fe74f27a9",
   "date": "2026-08-27T15:23:51Z",
   "message": "test: add functional test for outbound connection management\n\nThe test covers\n- Extra outbound connections when the tip is stale\n- Regularly scheduled extra block-relay-only connections\n- Management of full outbound connection slots with respect to network-specific peers"
  }
 ],
 "timeline": [
  {
   "t": "2025-11-26T19:11:04Z",
   "kind": "force_push",
   "who": "mzumsande",
   "commit": "1158564303b2e73d5254f258899b63007e3f52a1"
  },
  {
   "t": "2025-11-27T09:22:40Z",
   "kind": "comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "text": "Concept ACK, thanks!"
  },
  {
   "t": "2025-11-28T22:08:20Z",
   "kind": "comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "text": "there are still some intermittent timeouts, will put in draft until I've fixed them."
  },
  {
   "t": "2025-12-08T07:45:26Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "1158564303b2e73d5254f258899b63007e3f52a1",
   "in_reply_to": null,
   "text": "Would it make sense to introduce `auto_listener_port(n)`?"
  },
  {
   "t": "2025-12-08T07:48:13Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "0cb08573f38b7aa8a18147eef1a775b6c24bcb7c",
   "in_reply_to": null,
   "text": "This is somewhat of a misuse of `p2p_port()`. It works, but looking at it I wonder if it can be done better. Maybe introduce `proxy_port(n)`? At some point if we implement also SAM proxy (for hijacking I2P connections), then we might need e.g. `proxy_port(0)` for the SOCKS5 proxy and `proxy_port(1)` for the SAM proxy."
  },
  {
   "t": "2025-12-08T12:21:36Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "1158564303b2e73d5254f258899b63007e3f52a1",
   "in_reply_to": null,
   "text": "If this is changed to:\n\n```diff\n-                # Create listener with default P2PInterface\n-                listener = P2PInterface()\n-                actual_to_addr, actual_to_port = self._create_auto_outbound_listener(listener, i)\n+                if self.decide_auto_outbound_where_to:\n+                    actual_to_addr, actual_to_port, listener = self.decide_auto_outbound_where_to(i)\n+                else:\n+                    # Create listener with default P2PInterface\n+                    listener = P2PInterface()\n+                    actual_to_addr, actual_to_port = self._create_auto_outbound_listener(listener, i)\n\n                 self.log.debug(f\"Instructing the SOCKS5 proxy to redirect connection i={i} to \"\n-                               f\"{format_addr_port(actual_to_addr, actual_to_port)} (a Python node)\")\n+                               f\"{format_addr_port(actual_to_addr, actual_to_port)}\")\n```\n\nthen it is possible to override from the test:\n* the decision where to redirect and\n* the listener creation (in case something other than `P2PInterface` is needed, e.g. `P2PDataStore`)\n\nWith that change, the private broadcast test can take the following simplification (`28 insertions(+), 78 deletions(-)`):\n\n[patch] make use of this in p2p_private_broadcast.py\n\n```diff\ndiff --git i/test/functional/p2p_private_broadcast.py w/test/functional/p2p_private_broadcast.py\nindex 4e97da666a..697d44c299 100755\n--- i/test/functional/p2p_private_broadcast.py\n+++ w/test/functional/p2p_private_broadcast.py\n@@ -162,95 +162,45 @@ ADDRMAN_ADDRESSES = [\n     \"[fc00::20]\",\n ]\n\n class P2PPrivateBroadcast(BitcoinTestFramework):\n     def set_test_params(self):\n-        self.disable_autoconnect = False\n+        self.auto_outbound_mode = True\n         self.num_nodes = 2\n\n     def setup_nodes(self):\n-        # Start a SOCKS5 proxy server.\n-        socks5_server_config = Socks5Configuration()\n-        # self.nodes[0] listens on p2p_port(0),\n-        # self.nodes[1] listens on p2p_port(1),\n-        # thus we tell the SOCKS5 server to listen on p2p_port(self.num_nodes) (self.num_nodes is 2)\n-        socks5_server_config.addr = (\"127.0.0.1\", p2p_port(self.num_nodes))\n-        socks5_server_config.unauth = True\n-        socks5_server_config.auth = True\n-\n-        self.socks5_server = Socks5Server(socks5_server_config)\n-        self.socks5_server.start()\n-\n-        # Tor ports are the highest among p2p/rpc/tor, so this should be the first available port.\n-        ports_base = tor_port(MAX_NODES) + 1\n-\n-        self.destinations = []\n-\n-        self.destinations_lock = threading.Lock()\n-\n-        def destinations_factory(requested_to_addr, requested_to_port):\n-            with self.destinations_lock:\n-                i = len(self.destinations)\n-                actual_to_addr = \"\"\n-                actual_to_port = 0\n-                listener = None\n-                if i == NUM_INITIAL_CONNECTIONS:\n-                    # Instruct the SOCKS5 server to redirect the first private\n-                    # broadcast connection from nodes[0] to nodes[1]\n-                    actual_to_addr = \"127.0.0.1\" # nodes[1] listen address\n-                    actual_to_port = tor_port(1) # nodes[1] listen port for Tor\n-                    self.log.debug(f\"Instructing the SOCKS5 proxy to redirect connection i={i} to \"\n-                                   f\"{format_addr_port(actual_to_addr, actual_to_port)} (nodes[1])\")\n-                else:\n-                    # Create a Python P2P listening node and instruct the SOCKS5 proxy to\n-                    # redirect the connection to it. The first outbound connection is used\n-                    # later to serve GETDATA, thus make it P2PDataStore().\n-                    listener = P2PDataStore() if i == 0 else P2PInterface()\n-                    listener.peer_connect_helper(dstaddr=\"0.0.0.0\", dstport=0, net=self.chain, timeout_factor=self.options.timeout_factor)\n-                    listener.peer_connect_send_version(services=P2P_SERVICES)\n-\n-                    def on_listen_done(addr, port):\n-                        nonlocal actual_to_addr\n-                        nonlocal actual_to_port\n-                        actual_to_addr = addr\n-                        actual_to_port = port\n-\n-                    self.network_thread.listen(\n-                        addr=\"127.0.0.1\",\n-                        port=ports_base + i,\n-                        p2p=listener,\n-                        callback=on_listen_done)\n-                    # Wait until the callback has been called.\n-                    self.wait_until(lambda: actual_to_port != 0)\n-                    self.log.debug(f\"Instructing the SOCKS5 proxy to redirect connection i={i} to \"\n-                                   f\"{format_addr_port(actual_to_addr, actual_to_port)} (a Python node)\")\n-\n-                self.destinations.append({\n-                    \"requested_to\": format_addr_port(requested_to_addr, requested_to_port),\n-                    \"node\": listener,\n-                })\n-                assert_equal(len(self.destinations), i + 1)\n-\n-                return {\n-                    \"actual_to_addr\": actual_to_addr,\n-                    \"actual_to_port\": actual_to_port,\n-                }\n-\n-        self.socks5_server.conf.destinations_factory = destinations_factory\n+\n+        def decide_auto_outbound_where_to(connection_index):\n+            actual_to_addr = \"\"\n+            actual_to_port = 0\n+            listener = None\n+            if connection_index == NUM_INITIAL_CONNECTIONS:\n+                # Instruct the SOCKS5 server to redirect the first private\n+                # broadcast connection from nodes[0] to nodes[1]\n+                actual_to_addr = \"127.0.0.1\" # nodes[1] listen address\n+                actual_to_port = tor_port(1) # nodes[1] listen port for Tor\n+            else:\n+                # Create a Python P2P listening node and instruct the SOCKS5 proxy to\n+                # redirect the connection to it. The first outbound connection is used\n+                # later to serve GETDATA, thus make it P2PDataStore().\n+                listener = P2PDataStore() if connection_index == 0 else P2PInterface()\n+                actual_to_addr, actual_to_port = self._create_auto_outbound_listener(listener, connection_index)\n+            return actual_to_addr, actual_to_port, listener\n+\n+        self.decide_auto_outbound_where_to = decide_auto_outbound_where_to\n\n         self.extra_args = [\n             [\n                 # Needed to be able to add CJDNS addresses to addrman (otherwise they are unroutable).\n                 \"-cjdnsreachable\",\n                 # Connecting, sending garbage, being disconnected messes up with this test's\n                 # check_broadcasts() which waits for a particular Python node to receive a connection.\n                 \"-v2transport=0\",\n                 \"-test=addrman\",\n                 \"-privatebroadcast\",\n-                f\"-proxy={socks5_server_config.addr[0]}:{socks5_server_config.addr[1]}\",\n                 # To increase coverage, make it think that the I2P network is reachable so that it\n                 # selects such addresses as well. Pick a proxy address where nobody is listening\n                 # and connection attempts fail quickly.\n                 \"-i2psam=127.0.0.1:1\",\n             ],\n             [\n@@ -268,14 +218,14 @@ class P2PPrivateBroadcast(BitcoinTestFramework):\n         i = skip_destinations - 1\n         while broadcasts_done < broadcasts_to_expect:\n             i += 1\n             self.log.debug(f\"{label}: waiting for outbound connection i={i}\")\n             # At this point the connection may not yet have been established (A),\n             # may be active (B), or may have already been closed (C).\n-            self.wait_until(lambda: len(self.destinations) > i)\n-            dest = self.destinations[i]\n+            self.wait_until(lambda: len(self.auto_outbound_destinations) > i)\n+            dest = self.auto_outbound_destinations[i]\n             peer = dest[\"node\"]\n             peer.wait_until(lambda: peer.message_count[\"version\"] == 1, check_connected=False)\n             # Now it is either (B) or (C).\n             if peer.last_message[\"version\"].nServices != 0:\n                 self.log.debug(f\"{label}: outbound connection i={i} to {dest['requested_to']} not a private broadcast, ignoring it (maybe feeler or extra block only)\")\n                 continue\n@@ -313,13 +263,13 @@ class P2PPrivateBroadcast(BitcoinTestFramework):\n         # Fill tx_originator's addrman.\n         for addr in ADDRMAN_ADDRESSES:\n             res = tx_originator.addpeeraddress(address=addr, port=8333, tried=False)\n             if not res[\"success\"]:\n                 self.log.debug(f\"Could not add {addr} to tx_originator's addrman (collision?)\")\n\n-        self.wait_until(lambda: len(self.destinations) == NUM_INITIAL_CONNECTIONS)\n+        self.wait_until(lambda: len(self.auto_outbound_destinations) == NUM_INITIAL_CONNECTIONS)\n\n         # The next opened connection by tx_originator should be \"private broadcast\"\n         # for sending the transaction. The SOCKS5 proxy should redirect it to tx_receiver.\n\n         txs = wallet.create_self_transfer_chain(chain_length=3)\n         self.log.info(f\"Created txid={txs[0]['txid']}: for basic test\")\n@@ -353,31 +303,31 @@ class P2PPrivateBroadcast(BitcoinTestFramework):\n         assert_equal(len(tx_originator.getrawmempool()), 0)\n\n         wtxid_int = int(txs[0][\"wtxid\"], 16)\n         inv = CInv(MSG_WTX, wtxid_int)\n\n         self.log.info(\"Sending INV and waiting for GETDATA from node\")\n-        tx_returner = self.destinations[0][\"node\"] # Will return the transaction back to the originator.\n+        tx_returner = self.auto_outbound_destinations[0][\"node\"] # Will return the transaction back to the originator.\n         tx_returner.tx_store[wtxid_int] = txs[0][\"tx\"]\n         assert \"getdata\" not in tx_returner.last_message\n         received_back_msg = f\"Received our privately broadcast transaction (txid={txs[0]['txid']}) from the network\"\n         with tx_originator.assert_debug_log(expected_msgs=[received_back_msg]):\n             tx_returner.send_without_ping(msg_inv([inv]))\n             tx_returner.wait_until(lambda: \"getdata\" in tx_returner.last_message)\n             self.wait_until(lambda: len(tx_originator.getrawmempool()) > 0)\n\n         self.log.info(\"Waiting for normal broadcast to another peer\")\n-        self.destinations[1][\"node\"].wait_for_inv([inv])\n+        self.auto_outbound_destinations[1][\"node\"].wait_for_inv([inv])\n\n         self.log.info(\"Sending a transaction that is already in the mempool\")\n-        skip_destinations = len(self.destinations)\n+        skip_destinations = len(self.auto_outbound_destinations)\n         tx_originator.sendrawtransaction(hexstring=txs[0][\"hex\"], maxfeerate=0)\n         self.check_broadcasts(\"Broadcast of mempool transaction\", txs[0], NUM_PRIVATE_BROADCAST_PER_TX, skip_destinations)\n\n         self.log.info(\"Sending a transaction with a dependency in the mempool\")\n-        skip_destinations = len(self.destinations)\n+        skip_destinations = len(self.auto_outbound_destinations)\n         tx_originator.sendrawtransaction(hexstring=txs[1][\"hex\"], maxfeerate=0.1)\n         self.check_broadcasts(\"Dependency in mempool\", txs[1], NUM_PRIVATE_BROADCAST_PER_TX, skip_destinations)\n\n         self.log.info(\"Sending a transaction with a dependency not in the mempool (should be rejected)\")\n         assert_equal(len(tx_originator.getrawmempool()), 1)\n         assert_raises_rpc_error(-25, \"bad-txns-inputs-missingorspent\",\n@@ -388,13 +338,13 @@ class P2PPrivateBroadcast(BitcoinTestFramework):\n         # Since txs[1] has not been received back by tx_originator,\n         # it should be re-broadcast after a while. Advance tx_originator's clock\n         # to trigger a re-broadcast. Should be more than the maximum returned by\n         # NextTxBroadcast() in net_processing.cpp.\n         self.log.info(\"Checking that rebroadcast works\")\n         delta = 20 * 60 # 20min\n-        skip_destinations = len(self.destinations)\n+        skip_destinations = len(self.auto_outbound_destinations)\n         rebroadcast_msg = f\"Reattempting broadcast of stale txid={txs[1]['txid']}\"\n         with tx_originator.busy_wait_for_debug_log(expected_msgs=[rebroadcast_msg.encode()]):\n             tx_originator.setmocktime(int(time.time()) + delta)\n             tx_originator.mockscheduler(delta)\n         self.check_broadcasts(\"Rebroadcast\", txs[1], 1, skip_destinations)\n         tx_originator.setmocktime(0) # Let the clock tick again (it will go backwards due to this).\n```"
  },
  {
   "t": "2025-12-11T20:03:53Z",
   "kind": "review_comment",
   "who": "maflcko",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "1158564303b2e73d5254f258899b63007e3f52a1",
   "in_reply_to": 2597394343,
   "text": "Haven't looked here in detail, but my preference would be to not call `+` or `+=` on an integral port returned by a helper function. This will lead to issues down the line, and it would be better to always call the helper that internally asserts and checks the port range. See https://github.com/bitcoin/bitcoin/pull/33260/files"
  },
  {
   "t": "2025-12-11T20:10:10Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "1158564303b2e73d5254f258899b63007e3f52a1",
   "in_reply_to": 2597394343,
   "text": "In trying to apply this I think I found a more general problem with port collisions:\nAs soon as more than one test uses this feature, there is the possibility of port collisions between two tests that run in parallel. So we would need to assign separate ranges.\nWe know that a node will only make up to 11 outbound connections at a given moment, but currently disconnections/new connections will just be added to `self.auto_outbound_destinations`, so something like\n```\ndef auto_listener_port(n):\n    return p2p_port(n) + PORT_RANGE * 3\n```\n\nwouldn't work either. Maybe we could use the fact that the node will never make more than 11 outbound connection at the same time and re-use ports of disconnected nodes for later nodes. I'll have to think more about this."
  },
  {
   "t": "2025-12-11T20:10:26Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "1158564303b2e73d5254f258899b63007e3f52a1",
   "in_reply_to": 2598416420,
   "text": "done, used the name `auto_outbound_factory`"
  },
  {
   "t": "2025-12-11T21:00:35Z",
   "kind": "force_push",
   "who": "mzumsande",
   "commit": "b961ad4e7e3d8694237428cc53e32ba04be4ff56"
  },
  {
   "t": "2025-12-12T06:40:38Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "1158564303b2e73d5254f258899b63007e3f52a1",
   "in_reply_to": 2597394343,
   "text": "There are about 60k ports to choose from. There must be a way to pick in a collision free manner even without reuse..."
  },
  {
   "t": "2026-01-02T00:05:15Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "1158564303b2e73d5254f258899b63007e3f52a1",
   "in_reply_to": 2597394343,
   "text": "I cherry-picked the commit from #34186, making it possible to use `port=0` and then let the OS deal with it - much better than assigning the port by the test itself."
  },
  {
   "t": "2026-01-02T00:06:20Z",
   "kind": "force_push",
   "who": "mzumsande",
   "commit": "8c6151044c9315e6af9c10dbbc9951fa89e430b0"
  },
  {
   "t": "2026-01-02T00:11:56Z",
   "kind": "force_push",
   "who": "mzumsande",
   "commit": "53457d847cf2f7d2fe15eeaf7d4ab877bd4b4f69"
  },
  {
   "t": "2026-01-02T00:17:53Z",
   "kind": "force_push",
   "who": "mzumsande",
   "commit": "0cb08573f38b7aa8a18147eef1a775b6c24bcb7c"
  },
  {
   "t": "2026-01-09T09:02:14Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "0cb08573f38b7aa8a18147eef1a775b6c24bcb7c",
   "in_reply_to": 2597405891,
   "text": "With the changes from #34186 / `socks5.py` (now also included in this PR), this can be changed to:\n\n```py\nsocks5_server_config.addr = (\"127.0.0.1\", 0)\n```"
  },
  {
   "t": "2026-01-09T09:16:24Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "1562496a56eae31099020d45695c6d3e5391410b",
   "in_reply_to": null,
   "text": "Maybe get `auto_outbound_factory()` and `_create_auto_outbound_listener()` to have the same interface? That is, change `_create_auto_outbound_listener()` to also return the listener? Then this will be more consistent:\n\n```py\n                if self.auto_outbound_factory:\n                    actual_to_addr, actual_to_port, listener = self.auto_outbound_factory(i)\n                else:\n                    actual_to_addr, actual_to_port, listener = self._create_auto_outbound_listener(listener)\n```\nand then we can have one function (pointer) that either points to the default one or to the user-provided one and here just:\n```py\nactual_to_addr, actual_to_port, listener = self.auto_outbound_factory(i)\n```\nwithout an `if` condition. That would mean to also rename `_create_auto_outbound_listener()` to `auto_outbound_factory()` and remove the line `self.auto_outbound_factory = None` from `BitcoinTestFramework::__init__()`.\n\nFeel free to ignore if you do not see this as an improvement."
  },
  {
   "t": "2026-01-09T09:21:27Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "c733d112309321e3671d31cdca92e7aaa9274975",
   "in_reply_to": null,
   "text": "Maybe this needs `format_addr_port()` as well, in case the address happens to be IPv6 and needs to be enclosed in `[]`?"
  },
  {
   "t": "2026-01-09T09:49:14Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/p2p_outbound_management.py",
   "commit": "abf8fc4f7a2afaab86c26d94901ae78fe74f27a9",
   "in_reply_to": null,
   "text": "The other helper functions\n`has_regular_outbounds()`,\n`has_extra_block_relay_peer()` and\n`has_extra_full_outbound_peer()`\ntake a `node` argument. Maybe add such argument also to `disconnect_last_block_relay_peer()` for consistency?\n\nPS1 same for `wait_for_network_specific_peer()`\nPS2 all of those end up using `self.nodes[0]`, so another option is to drop the `node` argument from all."
  },
  {
   "t": "2026-01-09T09:53:22Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/p2p_outbound_management.py",
   "commit": "0cb08573f38b7aa8a18147eef1a775b6c24bcb7c",
   "in_reply_to": null,
   "text": "```suggestion\n        self.generate(node, 1)  # Disable stale-tip extra-outbound connections that could interfere\n```"
  },
  {
   "t": "2026-01-09T09:57:17Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/p2p_outbound_management.py",
   "commit": "0cb08573f38b7aa8a18147eef1a775b6c24bcb7c",
   "in_reply_to": null,
   "text": "Is there an unlikely scenario that a stale-tip extra-outbound connection is initiated after advancing the mocktime and before `self.generate(node, 1)`? And eventually causing the interference that this is trying to disable?"
  },
  {
   "t": "2026-01-09T10:30:35Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/p2p_outbound_management.py",
   "commit": "abf8fc4f7a2afaab86c26d94901ae78fe74f27a9",
   "in_reply_to": null,
   "text": "Is it possible that the node already has at least one connection to all reachable networks when this function starts? Would that result in timeouts due to `has_extra_full_outbound_peer(node)` never returning `True`?"
  },
  {
   "t": "2026-01-09T10:44:15Z",
   "kind": "review",
   "who": "vasild",
   "assoc": "MEMBER",
   "state": "APPROVED",
   "commit": "0cb08573f38b7aa8a18147eef1a775b6c24bcb7c",
   "text": "ACK 0cb08573f38b7aa8a18147eef1a775b6c24bcb7c\n\nI ran `test_runner.py` and `p2p_outbound_management.py` a bunch of times locally and did not observe timeouts."
  },
  {
   "t": "2026-02-24T15:41:12Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "0cb08573f38b7aa8a18147eef1a775b6c24bcb7c",
   "in_reply_to": 2597405891,
   "text": "done"
  },
  {
   "t": "2026-02-24T15:44:06Z",
   "kind": "force_push",
   "who": "mzumsande",
   "commit": "1562496a56eae31099020d45695c6d3e5391410b"
  },
  {
   "t": "2026-02-24T16:15:12Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "c733d112309321e3671d31cdca92e7aaa9274975",
   "in_reply_to": 2675441115,
   "text": "The config address is hardcoded  in `_setup_auto_outbound_mode` to `127.0.0.1`, so IPv6 shouldn't be a concern I think?"
  },
  {
   "t": "2026-02-24T16:16:21Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "1562496a56eae31099020d45695c6d3e5391410b",
   "in_reply_to": 2675425492,
   "text": "I like that suggestion - took it!"
  },
  {
   "t": "2026-02-24T16:17:19Z",
   "kind": "force_push",
   "who": "mzumsande",
   "commit": "b5e9e381381c9ff5f52f14001acfd442c5704c26"
  },
  {
   "t": "2026-02-24T16:55:07Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "test/functional/p2p_outbound_management.py",
   "commit": "0cb08573f38b7aa8a18147eef1a775b6c24bcb7c",
   "in_reply_to": 2675557061,
   "text": "I think probably not in practice - `CheckForStaleTipAndEvictPeers` fires every 45 seconds (on a steady clock, not influenced by the mocktime bump). So I think this means it would only fire if 45 real seconds passed since the last mockscheduler call, which seems very unlikely."
  },
  {
   "t": "2026-02-24T17:12:19Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "test/functional/p2p_outbound_management.py",
   "commit": "abf8fc4f7a2afaab86c26d94901ae78fe74f27a9",
   "in_reply_to": 2675662381,
   "text": "To avoid this, the test first adds 100 IPv4 addresses to addrman, then waits for the regular otubound connections (`setup_peers`), and only then the onion and cjdns addresses are added to addrman. So I think it shouldn't be possible."
  },
  {
   "t": "2026-02-24T17:22:28Z",
   "kind": "review_comment",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "path": "test/functional/p2p_outbound_management.py",
   "commit": "abf8fc4f7a2afaab86c26d94901ae78fe74f27a9",
   "in_reply_to": 2675530659,
   "text": "I decided to drop the `node` arg everywhere."
  },
  {
   "t": "2026-02-24T17:25:50Z",
   "kind": "force_push",
   "who": "mzumsande",
   "commit": "c733d112309321e3671d31cdca92e7aaa9274975"
  },
  {
   "t": "2026-02-24T17:28:50Z",
   "kind": "review",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "state": "COMMENTED",
   "commit": "c733d112309321e3671d31cdca92e7aaa9274975",
   "text": "Thanks for the review @vasild!\nRebased, addressed feedback, and also added a commit that uses the test-framework auto-outbound mode for `p2p_private_broadcast.py`, removing some duplicate code.\n\nI just had a rare (?) local intermittent failure of `p2p_private_broadcast.py` that I need to look into, so I will keep it in draft a little bit longer."
  },
  {
   "t": "2026-02-25T08:16:56Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "c733d112309321e3671d31cdca92e7aaa9274975",
   "in_reply_to": null,
   "text": "The order in which `bitcoind` makes connections is a bit undeterministic and also which connection is it going to make is not 100% deterministic either (e.g. is the next connection going to be full-outbound or feeler or net-specific?). https://github.com/bitcoin/bitcoin/pull/34410 addresses that by not relying only on the connection index but looking up the requested-to address and port in the debug log in order to figure out what the connection is.\n\nThat said, `auto_outbound_factory()` might benefit from getting more information about the connection, in addition to its index, like the requested address and port:\n\n```diff\n- actual_to_addr, actual_to_port, listener = self.auto_outbound_factory(i)\n+ actual_to_addr, actual_to_port, listener = self.auto_outbound_factory(i, requested_to_addr, requested_to_port)\n```"
  },
  {
   "t": "2026-02-25T08:27:07Z",
   "kind": "review",
   "who": "vasild",
   "assoc": "MEMBER",
   "state": "APPROVED",
   "commit": "c733d112309321e3671d31cdca92e7aaa9274975",
   "text": "ACK c733d112309321e3671d31cdca92e7aaa9274975\n\n[quoted text omitted]\nMight be related to https://github.com/bitcoin/bitcoin/pull/34410 or https://github.com/bitcoin/bitcoin/pull/34646"
  },
  {
   "t": "2026-02-25T08:30:25Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/test_framework/test_framework.py",
   "commit": "c733d112309321e3671d31cdca92e7aaa9274975",
   "in_reply_to": 2675441115,
   "text": "Right, only if changed in the future. Or if somebody is playing with this locally and changes it to fir their environment. An address like `::1` would also upset this because it would result in `-proxy=::1:12345` which I guess will result in \"syntax\" error."
  },
  {
   "t": "2026-02-25T08:55:09Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "test/functional/p2p_outbound_management.py",
   "commit": "abf8fc4f7a2afaab86c26d94901ae78fe74f27a9",
   "in_reply_to": 2675662381,
   "text": "What about the tor and cjdns connections being established after adding to addrman and before `wait_for_network_specific_peer()` is called?"
  },
  {
   "t": "2026-03-15T22:15:22Z",
   "kind": "review",
   "who": "Bortlesboat",
   "assoc": "CONTRIBUTOR",
   "state": "COMMENTED",
   "commit": "c733d112309321e3671d31cdca92e7aaa9274975",
   "text": "ACK c733d112309321e3671d31cdca92e7aaa9274975. Ran p2p_outbound_management.py and p2p_private_broadcast.py locally on Ubuntu 24.04, both pass."
  },
  {
   "t": "2026-03-15T22:15:32Z",
   "kind": "review_comment",
   "who": "Bortlesboat",
   "assoc": "CONTRIBUTOR",
   "path": "test/functional/p2p_outbound_management.py",
   "commit": "abf8fc4f7a2afaab86c26d94901ae78fe74f27a9",
   "in_reply_to": null,
   "text": "Could you add a brief comment explaining where 46 comes from? I traced it to the scheduler interval for extra peer eviction checks, but having an explicit reference to the C++ constant (or the logic behind this value) would help future readers understand why this specific number was chosen."
  },
  {
   "t": "2026-08-28T15:42:45Z",
   "kind": "force_push",
   "who": "mzumsande",
   "commit": "abf8fc4f7a2afaab86c26d94901ae78fe74f27a9"
  },
  {
   "t": "2026-08-28T15:45:45Z",
   "kind": "review",
   "who": "mzumsande",
   "assoc": "MEMBER",
   "state": "COMMENTED",
   "commit": "abf8fc4f7a2afaab86c26d94901ae78fe74f27a9",
   "text": "rebased after several changes to `p2p_private_broadcast.py` - not a trivial rebase and not ready for review yet - still need to change some things / address feedback"
  }
 ],
 "labels_log": [
  {
   "t": "2025-11-26T19:01:48Z",
   "action": "labeled",
   "label": "Tests",
   "who": "DrahtBot"
  },
  {
   "t": "2025-11-26T19:11:39Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2025-11-26T19:58:13Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-01-14T23:11:01Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-02-24T16:18:01Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-02-24T17:21:56Z",
   "action": "unlabeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-02-24T18:38:28Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-03-02T16:52:33Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-08-28T15:43:38Z",
   "action": "unlabeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-08-28T17:15:04Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  }
 ],
 "state_log": [
  {
   "t": "2025-11-26T20:14:32Z",
   "kind": "ready_for_review",
   "who": "mzumsande"
  },
  {
   "t": "2025-11-28T22:07:21Z",
   "kind": "convert_to_draft",
   "who": "mzumsande"
  }
 ],
 "text_chars": 22535,
 "text_tokens_estimate": 5633,
 "changed_paths": [],
 "files": [],
 "test_lines": null,
 "git": null,
 "input_hash": "8355fbcf6167eea7",
 "extracted_at": "2026-09-17T16:15:31+00:00"
}