Skip to content

Commit 31e555e

Browse files
committed
fix(zebrad): release the per-peer mempool slot on verification timeout
The mempool downloader's verification-timeout arm removed the `cancel_handles` entry (the GHSA-65jj memory fix) with a bare `remove`, discarding the stored `source` and skipping the shared per-peer slot release. After `MAX_INBOUND_CONCURRENCY_PER_PEER` timed-out transactions from one source, that source's `pending_per_peer` count stayed pinned at the cap with no tasks left, so further queueing from it returned `FullQueue`. Destructure the removed handle and call `release_peer_slot`, matching the success and verifier-error arms while preserving the GHSA-65jj handle removal. Adds a regression test that times out `MAX_INBOUND_CONCURRENCY_PER_PEER` peer-sourced transactions and asserts a further one from the same source queues.
1 parent 05d129b commit 31e555e

3 files changed

Lines changed: 96 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ and this project adheres to [Semantic Versioning](https://semver.org).
4343

4444
### Fixed
4545

46+
- The mempool now releases a peer's per-peer download slot when a transaction verification times
47+
out, instead of only on success or verifier error. Previously a peer whose transactions timed out
48+
could pin its per-peer slots at the cap and be unable to queue any further transactions from that
49+
address ([#10684](https://github.com/ZcashFoundation/zebra/issues/10684)).
50+
4651
- `getblocksubsidy` now returns NU6-era funding stream metadata (recipient names and
4752
specification URLs) for NU6.1 and later upgrades. Amounts and addresses were never
4853
affected ([#11172](https://github.com/ZcashFoundation/zebra/pull/11172)).

zebrad/src/components/mempool/downloads.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -265,12 +265,15 @@ where
265265
(Ok(Err(Box::new((hash, e)))), Some(hash))
266266
}
267267
Err((txid, elapsed)) => {
268-
// Remove the cancel handle so the spawned task's queued `Gossip`
269-
// doesn't stay resident in `cancel_handles` after a verification
270-
// timeout. Without this, a peer that gets each transaction to
271-
// hit `RATE_LIMIT_DELAY` can leak ~2 MB per tx until OOM.
272-
this.cancel_handles.remove(&txid);
273-
(Err((txid, elapsed)), None)
268+
// Treat a verification timeout as a terminal completion so the
269+
// shared cleanup below removes the `cancel_handles` entry — the
270+
// GHSA-65jj fix, which drops the resident `Gossip` (~2 MB per tx)
271+
// so it can't leak until OOM — and releases the per-peer queue
272+
// slot. A bare handle removal here left the slot pinned, so a
273+
// peer that timed out `MAX_INBOUND_CONCURRENCY_PER_PEER`
274+
// transactions could no longer queue any from that source.
275+
// See #10684.
276+
(Err((txid, elapsed)), Some(txid))
274277
}
275278
};
276279

zebrad/src/components/mempool/tests/vector.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2284,3 +2284,85 @@ async fn cancel_handles_drained_after_verification_timeout() {
22842284
"regression GHSA-65jj-fmw8-468q: cancel_handles must be drained after timeout"
22852285
);
22862286
}
2287+
2288+
/// Regression test for #10684: the mempool verification-timeout path must
2289+
/// release the per-peer queue slot, not just remove the cancel handle.
2290+
///
2291+
/// Before the fix, a peer whose `MAX_INBOUND_CONCURRENCY_PER_PEER` transactions
2292+
/// all hit the verification timeout kept its per-peer count pinned at the cap
2293+
/// even though no tasks remained, so any further transaction from that source
2294+
/// was rejected with `FullQueue`.
2295+
#[tokio::test(flavor = "current_thread", start_paused = true)]
2296+
async fn verification_timeout_releases_peer_slot() {
2297+
use std::net::SocketAddr;
2298+
2299+
use futures::stream::StreamExt;
2300+
use tower::timeout::Timeout;
2301+
use zebra_node_services::mempool::Gossip;
2302+
2303+
use crate::components::mempool::{
2304+
crawler::RATE_LIMIT_DELAY,
2305+
downloads::{
2306+
Downloads, MAX_INBOUND_CONCURRENCY_PER_PEER, TRANSACTION_DOWNLOAD_TIMEOUT,
2307+
TRANSACTION_VERIFY_TIMEOUT,
2308+
},
2309+
};
2310+
2311+
let _init_guard = zebra_test::init();
2312+
2313+
let peer_set: MockPeerSet = MockService::build().for_unit_tests();
2314+
let state: MockService<zs::Request, zs::Response, PanicAssertion> =
2315+
MockService::build().for_unit_tests();
2316+
let tx_verifier: MockTxVerifier = MockService::build().for_unit_tests();
2317+
2318+
let mut downloads = Box::pin(Downloads::new(
2319+
Timeout::new(peer_set, TRANSACTION_DOWNLOAD_TIMEOUT),
2320+
Timeout::new(tx_verifier, TRANSACTION_VERIFY_TIMEOUT),
2321+
state,
2322+
));
2323+
2324+
let source: SocketAddr = "127.0.0.1:8233".parse().expect("valid socket addr");
2325+
2326+
let mut iter = Network::Mainnet.unmined_transactions_in_blocks(1..=10);
2327+
2328+
// Fill the per-peer slot with `MAX_INBOUND_CONCURRENCY_PER_PEER` peer-sourced
2329+
// transactions from a single source.
2330+
for i in 0..MAX_INBOUND_CONCURRENCY_PER_PEER {
2331+
let tx = iter.next().expect("enough vector txs").transaction;
2332+
downloads
2333+
.as_mut()
2334+
.download_if_needed_and_verify(Gossip::Tx(tx), Some(source), None)
2335+
.unwrap_or_else(|e| panic!("queue tx {i} failed: {e:?}"));
2336+
}
2337+
2338+
assert_eq!(downloads.in_flight(), MAX_INBOUND_CONCURRENCY_PER_PEER);
2339+
2340+
// Advance past `RATE_LIMIT_DELAY` so every spawned task hits the verification
2341+
// timeout (the mocked services never make progress).
2342+
time::advance(RATE_LIMIT_DELAY + Duration::from_secs(5)).await;
2343+
tokio::task::yield_now().await;
2344+
2345+
for _ in 0..MAX_INBOUND_CONCURRENCY_PER_PEER {
2346+
match downloads.as_mut().next().await {
2347+
Some(Err(_)) => {}
2348+
Some(Ok(_)) => panic!("expected a verification timeout error"),
2349+
None => panic!("Downloads stream ended before all tasks resolved"),
2350+
}
2351+
}
2352+
2353+
assert_eq!(downloads.in_flight(), 0, "pending should be drained");
2354+
assert_eq!(
2355+
downloads.transaction_requests().count(),
2356+
0,
2357+
"cancel_handles must be drained after timeout (GHSA-65jj)"
2358+
);
2359+
2360+
// The per-peer slots must have been released: a further transaction from the
2361+
// same source must queue successfully. Before the fix this returned
2362+
// `FullQueue` because the timeout path never released the slots.
2363+
let tx = iter.next().expect("enough vector txs").transaction;
2364+
downloads
2365+
.as_mut()
2366+
.download_if_needed_and_verify(Gossip::Tx(tx), Some(source), None)
2367+
.expect("a further transaction from the same source should queue after slots are freed");
2368+
}

0 commit comments

Comments
 (0)