Reclaim outbound slots on peer disconnect:
- TcpClient_ThreadProc fired on_disconnect but never cleared the outbound
slot, closed the fd, joined the io thread, or freed the connection, so a
dead peer permanently held its outboundClients[] slot. After MAX_CONS (32)
churned connections the node could make no new outbound connections, and
leaked fds/threads/memory. (The inbound side already self-reclaimed.)
- Add a reaper (Node_ReapDeadOutbound) on the maintenance thread: under
outboundLock it detaches dead (disconnect-notified) slots, then joins the
io thread and destroys/frees each connection outside the lock.
- Guard against use-after-free with a pin count on tcp_connection_t
(TcpConnection_Pin/Unpin). The only cross-thread consumer holding a raw
connection pointer across a blocking op is the `sync` command (via
Node_GetBestOutboundPeer); it now pins the peer and unpins when done, and
the reaper skips pinned connections. Discovery's snapshots run on the
reaper's own thread, so they need no pin.
- Node_GetBestOutboundPeer/GetClientList/GetPeerEndpoints skip
disconnect-notified connections so a dead peer is never handed out.
- Node_Destroy stops+joins the maintenance thread before tearing down
outbound clients, so the reaper can't race shutdown.
Strike disconnected peers from the discovery peer list:
- Add NodeDiscovery_RemovePeer + Node_HandlePeerDisconnect: on disconnect,
remove the peer from the known-peer table once no live connection (inbound
or outbound) to its listen endpoint remains (Node_HasLiveConnectionTo;
disconnect-notified conns don't count, so both directions dropping at once
is handled). Wired into Node_Server_OnDisconnect and Node_Client_OnDisconnect.
- Implement NodeDiscovery engine: known-peer table (DynArr) with a
per-tick seed/ping/query/connect state machine driven by the node
maintenance thread; bounded multi-hop crawl (FANOUT peers per node,
hop-capped) that connects to reachable peers lowest-ping-first
- Add GET_PEERS/PEERS TCP opcodes for peer-list exchange, handled on
both inbound and outbound connections
- Measure UDP round-trip time and pass it to the on_pong callback
(previously the send timestamp was only used for retries)
- Advertise each node's listen port in HELLO/ACK_HELLO and store it
per-connection, so inbound-only peers and non-default ports are
discoverable (length-guarded parse; wire-compatible with old peers)
- Wire a udp_node_t + node_discovery_t into net_node_t: init/start in
Node_Create, tick in the maintenance loop, teardown in Node_Destroy
(stop UDP before destroying discovery to avoid callback races)
- Add Node_ConnListenEndpoint / Node_GetPeerEndpoints helpers to derive
peers' listen endpoints (outbound: dialed port; inbound: advertised),
with IPv4-mapped-IPv6 normalization and IP+port dedup
- Match ping pong/timeout callbacks by peer address (the UDP layer owns
the nonce), fixing discovered peers stuck UNREACHABLE
- Ping the peer's listen port instead of the ephemeral TCP source port
(fixes the original stub so pongs actually return)
- Add `peers` CLI command to dump the discovery table (endpoint/hop/
state/ping)
- Add discovery tunables to constants.h (fanout, max hops, target
connections, timeouts, caps)