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
- 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.
- 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.
- Guard the
.at(). Use find() + a checked miss in
try_to_connect_and_handshake_with_new_peer() rather than m_network_zones.at().
- 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.
Version: v0.18.5.1-release (
Fluorine Fermi), Linux x86_64, musl/Alpine build, Docker.Summary
boosted_tcp_server::global_timer_handler()re-arms itsdeadline_timerafter invoking thehandler. 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 p2pmaintenance at once:
connections_maker()stops → outbound peers decay to 0 and are never re-establishedpeer_sync_idle_maker()stops → dead peers are never detected or droppedgray_peerlist_housekeeping(),check_incoming_connections(),store_config(),update_dns_blocklist()all stopThe 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_idleis registered as a separateidle_callback_conextwith its owntimer (
net_node.inl~L1042-1043), so it keeps running — which makes the failure even harder tospot, 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; thepreceding 12 days were normal.
get_connections— 1660 connection objects:before_handshakebefore_handshakenormalbefore_handshakelive_time: min 97 s, p50 199 463 s (2.3 d), max 350 039 s (4.05 d){0: 311, 1: 373, 2: 456, 3: 484, 4: 2}— a hard cliff at day 4 datingonset precisely
before_handshakeconnections havesend_count == 0; 886 haverecv_count == 0File descriptors (pid 1 in container,
nofile= 65536, so this is not fd exhaustion):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:
Three healthy nodes running the identical image/config/version for the same 16 days show 0–1
before_handshakeconnections (all sub-4-second) and 12 outbound peers each.outgoing_connections_count = 0is truthful, not an accounting bugget_outgoing_connections_count()(src/p2p/net_node.inlL2029) counts!cntxt.m_is_income && !cntxt.is_ping. All 280 non-income connection objects on the affected nodeare
is_pingback-pings, so they are correctly excluded. The node genuinely has zero realoutbound peers and depends entirely on inbound peers for block propagation.
net.p2p:INFOover 120 s confirms it — everyOUTconnection is a back-ping opened millisecondsafter an
INCand closed ~10 ms later:Proof that
idle_worker()is not runningm_connections_maker_intervalisonce_a_time_seconds<1>(src/p2p/net_node.hL463), andtry_to_connect_and_handshake_with_new_peer()logsMDEBUG("Connecting to " << ...)as its firststatement after the guards. With
out_peers = 0againstmax_out_connection_count = 12, plus 5configured
--add-priority-nodepeers, this should fire continuously.Over a 90-second window at
net.p2p:DEBUG: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 infutex,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.hL490-498:If
call_handler()throws, both theexpires_from_nowand theasync_waitare skipped. Nothingever 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.inlL1433):The thread survives and re-enters
run()— which is why the process looks fine — but the idlechain is gone.
Why there is no trace of it in the log
abstract_tcp_server2.inlL56-57 setsMONERO_DEFAULT_LOG_CATEGORY "net", and the defaultcategory string (
contrib/epee/src/mlog.cppL103) containsnet:FATAL:_errois ERROR, which is below FATAL, so the one line that would identify the triggeringexception 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.inlL1383) opens with:std::map::atthrowsstd::out_of_rangefor any peerlist entry whose zone is not configured onthis 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()onattacker-influenceable peerlist data in exactly the call path that died.
Secondary failure: fd numbers crossing
FD_SETSIZEbreak libunboundOnce the leak pushed monerod's fd numbers past 1024, libunbound's select() backend could no longer
register descriptors:
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
nofileabove 1024 — which is necessaryfor 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:
Outbound peers return to the full
--out-peerscomplement within a minute. Zeroevent_add failedand 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 180consumed the entire 180 s grace periodand 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
wrap
call_handler()intry/catchinsideglobal_timer_handler()so a single exceptioncannot permanently disable p2p maintenance. A transient failure should not be terminal.
Exception at server worker threadis anever-should-happen event logged under a category defaulted to
FATAL. Raise it to FATAL, ormove it to a category that is visible by default. As it stands this failure is undiagnosable
from a stock log.
.at(). Usefind()+ a checked miss intry_to_connect_and_handshake_with_new_peer()rather thanm_network_zones.at().connections_maker()has not run in N minuteswhile
outgoing_connections_count < max_out_connection_count.Operator-visible signature
For anyone else hitting this: the node reports
status: OK, stays synchronized, serves RPCnormally, and shows
outgoing_connections_count: 0with a large and growingincoming_connections_count. fd usage is a poor signal — this node sat at 2.6 % of its 65536limit.
outgoing_connections_count == 0on a node with a non-zero--out-peersis the reliablealert. Only a restart recovers it.