Repository navigation
Restore post-v0.6.0 coverage and IBD recover resume - #441
Conversation
There was a problem hiding this comment.
Verdict: request changes (posted as a comment — the App token authored this PR and cannot formally request changes on it).
Reviewed at b7efcde6 against origin/master (27103190). Required checks are green. Two of the changes need to come out or be reworked before this merges; the rest is fine.
Blocking
1. DrainOnDrop fail-open reintroduces the use-after-free #425 closed
crates/rbitcoin-store/src/uring_session.rs:1014-1018 switches DrainOnDrop::drop to drain_all_inner(false). The PR body justifies this as matching UringSession::Drop, but the two guards protect different things.
DrainOnDrop exists to drain before SQE buffers drop (its own doc, and drain_all's doc at 730-735). Both users declare it after the buffers:
spend_annotate_uring.rs:546-547—slots: Vec<Option<PageSlot>>(each holdsbuf: Vec<u8>) thenlet mut session = session.drain_guard();spend_annotate_uring.rs:85-86— same shape withSlot.
Drop order is reverse declaration: the guard drops first, slots immediately after. On master a zero-CQE hard cap inside that drain calls abort_uring_unusable while the buffers are still pinned. With this PR it sets poisoned, logs, returns Err (swallowed by let _), and slots free the buffers while the kernel still owns the SQEs pointing at them. Then with_thread_local at 1138-1143 sees in_flight() != 0 and calls fail-closed drain_all(), which hits the hard cap and aborts anyway — roughly 120 s after the UAF window opened. So the change does not remove the abort; it moves it past the point where it was protecting memory.
UringSession::Drop is fail-open because the machines that own buffers have already drained (or aborted) by the time the TLS slot is torn down; the session does not own any buffers. That asymmetry was deliberate in #425 and is what docs/io-modality.md said before this PR edited it.
The new test drain_guard_hard_cap_does_not_abort does not pin the change: apply_drain_window (789-801) sets poisoned = true before finish_drain_hard_cap, and under cfg(test) finish_drain_hard_cap returns Err instead of aborting. I ran the test against master's DrainOnDrop (drain_all()) with everything else from this branch: test uring_session::tests::drain_guard_hard_cap_does_not_abort ... ok. It is not a Red test.
Fix: revert DrainOnDrop::drop to drain_all(), drop the test, and revert the docs/io-modality.md and CHANGELOG lines that describe the new behaviour. If "no abort from a guard" is a real requirement, the only memory-safe alternative is to leak the slot buffers on poison, which is a design change that belongs in its own PR.
2. IBD half of #421 P1-1 is not fixed; the new branch is unreachable and the requeue is dropped downstream
crates/rbitcoin-net/src/ibd/confirm/mod.rs:1532 adds confirm_write_err_action so a session fault wins over has_block. Two things stop it from doing anything in the scenario dpc described (Class C committed, then spend annotate or tx.head drain faults):
- The stale gate runs first. In the
Errarm, line 1520write_batch_is_stale_plan(&hub_wb, &feed_wb, height)executes before the new match. After Class C commits, tip ≥ every height in the batch, sofirst_h != tip + 1and the batch is dropped as stale (feed.finish,continue). TheUringRecover/AlreadyCommittedarms are never entered for a post-Class-C fault. No recover credit is taken, nonote_confirmed_tip, noblock_queue_dequeue_height, noAcceptedevents. - Even if reached, the requeue is discarded.
requeue_hashesputs the heights back inready. The load thread at 1753-1757 doesif hub_load.has_block(&hash) { g.ready.remove(&h); continue; }, and the write thread at 1439 gates onwrite_batch_is_stale_planbefore callingconfirm_write_phase. So theAllOld → finish_post_commit_hashesaddition inwrite.rs:60-64is never reached from the IBD pipeline. The only pipeline callers that can hitAllOldareconfirm_wire_run_preverifiedandChainHub::confirm_write.
confirm_write_session_fault_recovers_even_when_has_block tests the pure decision function, not the pipeline, which is why it is green.
What actually happens today for a post-Class-C fault in IBD: tx.head self-heals (the head_note_pending restore is picked up by the next batch's take_pending_queued), but spend annotations for that batch are lost in tip index mode, and the body-queue entries for those heights are never dequeued.
Fix: the write thread should own the retry, the same way connect_at does for tip. In the Err arm, classify is_uring_session_fault before the stale check. If Class C already committed (is_connected on every hash — not has_block, which is RAM-cache-or-confirmed-set and documented at chain.rs:606-610 as not meaning "on chain"), take recover credit and call a pub finish_post_commit_hashes(query, &hashes) in place, looping on further session faults; then run the normal Ok bookkeeping (note_confirmed_tip, dequeue, Accepted, finish). Requeue through lookup/load cannot work for already-connected heights. Pin it with a pipeline test that injects a post-Class-C session fault and asserts annotate completes, Accepted is emitted, and the BQ entry is dequeued — not a table test on the enum.
Non-blocking
write.rs:299annotate_jobs_from_connected_hashdoes not check thathashis connected at its height; it trustsget_header_by_hash+header_txs.get_list. Reads are still safe becauseis_confirmed_strong_atis the authority, but a stale-fork hash reachingAllOldwould stamp a non-active spender and flip a sole spender toMULTI_SPENDERwhen the real spend lands. Take(height, hash)and requireheight_of_hash(hash) == Some(height)so the name is honest.decide_annotate(spend_annotate_uring.rs:325-327) is idempotent for the sole-spender case (field == spend_fk → Skip) but appends unconditionally in the multi case. A repeatedfinish_post_commiton an already-multi output appends a duplicate list node. Rare (multi needs a fork double-spend), worth a Skip whenspend_fkis already in the list.finish_post_commit_hashesalways submits a head-drain job even whenqueuedis empty and the spend index is off. Cheap, but it is on the tip retry path; an early return when both are empty is one line.LoadAheadState::on_uring_recoverdoc says "before requeue"; the call at 1995 is afterrequeue_on_uring_recover. Same thread, same effect, but the comment should match.batch_pwrite_n/max_batch_pwrite_nare production fields bumped on everypush_pwritewith only acfg(test)reader. There is precedent (max_pwrite_len,sqe_n), so not blocking, but it is a test observer on the hot path.- CHANGELOG "IBD session-fault resume" claims the
has_blockrequeue "retries annotate+drain"; per (2) it does not. Reword once the write-thread retry lands.
Verified as claimed
accept_and_connect_block_preverifiedretry now runsfinish_post_commit(tip side of P1-1).already_at_height_retries_post_commit_spend_annotateis Red on master (left: Fk(0) right: Fk(2)), Green here.connect_at's recover loop atchain.rs:2042-2057re-enters it correctly.- P1-2:
on_uring_recover→clear_allsits afternote_lookup_ok(1889) and before the requeue-driven retry; the stamp-fault path (1833) does not need it becausenote_lookup_okhas not run yet. - Mid-wave
begin_batchfalse, Class A ≥3-SQE batch pin, owned-wave first-error, scripts publisher thread, 1p1cremove_txid_tree, RPCmaxfeerateoverflow: test additions read correctly and the production deltas are small and match their tests.
Session max_batch_pwrite_n is the IO-shape observer (TLS slot reader after with_thread_local). Do not restore test_take_pwrite_waves.
begin_batch fail on a live session is this-batch fallback only. Setup open fail still stores URING_MODE=2. No TEST_FORCE_SESSION_FALSE.
Explicit drain_all stays fail-closed. A hard cap while a drain guard is live poisons and returns Corrupt in tests; it does not abort.
…rain Class C can commit, then post_commit or tx.head drain session-fault. height_of_hash matching is not write-finished; post_commit is idempotent.
Write session-fault requeues even when has_block so annotate+drain can finish. Load recover clears speculative create FKs inline; sync_body_hwm max would not roll next_tx_start down.
remove_txid(parent) left a concurrent spender of the below-min-relay parent in the mempool. Rollback walks that parent txid's spenders.
IBD WAVES use start_for_each_owned. confirm_scripts_phase and the drive_script_waves publisher are not rbtc-scripts steal workers.
checked_add/sub: overflow is over-cap, missing prevout still skips the cap so accept_tx can report missing-inputs.
Align io-modality with session Drop. CHANGELOG Unreleased names the #421 resume, 1p1c rollback, and RPC fee overflow pins.
The guard drops before SQE buffers. Fail-open on hard cap frees those buffers while the kernel still owns the SQEs. Session Drop stays fail-open; it does not own buffers.
Session-fault after Class C is connected: take recover credit and call finish_post_commit_hashes in place. Requeue is stale vs tip and dropped by lookup. Annotate only hashes whose height_of_hash matches; multi-list repeat of the same spend_fk is Skip.
b7efcde to
988fc62
Compare
|
Addressed in 988fc62. Blocking 1 (DrainOnDrop): reverted to fail-closed Blocking 2 (IBD P1-1): the write thread now classifies Non-blocking: annotate jobs require CodeQL on |
start_for_each_owned publishes WAVES. Running it without the steal gate raced last_claim_holds_in_wave_before_next in CI.
There was a problem hiding this comment.
Summary
PR #441 restores post-v0.6.0 coverage pins and tries to close #421 recover holes: Class A max-batch pwrite observer, mid-wave session-false, DrainOnDrop fail-open, already-at-height finish_post_commit, IBD write has_block requeue, load clear_all after note_lookup_ok, 1p1c spender eviction, scripts first-error / publisher thread, and RPC maxfeerate overflow fail-closed. Prior review already covers the two blockers (DrainOnDrop UAF vs #425, and IBD P1-1 confirm_write_err_action unreachable behind the stale-plan gate / has_block skip). This pass found one additional issue: 1p1c rollback now evicts already-published concurrent spenders from the graph but does not run the hub unindex / template path that every other live eviction uses.
Issue counts by severity
- bugs: 1
- suggestions: 0
- nits: 0
| fn rollback_1p1c_parent(&self, txid: &Txid) { | ||
| let mut g = self.lock_write(); | ||
| let _ = g.remove_txid(txid); | ||
| g.remove_txid_tree(txid); |
There was a problem hiding this comment.
[bug] rollback_1p1c_parent now calls remove_txid_tree, which evicts live graph txs that spend the below-min-relay parent. That is the right graph fix for the window described in admit_1p1c (parent is committed and the write lock is dropped before child script/commit; a concurrent accept of a spender can publish_admitted in that window). Those concurrent spenders are already in hub relay maps, sh_index, unbroadcast, fee-deltas, and the template. remove_for_block_spent (tx_relay.rs:1734-1742) and expiry (tx_relay.rs:872-880) both unindex_txid + note_template_update after evict_conflicts_with / remove_txid. This rollback does neither. remove_txid_tree also drops the Vec<Txid> that evict_conflicts_with returns (accept.rs:1099), so the hub cannot unindex even if it wanted to. Result: INV/getdata and gettxspendingprevout can still name a tx that is gone from the mempool graph; the template can keep the evicted spender. The new test only asserts graph.contains. The parent itself was never published, so unindexing the parent is a no-op — the leak is the newly evicted published spenders.
Suggestion: Return the evicted txids from remove_txid_tree (or unroll evict+remove at the hub). In rollback_1p1c_parent, drop the mempool write lock, then for each gone spender (not the unpublished parent) call unindex_txid, remove fee-deltas, and note_template_update — same shape as remove_for_block_spent. Extend the pin so a hub-level 1p1c child-fail with a concurrent spender leaves wtxid_by_txid / sh_index empty for that spender.
There was a problem hiding this comment.
Addressed in f43faf8. remove_txid_tree returns the dropped txids. rollback_1p1c_parent drops the mempool write lock then unindex_evicted, the same helper remove_for_block / remove_for_block_spent use (relay maps, sh_index, fee-deltas, template). Pin: rollback_1p1c_unindexes_published_spenders (paying sibling of a live 1p1c parent; relay_seq_of / accept_time_txid empty after rollback).
remove_txid_tree returns the dropped txids. Hub rollback and remove_for_block_spent share unindex_evicted (relay maps, sh_index, fee-deltas, template) after dropping the mempool write lock.
Closes nothing; cites #421 (IBD stall / recover resume). Do not merge until required checks are green.
Why
v0.6.0 → master dropped a few coverage pins and left two recover holes that dpc’s review of #425 named as P1:
height_of_hash/ IBDhas_blocktreated the write as finished. A session fault on spend annotate ortx.headdrain skipped retry of those steps (connect_at’s fault loop no-op’d).note_lookup_okadvancednext_tx_startandin_flightbeforeconfirm_wire_load_from_plan. Session-fault recover requeued hashes withoutclear_all;sync_body_hwmusesmaxand will not roll a speculative HWM down.Also restore: Class A one ≥3-op write batch observer, mid-wave session-false must not store
URING_MODE=2,DrainOnDropmatches session Drop (no abort), 1p1c child-fail rollback evicts spenders of the parent, owned-wave first-error, scripts publisher thread, RPCmaxfeerateoverflow fail-closed.What
UringSessionmax-batch pwrite SQEs;put_full_batch_one_body_write_waveasserts ≥3 when uring is on. Notest_take_pwrite_waves.begin_batchis this-batch fallback only.drain_all_inner(false)likeUringSession::Drop. Explicitdrain_allstays fail-closed.post_commit+ head drain. IBD write session-fault requeues even whenhas_block.LoadAheadState::clear_allinline before requeue.remove_txid_treeevicts the parent and mempool txs that spend it.start_for_each_ownedfirst-error pin; publisher /confirm_scripts_phaseare notrbtc-scripts-*.Compact fill/duplicate reconstruct was already in
rbitcoin-net. DrainBudget trickle-vs-stall was already pinned on master.Out of scope
Clippy allow-list batches, recover credit gap (1000), leftover index refuse (#422),
--sptweaks-dustdefault,--rpcworkqueuedefault, musl, merge.