Skip to content

Exception escaping node_server::idle_worker() permanently disables all p2p maintenance (out-peers → 0, connections never reaped) #11059

Description

@sethforprivacy

Version: v0.18.5.1-release (Fluorine Fermi), Linux x86_64, musl/Alpine build, Docker.

Summary

boosted_tcp_server::global_timer_handler() re-arms its deadline_timer after invoking the
handler. If the handler throws, the re-arm is skipped and that idle-handler chain is permanently
dead for the lifetime of the process
— silently, with no recovery path.

When the handler in question is node_server::idle_worker(), the node loses every piece of p2p
maintenance at once:

  • connections_maker() stops → outbound peers decay to 0 and are never re-established
  • peer_sync_idle_maker() stops → dead peers are never detected or dropped
  • gray_peerlist_housekeeping(), check_incoming_connections(), store_config(),
    update_dns_blocklist() all stop

The node keeps accepting inbound connections and serving RPC, keeps reporting
"status": "OK" and "synchronized": true, and stays at chain tip via inbound peers only.
Externally it looks healthy. Internally it accumulates connection objects and file descriptors
forever.

cryptonote::core::on_idle is registered as a separate idle_callback_conext with its own
timer (net_node.inl ~L1042-1043), so it keeps running — which makes the failure even harder to
spot, since some periodic activity continues.

Observed on a production node

16-day-old container, RestartCount=0, chain at tip, status: OK. Onset was ~day 12; the
preceding 12 days were normal.

get_info:  incoming_connections_count=1660   outgoing_connections_count=0
           height=3734124  synchronized=true  status=OK
           white_peerlist_size=1000  grey_peerlist_size=3980

get_connections — 1660 connection objects:

incoming state count
true before_handshake 1346
false before_handshake 280
true normal 36
  • before_handshake live_time: min 97 s, p50 199 463 s (2.3 d), max 350 039 s (4.05 d)
  • age histogram by day: {0: 311, 1: 373, 2: 456, 3: 484, 4: 2} — a hard cliff at day 4 dating
    onset precisely
  • 1511 / 1626 before_handshake connections have send_count == 0; 886 have recv_count == 0

File descriptors (pid 1 in container, nofile = 65536, so this is not fd exhaustion):

total fds        1689
socket fds       1669
tcp rows in netns 311      <- CLOSE_WAIT 267, ESTAB 33, TIME_WAIT 6, LISTEN 5
socket fds with NO /proc/net/tcp entry:  1365

Those 1365 are sockets the kernel has fully torn down and unhashed, whose fd was never close()d.

CLOSE_WAIT breakdown — the leak is p2p, not RPC:

OUTBOUND -> remote port 18080   232     <- leaked back-ping sockets
INBOUND    local port 18080      28
everything else                   8

Three healthy nodes running the identical image/config/version for the same 16 days show 0–1
before_handshake connections (all sub-4-second) and 12 outbound peers each.

outgoing_connections_count = 0 is truthful, not an accounting bug

get_outgoing_connections_count() (src/p2p/net_node.inl L2029) counts
!cntxt.m_is_income && !cntxt.is_ping. All 280 non-income connection objects on the affected node
are is_ping back-pings, so they are correctly excluded. The node genuinely has zero real
outbound peers and depends entirely on inbound peers for block propagation.

net.p2p:INFO over 120 s confirms it — every OUT connection is a back-ping opened milliseconds
after an INC and closed ~10 ms later:

[45.142.30.74:36274 ... INC] NEW CONNECTION
[45.142.30.74:18080 ... OUT] NEW CONNECTION
[45.142.30.74:18080 ... OUT] CLOSE CONNECTION     <- 7 ms later
[<none> OUT] back ping connect failed to 79.204.56.99:18080

Proof that idle_worker() is not running

m_connections_maker_interval is once_a_time_seconds<1> (src/p2p/net_node.h L463), and
try_to_connect_and_handshake_with_new_peer() logs MDEBUG("Connecting to " << ...) as its first
statement after the guards. With out_peers = 0 against max_out_connection_count = 12, plus 5
configured --add-priority-node peers, this should fire continuously.

Over a 90-second window at net.p2p:DEBUG:

Connecting to                             0
Failed to connect to any, trying seeds    0
COMMAND_TIMED_SYNC                       26     <- inbound handling is fine
COMMAND_HANDSHAKE                         2
back ping connect failed                  1

Zero outbound attempts in 90 opportunities, while inbound event-driven work runs normally.

This is not lock contention. Sampling /proc/<pid>/task/*/syscall: 26 threads parked in futex,
on 26 distinct futex addresses — independent idle waits, no shared lock. The thread pool is
idle waiting for work that is never posted, not deadlocked.

Mechanism

contrib/epee/include/net/abstract_tcp_server2.h L490-498:

template<class t_handler>
bool global_timer_handler(/*const boost::system::error_code& err, */boost::shared_ptr<idle_callback_conext<t_handler>> ptr)
{
  //if handler return false - he don't want to be called anymore
  if(!ptr->call_handler())
    return true;
  ptr->m_timer.expires_from_now(boost::posix_time::milliseconds(ptr->m_period));
  ptr->m_timer.async_wait(boost::bind(&boosted_tcp_server<t_protocol_handler>::global_timer_handler<t_handler>, this, ptr));
  return true;
}

