fix: Fail fast on an invalid map, and bound late replies by peer asked - #8093
Conversation
There was a problem hiding this comment.
Pull request overview
This PR stops transaction and ledger acquisitions from retrying structurally invalid maps, refines peer charging, and adds regression coverage.
Changes:
- Terminates unrecoverable acquisitions.
- Adds differentiated fee handling and late-reply allowances.
- Expands transaction and ledger acquisition tests.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Review summary |
|---|---|
src/xrpld/app/ledger/detail/TransactionAcquire.h |
Updates acquisition state and documentation. |
src/xrpld/app/ledger/detail/TransactionAcquire.cpp |
Handles invalid maps and charging; targeted solicited peers may be excluded from late-reply allowances. |
src/xrpld/app/ledger/detail/InboundTransactions.cpp |
Adjusts dispatch and retention; post-completion replies can bypass the late-reply allowance. |
src/xrpld/app/ledger/detail/InboundLedger.cpp |
Handles invalid state-map failures; independently detect maps already invalidated by another walk. |
src/test/app/TransactionAcquire_test.cpp |
Adds transaction acquisition regression coverage. |
src/test/app/InboundLedger_test.cpp |
Adds ledger acquisition regression coverage. |
Suppressed comments (1)
src/xrpld/app/ledger/detail/TransactionAcquire.cpp:233
- For normal network traffic, this late-reply branch is bypassed after a successful acquisition is handed off:
done()postsgiveSet(), which clearsInboundTransactionSet::acquire; a TMLedgerData that arrives after that is charged byInboundTransactions::gotData()as useless before it can reachtakeNodes(). Thus the advertised free allowance is only effective until the asynchronous handoff, and an honest fan-out reply that arrives just afterward is still charged. Preserve the allowance in the container while late replies can arrive, or apply it before clearing the acquisition.
if (isDone())
{
JLOG(journal_.trace()) << (complete_ ? "TX set complete" : "TX set failed");
if (++lateReplies_ > peerSet_->getPeerIds().size())
peer->charge(resource::kFeeUselessData, "tx_set data after the set was settled");
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
bf26872 to
0ec627b
Compare
0ec627b to
2aa13f9
Compare
2aa13f9 to
7fae7a9
Compare
trigger() walks a ledger's state map with mtx_ released (the AS-node getMissingNodes() call), so it can invalidate the map while a different packet is still in flight in receiveNode(). SHAMap::addKnownNode() then reports every node in that packet a duplicate rather than invalid, so isSynching() alone cannot tell "this packet just finished the map" apart from "some other packet already broke it" - the acquisition can end up reporting itself satisfied on a map that is actually invalid, retrying a hash no peer can ever complete instead of failing fast. Check map.isValid() independently right after the locked receive, before isSynching() can misread the verdict, and fail without charging the packet that lost the race - it did nothing wrong. Addresses Copilot review feedback on PR #8093, split out into its own branch: this bug predates the stack and is not made worse by it, so there is no urgency tying it to that PR's release.
giveSet() resets the map entry's acquire pointer unconditionally once a set arrives, so any reply for that hash arriving after completion takes gotData()'s ta == nullptr branch - charged outright, since takeNodesLocked() is never reached to apply the late-reply allowance. The tests for that allowance call acquire->takeNodes() directly, bypassing gotData()/giveSet() entirely, so none of them exercise this: the real production entry point for peer replies never reaches the allowance at all. Only reset the entry's acquire pointer when something other than the acquisition itself supplied the set: a set arriving some other way still cancels an acquisition genuinely in flight, but the acquisition completing on its own is not that. Keeping it alive until newRound() sweeps the entry lets a late reply for this hash still reach getAcquire() and, through it, takeNodesLocked()'s allowance. Addresses Copilot review feedback on PR #8093, split out into its own branch: unlike the late-reply allowance bound in branch 14, this gap is unchanged pre-existing behavior, not something this stack makes worse, so there is no urgency tying it to that PR's release.
7fae7a9 to
edeb11f
Compare
trigger() walks a ledger's state map with mtx_ released (the AS-node getMissingNodes() call), so it can invalidate the map while a different packet is still in flight in receiveNode(). SHAMap::addKnownNode() then reports every node in that packet a duplicate rather than invalid, so isSynching() alone cannot tell "this packet just finished the map" apart from "some other packet already broke it" - the acquisition can end up reporting itself satisfied on a map that is actually invalid, retrying a hash no peer can ever complete instead of failing fast. Check map.isValid() independently right after the locked receive, before isSynching() can misread the verdict, and fail without charging the packet that lost the race - it did nothing wrong. Addresses Copilot review feedback on PR #8093, split out into its own branch: this bug predates the stack and is not made worse by it, so there is no urgency tying it to that PR's release.
giveSet() resets the map entry's acquire pointer unconditionally once a set arrives, so any reply for that hash arriving after completion takes gotData()'s ta == nullptr branch - charged outright, since takeNodesLocked() is never reached to apply the late-reply allowance. The tests for that allowance call acquire->takeNodes() directly, bypassing gotData()/giveSet() entirely, so none of them exercise this: the real production entry point for peer replies never reaches the allowance at all. Only reset the entry's acquire pointer when something other than the acquisition itself supplied the set: a set arriving some other way still cancels an acquisition genuinely in flight, but the acquisition completing on its own is not that. Keeping it alive until newRound() sweeps the entry lets a late reply for this hash still reach getAcquire() and, through it, takeNodesLocked()'s allowance. Addresses Copilot review feedback on PR #8093, split out into its own branch: unlike the late-reply allowance bound in branch 14, this gap is unchanged pre-existing behavior, not something this stack makes worse, so there is no urgency tying it to that PR's release.
edeb11f to
f64a56b
Compare
trigger() walks a ledger's state map with mtx_ released (the AS-node getMissingNodes() call), so it can invalidate the map while a different packet is still in flight in receiveNode(). SHAMap::addKnownNode() then reports every node in that packet a duplicate rather than invalid, so isSynching() alone cannot tell "this packet just finished the map" apart from "some other packet already broke it" - the acquisition can end up reporting itself satisfied on a map that is actually invalid, retrying a hash no peer can ever complete instead of failing fast. Check map.isValid() independently right after the locked receive, before isSynching() can misread the verdict, and fail without charging the packet that lost the race - it did nothing wrong. Addresses Copilot review feedback on PR #8093, split out into its own branch: this bug predates the stack and is not made worse by it, so there is no urgency tying it to that PR's release.
giveSet() resets the map entry's acquire pointer unconditionally once a set arrives, so any reply for that hash arriving after completion takes gotData()'s ta == nullptr branch - charged outright, since takeNodesLocked() is never reached to apply the late-reply allowance. The tests for that allowance call acquire->takeNodes() directly, bypassing gotData()/giveSet() entirely, so none of them exercise this: the real production entry point for peer replies never reaches the allowance at all. Only reset the entry's acquire pointer when something other than the acquisition itself supplied the set: a set arriving some other way still cancels an acquisition genuinely in flight, but the acquisition completing on its own is not that. Keeping it alive until newRound() sweeps the entry lets a late reply for this hash still reach getAcquire() and, through it, takeNodesLocked()'s allowance. Addresses Copilot review feedback on PR #8093, split out into its own branch: unlike the late-reply allowance bound in branch 14, this gap is unchanged pre-existing behavior, not something this stack makes worse, so there is no urgency tying it to that PR's release.
trigger() walks a ledger's state map with mtx_ released (the AS-node getMissingNodes() call), so it can invalidate the map while a different packet is still in flight in receiveNode(). SHAMap::addKnownNode() then reports every node in that packet a duplicate rather than invalid, so isSynching() alone cannot tell "this packet just finished the map" apart from "some other packet already broke it" - the acquisition can end up reporting itself satisfied on a map that is actually invalid, retrying a hash no peer can ever complete instead of failing fast. Check map.isValid() independently right after the locked receive, before isSynching() can misread the verdict, and fail without charging the packet that lost the race - it did nothing wrong. Addresses Copilot review feedback on PR #8093, split out into its own branch: this bug predates the stack and is not made worse by it, so there is no urgency tying it to that PR's release.
f64a56b to
693f0bc
Compare
|
This PR has conflicts, please resolve them in order for the PR to be reviewed. |
|
This PR has conflicts, please resolve them in order for the PR to be reviewed. |
|
All conflicts have been resolved. Assigned reviewers can now start or resume their review. |
giveSet() resets the map entry's acquire pointer unconditionally once a set arrives, so any reply for that hash arriving after completion takes gotData()'s ta == nullptr branch - charged outright, since takeNodesLocked() is never reached to apply the late-reply allowance. The tests for that allowance call acquire->takeNodes() directly, bypassing gotData()/giveSet() entirely, so none of them exercise this: the real production entry point for peer replies never reaches the allowance at all. Only reset the entry's acquire pointer when something other than the acquisition itself supplied the set: a set arriving some other way still cancels an acquisition genuinely in flight, but the acquisition completing on its own is not that. Keeping it alive until newRound() sweeps the entry lets a late reply for this hash still reach getAcquire() and, through it, takeNodesLocked()'s allowance. Addresses Copilot review feedback on PR #8093, split out into its own branch: unlike the late-reply allowance bound in branch 14, this gap is unchanged pre-existing behavior, not something this stack makes worse, so there is no urgency tying it to that PR's release.
trigger() walks a ledger's state map with mtx_ released (the AS-node getMissingNodes() call), so it can invalidate the map while a different packet is still in flight in receiveNode(). SHAMap::addKnownNode() then reports every node in that packet a duplicate rather than invalid, so isSynching() alone cannot tell "this packet just finished the map" apart from "some other packet already broke it" - the acquisition can end up reporting itself satisfied on a map that is actually invalid, retrying a hash no peer can ever complete instead of failing fast. Check map.isValid() independently right after the locked receive, before isSynching() can misread the verdict, and fail without charging the packet that lost the race - it did nothing wrong. Addresses Copilot review feedback on PR #8093, split out into its own branch: this bug predates the stack and is not made worse by it, so there is no urgency tying it to that PR's release.
|
This PR has conflicts, please resolve them in order for the PR to be reviewed. |
693f0bc to
9a11f79
Compare
|
This PR has conflicts, please resolve them in order for the PR to be reviewed. |
giveSet() resets the map entry's acquire pointer unconditionally once a set arrives, so any reply for that hash arriving after completion takes gotData()'s ta == nullptr branch - charged outright, since takeNodesLocked() is never reached to apply the late-reply allowance. The tests for that allowance call acquire->takeNodes() directly, bypassing gotData()/giveSet() entirely, so none of them exercise this: the real production entry point for peer replies never reaches the allowance at all. Only reset the entry's acquire pointer when something other than the acquisition itself supplied the set: a set arriving some other way still cancels an acquisition genuinely in flight, but the acquisition completing on its own is not that. Keeping it alive until newRound() sweeps the entry lets a late reply for this hash still reach getAcquire() and, through it, takeNodesLocked()'s allowance. Addresses Copilot review feedback on PR #8093, split out into its own branch: unlike the late-reply allowance bound in branch 14, this gap is unchanged pre-existing behavior, not something this stack makes worse, so there is no urgency tying it to that PR's release.
|
All conflicts have been resolved. Assigned reviewers can now start or resume their review. |
trigger() walks a ledger's state map with mtx_ released (the AS-node getMissingNodes() call), so it can invalidate the map while a different packet is still in flight in receiveNode(). SHAMap::addKnownNode() then reports every node in that packet a duplicate rather than invalid, so isSynching() alone cannot tell "this packet just finished the map" apart from "some other packet already broke it" - the acquisition can end up reporting itself satisfied on a map that is actually invalid, retrying a hash no peer can ever complete instead of failing fast. Check map.isValid() independently right after the locked receive, before isSynching() can misread the verdict, and fail without charging the packet that lost the race - it did nothing wrong. Addresses Copilot review feedback on PR #8093, split out into its own branch: this bug predates the stack and is not made worse by it, so there is no urgency tying it to that PR's release.
9a11f79 to
ac13a7b
Compare
giveSet() resets the map entry's acquire pointer unconditionally once a set arrives, so any reply for that hash arriving after completion takes gotData()'s ta == nullptr branch - charged outright, since takeNodesLocked() is never reached to apply the late-reply allowance. The tests for that allowance call acquire->takeNodes() directly, bypassing gotData()/giveSet() entirely, so none of them exercise this: the real production entry point for peer replies never reaches the allowance at all. Only reset the entry's acquire pointer when something other than the acquisition itself supplied the set: a set arriving some other way still cancels an acquisition genuinely in flight, but the acquisition completing on its own is not that. Keeping it alive until newRound() sweeps the entry lets a late reply for this hash still reach getAcquire() and, through it, takeNodesLocked()'s allowance. Addresses Copilot review feedback on PR #8093, split out into its own branch: unlike the late-reply allowance bound in branch 14, this gap is unchanged pre-existing behavior, not something this stack makes worse, so there is no urgency tying it to that PR's release.
A node that leaves a map invalid proves the hash being chased cannot belong to any valid tree (see SHAMap::addKnownNode), so no peer can ever complete it. TransactionAcquire::takeNodesLocked() and InboundLedger::receiveNode() now fail the acquisition there instead of retrying until the timeout chain runs out, discarding the whole batch: the nodes hooked in ahead of the bad one belong to a tree that cannot exist. stillNeed() and InboundTransactions::getSet() refuse to revive or refresh such an acquisition, so a dead entry is swept rather than held open. Charging happens under the same lock that reaches the verdict: kFeeMalformedData for a node that invalidates the map, kFeeInvalidData for data that is merely wrong. A reply arriving after the set is settled is free once per peer the acquisition actually asked - trigger() can send a targeted request to an unsolicited sender directly, not only to peers addPeers() selected, so requestedPeers_ tracks every peer sent a request either way. The allowance is keyed by peer identity (lateReplyGranted_), not a shared count, so one peer replaying its own already-accepted reply cannot exhaust the pass a different, honest peer is still owed. Past the allowance, a reply is a replay and costs kFeeUselessData.
trigger() walks a ledger's state map with mtx_ released (the AS-node getMissingNodes() call), so it can invalidate the map while a different packet is still in flight in receiveNode(). SHAMap::addKnownNode() then reports every node in that packet a duplicate rather than invalid, so isSynching() alone cannot tell "this packet just finished the map" apart from "some other packet already broke it" - the acquisition can end up reporting itself satisfied on a map that is actually invalid, retrying a hash no peer can ever complete instead of failing fast. Check map.isValid() independently right after the locked receive, before isSynching() can misread the verdict, and fail without charging the packet that lost the race - it did nothing wrong. Addresses Copilot review feedback on PR #8093, split out into its own branch: this bug predates the stack and is not made worse by it, so there is no urgency tying it to that PR's release.
ac13a7b to
242c6e2
Compare
giveSet() resets the map entry's acquire pointer unconditionally once a set arrives, so any reply for that hash arriving after completion takes gotData()'s ta == nullptr branch - charged outright, since takeNodesLocked() is never reached to apply the late-reply allowance. The tests for that allowance call acquire->takeNodes() directly, bypassing gotData()/giveSet() entirely, so none of them exercise this: the real production entry point for peer replies never reaches the allowance at all. Only reset the entry's acquire pointer when something other than the acquisition itself supplied the set: a set arriving some other way still cancels an acquisition genuinely in flight, but the acquisition completing on its own is not that. Keeping it alive until newRound() sweeps the entry lets a late reply for this hash still reach getAcquire() and, through it, takeNodesLocked()'s allowance. Addresses Copilot review feedback on PR #8093, split out into its own branch: unlike the late-reply allowance bound in branch 14, this gap is unchanged pre-existing behavior, not something this stack makes worse, so there is no urgency tying it to that PR's release.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Part 14/16 of a stack. Base: part 13 (
bthomee/shamap-invalid-13-no-ledger-on-failure).High Level Overview of Change
A node that leaves a map invalid proves the hash being chased cannot belong to any valid tree, so
TransactionAcquireandInboundLedgernow fail such an acquisition immediately instead of retryinguntil the timeout chain runs out. A reply arriving after the set is settled is free once per peer the
acquisition actually asked, tracked by peer identity rather than by a shared count, so replaying an
already-accepted reply cannot exhaust the allowance a different, honest peer is still owed.
Context of Change
SHAMap::addKnownNode()reportsinvalid()for a node that leaves the map invalid (see part 5), whichproves the root hash being chased commits to a shape no valid tree can have — no peer can ever complete
it.
TransactionAcquire::takeNodesLocked()andInboundLedger::receiveNode()now fail the acquisitionat that point rather than retrying, discarding the whole batch: the nodes hooked in ahead of the bad one
belong to a tree that cannot exist.
TransactionAcquire::stillNeed()refuses to revive such anacquisition, and
InboundTransactions::getSet()refreshes its retention window only while it is stillworth keeping, so a dead entry is swept rather than held open. Charging happens under the same lock that
reaches the verdict:
kFeeMalformedDatafor a node that invalidates the map,kFeeInvalidDatafor datathat is merely wrong.
A reply arriving after the set is settled is free once per peer the acquisition actually asked.
trigger()can send a targeted request directly to an unsolicited sender, not only to peersaddPeers()selected, so
requestedPeers_tracks every peer sent a request either way — bounding bypeerSet_->getPeerIds()alone would let an honest peer that only ever received a targeted request becharged for a reply we ourselves asked for. The allowance itself is keyed by peer identity
(
lateReplyGranted_), not a shared count: a plain counter compared against the number of peers askedwould let one peer's replayed replies exhaust the allowance a different, genuinely honest peer is still
owed, since a count alone cannot tell whose slot it is spending. Past the allowance, a reply is a replay
and costs
kFeeUselessData—InboundTransactions::gotData()deserializes and hashes the whole nodelist before this point, so an unbounded number of replays would otherwise cost only the trivial
per-message fee.
Covered by the
TransactionAcquireandInboundLedgergtest/boost suites: a chain reachingkLeafDepthfails the acquisition outright, is not revived bystillNeed(), and rejects later replies;a merely-wrong node or root leaves the acquisition able to try another peer; the fee-tier split is driven
through the real
InboundTransactions::gotData()dispatch; a late reply is free once for the peer thatearned it and charged on a second attempt from the same peer or from a peer never asked at all; one
peer's replayed replies are shown not to spend a different, honestly-asked peer's own allowance; and a
revived acquisition's allowance is shown not to carry over a spent pass from the round that just failed.
API Impact
None of the checkboxes below apply: this is an internal
xrpldcorrectness fix with nolibxrpl,public-API, or peer-protocol surface.