Repository navigation
net: treat manual peers as Core does for misbehavior and addnode - #926
Conversation
Core's MaybeDiscourageAndDisconnect never disconnects or discourages a manual peer. Since --connect peers became manual, a --connect or addnode peer was still dropped, and its address refused, at every Misbehaving site. Those sites now call misbehaving(), which keeps a manual peer like a noban one. Sites where Core sets fDisconnect keep punish_disconnect and still drop a manual peer. getblocktxn with out-of-range indexes was a direct disconnect; it is Misbehaving in Core and now goes through misbehaving() too. addnode onetry and add dialled an endpoint that already had a live session or a dial in flight, and each one that connected became a second session. As with Core's AlreadyConnectedToHost and AlreadyConnectedToAddressPort, addnode now succeeds without dialling. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
538dec0 to
8c08626
Compare
addnode and the redial pass checked for a live session or a dial in flight, but a dial was counted only once the dial task picked up its request. Two addnode calls in a row, or addnode beside the 2 s redial, could each queue a dial to one endpoint. Every DialRequest now carries its in-flight count from the moment it is queued, and addnode and the redial pass claim the endpoint and check for a live session in that order, so a dial registers its session before it gives up its claim. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
8c08626 to
0c00151
Compare
There was a problem hiding this comment.
Summary
The misbehavior split matches the Core claim: misbehaving logs and keeps a manual peer with no score and no address refusal, while the fDisconnect sites still go through punish_disconnect and drop that peer. dial_manual_target claims under one lock before the live-session check, and a queued dial keeps that claim until prepare_outbound_session has registered the session, so two addnodes or addnode beside the redial pass cannot both queue. The tests pin those guards and would fail if they were reverted. One regression sits outside that path: every dial / dial_domain now owns a DialInFlight from enqueue time, including feelers, which never enter the connect timeout that exists so a stuck dial cannot pin the mark.
Issue counts by severity
- bugs: 1
- suggestions: 0
- nits: 0
| #[allow(clippy::too_many_arguments)] // call-site args stay unbundled | ||
| async fn run_outbound_session_with_abort( | ||
| peer: DialTarget, | ||
| in_flight: crate::peers::DialInFlight, |
There was a problem hiding this comment.
[bug] dial and dial_domain now take DialInFlight before enqueue and move it into this task. The feeler branch returns before prepare_outbound_session, so that guard stays alive across connect_net and the whole run_feeler call. connect_net has no deadline here. OUTBOUND_CONNECT_TIMEOUT exists so a proxy that accepts and never answers cannot hold a DialInFlight mark forever and block the redial pass; feelers used to skip that mark entirely. addconnection feeler is the caller (PeerHub::dial). A stuck SOCKS handshake to an address that is also --connect or addnode makes claim_dial keep returning None, so the manual peer is not redialled until the feeler task ends. A domain feeler drops the guard on the early Err, but an IP feeler does not.
Suggestion: Drop in_flight at the start of the feeler branch (feelers do not register a session and were not part of the in-flight set), or apply the same connect_timeout_for(..., OUTBOUND_CONNECT_TIMEOUT) around connect_net before the probe and do not hold the mark past that connect.
There was a problem hiding this comment.
Fixed in cd30a67. A feeler is queued without a DialInFlight again (PeerHub::queue_dial; DialRequest::in_flight is now an Option), so it never enters the in-flight set, as before this PR. The new a_queued_feeler_does_not_hold_off_a_manual_dial holds a queued feeler and checks that addnode onetry of the same address still queues a manual dial; it fails if the feeler is counted.
The feeler's own connect_net still has no deadline, as before this PR. I left that alone here.
Since the previous commit, every queued dial carried a `DialInFlight` from enqueue, feelers included. A feeler returns before `prepare_outbound_session`, so its mark lived across a `connect_net` with no deadline and the whole probe. A stuck probe to an address that is also `--connect` or `addnode` kept `claim_dial` refusing, and the manual peer was not redialled until the probe ended. Feelers never took the mark before this PR. Queue them without one again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Summary
The previous bug is fixed. Every queued feeler goes through queue_dial (dial, dial_net, dial_domain, and addconnection), which leaves in_flight as None and does not touch dials_in_flight. send_dial is the only DialRequest constructor, and dial_manual_target always passes Some(claim) with PeerConnType::Manual. In the dial task, a feeler still returns from run_outbound_session_with_abort before prepare_outbound_session, so the unwrap_or_else(|| peers.dial_in_flight(...)) fallback never runs for one and cannot start a count. A manual, addnode, or redial dial is still claimed at enqueue; claim_dial then the live-session check is unchanged, so a second addnode or a redial beside an in-flight manual dial still queues nothing. a_queued_feeler_does_not_hold_off_a_manual_dial holds the queued feeler across addnode and would fail if that request carried a mark. It does not run the dial task; that is not a hole, because nothing after dequeue attaches a mark to a feeler. No new defect in the fix. Ready to approve.
Issue counts by severity
- bugs: 0
- suggestions: 0
- nits: 0
Problem
Two gaps left after #876 made
--connectpeersmanual:MaybeDiscourageAndDisconnectnever disconnects or discourages a manual peer ("Not punishing manually connected peer"). Herepunish_disconnectand the per-framemisbehavior_disconnectscheck exempted only noban, so a--connectoraddnodepeer that sent, for example, a block with bad proof of work was dropped, and inbound connections from its address were refused for a day. Since net: --connect peers are manual, as in Core #876 that applies to every--connectpeer. net: redial --connect addresses that drop, as in Core #908 redials it, but each such message costs the session.addnodeopens a second session.addnode onetryandaddnode adddialled their target even when it had a live session or a dial in flight, and each dial that connected became its own session. Core'sOpenNetworkConnectionreturns early onAlreadyConnectedToHost(pszDest), andConnectNodedrops a name that resolves to a connected address (AlreadyConnectedToAddressPort).Fix
misbehaving()is Core'sMisbehaving: a noban or manual peer is logged and kept, with no score. The 15 sites that areMisbehavingin Core call it: oversizedinv,getdataandaddrv2; mutated blocks (merkle root, duplicate txs, 64-byte tx without a coinbase); header and block failures thatMaybePunishNodeForBlockpunishes (bad header, unknown or cached-invalid parent, also viacmpctblock); an invalid compact block; acmpctblockorblocktxnafter a failed reconstruction; andgetblocktxnwith out-of-range indexes.punish_disconnectstays at the other 9 sites. At 8 of them Core setsfDisconnect, which drops a manual peer too:sendaddrv2afterverack, oversizedgetheadersandgetblockslocators, atxor txinvthe node does not take,filterloadwithoutNODE_BLOOM, and theaddrreply that ends an addr-fetch connection (two sites). The ninth, the per-peer tx request cap, has no Core counterpart.misbehavior_disconnectsand the address refusal treat a manual peer like noban. Rate-limit and oversize-frame exits still disconnect a manual peer, as they do a noban one, but no longer refuse its address.getblocktxnwith out-of-range indexes disconnected directly, noban peers included. It isMisbehavingin Core and now goes throughmisbehaving().addnode(onetry,add, andPeerHub::addnode) skips a target with a live session or a dial in flight, and still returns success.addstill records the target, so the redial pass dials it once that session ends.DialRequestcarries itsDialInFlightto the dial task, which used to take one only when it began to connect.DialRequestlosesCloneand gains a crate-private field, so code outsiderbitcoin-netcan no longer build one; nothing in the workspace did.addnodeand the redial pass sharedial_manual_target, which claims the endpoint (check and count under one lock) and then checks for a live session. A dial registers its session before it drops its claim, so twoaddnodecalls in a row, oraddnodebeside the 2 s redial, queue one dial. A feeler is queued without a mark, as before: it registers no session, and its probe has no connect deadline, so a stuck probe would keep the redial pass off a manual peer at the same address.Tests
peer_header_dos_and_self_announce: the noban bad-block beat, nownoban_and_manual_bad_block_is_not_punished, adds a manual peer. The bad block leaves it connected with no score,misbehavior_disconnectsis false for it at the threshold, and asendaddrv2afterverackstill drops it.threshold_exit_refuses_the_address_without_punish_disconnect: a manual peer's threshold exit refuses no address, andpunish_disconnectdrops it.peers::tests::addnode_skips_an_endpoint_that_is_connected_or_dialling: with a live session,addnode onetryandaddby string andPeerHub::addnodeby address send no dial. An onion with a dial in flight is not dialled until that dial ends, and thenonetry,addand a redial pass right behind each other queue one dial. Once the session ends,onetrydials it asmanual.peers::tests::a_queued_feeler_does_not_hold_off_a_manual_dial: with a feeler to an address queued and held,addnode onetryof that address still queues amanualdial.redial_same_endpoint_in_addnode_and_connect_dials_oncedrops the requests it took before the next pass, since a request it holds now counts as a dial in flight.node_run_p2p_short: after the--connectpeer is redialled,addnode onetryandaddof its address succeed, andgetpeerinfokeeps one row for it over 500 ms. The existing beat where the seeder'stxends the manual--connectsession on a--blocks-onlynode still passes, which covers thefDisconnectside end to end.Each new assertion fails when its guard is reverted:
misbehaving()fails the journey on "Core never disconnects a manual peer for misbehavior".misbehavior_exemptfails it on "a manual peer at the threshold stays connected", and fails the threshold test on "a manual peer is not recorded".punish_disconnectas well fails both on "a protocol violation still drops a manual peer" and "a protocol violation drops a manual peer".dial_manual_targetfails the unit test on "a connected endpoint is not dialled again", and failsnode_run_p2p_shorton "addnode opened a second session".claim_dialignore a dial in flight fails the unit test on "nor is one still dialling".Ran locally:
cargo fmt --all -- --check,./scripts/ast-grep.sh,cargo clippy --workspace --all-targets -- -D warnings, the fullrbitcoin-netlib tests, therbitcoin-rpcaddnodetests, therbitcoin-noderun::tests,node_run_p2p_shortthree times, andp2p_feeler_completes_and_closes.Not changed
fDisconnectsites a noban peer is still kept and a plain peer's address is still refused. Core drops the noban peer and refuses no address there. Both predate this change.announced_tip_is_hopeless) still drops a manual peer. The rule is rbitcoin's own, and this change leaves it alone. Since net: redial --connect addresses that drop, as in Core #908, a--connectpeer dropped there is redialled every 2 s.🤖 Generated with Claude Code