net: Reduce local network activity when networkactive=0 #34486

pull willcl-ark wants to merge 5 commits into bitcoin:master from willcl-ark:respect-networkactive changing 12 files +222 −8
  1. willcl-ark commented at 5:46 PM on February 2, 2026: member

    Fixes #34190

    When networkactive=0 is set, NAT-PMP port mapping and Tor control connections still run in the background, mapping ports and logging retry attempts despite the node being "inactive."

    This wires both subsystems to CConnman::SetNetworkActive so they start and stop with the network state:

    • mapport: EnableMapPort is injected as a callback via CConnman::Options. SetNetworkActive and SetMapPortEnabled both gate on network state.

    • torcontrol: TorController is injected similarly through a CConnman::Options callback. The controller thread stays alive while networkactive=0, but idles without connecting or reconnecting to the Tor control port. Re-enabling the network wakes the controller promptly and resets reconnect backoff.

  2. DrahtBot added the label P2P on Feb 2, 2026
  3. willcl-ark commented at 5:47 PM on February 2, 2026: member

    This is an alternative to #34467. The key difference is that #34467 gates mapport and tor control at init time with if (networkactive) guards, which means those subsystems are permanently disabled for the lifetime of the process. setnetworkactive true via RPC won't start them. Boot-time and runtime behavior would now differ.

    This PR instead wires both subsystems into CConnman::SetNetworkActive so they follow the network state through the full lifecycle: starting with -networkactive=0 and later calling setnetworkactive true works as expected.

    I'm unsure how this will conflict with the work currently being done to remove libevent.

  4. DrahtBot commented at 5:47 PM on February 2, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/34486.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    Concept ACK sedited, fanquake, Jhackman2019
    Approach ACK winterrdog
    Stale ACK brunoerg

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #35292 (test: Add coverage for Tor control HASHEDPASSWORD authentication by winterrdog)
    • #34892 (net: Warn when Tor onion service lacks a dedicated onion bind by HouseOfHufflepuff)
    • #34534 (rpc: Manual prune lock management (Take 2) by fjahr)
    • #34213 (net: preserve anchors when network is disabled by brunoerg)
    • #31260 (scripted-diff: Type-safe settings retrieval by ryanofsky)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  5. sedited commented at 5:50 PM on February 2, 2026: contributor

    Concept ACK

  6. bradleystachurski commented at 9:42 PM on February 2, 2026: none

    Reviewed 35da4e03b18b7ef5eb23e1b038f5755fe37825a1

    Tested on Linux: started with -networkactive=0 -natpmp=1 -debug=net -debug=tor, confirmed no portmap/tor activity at startup, toggled via setnetworkactive, confirmed mapport thread start/stop and tor connection attempts follow network state.

    Verified feature_mapport.py fails when the m_mapport callback is removed from SetNetworkActive.

    nit: TorController::SetNetworkActive doesn't reset reconnect_timeout, so re-enabling network after backoff has grown continues from the stale value. Verified this fixes it:

    diff --git a/src/torcontrol.cpp b/src/torcontrol.cpp
    index d5aeb55a72..934499d72b 100644
    --- a/src/torcontrol.cpp
    +++ b/src/torcontrol.cpp
    @@ -677,6 +677,8 @@ void TorController::SetNetworkActive(bool set_active)
             // Disconnect if currently connected
             conn.Disconnect();
         }
    +    // Reset backoff when re-enabling network
    +    reconnect_timeout = RECONNECT_TIMEOUT_START;
         // Connect handles both cases: connects if active, reschedules if inactive
         Connect();
     }
    
  7. willcl-ark force-pushed on Feb 3, 2026
  8. willcl-ark commented at 9:28 AM on February 3, 2026: member

    Thanks @bradleystachurski that's a nice suggestion which I've taken in 745aed37224102888332823aadc6d766e701aa0b

    I also reworked the if logic slightly to make the flow of this function clearer (and not always call Connect()).

  9. fanquake commented at 10:10 AM on February 3, 2026: member

    Concept ACK

  10. willcl-ark commented at 10:25 AM on February 3, 2026: member

    I will address the LLM linter suggestion if I push again.

  11. Jhackman2019 commented at 3:37 AM on February 7, 2026: none

    Tested this on my Pi 5 (ARM64, Debian Bookworm). Builds clean, all the networking-related tests pass:

    feature_mapport.py                  PASSED
    feature_proxy.py                    PASSED
    p2p_addr_relay.py                   PASSED
    p2p_disconnect_ban.py --v1transport PASSED
    p2p_disconnect_ban.py --v2transport PASSED
    p2p_dns_seeds.py                    PASSED
    

    Had to clear my test/cache first since I had a stale cache from an autotools build — after that everything was smooth.

    Concept ACK

  12. brunoerg commented at 1:47 PM on February 17, 2026: contributor

    Concept ACK

  13. brunoerg commented at 5:10 PM on February 17, 2026: contributor

    tested up to 396b56af0ac261863b076ab8d2d02a99f6e03f3a: I checked that portmap activity follows network activity.

  14. in src/torcontrol.cpp:639 in 745aed3722 outdated
     635 | @@ -640,6 +636,11 @@ void TorController::disconnected_cb(TorControlConnection& _conn)
     636 |      if (!reconnect)
     637 |          return;
     638 |  
     639 | +    if (!m_network_active) {
    


    brunoerg commented at 5:18 PM on February 17, 2026:

    Is there any way to test this condition? I've tried several scenarios manually, but haven't been able to achieve it.

    However, checking that it is not connected to any Tor control port and then will retry was easier, could also check it on a functional test, e.g:

    diff --git a/test/functional/p2p_private_broadcast.py b/test/functional/p2p_private_broadcast.py
    index 4943841790..b163020e85 100755
    --- a/test/functional/p2p_private_broadcast.py
    +++ b/test/functional/p2p_private_broadcast.py
    @@ -433,9 +433,11 @@ class P2PPrivateBroadcast(BitcoinTestFramework):
                 # the RPC should throw.
                 "-torcontrol=127.0.0.1:1",
                 "-listenonion",
    +            "-debug=tor",
             ])
    -        assert_raises_rpc_error(-1, "none of the Tor or I2P networks is reachable",
    -                                tx_originator.sendrawtransaction, hexstring=txs[0]["hex"], maxfeerate=0.1)
    +        with tx_originator.assert_debug_log(['Not connected to Tor control port'], timeout=5):
    +            assert_raises_rpc_error(-1, "none of the Tor or I2P networks is reachable",
    +                                    tx_originator.sendrawtransaction, hexstring=txs[0]["hex"], maxfeerate=0.1)
    
    
    

    willcl-ark commented at 4:20 PM on February 19, 2026:

    Thanksf or the review!

    Yes I think this is hard to reach in a functional test, as conn.Disconnect() frees the bufferevent without firing the callback, so this guard only triggers if a libevent disconnect event was already queued when SetNetworkActive(false) runs.

    It's mainly intended to be defensive against an event-loop ordering edge case, and even if it wasn't here, Connect() has its own m_network_active check, but this guard also prevents a misleading "retrying" log message.

    Your p2p_private_broadcast.py change seems like good coverage for the reworked connection initiation path. Happy to include it.

  15. willcl-ark marked this as a draft on Mar 15, 2026
  16. willcl-ark commented at 9:03 PM on March 15, 2026: member

    Drafting this to rework/rebase on #34158, and not undo libevent removal.

  17. willcl-ark force-pushed on Mar 16, 2026
  18. DrahtBot added the label Needs rebase on Mar 23, 2026
  19. willcl-ark force-pushed on Mar 24, 2026
  20. DrahtBot added the label CI failed on Mar 24, 2026
  21. DrahtBot removed the label Needs rebase on Mar 24, 2026
  22. DrahtBot added the label Needs rebase on Mar 26, 2026
  23. net: wire mapport lifecycle to CConnman
    When -networkactive=0 is set, NAT-PMP port mapping was still running and
    attempting gateway queries. Move mapport lifecycle management into
    CConnman, which already owns SetNetworkActive, so it can control mapport
    based on both the -natpmp setting and network state.
    
    EnableMapPort is injected as a callback via CConnman::Options to avoid a
    circular dependency between net and mapport.
    1bf49e5475
  24. willcl-ark force-pushed on Jun 2, 2026
  25. DrahtBot removed the label Needs rebase on Jun 2, 2026
  26. willcl-ark commented at 12:25 PM on June 2, 2026: member

    Sorry I forgot about this while #34158 was in progress. Rebased and undrafted.

  27. willcl-ark marked this as ready for review on Jun 2, 2026
  28. DrahtBot removed the label CI failed on Jun 2, 2026
  29. sedited requested review from brunoerg on Jul 10, 2026
  30. brunoerg commented at 6:39 PM on July 13, 2026: contributor

    I manually tested it (e5ed5f279b471c533ea94140aadcb447c480f473), worked fine. Unfortunately, our functional tests are somewhat limited for scenarios like these. So we end up relying on logs.

    I've created (vibe-coded) a docker environment with Tor, a minimal real NAT-PMP/PCP gateway and a script to assert basically the same that the functional test does but based on the actual captured packets, worked fine as well (happy to share).

     ✔ Image networkactive_docker-natpmp_gw       Built                                                                                                                                                                                         7.5s
     ✔ Image networkactive_docker-tor             Built                                                                                                                                                                                        12.7s
     ✔ Image networkactive_docker-node            Built                                                                                                                                                                                       711.5s
     ✔ Network networkactive_docker_netactive_net Created                                                                                                                                                                                       0.0s
     ✔ Container networkactive_docker-tor-1       Created                                                                                                                                                                                       0.7s
     ✔ Container networkactive_docker-natpmp_gw-1 Created                                                                                                                                                                                       0.7s
     ✔ Container networkactive_docker-node-1      Created                                                                                                                                                                                       0.0s
    == Waiting for bitcoind RPC ==
    
    == Phase 0: startup state ==
      PASS: node starts with networkactive=false (-networkactive=0)
    
    == Phase 1: idle (networkactive=0) -- expect ZERO packets on the wire ==
      PASS: zero packets captured on eth0 while networkactive=0 (any protocol)
      PASS: NAT-PMP/PCP responder received no requests while networkactive=0
      PASS: tor daemon has no established connection from the node while networkactive=0
    
    == Phase 2: setnetworkactive true -- expect REAL NAT-PMP/PCP + tor traffic ==
      PASS: getnetworkinfo reports networkactive=true
      PASS: real NAT-PMP/PCP UDP packets seen on the wire (port 5351): 6
      PASS: real TCP packets seen on the wire to tor control port (9051): 15
      PASS: NAT-PMP/PCP responder actually parsed a MAP request and granted a mapping
      PASS: tor daemon shows a real ESTABLISHED connection from the node
    
    == Phase 3: setnetworkactive false -- expect traffic to STOP and tor connection to CLOSE ==
      PASS: getnetworkinfo reports networkactive=false
      PASS: zero new packets on eth0 after deactivation
      PASS: tor daemon's connection from the node was actually torn down
    
    == Phase 4: setnetworkactive true again -- expect traffic to RESUME ==
      PASS: NAT-PMP/PCP traffic resumed after reactivation: 6 packets
      PASS: tor control connection re-established after reactivation
    
    == Summary: 14 passed, 0 failed ==
    

    I intend to review the code by tomorrow.

  31. in test/functional/feature_mapport.py:30 in aef9e74169 outdated
      25 | +        self.restart_node(0, extra_args=["-natpmp=1", "-networkactive=0", "-debug=net"])
      26 | +        assert_equal(node.getnetworkinfo()["networkactive"], False)
      27 | +        with node.assert_debug_log(
      28 | +            expected_msgs=[], unexpected_msgs=["portmap:"], timeout=2
      29 | +        ):
      30 | +            time.sleep(2)
    


    brunoerg commented at 1:27 PM on July 14, 2026:

    aef9e741697d041137b594682f77c06f7d8cd821: nit: Since "portmap:" logs can also appear during the startup, we could move the self.restart_node to assert_debug_log as well.

  32. brunoerg approved
  33. brunoerg commented at 1:31 PM on July 14, 2026: contributor

    ACK e5ed5f279b471c533ea94140aadcb447c480f473

    edit: I didn't get any, but timeouts+sleeps on functional tests are a good territory to be flaky.

  34. DrahtBot requested review from sedited on Jul 14, 2026
  35. DrahtBot requested review from fanquake on Jul 14, 2026
  36. test: add test for mapport networkactive
    Add functional test to verify that PCP/NAT-PMP port mapping respects the
    networkactive state:
    
    - Does not run when started with -networkactive=0
    - Starts when network is activated via setnetworkactive RPC
    - Stops when network is deactivated
    - Resumes when network is reactivated
    bfce4f989b
  37. willcl-ark force-pushed on Jul 20, 2026
  38. willcl-ark commented at 11:17 AM on July 20, 2026: member

    Thanks for your review bruno. I've pushed a small update to address the points you raised:

    • restart_node() moved inside assert_debug_log moved inside assert_debug_log
    • drop the time.sleep() from feature_torcontrol.py and require waiting on "Retrying in 1.0 seconds"
  39. in src/torcontrol.cpp:413 in d31aaef595 outdated
     408 | +        }
     409 | +        remaining -= sleep_time;
     410 | +        if (m_network_active_change_count.load() != expected_change_count) {
     411 | +            return true;
     412 | +        }
     413 | +    }
    


    winterrdog commented at 7:47 PM on August 15, 2026:

    i noticed m_network_active_change_count is checked both at the top of the loop and right after m_interrupt.sleep_for()

    since sleep_for() handles thread signaling and the next loop iteration re-evaluates the atomic load anyway, is there a subtle memory ordering edge case i am missing where omitting the second check would be problematic ?


    willcl-ark commented at 8:13 AM on August 17, 2026:

    Yep, I agree with you that this is redundant. If the counter changes during sleep it's checked on the next iteration before sleeping again.

    Will push an update when the CI is not being problematic.

  40. net: run tor control based on networkactive
    Wire tor control lifecycle to CConnman on top of the libevent-free tor
    controller being introduced in upstream/pr/34158.
    
    Have CConnman propagate networkactive changes to the node-owned
    TorController and make the controller thread idle while networking is
    disabled instead of attempting Tor control connections or reconnects.
    c0b6b23f19
  41. test: add torcontrol networkactive coverage
    Add functional coverage for torcontrol when networkactive is disabled at startup and toggled back on later.
    
    Also clear the mock server connection handle on close so the disconnect assertion is reliable.
    ca870adb70
  42. test: check tor control reconnection in p2p_private_broadcast
    Verify that the tor controller attempts reconnection to the control port
    by asserting the retry log message when started with an unreachable
    `-torcontrol` address.
    
    Co-authored-by: brunoerg <brunoerg@users.noreply.github.com>
    48f1321a60
  43. willcl-ark force-pushed on Aug 17, 2026
  44. fanquake closed this on Aug 17, 2026

  45. fanquake reopened this on Aug 17, 2026

  46. DrahtBot added the label CI failed on Aug 17, 2026
  47. DrahtBot removed the label CI failed on Aug 17, 2026
  48. in src/torcontrol.cpp:454 in c0b6b23f19
     450 | +                if (!SleepWithNetworkPolling(std::chrono::duration_cast<std::chrono::milliseconds>(m_reconnect_timeout), change_count) && m_interrupt) {
     451 |                      break;
     452 |                  }
     453 | +                if (!m_network_active) {
     454 | +                    continue;
     455 | +                }
    


    winterrdog commented at 10:58 AM on August 18, 2026:

    premise: backoff keeps growing after a quick off/on during sleep

    there is a subtle gap in how ThreadControl handles backoff timeouts during rapid state toggles i.e. something that could happen when automated scripts or tests issue setnetworkactive false followed immediately by setnetworkactive true

    if the node is currently in a backoff sleep (say, waiting 30 seconds after several failed Tor connection attempts) and a script toggles the network off and back on within that single sleep window, SleepWithNetworkPolling wakes up early due to the change count. however, because the script already flipped network activity back to true, the live if (!m_network_active) check evaluates to false. execution then falls through directly into growing the backoff delay (m_reconnect_timeout *= RECONNECT_TIMEOUT_EXP), punishing the node with a longer reconnect delay instead of resetting it back to 1.0s despite a fresh reactivation occurring

    the test below reproduces this by doing the off/on calls back-to-back while the node is inside a reconnect sleep. the next retry incorrectly gets the grown delay (1.5s) instead of starting again at 1.0s:

    <details> <summary>diff </summary>

    diff --git a/test/functional/feature_torcontrol.py b/test/functional/feature_torcontrol.py
    index 7a6fc4e6c0..053fc65b3f 100755
    --- a/test/functional/feature_torcontrol.py
    +++ b/test/functional/feature_torcontrol.py
    @@ -313,6 +313,40 @@ class TorControlTest(BitcoinTestFramework):
             ], timeout=2):
                 node.setnetworkactive(state=True)
     
    +    def test_networkactive_race_during_backoff(self):
    +        self.log.info("Test that a rapid off->on cycle during a reconnect backoff sleep still resets the backoff")
    +
    +        node = self.nodes[0]
    +
    +        # nothing listens on 127.0.0.1:1, so every connection attempt fails
    +        # immediately thus the retry/backoff cycle is predictable: the first
    +        # retry is always logged at RECONNECT_TIMEOUT_START (1.0s)
    +        with node.assert_debug_log(expected_msgs=["Retrying in 1.0 seconds"], timeout=10):
    +            self.restart_node(0, extra_args=[
    +                "-torcontrol=127.0.0.1:1",
    +                "-listenonion=1",
    +                "-debug=tor",
    +            ])
    +
    +        # we are now inside that first ~1.0s backoff sleep. toggle the network
    +        # off then immediately back on, as quick as possible, so the whole
    +        # round trip lands inside this single sleep window rather than
    +        # spanning two separate ThreadControl loop iterations
    +        node.setnetworkactive(state=False)
    +        node.setnetworkactive(state=True)
    +
    +        # a reactivation should always reset the backoff to
    +        # RECONNECT_TIMEOUT_START, regardless of whether the deactivate/
    +        # reactivate pair happened between loop iterations or inside one
    +        # backoff sleep. so the *next* retry should again log 1.0s, not a
    +        # grown value (i.e. 1.5s) carried over from before the toggle
    +        with node.assert_debug_log(
    +            expected_msgs=["Retrying in 1.0 seconds"],
    +            unexpected_msgs=["Retrying in 1.5 seconds"],
    +            timeout=3,
    +        ):
    +            pass
    +
         def run_test(self):
             self.test_basic()
             self.test_partial_data()
    @@ -321,6 +355,7 @@ class TorControlTest(BitcoinTestFramework):
             self.test_overmany_lines()
             self.test_networkactive()
             self.test_networkactive_reactivation_resets_backoff()
    +        self.test_networkactive_race_during_backoff()
     
     
     if __name__ == '__main__':
    

    </details>

    potential fix

    the fix is to check whether the network-active change count changed during the sleep itself. if it did and the network is active again, treat that as a fresh reactivation & reset the backoff:

    <details open> <summary>diff </summary>

    diff --git a/src/torcontrol.cpp b/src/torcontrol.cpp
    index dfded42746..5cfaaad515 100644
    --- a/src/torcontrol.cpp
    +++ b/src/torcontrol.cpp
    @@ -447,11 +447,14 @@ void TorController::ThreadControl()
                     LogDebug(BCLog::TOR, "Retrying in %.1f seconds", m_reconnect_timeout.count());
                     const auto change_count{m_network_active_change_count.load()};
                     if (!SleepWithNetworkPolling(std::chrono::duration_cast<std::chrono::milliseconds>(m_reconnect_timeout), change_count) && m_interrupt) {
                         break;
                     }
    -                if (!m_network_active) {
    +                if (change_count != m_network_active_change_count.load()) {
    +                    if (m_network_active) {
    +                        m_reconnect_timeout = RECONNECT_TIMEOUT_START;
    +                    }
                         continue;
                     }
                     m_reconnect_timeout = std::min(m_reconnect_timeout * RECONNECT_TIMEOUT_EXP, RECONNECT_TIMEOUT_MAX);
                     continue;
                 }
    

    </details>

    [!NOTE] this gap will not crash the node. it quietly increases the delay before Tor Control reconnects, which can be annoying and surprising, but it is not dangerous. if it happens repeatedly, the delay can compound noticeably, but the node will eventually reconnect once the delay elapses

    thoughts ?

  49. winterrdog commented at 11:17 AM on August 18, 2026: contributor

    approach ACK 48f1321a603f565b8264bb2adb9d0bb4c54a68c6


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-08-26 20:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me