Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
dbc8bef
mempool: promote orphans with a worklist
rearden-grok[bot] Oct 18, 2026
042f55c
query: refuse a history window wider than eight weeks
rearden-grok[bot] Oct 18, 2026
1edee58
electrum: time out a response write
rearden-grok[bot] Oct 18, 2026
add1d61
sv2: drop a silent session after setup
rearden-grok[bot] Oct 18, 2026
e1ba375
mempool: limit unconfirmed inputs on a replacement
rearden-grok[bot] Oct 18, 2026
00c6b3d
mempool: stop the invalid-txid set at its cap
rearden-grok[bot] Oct 18, 2026
98ff1e1
net: discourage an overlay peer by its NetAddr
rearden-grok[bot] Oct 18, 2026
96f7ef4
net: keep the clearnet discourage helper test-only
rearden-grok[bot] Oct 18, 2026
fc1c2e2
esplora: drop GET /broadcast
rearden-grok[bot] Oct 18, 2026
d0043e7
consensus: release a script wave if a chunk panics
rearden-grok[bot] Oct 18, 2026
b97483e
docs: note the remote-limit fixes
rearden-grok[bot] Oct 18, 2026
968bdeb
electrum: read the tweaks done line before the next call
rearden-grok[bot] Oct 18, 2026
a4412d6
mempool: restore below-floor conflicts without a new unconfirmed input
rearden-grok[bot] Oct 18, 2026
08d9ac0
net: restore failed replacements without a new unconfirmed input
rearden-grok[bot] Oct 18, 2026
86ec9dd
net: keep the session-table clippy allow on PeerHub
rearden-grok[bot] Oct 18, 2026
ddc608c
dos: end a stalled status push and walk orphans in one frame
rearden-grok[bot] Oct 18, 2026
6475d5c
dos: queue orphans of every tx admitted in one walk
rearden-grok[bot] Oct 18, 2026
b8d14cc
dos: keep a slot-eviction netgroup off outbound dials
rearden-grok[bot] Oct 18, 2026
d5dafe3
dos: refuse a misbehavior identity on every outbound dial
rearden-grok[bot] Oct 18, 2026
d612ddd
mempool: keep an orphan parked until every parent is in
rearden-grok[bot] Oct 18, 2026
0303c5c
mempool: admit a package before promoting its parked child
rearden-grok[bot] Oct 18, 2026
1a761e7
mempool: admit a parked package member on the hub path
rearden-grok[bot] Oct 18, 2026
89f92aa
mempool: admit a lifted child whose parents are already in
rearden-grok[bot] Oct 18, 2026
d3c1ff5
mempool: keep a prioritised child a failed package lifted
rearden-grok[bot] Oct 19, 2026
a1ba605
ibd: plant a stored tip child that has no path prev
rearden-grok[bot] Oct 19, 2026
8f2e565
ibd: re-get a tip child after BadPrev
rearden-grok[bot] Oct 19, 2026
a3ca78b
mempool: name a spend of the conflict's own output
rearden-grok[bot] Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion COMPAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ App `ServeLimits` always on (same model as Electrum).
| Address / scripthash | done | stats + `/utxo` + `/txs` + `/txs/mempool` + `/txs/chain[/:last_seen_txid]` + `/txs/summary[/:last_seen_txid]` (dialect; next row). `/utxo` matches Electrum listunspent (mempool funding + drop mempool-spent confirmed); `/txs` and `/txs/mempool` use full Esplora tx JSON for mempool-only rows (wire from the hub). `?after_txid=` on `/txs` and `/txs/summary` skips through that tx (mempool then chain); unknown or unparseable → **422** `after_txid not found`. A mempool-only cursor past the first 50 mempool rows returns the confirmed page. `POST /addresses/txs` and `POST /scripthashes/txs` (and `/txs/summary`) take a JSON array (max **300**; over → **422** `body too long`); unique scripts join once per request and merge newest-first into one page of 25 confirmed rows (mempool rows stay in front on `/txs`, 50 per script). A later page walks past each script's first 25. Unix/loopback `X-Rbitcoin-Client`: last-1 GET join + last-bulk POST (16 MiB packed/client), 30s idle, 256 clients, singleflight on the same `(client, sh)`. Public TCP ignores the header (stack-local join). Concurrent different GET scripts replace last-1. Unbounded process LRU stays **X-M3**. Needs SH finalize. Stamp is visible SH (durable + pending write-behind), matching live tip while jobs sit in RAM. `?asof=<hash>` on `/`, `/utxo`, `/txs`, `/txs/chain`, `/txs/summary`: confirmed join at that ancestor **at or behind visible SH**, **no** mempool; headers are the asof hash; 404 if not on chain or ahead of visible SH. |
| `/txs/summary` | dialect | **Not** in Blockstream Esplora [`API.md`](https://github.com/Blockstream/esplora/blob/master/API.md). Compact `{txid, value, height, time, tx_position}` like mempool.space `/address/:addr/txs/summary`. Confirmed only (25/page, newest first, including a multi-script post of that same merged page); path cursor `/:last_seen_txid` and mempool.space `?after_txid=` (unknown → **422**). `value` is net sats for that script in that tx (funded − spent). `time` is the confirming header timestamp (`0` if the header is missing). `tx_position` is that tx's index in the block. Mempool rows stay on `/txs` and `/txs/mempool`. A page is served above `--max-sh-creates`; unpaged stats and `/utxo` still **503**. |
| Mempool / fees | done | `/mempool`, `/mempool/txids`, `/mempool/txids/page[/:last]`, `/mempool/recent` (accept-order ring), `/fee-estimates` (sat/vB at the estimator's 1 sat/kvB resolution; a target without an estimate is left out, and **503** `fee estimates unavailable` when none has one). mempool.space `/api/v1/fees/recommended` is its backend's surface, not served here (**404**) |
| `POST /tx` | done | broadcast via mempool hub; **503** if hub absent. `GET /broadcast?tx=` is the same admit path (legacy electrs). |
| `POST /tx` | done | broadcast via mempool hub; **503** if hub absent. `GET /broadcast` is not served (**404**). |
| `POST /txs/test` | done | JSON array of hex (max 25) → `MempoolHub::test_accept` (no admit). `?maxfeerate=` is BTC/kvB like electrs (`0` unlimited; omitted → 0.1 BTC/kvB). |
| `POST /txs/package` | done | JSON array of hex txs → `accept_package`; **503** without hub; max 25 txs |
| electrs `/internal/*` | done | **Unix listen only.** TCP GET/POST `/internal/…` → **404** (same unknown-path fallback). `POST /internal/txs` (400 on unparseable id; missing omitted); `POST /internal/mempool/txs` (mempool only); `GET /internal/mempool/txs[/all or /:last]` (txid-sort pages, default `max_txs=10000`; `/all` registered first); `GET /internal/block/:hash/txs` (full list; public `/txs` stays 25/page); `POST /internal/txs/outspends/by-txid` (same-length slots, unknown → `[]`); `POST /internal/txs/outspends/by-outpoint` (`txid:vout`; malformed → `{"spent":false}`). Snapshot JSON is published on the hub (no admit-path build). `GET /mempool/txids/page` is public electrs (both listeners). |
Expand Down
44 changes: 44 additions & 0 deletions changelog.d/bobberb-remote.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
Fixed

- **Orphan promotion walks one worklist.** The mempool walk and the relay
hub walk both admit a parked chain child then descendants, without one
stack frame per descendant. A parent published inside that walk, including
a one-parent package, releases its parked spends on the same list. A child
that still misses another parent stays parked. A package admits its own
members before that walk, including on the hub path used by
`submitpackage` and Electrum, so a parked child included in the package
is not rolled out of the mempool. A failed package puts that child back
under the parents it still does not have, keeps the peers who announced
it, and admits it when those parents are already in the mempool. That
admit uses the child's `prioritisetransaction` delta, so a fee under the
raw relay floor is not thrown away.
- **Electrum history refuses a window wider than 8,064 blocks.** A
`from_height` of 1 or more still scans that window and does not use
the scripthash create cap.
- **An Electrum response write uses the idle deadline.** A client that
stops reading loses the connection. A scripthash status push uses that
same deadline, and a timeout ends the connection.
- **An SV2 session goes idle after setup.** The setup timeout also
closes a client that sent one `CoinbaseOutputConstraints` and then
stopped sending. Tip and fee pushes do not refresh it.
- **A replacement may spend an unconfirmed input only from its one
direct conflict.** That parent stays a valid child-pays-for-parent.
Spending the conflict's own output is refused as
`bad-txns-spends-conflicting-tx`. Direct-set feerate is unchanged.
- **The invalid-txid set stops at 4,096.** A newer id does not clear
the ones already stored.
- **Onion and I2P misbehavior is remembered for a day.** The key is
the overlay identity, not the port. Loopback is still not recorded,
and nothing is written to `banlist.dat`. An outbound dial, including
a feeler and an IBD dial, is refused only for that key. A netgroup
that just lost an inbound slot still cannot take another inbound
slot, and it does not block dials to the rest of the prefix.
- **`GET /broadcast` is not served.** `POST /tx` remains the admit path.
- **A panicking script-pool chunk releases the wave.** `wait_done`
returns, and the worker keeps running.
- **IBD plants a stored sibling of an invalid tip block.** The header
store supplies the parent when the work path has not recorded it yet,
so download continues onto that sibling.
- **IBD retries a tip child after BadPrev.** A block whose parent is the
current tip is fetched again when confirm reports a missing previous
header.
95 changes: 70 additions & 25 deletions crates/rbitcoin-consensus/src/script_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,31 +71,30 @@ impl Wave {
unpark_script_publisher();
}

fn claim_chunk(&self) -> Option<Range<usize>> {
if self.failed.load(Ordering::Acquire) {
fn claim_chunk(wave: &Arc<Wave>) -> Option<(Range<usize>, InWave)> {
if wave.failed.load(Ordering::Acquire) {
return None;
}
// `in_wave` before `next`: a last-chunk claimer is visible to
// `is_complete` before `next >= n`, so the publisher cannot free ctx
// under a worker about to `apply`.
self.in_wave.fetch_add(1, Ordering::AcqRel);
if self.failed.load(Ordering::Acquire) {
self.in_wave.fetch_sub(1, Ordering::AcqRel);
self.notify_if_complete();
wave.in_wave.fetch_add(1, Ordering::AcqRel);
let hold = InWave {
wave: Arc::clone(wave),
};
if wave.failed.load(Ordering::Acquire) {
return None;
}
let chunk = self.chunk.max(1);
let i = self.next.fetch_add(chunk, Ordering::Relaxed);
if i >= self.n {
self.in_wave.fetch_sub(1, Ordering::AcqRel);
self.notify_if_complete();
let chunk = wave.chunk.max(1);
let i = wave.next.fetch_add(chunk, Ordering::Relaxed);
if i >= wave.n {
return None;
}
#[cfg(test)]
if STEAL_CLAIMS_ON.load(Ordering::Relaxed) {
STEAL_CLAIMS.fetch_add(1, Ordering::Relaxed);
}
Some(i..self.n.min(i.saturating_add(chunk)))
Some((i..wave.n.min(i.saturating_add(chunk)), hold))
}

fn is_complete(&self) -> bool {
Expand All @@ -121,8 +120,6 @@ impl Wave {
break;
}
}
self.in_wave.fetch_sub(1, Ordering::AcqRel);
self.notify_if_complete();
}

fn has_unclaimed(&self) -> bool {
Expand Down Expand Up @@ -191,19 +188,45 @@ fn publish_waves(waves: &[Arc<Wave>]) {
waves_snap().store(Arc::new(waves.to_vec()));
}

/// Drops `in_wave` on the success path and on a panic inside the chunk.
struct InWave {
wave: Arc<Wave>,
}

impl Drop for InWave {
fn drop(&mut self) {
self.wave.in_wave.fetch_sub(1, Ordering::AcqRel);
self.wave.notify_if_complete();
}
}

/// Lock-free claim: load the published wave list. Must not lock [`WAVES`].
fn steal_chunk() -> Option<(Arc<Wave>, Range<usize>)> {
let claimed = waves_snap()
.load()
.iter()
.find_map(|w| w.claim_chunk().map(|range| (Arc::clone(w), range)));
fn steal_chunk() -> Option<(Range<usize>, InWave)> {
let claimed = waves_snap().load().iter().find_map(Wave::claim_chunk);
#[cfg(test)]
if claimed.is_some() {
maybe_delay_claim();
}
claimed
}

fn run_held(range: Range<usize>, hold: InWave) {
let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
hold.wave.run_chunk(range);
}));
if panicked.is_err() {
hold.wave.failed.store(true, Ordering::Release);
let mut g = hold
.wave
.first_err
.lock()
.unwrap_or_else(|p| p.into_inner());
if g.is_none() {
*g = Some(ConsensusError::Internal("script worker panic"));
}
}
}

/// True when steal workers can still claim a foreground job.
pub fn fg_has_unclaimed() -> bool {
waves_snap().load().iter().any(|w| w.has_unclaimed())
Expand All @@ -217,10 +240,10 @@ pub fn help_steal() -> bool {
if on_steal_worker() {
return false;
}
let Some((w, range)) = steal_chunk() else {
let Some((range, hold)) = steal_chunk() else {
return false;
};
w.run_chunk(range);
run_held(range, hold);
true
}

Expand Down Expand Up @@ -385,16 +408,16 @@ fn take_detached_job(pool: &ScriptWorkers) -> Option<Job> {
}

fn steal_or_job(pool: &ScriptWorkers) -> bool {
if let Some((w, range)) = steal_chunk() {
w.run_chunk(range);
if let Some((range, hold)) = steal_chunk() {
run_held(range, hold);
return true;
}
if let Some(job) = take_detached_job(pool) {
job();
return true;
}
if let Some((w, range)) = steal_chunk() {
w.run_chunk(range);
if let Some((range, hold)) = steal_chunk() {
run_held(range, hold);
return true;
}
false
Expand Down Expand Up @@ -1058,6 +1081,28 @@ mod tests {
}
}

/// A panicking chunk must not leave `in_wave` elevated. `wait_done` returns
/// inside the test's own bound.
#[test]
fn chunk_panic_releases_the_wave() {
let _gate = STEAL_TEST.lock().unwrap_or_else(|p| p.into_inner());
fn boom(_: &u32) -> Result<(), ConsensusError> {
panic!("chunk panic");
}
let wave = start_for_each_owned((0..64u32).collect(), boom)
.unwrap()
.expect("wave");
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let failed = wave.finish().is_err();
let _ = tx.send(failed);
});
let failed = rx
.recv_timeout(Duration::from_secs(2))
.expect("wait_done after a chunk panic");
assert!(failed, "chunk panic is an error");
}

/// After `in_wave++` and before `next += chunk`, `is_complete` must be
/// false (publisher must not free ctx under the claimer).
#[test]
Expand Down
Loading
Loading