If call_handler() throws, both the expires_from_now and the async_wait are skipped. Nothing
ever re-arms that timer. There is no supervision and no restart path.

The exception then unwinds into worker_thread()
(contrib/epee/include/net/abstract_tcp_server2.inl L1433):

while(!m_stop_signal_sent)
{
  try { io_context_.run(); return true; }
  catch(const std::exception& ex) { _erro("Exception at server worker thread, what=" << ex.what()); }
  catch(...)                      { _erro("Exception at server worker thread, unknown execption"); }
}

The thread survives and re-enters run() — which is why the process looks fine — but the idle
chain is gone.

Why there is no trace of it in the log

abstract_tcp_server2.inl L56-57 sets MONERO_DEFAULT_LOG_CATEGORY "net", and the default
category string (contrib/epee/src/mlog.cpp L103) contains net:FATAL:

*:WARNING,net:FATAL,net.http:FATAL,net.ssl:FATAL,net.p2p:FATAL,net.cn:FATAL,daemon.rpc:FATAL,...

_erro is ERROR, which is below FATAL, so the one line that would identify the triggering
exception is discarded under the stock configuration. On the affected node the container log
covers the full 16 days from startup and contains no occurrence — consistent with suppression
rather than absence.

Candidate throw sites reachable from idle_worker()

try_to_connect_and_handshake_with_new_peer() (src/p2p/net_node.inl L1383) opens with:

network_zone& zone = m_network_zones.at(na.get_zone());

std::map::at throws std::out_of_range for any peerlist entry whose zone is not configured on
this node. This is reachable from idle_worker() → connections_maker() → connect_to_peerlist() / make_expected_connections_count(). I have not proven this is the specific throw that fired here —
the log line that would say so was suppressed — but it is a reachable, unguarded .at() on
attacker-influenceable peerlist data in exactly the call path that died.

Secondary failure: fd numbers crossing FD_SETSIZE break libunbound

Once the leak pushed monerod's fd numbers past 1024, libunbound's select() backend could no longer
register descriptors:

[1785839031] libunbound[1:0] error: event_add failed. in cpsl.       (4399 occurrences)
WARNING: no two valid DNS TXT records were received                  (43 occurrences)

First occurrence 2026-08-04 10:23:51 UTC, versus leak onset 2026-08-02 07:25 UTC — ~2 days later,
so this is a consequence of the leak, not its cause. Zero occurrences on the three healthy
nodes. Worth noting independently: any monerod holding >1024 fds loses DNS resolution
(seed nodes, DNS checkpoints, DNS blocklist), and raising nofile above 1024 — which is necessary
for a busy public node — makes this reachable.

Recovery

A restart clears it completely and immediately — consistent with a dead timer chain rather than
corrupted state:

before restart   in=1665  out=0   fds=1695  close_wait=270  before_handshake=1632
t+60s            in=1     out=12  fds=40    close_wait=0    before_handshake=0
t+120s           in=3     out=12  fds=42    close_wait=0    before_handshake=0
t+300s           in=6     out=12  fds=46    close_wait=0    before_handshake=0

Outbound peers return to the full --out-peers complement within a minute. Zero event_add failed
and zero DNS TXT warnings after the new startup banner, confirming the libunbound failure was purely
a function of fd numbering.

Worth noting for the exit path: docker restart -t 180 consumed the entire 180 s grace period
and the daemon had to be SIGKILLed — no orderly-shutdown messages were logged. Tearing down 1665
stuck connection objects with the p2p timer machinery already dead appears to hang the exit path,
which looks like the same underlying condition reported in #9482 ("monerod exit gets stuck/hangs at
deinitializing p2p"). LMDB survived it — no recovery or corruption messages on the next start.

Suggested fixes

  1. Make the idle chain exception-safe. Either re-arm the timer before invoking the handler, or
    wrap call_handler() in try/catch inside global_timer_handler() so a single exception
    cannot permanently disable p2p maintenance. A transient failure should not be terminal.
  2. Stop suppressing the diagnostic. Exception at server worker thread is a
    never-should-happen event logged under a category defaulted to FATAL. Raise it to FATAL, or
    move it to a category that is visible by default. As it stands this failure is undiagnosable
    from a stock log.
  3. Guard the .at(). Use find() + a checked miss in
    try_to_connect_and_handshake_with_new_peer() rather than m_network_zones.at().
  4. Consider a liveness assertion — e.g. warn if connections_maker() has not run in N minutes
    while outgoing_connections_count < max_out_connection_count.

Operator-visible signature

For anyone else hitting this: the node reports status: OK, stays synchronized, serves RPC
normally, and shows outgoing_connections_count: 0 with a large and growing
incoming_connections_count. fd usage is a poor signal — this node sat at 2.6 % of its 65536
limit. outgoing_connections_count == 0 on a node with a non-zero --out-peers is the reliable
alert. Only a restart recovers it.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions