From 3d4d31463943428c2559afb36f719c343d775508 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Fri, 2 Oct 2026 09:28:28 -0700 Subject: [PATCH 1/7] store: pack-complete scripthash heads use a .packed mark Reopen treated a pass-1 MPHF as a sealed durable head, so restart skipped pass 2 and multi-script history never landed. The pack commit is now scripthash.head/NN.packed. Open loads a shard only when that mark exists. A finished unmarked head with no extract in progress is soft-migrated. Schema refuse still uses index occupancy. --- SCHEMA.md | 7 +- changelog.d/sh-pack-resume.md | 3 + crates/rbitcoin-query/src/query_tests.rs | 6 +- crates/rbitcoin-store/src/scripthash.rs | 139 ++++++++++++- .../src/scripthash_materialize.rs | 4 +- crates/rbitcoin-store/src/scripthash_tests.rs | 194 +++++++++++++++++- crates/rbitcoin-store/src/store.rs | 8 +- docs/concurrency.md | 2 +- docs/crash-recovery.md | 2 +- docs/operator/storage.md | 41 ++-- 10 files changed, 365 insertions(+), 41 deletions(-) create mode 100644 changelog.d/sh-pack-resume.md diff --git a/SCHEMA.md b/SCHEMA.md index 615572692..235bcd62c 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -267,7 +267,7 @@ itself changed. scripthash.body # 17 file variant: one shared TableFile scripthash.body/NN # 17 dir variant: one TableFile per main shard scripthash.ovf/body # dir variant: ingest + all sealed ovf - scripthash.head/NN.mphf + NN.val # Class B sealed MPHF main (8 B pack8; no fuse) + scripthash.head/NN.mphf + NN.val + NN.packed # Class B MPHF main; `.packed` is the pack commit (pass-1 BDZ has no mark) scripthash.ovf/ingest # global OA ingest (key16+pack8, 2^25) scripthash.ovf/NNNNNN[.fuse8][.idx] # L0 SHSR pack8 scripthash.ovf/NNNNNN.mphf|.val|.fuse8 # L1 promoted ovf (at most one) @@ -798,7 +798,10 @@ the directory variant. A leftover file `scripthash.body` **refuses**. New `Store::create` writes the dir variant. ColdProgress `SHCOLDP1`: `next_shard` is the **lowest unsealed** main shard (holes after it -stay); sealed `scripthash.head/NN.mphf`+`.val` is the per-shard commit. Overflow +stay). The per-shard pack commit is `scripthash.head/NN.packed` next to +`.mphf`+`.val`. Pass-1 BDZ writes the MPHF and is not that mark. A complete +head with no extract in progress and no marks is soft-migrated on open +(marks written; missing `include_hwm` set from the create count). Overflow compact still merges **heads only** — all ovf keys share `scripthash.ovf/body`. diff --git a/changelog.d/sh-pack-resume.md b/changelog.d/sh-pack-resume.md new file mode 100644 index 000000000..92b356ffb --- /dev/null +++ b/changelog.d/sh-pack-resume.md @@ -0,0 +1,3 @@ +Fixed + +- A scripthash pass-1 MPHF without `scripthash.head/NN.packed` is not a durable head. Restart after `DONE.keys` resumes pass 2 and pack instead of reporting Electrum-ready on an index that has no multi-script history. A finished unmarked head is soft-migrated. diff --git a/crates/rbitcoin-query/src/query_tests.rs b/crates/rbitcoin-query/src/query_tests.rs index cc88f5f4f..0714af929 100644 --- a/crates/rbitcoin-query/src/query_tests.rs +++ b/crates/rbitcoin-query/src/query_tests.rs @@ -624,13 +624,17 @@ fn sh_writebehind_recover_requeues_unapplied_heights() { ); let hash0 = h0.hash; q.connect_block(Height(0), &h0, &[t0]).unwrap(); + q.finalize_sh_runs().unwrap(); + assert!( + q.store().scripthash.has_durable_index(), + "write-behind recover requires a pack-complete head" + ); let prev_fk = q.tip_header_fk().unwrap().unwrap(); let (mut h1, t1) = coinbase_block(1, prev_fk, Some(hash0)); h1.merkle_root = t1.tx.txid; rehash_header(&mut h1, &hash0); q.commit_class_a_only(&h1, &[t1]).unwrap(); q.confirm_block(Height(1), &h1.hash).unwrap(); - assert_eq!(q.sh_indexed_through_height(), Some(0)); q.store().flush_class_c_tip().unwrap(); drop(q); let q = Query::open_or_create_tiny(dir.path()).unwrap(); diff --git a/crates/rbitcoin-store/src/scripthash.rs b/crates/rbitcoin-store/src/scripthash.rs index 3b401c635..e8eb28660 100644 --- a/crates/rbitcoin-store/src/scripthash.rs +++ b/crates/rbitcoin-store/src/scripthash.rs @@ -296,6 +296,8 @@ pub struct ScriptHashTable { l1_frozen_warned: AtomicBool, /// At least one sealed sorted main shard is installed. sorted_main_on: std::sync::atomic::AtomicBool, + /// Pack commit per shard. True only after `scripthash.head/NN.packed` is durable. + pack_marked: Box<[AtomicBool]>, /// One alloc per `bodies` entry (Shared: len 1). allocs: Vec>, /// Dir-variant ovf alloc. Shared: `None` (ovf uses `allocs[0]`). @@ -399,6 +401,83 @@ pub(crate) fn sorted_main_shard_path(dir: &Path, shard: usize, n_shards: usize) } } +/// Pack commit sidecar. Pass-1 BDZ writes `.mphf`+`.val` and is not this file. +pub(crate) fn shard_pack_mark_path(base: &Path) -> PathBuf { + let mut s = base.as_os_str().to_os_string(); + s.push(".packed"); + PathBuf::from(s) +} + +fn write_shard_pack_mark(base: &Path) -> Result<(), StoreError> { + let p = shard_pack_mark_path(base); + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent).map_err(|e| StoreError::io(parent, e))?; + } + let mut tmp_s = p.as_os_str().to_os_string(); + tmp_s.push(".tmp"); + let tmp = PathBuf::from(tmp_s); + std::fs::write(&tmp, [1u8]).map_err(|e| StoreError::io(&tmp, e))?; + { + let f = std::fs::OpenOptions::new() + .write(true) + .open(&tmp) + .map_err(|e| StoreError::io(&tmp, e))?; + f.sync_all().map_err(|e| StoreError::io(&tmp, e))?; + } + std::fs::rename(&tmp, &p).map_err(|e| StoreError::io(&p, e))?; + Ok(()) +} + +fn unlink_shard_pack_mark(base: &Path) { + let p = shard_pack_mark_path(base); + let _ = std::fs::remove_file(&p); + let mut tmp_s = p.as_os_str().to_os_string(); + tmp_s.push(".tmp"); + let _ = std::fs::remove_file(PathBuf::from(tmp_s)); +} + +/// `scripthash.unsorted` still has a pass-1 or pass-2 phase. Matches +/// `UNSORTED_SHARD_DIR` in the materialize module (that module depends on this one). +fn sh_unsorted_extract_in_progress(dir: &Path) -> bool { + let u = dir.join("scripthash.unsorted"); + if !u.is_dir() { + return false; + } + if u.join("DONE.keys").is_file() || u.join("DONE.post").is_file() { + return true; + } + u.join("keys").exists() || u.join("post").exists() +} + +/// Complete pre-marker heads (unsorted gone, every shard has MPHF+val) get +/// `.packed`. An extract still on disk does not, even when `include_hwm` is at the tip. +fn migrate_legacy_pack_marks( + dir: &Path, + n_shards: usize, + create_count: u64, +) -> Result<(), StoreError> { + if sh_unsorted_extract_in_progress(dir) { + return Ok(()); + } + let n = n_shards.max(1); + let bases: Vec = (0..n).map(|i| sorted_main_shard_path(dir, i, n)).collect(); + if !bases.iter().all(|b| MphfHead::exists(b)) { + return Ok(()); + } + let mut wrote = false; + for b in &bases { + if shard_pack_mark_path(b).is_file() { + continue; + } + write_shard_pack_mark(b)?; + wrote = true; + } + if wrote && load_include_hwm(dir) == 0 && create_count > 0 { + store_include_hwm(dir, create_count)?; + } + Ok(()) +} + fn open_sorted_main_shards( dir: &Path, n_shards: usize, @@ -407,7 +486,9 @@ fn open_sorted_main_shards( let mut out = Vec::with_capacity(n); for i in 0..n { let p = sorted_main_shard_path(dir, i, n); - if MphfHead::exists(&p) { + // Pass-1 BDZ exists without `.packed`. Leave the slot unsealed so pass 2 + // can `MphfHead::open` by path. + if MphfHead::exists(&p) && shard_pack_mark_path(&p).is_file() { out.push(Some(MphfHead::open(&p)?)); } else { out.push(None); @@ -795,6 +876,10 @@ impl ScriptHashTable { ovf_l1: Mutex::new(None), l1_frozen_warned: AtomicBool::new(false), sorted_main_on: std::sync::atomic::AtomicBool::new(false), + pack_marked: std::iter::repeat_with(|| AtomicBool::new(false)) + .take(n_shards) + .collect::>() + .into_boxed_slice(), allocs, ovf_alloc: Some(Mutex::new(ovf_st)), page_ios: AtomicU64::new(0), @@ -850,10 +935,22 @@ impl ScriptHashTable { let ovf_body = Some(ovf); let ovf_alloc = Some(Mutex::new(ost)); wipe_legacy_fullsize_overflow(dir)?; + let mut create_count = 0u64; + for a in &allocs { + create_count = create_count.saturating_add(a.lock().unwrap().live_count); + } + create_count = + create_count.saturating_add(ovf_alloc.as_ref().unwrap().lock().unwrap().live_count); + migrate_legacy_pack_marks(dir, n_shards, create_count)?; let sorted_main = open_sorted_main_shards(dir, n_shards)?; let sealed_ovf = open_sealed_sorted_ovf(dir)?; let ovf_l1 = open_ovf_l1(dir)?; let sorted_on = sorted_main.iter().any(|s| s.is_some()); + let pack_marked: Box<[AtomicBool]> = sorted_main + .iter() + .map(|s| AtomicBool::new(s.is_some())) + .collect::>() + .into_boxed_slice(); let table = Self { store_dir: dir.to_path_buf(), layout, @@ -867,6 +964,7 @@ impl ScriptHashTable { ovf_l1: Mutex::new(ovf_l1), l1_frozen_warned: AtomicBool::new(false), sorted_main_on: std::sync::atomic::AtomicBool::new(sorted_on), + pack_marked, allocs, ovf_alloc, page_ios: AtomicU64::new(0), @@ -874,7 +972,7 @@ impl ScriptHashTable { // v1 = schema-13 slabs; v2 = schema-14 pages; v3 = schema-15 slabs. // Field layout is the same; only an empty older header upgrades silently. if alloc_ver != SH_ALLOC_VERSION { - if table.has_durable_index() { + if table.has_index_occupancy() { return Err(StoreError::Corrupt( "scripthash alloc is a pre-schema-15 body; wipe store/scripthash* (head, body, ovf, runs, include_hwm, cold_progress) and rematerialize", )); @@ -1039,8 +1137,9 @@ impl ScriptHashTable { /// Wipe body alloc + all head slots for a full cold rematerialize. /// /// Used when runs/`*.run.mat` still hold the complete create set after a - /// partial/crashed bulk load. Does not delete files — resets in place so - /// open table handles stay valid. Exclusive: no concurrent SH readers/writers. + /// partial/crashed bulk load. Resets body alloc in place and unlinks + /// `.packed` so the emptied head is not pack-complete. MPHF files stay + /// until the next seal overwrites them. Exclusive: no concurrent SH readers/writers. /// /// Must run whenever claims are about to cold-load, not only when /// `entry_count > 0`: crash mid-finish can leave head shards occupied while @@ -1052,6 +1151,13 @@ impl ScriptHashTable { } self.sorted_main_on .store(false, std::sync::atomic::Ordering::Release); + for b in self.pack_marked.iter() { + b.store(false, Ordering::Release); + } + let n = self.n_shards; + for i in 0..n { + unlink_shard_pack_mark(&sorted_main_shard_path(&self.store_dir, i, n)); + } Ok(()) } @@ -1090,6 +1196,10 @@ impl ScriptHashTable { let _ = std::fs::remove_file(&p); let _ = std::fs::remove_file(scripthash_mphf::mphf_path(&p)); let _ = std::fs::remove_file(scripthash_mphf::val_path(&p)); + unlink_shard_pack_mark(&p); + if let Some(b) = self.pack_marked.get(i) { + b.store(false, Ordering::Release); + } let mut idx = p.clone().into_os_string(); idx.push(".idx"); let _ = std::fs::remove_file(idx); @@ -1148,8 +1258,16 @@ impl ScriptHashTable { store_include_hwm(self.store_dir(), max_create_fk) } - /// True if durable head has any occupancy or live creates (protect from wipe). + /// True when every main shard's pack mark is durable. + /// + /// Pass-1 BDZ, ingest creates, and overflow are not a pack-complete head. + /// Schema refuse uses [`Self::has_index_occupancy`]. pub fn has_durable_index(&self) -> bool { + !self.pack_marked.is_empty() && self.pack_marked.iter().all(|b| b.load(Ordering::Acquire)) + } + + /// Live creates, a loaded head, ingest, or sealed overflow. Not pack-complete. + pub fn has_index_occupancy(&self) -> bool { if self.entry_count() > 0 || !self.head_is_empty() { return true; } @@ -2624,6 +2742,7 @@ impl ScriptHashTable { }; write_alloc_header(body, &state)?; *self.shard_alloc(shard).lock().unwrap() = state; + self.note_shard_packed(shard)?; Ok(()) } @@ -2660,8 +2779,18 @@ impl ScriptHashTable { }; write_alloc_header(body, &state)?; *self.shard_alloc(shard).lock().unwrap() = state; + self.note_shard_packed(shard)?; Ok(bump) } + + fn note_shard_packed(&self, shard: usize) -> Result<(), StoreError> { + let path = sorted_main_shard_path(&self.store_dir, shard, self.n_shards); + write_shard_pack_mark(&path)?; + if let Some(b) = self.pack_marked.get(shard) { + b.store(true, Ordering::Release); + } + Ok(()) + } } /// Live-OA bulk writer for cold SH materialize. diff --git a/crates/rbitcoin-store/src/scripthash_materialize.rs b/crates/rbitcoin-store/src/scripthash_materialize.rs index e76ae207e..360ea9ac5 100644 --- a/crates/rbitcoin-store/src/scripthash_materialize.rs +++ b/crates/rbitcoin-store/src/scripthash_materialize.rs @@ -2015,7 +2015,9 @@ pub fn materialize_sh_unsorted_from_class_a( }); } - if table.head_is_empty() { + // Pass-1 sets `live_count` and leaves MPHF on disk without loading it, so + // `head_is_empty` is true. Reinit would drop write-behind body rows. + if table.head_is_empty() && table.entry_count() == 0 { table.reinit_empty_for_cold_materialize()?; } diff --git a/crates/rbitcoin-store/src/scripthash_tests.rs b/crates/rbitcoin-store/src/scripthash_tests.rs index 36b3a0389..db63eebcb 100644 --- a/crates/rbitcoin-store/src/scripthash_tests.rs +++ b/crates/rbitcoin-store/src/scripthash_tests.rs @@ -940,7 +940,8 @@ fn open_durable_alloc_v1_refused() { { let t = ScriptHashTable::create_tiny(&dir).unwrap(); put_create(&t, rec(script_hash(&[0x99]), 7, 0)); - assert!(t.has_durable_index()); + assert!(t.has_index_occupancy()); + assert!(!t.has_durable_index()); t.flush().unwrap(); } let body_path = dir.join("scripthash.body").join("00"); @@ -2764,6 +2765,197 @@ fn unsorted_cancel_before_collect_is_cancelled() { } } +#[test] +fn pass1_mphf_is_not_a_durable_head_on_reopen() { + let dir = tmp(); + let s = crate::Store::create_tiny(&dir).unwrap(); + let script = vec![0x51]; + s.put_tx_full_batch_indexed(&[class_a_coinbase([1u8; 32], script.clone())], true) + .unwrap(); + s.put_tx_full_batch_indexed(&[class_a_coinbase([2u8; 32], script.clone())], true) + .unwrap(); + let n_shards = s.scripthash.head_shard_count(); + let udir = crate::unsorted_shard_dir(s.path()); + crate::collect_unsorted_shard_files(&s, &udir, n_shards, 1, None).unwrap(); + assert!(udir.join("DONE.keys").is_file()); + assert!(!udir.join("DONE.post").is_file()); + drop(s); + let s = crate::Store::open_tiny(&dir).unwrap(); + assert!( + !s.scripthash.has_durable_index(), + "pass-1 mphf is not a durable head" + ); + assert!( + !s.scripthash.unsealed_main_shards().is_empty(), + "pass-1 mphf must stay unsealed until pack" + ); + crate::materialize_sh_unsorted_from_class_a(&s, 1, 1, None).unwrap(); + let sh = script_hash(&script); + let mut fks: Vec = s + .scripthash + .entries(&sh) + .unwrap() + .into_iter() + .map(|e| e.0 .0) + .collect(); + fks.sort_unstable(); + assert_eq!(fks, vec![1, 2]); + assert!(s.scripthash.has_durable_index()); + let base = sorted_main_shard_path(s.path(), 0, s.scripthash.head_shard_count()); + assert!(shard_pack_mark_path(&base).is_file()); + let again = crate::materialize_sh_unsorted_from_class_a(&s, 1, 1, None).unwrap(); + assert_eq!(again.keys, 0, "packed head must not collect again"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn pass1_lying_include_hwm_still_resumes_pass2() { + let dir = tmp(); + let s = crate::Store::create_tiny(&dir).unwrap(); + let script = vec![0x51]; + s.put_tx_full_batch_indexed(&[class_a_coinbase([1u8; 32], script.clone())], true) + .unwrap(); + s.put_tx_full_batch_indexed(&[class_a_coinbase([2u8; 32], script.clone())], true) + .unwrap(); + let n_shards = s.scripthash.head_shard_count(); + let udir = crate::unsorted_shard_dir(s.path()); + crate::collect_unsorted_shard_files(&s, &udir, n_shards, 1, None).unwrap(); + store_include_hwm(s.path(), 99).unwrap(); + drop(s); + let s = crate::Store::open_tiny(&dir).unwrap(); + assert!(!s.scripthash.has_durable_index()); + assert_eq!(s.scripthash.include_hwm(), 99); + crate::materialize_sh_unsorted_from_class_a(&s, 1, 1, None).unwrap(); + let mut fks: Vec = s + .scripthash + .entries(&script_hash(&script)) + .unwrap() + .into_iter() + .map(|e| e.0 .0) + .collect(); + fks.sort_unstable(); + assert_eq!(fks, vec![1, 2]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn legacy_unmarked_head_soft_migrates_without_rescan() { + let dir = tmp(); + let s = crate::Store::create_tiny(&dir).unwrap(); + let script = vec![0x51]; + s.put_tx_full_batch_indexed(&[class_a_coinbase([1u8; 32], script.clone())], true) + .unwrap(); + s.put_tx_full_batch_indexed(&[class_a_coinbase([2u8; 32], script.clone())], true) + .unwrap(); + crate::materialize_sh_unsorted_from_class_a(&s, 1, 1, None).unwrap(); + assert!(s.scripthash.has_durable_index()); + let n_shards = s.scripthash.head_shard_count(); + let base = sorted_main_shard_path(s.path(), 0, n_shards); + std::fs::remove_file(shard_pack_mark_path(&base)).unwrap(); + let _ = std::fs::remove_file(dir.join(crate::INCLUDE_HWM_NAME)); + drop(s); + let s = crate::Store::open_tiny(&dir).unwrap(); + assert!( + shard_pack_mark_path(&base).is_file(), + "complete unmarked head must gain .packed" + ); + assert!(s.scripthash.has_durable_index()); + assert!(s.scripthash.include_hwm() > 0); + let again = crate::materialize_sh_unsorted_from_class_a(&s, 1, 1, None).unwrap(); + assert_eq!(again.keys, 0); + let mut fks: Vec = s + .scripthash + .entries(&script_hash(&script)) + .unwrap() + .into_iter() + .map(|e| e.0 .0) + .collect(); + fks.sort_unstable(); + assert_eq!(fks, vec![1, 2]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn partial_post_spills_without_done_post_are_recollected() { + let dir = tmp(); + let s = crate::Store::create_tiny(&dir).unwrap(); + let multi = vec![0x51]; + let single = vec![0x52]; + s.put_tx_full_batch_indexed(&[class_a_coinbase([1u8; 32], multi.clone())], true) + .unwrap(); + s.put_tx_full_batch_indexed(&[class_a_coinbase([2u8; 32], multi.clone())], true) + .unwrap(); + s.put_tx_full_batch_indexed(&[class_a_coinbase([3u8; 32], single.clone())], true) + .unwrap(); + let n_shards = s.scripthash.head_shard_count(); + let udir = crate::unsorted_shard_dir(s.path()); + crate::collect_unsorted_shard_files(&s, &udir, n_shards, 1, None).unwrap(); + let spill = udir.join("post").join("00"); + std::fs::create_dir_all(&spill).unwrap(); + std::fs::write(spill.join("000000"), b"not-a-finished-post").unwrap(); + assert!(!udir.join("DONE.post").is_file()); + crate::materialize_sh_unsorted_from_class_a(&s, 1, 1, None).unwrap(); + let mut multi_fks: Vec = s + .scripthash + .entries(&script_hash(&multi)) + .unwrap() + .into_iter() + .map(|e| e.0 .0) + .collect(); + multi_fks.sort_unstable(); + assert_eq!(multi_fks, vec![1, 2]); + assert_eq!( + s.scripthash + .entries(&script_hash(&single)) + .unwrap() + .into_iter() + .map(|e| e.0 .0) + .collect::>(), + vec![3] + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn packed_subset_reopen_packs_the_rest_once() { + let dir = tmp(); + let s = crate::Store::create_tiny(&dir).unwrap(); + let n_shards = 4usize; + let mut keys = Vec::new(); + for shard in 0..n_shards { + let script = script_for_prefix_shard(shard, n_shards); + keys.push(script_hash(&script)); + let mut txid = [0u8; 32]; + txid[0] = shard as u8; + s.put_tx_full_batch_indexed(&[class_a_coinbase(txid, script)], true) + .unwrap(); + } + let sh_dir = dir.join("sh4"); + std::fs::create_dir_all(&sh_dir).unwrap(); + let table = four_shard_dir_table(&sh_dir); + let udir = sh_dir.join(UNSORTED_SHARD_DIR); + collect_unsorted_covering_txs(&s.txs, &table, &udir, n_shards, 1, false, None).unwrap(); + seal_mphf_from_keys(&table, &udir, n_shards, None).unwrap(); + crate::scripthash_materialize::collect_posts_covering(&s.txs, &table, &udir, 1, false, None) + .unwrap(); + pack_one_extract_shard(&table, &udir, 0).unwrap(); + pack_one_extract_shard(&table, &udir, 1).unwrap(); + assert!(shard_pack_mark_path(&sorted_main_shard_path(&sh_dir, 0, n_shards)).is_file()); + assert!(!shard_pack_mark_path(&sorted_main_shard_path(&sh_dir, 2, n_shards)).is_file()); + drop(table); + let table = ScriptHashTable::open_tiny(&sh_dir).unwrap(); + assert!(!table.has_durable_index()); + let unsealed = table.unsealed_main_shards(); + assert!(!unsealed.contains(&0) && !unsealed.contains(&1)); + assert!(unsealed.contains(&2) && unsealed.contains(&3)); + materialize_sh_from_unsorted(&table, &udir, 1, None).unwrap(); + assert!(table.has_durable_index()); + for k in &keys { + assert_eq!(table.entries(k).unwrap().len(), 1); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn unsorted_done_records_class_a_last_fk() { { diff --git a/crates/rbitcoin-store/src/store.rs b/crates/rbitcoin-store/src/store.rs index 752bcfef0..cbd93f8b2 100644 --- a/crates/rbitcoin-store/src/store.rs +++ b/crates/rbitcoin-store/src/store.rs @@ -1832,7 +1832,7 @@ fn open_layout_rewrite_pre15( scripthash: &ScriptHashTable, ) -> Result<(), StoreError> { if (meta_ver == 13 || meta_ver == 14) && SCHEMA_VERSION >= 15 { - if scripthash.has_durable_index() { + if scripthash.has_index_occupancy() { return Err(StoreError::Corrupt( "schema 14 store has a materialized scripthash index; wipe store/scripthash* (head, body, ovf, runs, include_hwm, cold_progress) and rematerialize for schema 15", )); @@ -2888,7 +2888,8 @@ mod tests { let s = Store::create_tiny(&dir).unwrap(); let sh = [0xcdu8; 32]; sh_put_create(&s, crate::scripthash::ScriptHashRecord::from_fk(sh, Fk(1))); - assert!(s.scripthash.has_durable_index()); + assert!(s.scripthash.has_index_occupancy()); + assert!(!s.scripthash.has_durable_index()); s.flush().unwrap(); } write_store_meta_ver(&dir, 14); @@ -2915,7 +2916,8 @@ mod tests { let s = Store::create_tiny(&dir).unwrap(); let sh = [0xabu8; 32]; sh_put_create(&s, crate::scripthash::ScriptHashRecord::from_fk(sh, Fk(1))); - assert!(s.scripthash.has_durable_index()); + assert!(s.scripthash.has_index_occupancy()); + assert!(!s.scripthash.has_durable_index()); s.flush().unwrap(); } write_store_meta_ver(&dir, 13); diff --git a/docs/concurrency.md b/docs/concurrency.md index c0e483980..dde238aa5 100644 --- a/docs/concurrency.md +++ b/docs/concurrency.md @@ -61,7 +61,7 @@ Three thread kinds only. **Tokio workers must not wait on a `std` mutex/rwlock, | Mode | When | Spentness | Durable `tx.head` / spends | SH | |------|------|-----------|----------------------------|-----| | **Direct** | IBD (`enter_direct_index_mode`) | confirmed-strong annotations | commit-stage head insert; spend annotate in same stage | Class A collect → unsorted shards → seal at tip | -| **Tip** | after IBD (`enter_tip_mode`) | confirmed-strong annotations | live heads + confirm spends | write-behind after tip commit (may lag live tip by 1+ blocks) | +| **Tip** | after IBD (`enter_tip_mode`) | confirmed-strong annotations | live heads + confirm spends | write-behind after a pack-complete head (every `scripthash.head/NN.packed`; may lag live tip by 1+ blocks) | Do not enter Tip until IBD catch-up complete: no best-chain remainder (ordered / `height_to_hash` above tip / BQ ready ahead / awaiting reorg / diff --git a/docs/crash-recovery.md b/docs/crash-recovery.md index 251fc4db3..ed958fd82 100644 --- a/docs/crash-recovery.md +++ b/docs/crash-recovery.md @@ -117,7 +117,7 @@ Private, **not** Class A. RAM graph is source of truth; files may lag. - **Segmented `tx.head`:** directory `tx.head/` with `meta` + open OA `NNNNNN`; sealed `NNNNNN.mphf` + `.fuse8`. Packed BDZ `g` is FdOnly (4 KiB page stream); MPHF output is `rel−1`. Flat `tx.head.meta` / `tx.head.NNNNNN` are **migrated into** `tx.head/` on open. Roll opens the next OA first; seal runs on a sidecar and publishes later. Seal/install of `meta` / `.mphf` is sibling tmp + `sync_all` then rename (empty truncate of the live name is not a seal). Publish persists sealed `meta` **before** unlinking the segment's OA; a leftover OA next to a sealed `.mphf` is discarded on open. Kill mid-seal leaves **at most one** unsealed non-tail OA: open collects fuse keys once from `txid.body` and seals it (does not retain `open_keys`). Two unsealed non-tails is **Corrupt**. Leftover fuse8 v1 and flat `tx.head.meta` **refuse** (`Query::open`); wipe `store/tx.head` (Class A kept) then restart. Unreadable **current** head (empty/truncated `meta`, truncated/missing sealed MPHF) with Class A: wipe+rebuild from `txid.body` (same cost as a clean wipe). Wipe or empty occupancy + Class A: open rebuilds **MPHF+fuse8 directly** from `txid.body` in parallel (default **2²⁵** keys/range; `RBITCOIN_TX_HEAD_REBUILD_WORKERS`); no historical OA. Legacy mono `tx.head` file / `.new` / `.resize` are not opened — reindex. - Scripthash: Direct IBD **defers** SH (no memtable, no confirm enqueue). After the horizon, two Class A `txout` scans: each worker owns a contiguous create-fk span and unsized maps (1.5 GiB estimate cap; spill the largest shard map while over budget; one writer, 1-slot queue) into `scripthash.unsorted/keys/NN/` (`SHKSP01` files, first-fk delta singles; tmp+rename, not a kill-9 barrier — no `DONE.keys` still wipes unsorted); merge folds those spills into one map, one walk to `scripthash.head/NN` + `multi/NN.fuse8`, and unlinks `keys/NN/`; then fuse-hit `post` (`SHPST01` spills under `post/NN/`), pack folds those spills then 2+ bodies, unlink each shard's extract as it seals. A **durable head** on restart stays Tip: write-behind / `recover_sh_writebehind` fills any HWM lag; leftover `scripthash.runs` are discarded (not WarmOnly-merged). - **Full cold** when head empty. **`RBITCOIN_SH_FORCE_REBUILD=1`:** wipe head + full two-scan collect + pack. Empty collect after Class A creates remain is fatal. - - **Cold resume:** RAM-published `scripthash.head/NN` is the pack commit (holes stay). `MphfHead::exists` after pass-1 BDZ is **not** pack-done. No valid `DONE.keys` (including leftover `SHUNSRT3` / 24 B `NN`, or `keys/NN` / `post/NN` as a file) deletes unsorted and restarts pass 1. A spill whose first 8 B are not `SHKSP01` is Corrupt — wipe unsorted; do not parse or migrate. `DONE.keys` / `DONE.post` record the inclusive Class A `create_fk` scanned (`SHKEYS02` last_fk marker / `SHPOST02`). Missing current `keys/NN/` spills with unsealed shards finishes BDZ from those files. Head files present and no `DONE.post` restarts pass 2 from fk 1. A `post/NN` **file**, or a spill whose first 8 B are not `SHPST01`, is Corrupt (wipe unsorted). If Class A grew after `DONE.post` and **no** shards are packed, append postings. If any shard is already RAM-sealed, pack remaining unsealed `post/NN/`, then Class A tail-append onto the durable head (Direct write-behind no-ops). After all shards seal, the unsorted dir is removed. + - **Cold resume:** the pack commit is `scripthash.head/NN.packed` (sibling of the MPHF base; holes stay). Open loads a shard's MPHF only when that mark exists. `MphfHead::exists` after pass-1 BDZ is **not** pack-done. A complete unmarked head (every shard has `.mphf`+`.val`, and `scripthash.unsorted` has no `DONE.keys` / `DONE.post` / `keys/` / `post/`) is soft-migrated: the marks are written, and a missing `include_hwm` is set from the create count. An extract still on disk never gets a mark, even if `include_hwm` already names the tip. No valid `DONE.keys` (including leftover `SHUNSRT3` / 24 B `NN`, or `keys/NN` / `post/NN` as a file) deletes unsorted and restarts pass 1. A spill whose first 8 B are not `SHKSP01` is Corrupt — wipe unsorted; do not parse or migrate. `DONE.keys` / `DONE.post` record the inclusive Class A `create_fk` scanned (`SHKEYS02` last_fk marker / `SHPOST02`). Missing current `keys/NN/` spills with unsealed shards finishes BDZ from those files. Head files present and no `DONE.post` restarts pass 2 from fk 1 (partial `post/` spills are deleted and recollected). A `post/NN` **file**, or a spill whose first 8 B are not `SHPST01`, is Corrupt (wipe unsorted). If Class A grew after `DONE.post` and **no** shards are packed, append postings. If any shard is already pack-marked, pack remaining unsealed `post/NN/`, then Class A tail-append onto the durable head (Direct write-behind no-ops). After all shards seal, the unsorted dir is removed. Empty head + leftover catalog: wipe leftover runs + SEAL, then Class A collect (not k-way from `scripthash.runs`). Durable head: leftover runs are discarded, `SEAL` kept; missing `include_hwm` bootstraps from SEAL. Inclusion HWM: `scripthash.include_hwm`. **Leftover live OA** at `scripthash.head` (or non-`SHSR` `ovf/NNNNNN`): refuse — wipe `store/scripthash*` and restart with `--shindex`. **SH head open:** sealed **main** shards load `.idx` only (one entry per 128 records; no fuse). Sealed **ovf** loads `.idx` + BF8R. Occupancy scan is not diff --git a/docs/operator/storage.md b/docs/operator/storage.md index e24d0013b..0d61de19a 100644 --- a/docs/operator/storage.md +++ b/docs/operator/storage.md @@ -195,35 +195,24 @@ Tip-follow readiness is **independent** of SH materialize (`tip_follow_ready` ### Abort / resume (tip materialize) -Keep **`store/scripthash.unsorted/`** until all shards seal. Extra disk during -build is **`SHKSP01` spills** (one rec per unique key per worker map) plus -**`SHPST01` post spills** (one rec per unique multi key per map, delta fks). -SIGINT / SIGTERM -mid-cold keeps every **RAM-published** `scripthash.head/NN`; restart with the same -`--datadir --sh-index` packs **unsealed** shards only (holes stay). Incomplete -pass 1 (no `DONE.keys`) restarts the first Class A scan. A previous layout -(`DONE` / 24 B `NN` files, or `keys/NN` / `post/NN` as a file) with no valid -`DONE.keys` is deleted and pass 1 starts over. A spill whose magic is not -`SHKSP01` or `SHPST01` is Corrupt — wipe `store/scripthash.unsorted` and -rematerialize. `DONE.keys` / `DONE.post` name the Class A -`create_fk` scanned; restart appends new creates when no -shards are sealed, or tail-appends onto the durable head after pack when any -`head/NN` is already published. Extract phases (collect, merge, BDZ, fuse, -pass 2, pack) share one worker cap (`store: scripthash … workers=`; -`RBITCOIN_SH_MERGE_WORKERS` override). Do not -delete unsorted files -to “start over” unless you intend a full Class A collect -(`RBITCOIN_SH_FORCE_REBUILD`). Leftover `scripthash.runs` are discarded at tip -(never k-way rematerialized). +Keep **`store/scripthash.unsorted/`** until every shard has +`scripthash.head/NN.packed`. That mark is the pack commit. Pass-1 +`.mphf`+`.val` without it is not sealed. Resume rules: +[`docs/crash-recovery.md`](docs/crash-recovery.md) (scripthash cold resume). +Extra disk during build is **`SHKSP01` spills** plus **`SHPST01` post spills**. +Restart with the same `--datadir --sh-index`. Do not delete unsorted files +to start over unless you intend a full Class A collect +(`RBITCOIN_SH_FORCE_REBUILD`). A spill whose magic is not `SHKSP01` or +`SHPST01` is Corrupt — wipe `store/scripthash.unsorted` and rematerialize. | Stop | What restart does | |------|-------------------| -| SIGTERM / SIGINT mid pack | Resume. Sealed `head/NN` stays; unsealed shards re-pack from unsorted files. | -| Kill-9 mid pack | Same idea; unfinished shard work is redone. Open follows [`docs/crash-recovery.md`](docs/crash-recovery.md) (scripthash Direct). | -| `DONE.keys` / `DONE.post` then more Class A, no sealed shards | Append the new fk span into keys then postings, then pack. | -| `DONE.keys` / `DONE.post` then more Class A, some/all shards sealed | Pack remaining unsealed `post/NN`; Class A tail onto the durable head (Direct) or write-behind (Tip). | -| Empty SH head + leftover catalog | Wipe leftover runs + SEAL, then Class A collect into unsorted shards. | -| Durable SH head + leftover runs | Discard leftover runs (keep SEAL); write-behind fills HWM lag. | +| No valid `DONE.keys` | Delete unsorted and restart pass 1. Seal rewrites the MPHF. | +| `DONE.keys`, no `DONE.post` | Keep pass-1 MPHF; discard partial `post/` spills; pass 2 from fk 1; pack. Electrum stays down. | +| `DONE.post`, some `.packed` | Pack only the unmarked shards. | +| All `.packed` | Tip write-behind. A second start does not collect. | +| Complete head, no marks, no extract | Soft-migrate: write `.packed`. Missing `include_hwm` is set from the create count. | +| Kill-9 mid pack | Unfinished shard is redone. Open follows [`docs/crash-recovery.md`](docs/crash-recovery.md). | | Corrupt SH (leftover live OA, mixed body, refuse line) | Wipe `store/scripthash*` only, keep Class A, rematerialize with `--sh-index`. | Electrum waits until SH is tip-ready. Do **not** `rm -rf store/` for an SH From dcb5ff72e4695b4dcd96762f070e75791d3049cc Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Fri, 2 Oct 2026 11:37:15 -0700 Subject: [PATCH 2/7] node: open Electrum only once scripthash inclusion covers the tip A durable head whose include_hwm lagged the tip was marked ready at tip entry, and a cancel before any extract file named scripthash.cold_progress. Electrum stays down until sh_is_tip_ready, then this process binds it once write-behind catches up. The cancel line names cold_progress or scripthash.unsorted only when that path is on disk. --- changelog.d/sh-pack-resume.md | 1 + crates/rbitcoin-node/src/run.rs | 221 +++++++++++++++++++++++++++++--- docs/operator/storage.md | 2 +- 3 files changed, 204 insertions(+), 20 deletions(-) diff --git a/changelog.d/sh-pack-resume.md b/changelog.d/sh-pack-resume.md index 92b356ffb..95578a819 100644 --- a/changelog.d/sh-pack-resume.md +++ b/changelog.d/sh-pack-resume.md @@ -1,3 +1,4 @@ Fixed - A scripthash pass-1 MPHF without `scripthash.head/NN.packed` is not a durable head. Restart after `DONE.keys` resumes pass 2 and pack instead of reporting Electrum-ready on an index that has no multi-script history. A finished unmarked head is soft-migrated. +- Electrum stays down when a durable scripthash head's inclusion floor is behind the tip, and the same process binds it once write-behind catches up. A cancelled extract names `scripthash.cold_progress` or `scripthash.unsorted` only when that path is on disk. diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 227d3dbc9..a8c9cb1fc 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -785,7 +785,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { } } - let (electrum_handles, electrum_bridge, electrum_onion) = start_electrum_if_ready( + let (mut electrum_handles, mut electrum_bridge, electrum_onion) = start_electrum_if_ready( sh_tip_ready, config.listen.electrum, config.sptweaks_dust, @@ -809,7 +809,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { node.peers .set_wallet_onion(format!("{}.onion", hs.service_id), h.local_addr.port()); } - let esplora_handles = start_esplora_if_ready( + let mut esplora_handles = start_esplora_if_ready( sh_tip_ready, config.listen.esplora.clone(), config.network, @@ -860,7 +860,6 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { } } } - let _i2p_wallet = i2p_wallet; let mut rpc_handle: Option = None; if (config.rpc.socket || config.rpc.listen.is_some()) && !shutdown.requested() { @@ -985,6 +984,105 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { } } + // A durable head can lag `include_hwm` at tip entry. Electrum stays + // down until write-behind covers the tip, then this same process binds. + let electrum_due = + config.shindex && config.listen.electrum.is_some() && electrum_handles.is_empty(); + let esplora_due = + config.shindex && config.listen.esplora.is_some() && esplora_handles.is_empty(); + if (electrum_due || esplora_due) && node.hub.query.sh_is_tip_ready() { + if electrum_due { + let (handles, bridge, onion) = start_electrum_if_ready( + true, + config.listen.electrum, + config.sptweaks_dust, + &shutdown, + &node.hub, + ¶ms, + &mempool, + ) + .await; + if let (Some(ctl), Some(h)) = (tor_ctl.as_mut(), handles.first()) { + let hs = ctl + .add_electrum_onion(config.datadir.path(), h.local_addr) + .await?; + info!( + "electrum onion {}.onion:{}", + hs.service_id, + h.local_addr.port() + ); + let _ = + onion.set((format!("{}.onion", hs.service_id), h.local_addr.port())); + node.peers.set_wallet_onion( + format!("{}.onion", hs.service_id), + h.local_addr.port(), + ); + } + if config.listen.i2p_accept_incoming { + if let Some(addr) = config.listen.i2p_sam { + if let Some(h) = handles.first() { + i2p_wallet.push( + start_i2p_named_forward( + addr, + config.datadir.path(), + "electrum", + h.local_addr.port(), + ) + .await?, + ); + } + } + } + electrum_bridge = bridge; + electrum_handles = handles; + } + if esplora_due { + let handles = start_esplora_if_ready( + true, + config.listen.esplora.clone(), + config.network, + config.esplora_block_template, + &shutdown, + Arc::clone(&node.hub), + &mempool, + ) + .await; + if config.esplora_onion { + if let (Some(ctl), Some(h)) = (tor_ctl.as_mut(), handles.first()) { + let hs = ctl + .add_esplora_onion(config.datadir.path(), h.local_addr) + .await?; + info!( + "esplora onion http://{}.onion:{} (/ws same port)", + hs.service_id, + h.local_addr.port() + ); + node.peers.set_wallet_onion( + format!("{}.onion", hs.service_id), + h.local_addr.port(), + ); + } + } + if config.listen.i2p_accept_incoming { + if let Some(addr) = config.listen.i2p_sam { + if let Some(h) = handles.first() { + i2p_wallet.push( + start_i2p_named_forward( + addr, + config.datadir.path(), + "esplora", + h.local_addr.port(), + ) + .await?, + ); + } + } + } + esplora_handles = handles; + } + info!("node: scripthash inclusion reached the tip — wallet services bound"); + } + // Prefer shutdown, then the 5s perf tick when both ready. Do **not** // put tip_rx ahead of perf under `biased` — multi-block catch-up can // keep tip events always ready and starve meters (no tip: perf lines). @@ -1753,6 +1851,33 @@ pub(crate) struct TipModeGates { pub sh_tip_ready: bool, } +fn sh_cancel_resume_note(query: &Query) -> &'static str { + let root = query.store().path(); + if root.join("scripthash.cold_progress").is_file() { + "partial cold shards kept (scripthash.cold_progress) — \ + restart to resume; Electrum not ready yet (stay Direct; tip follow on)" + } else if root.join("scripthash.unsorted").is_dir() { + "partial scripthash extract kept (scripthash.unsorted) — \ + restart to resume; Electrum not ready yet (stay Direct; tip follow on)" + } else { + "cancelled before a durable scripthash extract — \ + restart to resume; Electrum not ready yet (stay Direct; tip follow on)" + } +} + +fn sh_tip_ready_gates(query: &Query) -> TipModeGates { + let ready = query.sh_is_tip_ready(); + if ready { + info!("node: tip-mode complete — safe to start Electrum"); + } else { + info!("node: scripthash head is not tip-ready; Electrum stays down"); + } + TipModeGates { + tip_follow_ready: true, + sh_tip_ready: ready, + } +} + /// Enter steady-state after true catch-up. /// /// **Preconditions (enforced by IBD, not repaired here):** Direct catch-up already @@ -1760,8 +1885,9 @@ pub(crate) struct TipModeGates { /// Incomplete IBD must not call this (`CatchUp::Complete` only after full horizon). /// /// **SH methods (exactly two):** -/// - Durable head: stay/flip [`IndexMode::Tip`], discard leftover runs, Electrum -/// on (`sh_tip_ready`); catch-up / follow use write-behind. +/// - Durable head: stay/flip [`IndexMode::Tip`], discard leftover runs; +/// catch-up / follow use write-behind. `sh_tip_ready` only when inclusion +/// already covers the tip. /// - No head: Class A collect + unsorted pack **while Direct** (write-behind /// no-ops), then Tip. Cancel leaves Direct; Electrum stays closed. /// @@ -1803,11 +1929,7 @@ pub(crate) fn enter_tip_mode( "node: scripthash write-behind — skip collect; rows={}", query.scripthash_entry_count() ); - info!("node: tip-mode complete — safe to start Electrum"); - return TipModeGates { - tip_follow_ready: true, - sh_tip_ready: true, - }; + return sh_tip_ready_gates(query); } info!("node: index materialize from Class A (Direct collect, then Tip)…"); @@ -1824,10 +1946,7 @@ pub(crate) fn enter_tip_mode( } Err(StoreError::Cancelled(msg)) => { warn!("node: index materialize cancelled ({msg})"); - warn!( - "node: partial cold shards kept (scripthash.cold_progress) — \ - restart to resume; Electrum not ready yet (stay Direct; tip follow on)" - ); + warn!("node: {}", sh_cancel_resume_note(query)); false } Err(e) => { @@ -1869,11 +1988,7 @@ pub(crate) fn enter_tip_mode( "node: scripthash rows={} (thin creates from Class A collect; spentness = confirmed-strong annotations)", query.scripthash_entry_count() ); - info!("node: tip-mode complete — safe to start Electrum"); - TipModeGates { - tip_follow_ready: true, - sh_tip_ready: true, - } + sh_tip_ready_gates(query) } /// Production IBD knobs for a single-peer catch-up retry (stale tip, incomplete catch-up). @@ -2618,4 +2733,72 @@ mod tests { sd.request(); j.await.unwrap(); } + + fn scratch_dir(label: &str) -> std::path::PathBuf { + let n = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("rbitcoin-{label}-{n}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// A pack-marked head whose inclusion floor is behind the tip must not open + /// Electrum. Tip follow still starts so write-behind can catch up. + #[test] + fn enter_tip_mode_does_not_ready_electrum_when_inclusion_lags() { + let dir = scratch_dir("tip-gate"); + let q = Query::open_or_create_tiny(&dir).unwrap(); + let bump = q.store().scripthash.alloc_bump(); + q.store() + .scripthash + .publish_sorted_shard(0, &[], 0, bump) + .unwrap(); + assert!(q.store().scripthash.has_durable_index()); + assert!(q.sh_use_writebehind()); + assert!( + !q.sh_is_tip_ready(), + "empty tip is not an inclusion-complete scripthash head" + ); + let gates = enter_tip_mode(&q, None, true); + assert!(gates.tip_follow_ready); + assert!( + !gates.sh_tip_ready, + "lagging inclusion must not start Electrum" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Cancel before any extract file exists must not tell the operator to + /// resume from `scripthash.cold_progress`. + #[test] + fn enter_tip_mode_cancel_does_not_name_missing_cold_progress() { + let dir = scratch_dir("tip-cancel"); + let q = Query::open_or_create_tiny(&dir).unwrap(); + assert!(!q.store().path().join("scripthash.cold_progress").is_file()); + assert!(!q.store().path().join("scripthash.unsorted").is_dir()); + let cancel = Arc::new(AtomicBool::new(true)); + rbitcoin_log::capture_logs(true); + let gates = enter_tip_mode(&q, Some(cancel), true); + let lines = rbitcoin_log::take_logs(); + rbitcoin_log::capture_logs(false); + assert!(gates.tip_follow_ready); + assert!(!gates.sh_tip_ready); + let joined = lines + .iter() + .map(|(_, line)| line.as_str()) + .collect::>() + .join("\n"); + assert!( + !joined.contains("scripthash.cold_progress"), + "cancel warn named a resume file that is not on disk:\n{joined}" + ); + assert!( + joined.contains("cancelled before a durable scripthash extract"), + "cancel warn should name the phase that actually exists:\n{joined}" + ); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/docs/operator/storage.md b/docs/operator/storage.md index 0d61de19a..f1a0a19cf 100644 --- a/docs/operator/storage.md +++ b/docs/operator/storage.md @@ -210,7 +210,7 @@ to start over unless you intend a full Class A collect | No valid `DONE.keys` | Delete unsorted and restart pass 1. Seal rewrites the MPHF. | | `DONE.keys`, no `DONE.post` | Keep pass-1 MPHF; discard partial `post/` spills; pass 2 from fk 1; pack. Electrum stays down. | | `DONE.post`, some `.packed` | Pack only the unmarked shards. | -| All `.packed` | Tip write-behind. A second start does not collect. | +| All `.packed` | Tip write-behind. A second start does not collect. Electrum stays down until `include_hwm` covers the tip, then this process binds it. | | Complete head, no marks, no extract | Soft-migrate: write `.packed`. Missing `include_hwm` is set from the create count. | | Kill-9 mid pack | Unfinished shard is redone. Open follows [`docs/crash-recovery.md`](docs/crash-recovery.md). | | Corrupt SH (leftover live OA, mixed body, refuse line) | Wipe `store/scripthash*` only, keep Class A, rematerialize with `--sh-index`. | From 9e2d6e82e84f37806f0242327254380c651d0b3b Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Fri, 2 Oct 2026 13:23:38 -0700 Subject: [PATCH 3/7] test: pin end-of-IBD tip follow and restart The syncer catches a miner, serves Electrum history, tip-follows, and restarts on write-behind without rewriting the pack mark. A cancelled IBD resumes to the same tip. With the miner down, a partial datadir stays short of that tip and does not open Electrum; the miner coming back lets it finish. --- TESTING.md | 3 +- crates/rbitcoin-test/Cargo.toml | 4 + crates/rbitcoin-test/tests/end_of_ibd.rs | 561 +++++++++++++++++++++++ 3 files changed, 567 insertions(+), 1 deletion(-) create mode 100644 crates/rbitcoin-test/tests/end_of_ibd.rs diff --git a/TESTING.md b/TESTING.md index b800ecfb5..667b8a05c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -179,7 +179,7 @@ test bytes are RAM. | Remining 100-block maturity pads with `confirm_wire_run` | `pad_empty_from` / `build_mature_regtest_with_spend` **once per binary journey** (not once per skinny test) | | Wall-time multi-round microbenches in default suite | Deterministic structure / chunk-load asserts; demote wall arms to `#[ignore]` | -**P2P walls:** `two_node_header_and_block_sync`, `three_node_relay_path`, `ibd_two_peers`, `tip_follow_after_ibd`, `tip_follow_getheaders_catches_missed_blocks`, and `node_run_p2p_short` 60s wall (180s under `coverage.sh` / llvm-cov). `serve_after_restart_via_reconstruct` 90s wall (180s under llvm-cov). `p2p_compact_hb_getblocktxn_and_orphan` 30s wall (90s under llvm-cov). `p2p_timeout_getaddr_and_keepalive_ping`, `p2p_feeler_completes_and_closes`, and `p2p_inbound_full_rejects_extra` 20s wall. Live `P2PNode` tests in `integration_multinode` take a process mutex (shared `rbtc-scripts` pool / confirm OS threads); hub-only reorgs do not. +**P2P walls:** `two_node_header_and_block_sync`, `three_node_relay_path`, `ibd_two_peers`, `tip_follow_after_ibd`, `tip_follow_getheaders_catches_missed_blocks`, and `node_run_p2p_short` 60s wall (180s under `coverage.sh` / llvm-cov). `serve_after_restart_via_reconstruct` and `end_of_ibd_follow` 90s wall (180s under llvm-cov). `p2p_compact_hb_getblocktxn_and_orphan` 30s wall (90s under llvm-cov). `p2p_timeout_getaddr_and_keepalive_ping`, `p2p_feeler_completes_and_closes`, and `p2p_inbound_full_rejects_extra` 20s wall. Live `P2PNode` tests in `integration_multinode` take a process mutex (shared `rbtc-scripts` pool / confirm OS threads); hub-only reorgs do not. **Speed / reliability (default suite):** prefer `pad_empty_from` / `build_mature_regtest_with_spend` **once per journey** (tx_relay live hub, Electrum protocol, core_analogs assumevalid+mempool) over remine pads; SH run-builder sleeps are 1 ms under `cfg(test)` (40 ms in production). `pin_compose_multi_pack_timed` keeps functional + layout/covered short-circuit gates (multi-ms floor); sticky vs cold assemble is log-only (not a hard timing assert). Schema-13 wire rebuild must stamp create identity from `txid.body` — zero batch identity is treated as missing (regression covered by the spend reconstruct in `consensus_mature_chain_spend_reconstruct_and_scripthash` + multi-vout confirm scenarios). Coverage vs speed: prefer **one** scenario at the real entry over N micro-opens that only paint lines; when adding coverage for reduce/materialize, use a **tiny** target, not production stream depth. @@ -370,6 +370,7 @@ Prefer **one high-level scenario** per behavior cluster. Delete lower-level test | `ibd_two_peers` | P2P (**default**) | Dual live seeders, 8-block IBD | | `tip_follow_after_ibd` | P2P (**default**) | After IBD, follow + one new tip via inv/headers. With the filter index on, IBD confirm writes no basic filters; `rbtc-idx-wb` materializes them to the tip (its caught-up callback fires once, at the tip), then seals the followed block. With `--sp-tweaks` too: the builder brings both indexes to 5 and is stopped; a followed block with no builder running moves neither (no confirm path writes index data); a new builder seals 6, then follows 7 | | `tip_follow_getheaders_catches_missed_blocks` | P2P (**default**) | Blocks mined while disconnected fill via post-connect `getheaders` | +| `end_of_ibd_follow` | P2P (**default**) | Miner plus `--sh-index` syncer. One mature regtest: coinbases pay script A, one spend pays script B. IBD reaches that tip, leaves IBD, and Electrum history matches. The next block tip-follows. Restart does not rewrite the scripthash pack mark; one more block arrives by write-behind. Cancelling IBD once the height is below the miner, then `run_p2p`, finishes the same tip and history. With the syncer caught up, dropping the miner leaves tip follow (not IBD). A partial datadir whose miner is down does not open Electrum and stays short of that tip; the same miner address coming back lets that datadir finish | | `node_run_p2p_short` | Node (**default**) | Product `run_p2p` `--blocks-only` `--connect` to a live seeder (`--max-tip-age` so the 3-block pad is not stale IBD); process `getpeerinfo` / `getconnectioncount` / `getnetworkinfo` / `getnettotals` / `ping` while connected (v2 outbound-full-relay; handshake `startingheight` equals the seeder tip; `timeoffset` present; `synced_headers`/`synced_blocks` stay `-1` until the peer announces a header hash (empty getheaders at tip does not copy VERSION height); `servicesnames` present; `getnetworkinfo.timeoffset` present); after catch-up `localrelay` / mempool `relay_enabled` stay false and `sendrawtransaction` is not `relay disabled`; Electrum `broadcast` and Esplora `POST /tx` admit decode/consensus errors (not hub-missing / not `relay disabled`); `addconnection inbound` refuses; `disconnectnode` unknown `nodeid` / empty params error then a real addr drops that row from the next `getpeerinfo`, the seeder sees it go, and a second `disconnectnode` is `-29`; `addnode onetry` reconnects as `manual` and the seeder sees the inbound; seeder inbound `tx` then disconnects. Exit via `stop`. `max_run_secs=0` is `node_listen_and_exit`. Mock-clock `timeoffset` median (odd N, even N upper-middle, inbound-only 0, peer clock behind), connecting dummy `-1`, header-only vs connected `synced_blocks`, query-without-chain, `pingwait` / `NETWORK_LIMITED` / `noban` stay RPC guts | Removed (covered by the rows above): `confirm_cross_block_prevout_without_tx_head`, diff --git a/crates/rbitcoin-test/Cargo.toml b/crates/rbitcoin-test/Cargo.toml index 06d64c34d..df456c03b 100644 --- a/crates/rbitcoin-test/Cargo.toml +++ b/crates/rbitcoin-test/Cargo.toml @@ -48,6 +48,10 @@ path = "tests/core_analogs.rs" name = "cross_surface" path = "tests/cross_surface.rs" +[[test]] +name = "end_of_ibd" +path = "tests/end_of_ibd.rs" + [features] # Live private-mesh journeys. Default `cargo test` stays fake SOCKS/SAM/control. overlay = [] diff --git a/crates/rbitcoin-test/tests/end_of_ibd.rs b/crates/rbitcoin-test/tests/end_of_ibd.rs new file mode 100644 index 000000000..18b118605 --- /dev/null +++ b/crates/rbitcoin-test/tests/end_of_ibd.rs @@ -0,0 +1,561 @@ +//! End of IBD: tip follow, scripthash history, and restart on a live miner. + +use bitcoin::absolute::LockTime; +use bitcoin::script::ScriptBuf; +use bitcoin::transaction::Version as TxVersion; +use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; +use rbitcoin_consensus::{ChainParams, Milestone}; +use rbitcoin_electrum::electrum_scripthash_hex; +use rbitcoin_net::{IbdConfig, NetAddr, P2PNode}; +use rbitcoin_node::{run_p2p, NodeConfig}; +use rbitcoin_primitives::Network; +use rbitcoin_query::Query; +use rbitcoin_test::mine::{mine_regtest_block, regtest_genesis}; +use rbitcoin_test::TestDatadir; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; + +const RPC_BEARER: &str = "Bearer pass"; + +fn llvm_cov_wall(default_secs: u64, llvm_secs: u64) -> Duration { + if std::env::var_os("CARGO_LLVM_COV").is_some() { + Duration::from_secs(llvm_secs) + } else { + Duration::from_secs(default_secs) + } +} + +async fn live_p2p_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await +} + +fn reserve_addr() -> SocketAddr { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + addr +} + +fn spend_to(prev: Txid, value: Amount, script: Vec) -> Transaction { + Transaction { + version: TxVersion::ONE, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: prev, + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value, + script_pubkey: ScriptBuf::from_bytes(script), + }], + } +} + +/// Coinbases pay script A (`0x51`). One mature spend pays script B (`0x52`). +fn fixture_chain() -> (Vec, BTreeSet, BTreeSet) { + let maturity = ChainParams::regtest().coinbase_maturity(); + let genesis = regtest_genesis(); + let mut blocks = vec![genesis.clone()]; + let mut tip = genesis.block_hash(); + let mut time = genesis.header.time; + let mut script_a = BTreeSet::new(); + let mut script_b = BTreeSet::new(); + + let height1 = mine_regtest_block(tip, time + 1, 1, vec![]); + let matured = height1.txdata[0].compute_txid(); + script_a.insert(matured.to_string()); + tip = height1.block_hash(); + time = height1.header.time; + blocks.push(height1); + + let last_pad = maturity + 1; + for height in 2..=last_pad { + let block = mine_regtest_block(tip, time + 1, height, vec![]); + script_a.insert(block.txdata[0].compute_txid().to_string()); + tip = block.block_hash(); + time = block.header.time; + blocks.push(block); + } + + let spend = spend_to(matured, Amount::from_sat(49_0000_0000), vec![0x52]); + let spend_txid = spend.compute_txid().to_string(); + let spend_block = mine_regtest_block(tip, time + 1, last_pad + 1, vec![spend]); + script_a.insert(spend_block.txdata[0].compute_txid().to_string()); + script_a.insert(spend_txid.clone()); + script_b.insert(spend_txid); + blocks.push(spend_block); + (blocks, script_a, script_b) +} + +async fn start_miner(dir: &Path, addr: SocketAddr) -> P2PNode { + let query = Query::open_or_create_tiny(dir.join("store")).unwrap(); + P2PNode::start(addr, query, ChainParams::regtest(), Milestone::NONE) + .await + .expect("miner listen") +} + +fn load_chain(miner: &P2PNode, blocks: &[bitcoin::Block]) { + for (height, block) in blocks.iter().enumerate() { + miner + .ingest_block(height as u32, block.clone()) + .unwrap_or_else(|e| panic!("ingest {height}: {e}")); + } +} + +fn syncer_cfg(dir: &Path, miner: SocketAddr, rpc: SocketAddr, electrum: SocketAddr) -> NodeConfig { + let mut cfg = NodeConfig::default() + .with_datadir(dir) + .with_network(Network::Regtest) + .with_p2p_listen("127.0.0.1:0".parse().unwrap()) + .with_tiny_heads(); + cfg.listen.connect = vec![NetAddr::Ip(miner)]; + cfg.listen.use_seeds = false; + cfg.listen.electrum = Some(electrum); + cfg.shindex = true; + cfg.rpc.listen = Some(rpc); + cfg.max_tip_age_secs = Some(u64::MAX); + std::fs::write(dir.join("rpc.token"), "pass").unwrap(); + cfg +} + +fn spawn_run_p2p(cfg: NodeConfig) -> tokio::task::JoinHandle> { + tokio::task::spawn_blocking(move || { + let _block = rbitcoin_net::BlockingRegion::enter(); + tokio::runtime::Handle::current().block_on(run_p2p(cfg)) + }) +} + +async fn jsonrpc(addr: SocketAddr, method: &str, params: Value) -> Value { + let body = json!({"jsonrpc":"1.0","id":"test","method":method,"params":params}).to_string(); + let req = format!( + "POST / HTTP/1.1\r\nHost: {addr}\r\nAuthorization: {RPC_BEARER}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let mut stream = TcpStream::connect(addr).await.expect("rpc connect"); + stream.write_all(req.as_bytes()).await.unwrap(); + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).await.unwrap(); + let text = String::from_utf8_lossy(&buf); + let json_body = text.split("\r\n\r\n").nth(1).unwrap_or("").trim(); + serde_json::from_str(json_body) + .unwrap_or_else(|e| panic!("rpc {method} json: {e} body={json_body}")) +} + +async fn history_txids(electrum: SocketAddr, script: &[u8]) -> BTreeSet { + let mut stream = TcpStream::connect(electrum) + .await + .expect("electrum connect"); + let req = json!({ + "id": 1, + "jsonrpc": "2.0", + "method": "blockchain.scripthash.get_history", + "params": [electrum_scripthash_hex(script)] + }) + .to_string() + + "\n"; + stream.write_all(req.as_bytes()).await.unwrap(); + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line).await.unwrap(); + let hist: Value = serde_json::from_str(line.trim()) + .unwrap_or_else(|e| panic!("history json: {e} body={line}")); + hist["result"] + .as_array() + .unwrap_or_else(|| panic!("{hist}")) + .iter() + .map(|row| { + row["tx_hash"] + .as_str() + .unwrap_or_else(|| panic!("{row}")) + .to_string() + }) + .collect() +} + +async fn wait_listeners(addrs: &[SocketAddr]) { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let mut missing = None; + for addr in addrs { + if TcpStream::connect(*addr).await.is_err() { + missing = Some(*addr); + break; + } + } + if missing.is_none() { + return; + } + if Instant::now() >= deadline { + panic!("listeners not up ({missing:?}): {addrs:?}"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn stop_run_p2p( + rpc: SocketAddr, + node: tokio::task::JoinHandle>, +) { + let _ = jsonrpc(rpc, "stop", json!([])).await; + match tokio::time::timeout(Duration::from_secs(20), node).await { + Ok(Ok(Ok(()))) => {} + Ok(Ok(Err(e))) => panic!("run_p2p error after stop: {e}"), + Ok(Err(e)) => panic!("run_p2p join: {e}"), + Err(_) => panic!("run_p2p did not exit after stop"), + } +} + +async fn wait_caught_up( + rpc: SocketAddr, + electrum: SocketAddr, + height: u32, + hash: &str, + script_a: &BTreeSet, + script_b: &BTreeSet, +) { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let count = jsonrpc(rpc, "getblockcount", json!([])).await; + let best = jsonrpc(rpc, "getbestblockhash", json!([])).await; + let height_ok = count["result"].as_u64() == Some(u64::from(height)); + let hash_ok = best["result"].as_str() == Some(hash); + let hist_a = history_txids(electrum, &[0x51]).await; + let hist_b = history_txids(electrum, &[0x52]).await; + if height_ok && hash_ok && &hist_a == script_a && &hist_b == script_b { + return; + } + if Instant::now() >= deadline { + panic!("catch-up count={count} best={best} A={hist_a:?} B={hist_b:?} want height={height} {hash}"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn assert_tip_view(rpc: SocketAddr, height: u32, hash: &str) { + let info = jsonrpc(rpc, "getblockchaininfo", json!([])).await; + assert_eq!( + info["result"]["initialblockdownload"], false, + "caught up must leave IBD: {info}" + ); + let tips = jsonrpc(rpc, "getchaintips", json!([])).await; + let rows = tips["result"] + .as_array() + .unwrap_or_else(|| panic!("{tips}")); + let active = rows + .iter() + .find(|tip| tip["status"] == "active") + .unwrap_or_else(|| panic!("no active tip: {tips}")); + assert_eq!(active["hash"].as_str(), Some(hash), "{tips}"); + assert_eq!(active["height"].as_u64(), Some(u64::from(height)), "{tips}"); +} + +fn pack_mark(store: &Path) -> PathBuf { + let flat = store.join("scripthash.head.packed"); + if flat.is_file() { + return flat; + } + let shard = store.join("scripthash.head").join("00.packed"); + assert!( + shard.is_file(), + "pack mark missing under {}", + store.display() + ); + shard +} + +fn mark_mtime(path: &Path) -> SystemTime { + std::fs::metadata(path) + .unwrap_or_else(|e| panic!("metadata {}: {e}", path.display())) + .modified() + .unwrap() +} + +fn copy_dir(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for ent in std::fs::read_dir(src).unwrap() { + let ent = ent.unwrap(); + let to = dst.join(ent.file_name()); + if ent.file_type().unwrap().is_dir() { + copy_dir(&ent.path(), &to); + } else { + std::fs::copy(ent.path(), &to).unwrap(); + } + } +} + +fn store_tip(store: &Path) -> u32 { + let query = Query::open_or_create_tiny(store).unwrap(); + query.tip_height().map(|h| h.0).unwrap_or(0) +} + +/// Cancel IBD once the tip is in `(0, miner_tip)`. Returns that height. +async fn stop_ibd_below_tip(dir: &Path, miner: SocketAddr, miner_tip: u32) -> u32 { + let query = Query::open_or_create_tiny(dir.join("store")).unwrap(); + let node = P2PNode::start( + "127.0.0.1:0".parse().unwrap(), + query, + ChainParams::regtest(), + Milestone::NONE, + ) + .await + .expect("partial syncer"); + let hub = Arc::clone(&node.hub); + let tip = { + let cancel = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&cancel); + let peers = [NetAddr::Ip(miner)]; + let sync = node.sync_cancellable(&peers, IbdConfig::for_test(), Some(flag)); + tokio::pin!(sync); + let mut saw_mid = false; + let joined = loop { + tokio::select! { + result = &mut sync => break result, + _ = tokio::time::sleep(Duration::from_millis(5)) => { + let height = hub.tip_height().unwrap_or(0); + if height > 0 && height < miner_tip { + saw_mid = true; + cancel.store(true, Ordering::SeqCst); + } + } + } + }; + joined.expect("cancelled IBD still returns after teardown"); + let tip = hub.tip_height().unwrap_or(0); + assert!( + saw_mid && tip > 0 && tip < miner_tip, + "IBD never observed below the miner tip (saw_mid={saw_mid} tip={tip} miner={miner_tip})" + ); + tip + }; + node.shutdown().await; + tip +} + +async fn expect_process_exit_without_electrum( + node: tokio::task::JoinHandle>, + electrum: SocketAddr, +) { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + assert!( + TcpStream::connect(electrum).await.is_err(), + "Electrum listened before the chain was complete" + ); + if node.is_finished() { + break; + } + if Instant::now() >= deadline { + panic!("syncer with the miner down stayed up"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + match node.await { + Ok(Ok(())) => {} + Ok(Err(e)) => panic!("incomplete run_p2p: {e}"), + Err(e) => panic!("incomplete join: {e}"), + } +} + +async fn extend_one( + miner: &P2PNode, + rpc: SocketAddr, + electrum: SocketAddr, + script_a: &mut BTreeSet, +) -> (u32, String) { + let before = history_txids(electrum, &[0x51]).await; + miner + .hub + .generate_to_script(1, ScriptBuf::from_bytes(vec![0x51]), vec![]) + .unwrap(); + let height = miner.tip_height().unwrap(); + let hash = miner.hub.tip_hash().unwrap().to_string(); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let got = history_txids(electrum, &[0x51]).await; + let count = jsonrpc(rpc, "getblockcount", json!([])).await; + let best = jsonrpc(rpc, "getbestblockhash", json!([])).await; + let added: BTreeSet<_> = got.difference(&before).cloned().collect(); + if count["result"].as_u64() == Some(u64::from(height)) + && best["result"].as_str() == Some(hash.as_str()) + && added.len() == 1 + && got.is_superset(&before) + { + script_a.extend(added); + assert_eq!(&got, script_a); + return (height, hash); + } + if Instant::now() >= deadline { + panic!("tip-follow height={height} {hash} history={got:?} count={count} best={best}"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn end_of_ibd_follow() { + let _live = live_p2p_lock().await; + let wall = llvm_cov_wall(90, 180); + tokio::time::timeout(wall, follow_journey()) + .await + .expect("end_of_ibd_follow wall"); +} + +struct Synced { + height: u32, + hash: String, + script_a: BTreeSet, + script_b: BTreeSet, +} + +async fn ibd_then_restart(miner: P2PNode, miner_addr: SocketAddr, synced: &mut Synced) { + let syncer_dir = TestDatadir::new().unwrap(); + let rpc = reserve_addr(); + let electrum = reserve_addr(); + let node = spawn_run_p2p(syncer_cfg( + syncer_dir.path().as_path(), + miner_addr, + rpc, + electrum, + )); + wait_listeners(&[rpc, electrum]).await; + wait_caught_up( + rpc, + electrum, + synced.height, + &synced.hash, + &synced.script_a, + &synced.script_b, + ) + .await; + assert_tip_view(rpc, synced.height, &synced.hash).await; + + let (height, hash) = extend_one(&miner, rpc, electrum, &mut synced.script_a).await; + synced.height = height; + synced.hash = hash; + assert_tip_view(rpc, synced.height, &synced.hash).await; + + let store = syncer_dir.store_path(); + let mark = pack_mark(&store); + let packed_at = mark_mtime(&mark); + assert!(!store.join("scripthash.unsorted").is_dir()); + stop_run_p2p(rpc, node).await; + + let node = spawn_run_p2p(syncer_cfg( + syncer_dir.path().as_path(), + miner_addr, + rpc, + electrum, + )); + wait_listeners(&[rpc, electrum]).await; + wait_caught_up( + rpc, + electrum, + synced.height, + &synced.hash, + &synced.script_a, + &synced.script_b, + ) + .await; + assert_eq!(mark_mtime(&mark), packed_at, "restart collected again"); + assert!(!store.join("scripthash.unsorted").is_dir()); + let (height, hash) = extend_one(&miner, rpc, electrum, &mut synced.script_a).await; + synced.height = height; + synced.hash = hash; + assert_eq!( + mark_mtime(&mark), + packed_at, + "write-behind rewrote the pack mark" + ); + assert_tip_view(rpc, synced.height, &synced.hash).await; + + miner.shutdown().await; + tokio::time::sleep(Duration::from_millis(300)).await; + assert_tip_view(rpc, synced.height, &synced.hash).await; + assert_eq!(history_txids(electrum, &[0x51]).await, synced.script_a); + assert_eq!(history_txids(electrum, &[0x52]).await, synced.script_b); + stop_run_p2p(rpc, node).await; +} + +async fn finish_partial(dir: &Path, miner: SocketAddr, synced: &Synced) { + let rpc = reserve_addr(); + let electrum = reserve_addr(); + let node = spawn_run_p2p(syncer_cfg(dir, miner, rpc, electrum)); + wait_listeners(&[rpc, electrum]).await; + wait_caught_up( + rpc, + electrum, + synced.height, + &synced.hash, + &synced.script_a, + &synced.script_b, + ) + .await; + assert_tip_view(rpc, synced.height, &synced.hash).await; + stop_run_p2p(rpc, node).await; +} + +async fn partial_with_miner_down(miner_dir: &Path, miner_addr: SocketAddr, synced: &Synced) { + let miner = start_miner(miner_dir, miner_addr).await; + assert_eq!(miner.tip_height().unwrap(), synced.height); + assert_eq!(miner.hub.tip_hash().unwrap().to_string(), synced.hash); + + let partial_dir = TestDatadir::new().unwrap(); + let partial_tip = + stop_ibd_below_tip(partial_dir.path().as_path(), miner_addr, synced.height).await; + let held_dir = TestDatadir::new().unwrap(); + copy_dir(&partial_dir.store_path(), &held_dir.store_path()); + + miner.shutdown().await; + let held_rpc = reserve_addr(); + let held_el = reserve_addr(); + let held = spawn_run_p2p(syncer_cfg( + held_dir.path().as_path(), + miner_addr, + held_rpc, + held_el, + )); + expect_process_exit_without_electrum(held, held_el).await; + let held_tip = store_tip(&held_dir.store_path()); + assert!( + held_tip > 0 && held_tip < synced.height, + "miner-down restart tip={held_tip} partial={partial_tip} full={}", + synced.height + ); + + let miner = start_miner(miner_dir, miner_addr).await; + finish_partial(partial_dir.path().as_path(), miner_addr, synced).await; + finish_partial(held_dir.path().as_path(), miner_addr, synced).await; + miner.shutdown().await; +} + +async fn follow_journey() { + let (blocks, script_a, script_b) = fixture_chain(); + let miner_dir = TestDatadir::new().unwrap(); + let miner_addr = reserve_addr(); + let miner = start_miner(miner_dir.path().as_path(), miner_addr).await; + load_chain(&miner, &blocks); + let mut synced = Synced { + height: miner.tip_height().unwrap(), + hash: miner.hub.tip_hash().unwrap().to_string(), + script_a, + script_b, + }; + assert_eq!(synced.height as usize, blocks.len() - 1); + + ibd_then_restart(miner, miner_addr, &mut synced).await; + partial_with_miner_down(miner_dir.path().as_path(), miner_addr, &synced).await; +} From 2ac71d836c30ed7bcdd335b1f694d4b82dfee5a0 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Fri, 2 Oct 2026 14:53:33 -0700 Subject: [PATCH 4/7] store: drop ingest rows a packed scripthash shard now owns A tip append while pass 1 is unsealed lands on ingest and hides the packed multi-script chain. Packing the shard soft-clears those keys. The end-of-IBD journey freezes after DONE.keys and checks Electrum history across that resume, a later block, and the lying include_hwm. --- TESTING.md | 3 +- changelog.d/sh-pack-resume.md | 1 + crates/rbitcoin-store/src/scripthash.rs | 36 +++ crates/rbitcoin-store/src/scripthash_tests.rs | 30 ++ crates/rbitcoin-test/tests/end_of_ibd.rs | 280 +++++++++++++++++- docs/crash-recovery.md | 2 +- 6 files changed, 348 insertions(+), 4 deletions(-) diff --git a/TESTING.md b/TESTING.md index 667b8a05c..4f4a6ff6c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -179,7 +179,7 @@ test bytes are RAM. | Remining 100-block maturity pads with `confirm_wire_run` | `pad_empty_from` / `build_mature_regtest_with_spend` **once per binary journey** (not once per skinny test) | | Wall-time multi-round microbenches in default suite | Deterministic structure / chunk-load asserts; demote wall arms to `#[ignore]` | -**P2P walls:** `two_node_header_and_block_sync`, `three_node_relay_path`, `ibd_two_peers`, `tip_follow_after_ibd`, `tip_follow_getheaders_catches_missed_blocks`, and `node_run_p2p_short` 60s wall (180s under `coverage.sh` / llvm-cov). `serve_after_restart_via_reconstruct` and `end_of_ibd_follow` 90s wall (180s under llvm-cov). `p2p_compact_hb_getblocktxn_and_orphan` 30s wall (90s under llvm-cov). `p2p_timeout_getaddr_and_keepalive_ping`, `p2p_feeler_completes_and_closes`, and `p2p_inbound_full_rejects_extra` 20s wall. Live `P2PNode` tests in `integration_multinode` take a process mutex (shared `rbtc-scripts` pool / confirm OS threads); hub-only reorgs do not. +**P2P walls:** `two_node_header_and_block_sync`, `three_node_relay_path`, `ibd_two_peers`, `tip_follow_after_ibd`, `tip_follow_getheaders_catches_missed_blocks`, and `node_run_p2p_short` 60s wall (180s under `coverage.sh` / llvm-cov). `serve_after_restart_via_reconstruct` and `end_of_ibd_follow` 90s wall (180s under llvm-cov). `end_of_ibd_sh_interrupt` 120s wall (240s under llvm-cov). `p2p_compact_hb_getblocktxn_and_orphan` 30s wall (90s under llvm-cov). `p2p_timeout_getaddr_and_keepalive_ping`, `p2p_feeler_completes_and_closes`, and `p2p_inbound_full_rejects_extra` 20s wall. Live `P2PNode` tests in `integration_multinode` take a process mutex (shared `rbtc-scripts` pool / confirm OS threads); hub-only reorgs do not. **Speed / reliability (default suite):** prefer `pad_empty_from` / `build_mature_regtest_with_spend` **once per journey** (tx_relay live hub, Electrum protocol, core_analogs assumevalid+mempool) over remine pads; SH run-builder sleeps are 1 ms under `cfg(test)` (40 ms in production). `pin_compose_multi_pack_timed` keeps functional + layout/covered short-circuit gates (multi-ms floor); sticky vs cold assemble is log-only (not a hard timing assert). Schema-13 wire rebuild must stamp create identity from `txid.body` — zero batch identity is treated as missing (regression covered by the spend reconstruct in `consensus_mature_chain_spend_reconstruct_and_scripthash` + multi-vout confirm scenarios). Coverage vs speed: prefer **one** scenario at the real entry over N micro-opens that only paint lines; when adding coverage for reduce/materialize, use a **tiny** target, not production stream depth. @@ -371,6 +371,7 @@ Prefer **one high-level scenario** per behavior cluster. Delete lower-level test | `tip_follow_after_ibd` | P2P (**default**) | After IBD, follow + one new tip via inv/headers. With the filter index on, IBD confirm writes no basic filters; `rbtc-idx-wb` materializes them to the tip (its caught-up callback fires once, at the tip), then seals the followed block. With `--sp-tweaks` too: the builder brings both indexes to 5 and is stopped; a followed block with no builder running moves neither (no confirm path writes index data); a new builder seals 6, then follows 7 | | `tip_follow_getheaders_catches_missed_blocks` | P2P (**default**) | Blocks mined while disconnected fill via post-connect `getheaders` | | `end_of_ibd_follow` | P2P (**default**) | Miner plus `--sh-index` syncer. One mature regtest: coinbases pay script A, one spend pays script B. IBD reaches that tip, leaves IBD, and Electrum history matches. The next block tip-follows. Restart does not rewrite the scripthash pack mark; one more block arrives by write-behind. Cancelling IBD once the height is below the miner, then `run_p2p`, finishes the same tip and history. With the syncer caught up, dropping the miner leaves tip follow (not IBD). A partial datadir whose miner is down does not open Electrum and stays short of that tip; the same miner address coming back lets that datadir finish | +| `end_of_ibd_sh_interrupt` | P2P (**default**) | Same miner and scripts. The syncer first catches up with `--sh-index` off, then the datadir is frozen after pass 1 (`DONE.keys`, no `DONE.post`). Resume builds the index and Electrum's first answer is the full A/B history; restart does not rewrite the pack mark. A block mined after the freeze is in that history. A copy that already has the block, with `include_hwm` at the new tip and that create appended on the unsealed head, still serves the same history and does not open Electrum early | | `node_run_p2p_short` | Node (**default**) | Product `run_p2p` `--blocks-only` `--connect` to a live seeder (`--max-tip-age` so the 3-block pad is not stale IBD); process `getpeerinfo` / `getconnectioncount` / `getnetworkinfo` / `getnettotals` / `ping` while connected (v2 outbound-full-relay; handshake `startingheight` equals the seeder tip; `timeoffset` present; `synced_headers`/`synced_blocks` stay `-1` until the peer announces a header hash (empty getheaders at tip does not copy VERSION height); `servicesnames` present; `getnetworkinfo.timeoffset` present); after catch-up `localrelay` / mempool `relay_enabled` stay false and `sendrawtransaction` is not `relay disabled`; Electrum `broadcast` and Esplora `POST /tx` admit decode/consensus errors (not hub-missing / not `relay disabled`); `addconnection inbound` refuses; `disconnectnode` unknown `nodeid` / empty params error then a real addr drops that row from the next `getpeerinfo`, the seeder sees it go, and a second `disconnectnode` is `-29`; `addnode onetry` reconnects as `manual` and the seeder sees the inbound; seeder inbound `tx` then disconnects. Exit via `stop`. `max_run_secs=0` is `node_listen_and_exit`. Mock-clock `timeoffset` median (odd N, even N upper-middle, inbound-only 0, peer clock behind), connecting dummy `-1`, header-only vs connected `synced_blocks`, query-without-chain, `pingwait` / `NETWORK_LIMITED` / `noban` stay RPC guts | Removed (covered by the rows above): `confirm_cross_block_prevout_without_tx_head`, diff --git a/changelog.d/sh-pack-resume.md b/changelog.d/sh-pack-resume.md index 95578a819..b98a59cf9 100644 --- a/changelog.d/sh-pack-resume.md +++ b/changelog.d/sh-pack-resume.md @@ -2,3 +2,4 @@ Fixed - A scripthash pass-1 MPHF without `scripthash.head/NN.packed` is not a durable head. Restart after `DONE.keys` resumes pass 2 and pack instead of reporting Electrum-ready on an index that has no multi-script history. A finished unmarked head is soft-migrated. - Electrum stays down when a durable scripthash head's inclusion floor is behind the tip, and the same process binds it once write-behind catches up. A cancelled extract names `scripthash.cold_progress` or `scripthash.unsorted` only when that path is on disk. +- A tip append onto an unsealed pass-1 head no longer hides the packed multi-script chain. Packing the shard drops that ingest row once main owns the key. diff --git a/crates/rbitcoin-store/src/scripthash.rs b/crates/rbitcoin-store/src/scripthash.rs index e8eb28660..eefa3c629 100644 --- a/crates/rbitcoin-store/src/scripthash.rs +++ b/crates/rbitcoin-store/src/scripthash.rs @@ -2743,6 +2743,7 @@ impl ScriptHashTable { write_alloc_header(body, &state)?; *self.shard_alloc(shard).lock().unwrap() = state; self.note_shard_packed(shard)?; + self.drop_ingest_covered_by_packed_main(shard)?; Ok(()) } @@ -2780,9 +2781,44 @@ impl ScriptHashTable { write_alloc_header(body, &state)?; *self.shard_alloc(shard).lock().unwrap() = state; self.note_shard_packed(shard)?; + self.drop_ingest_covered_by_packed_main(shard)?; Ok(bump) } + /// A tip append while this shard is unsealed lands on ingest (main MPHF is + /// not loaded). Lookup reads ingest first, so that row would hide the + /// packed chain. Clear ingest keys this shard's main now owns. Ingest body + /// bytes stay; only the head slot is soft-cleared. + fn drop_ingest_covered_by_packed_main(&self, shard: usize) -> Result<(), StoreError> { + let Some(slot) = self.sorted_main.get(shard) else { + return Ok(()); + }; + let main = slot.read().unwrap(); + let Some(head) = main.as_ref() else { + return Ok(()); + }; + let mut covered = Vec::new(); + self.ingest.lock().unwrap().for_each_occupied(|key, _val| { + if self.shard_index(&key) != shard { + return Ok(()); + } + let hk = head_key_from_full(&key); + if head.get(&hk)?.is_some() { + covered.push(key); + } + Ok(()) + })?; + drop(main); + if covered.is_empty() { + return Ok(()); + } + let ingest = self.ingest.lock().unwrap(); + for key in &covered { + ingest.clear_key(key)?; + } + Ok(()) + } + fn note_shard_packed(&self, shard: usize) -> Result<(), StoreError> { let path = sorted_main_shard_path(&self.store_dir, shard, self.n_shards); write_shard_pack_mark(&path)?; diff --git a/crates/rbitcoin-store/src/scripthash_tests.rs b/crates/rbitcoin-store/src/scripthash_tests.rs index db63eebcb..d34e4064a 100644 --- a/crates/rbitcoin-store/src/scripthash_tests.rs +++ b/crates/rbitcoin-store/src/scripthash_tests.rs @@ -2838,6 +2838,36 @@ fn pass1_lying_include_hwm_still_resumes_pass2() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn pass1_ingest_append_does_not_hide_packed_history() { + let dir = tmp(); + let s = crate::Store::create_tiny(&dir).unwrap(); + let script = vec![0x51]; + s.put_tx_full_batch_indexed(&[class_a_coinbase([1u8; 32], script.clone())], true) + .unwrap(); + s.put_tx_full_batch_indexed(&[class_a_coinbase([2u8; 32], script.clone())], true) + .unwrap(); + let n_shards = s.scripthash.head_shard_count(); + let udir = crate::unsorted_shard_dir(s.path()); + crate::collect_unsorted_shard_files(&s, &udir, n_shards, 1, None).unwrap(); + let rec = ScriptHashRecord::from_fk(script_hash(&script), Fk(2)); + let mut heads = std::collections::HashMap::new(); + s.scripthash + .put_create_batch_append(&[rec], &mut heads) + .unwrap(); + crate::materialize_sh_unsorted_from_class_a(&s, 1, 1, None).unwrap(); + let mut fks: Vec = s + .scripthash + .entries(&script_hash(&script)) + .unwrap() + .into_iter() + .map(|e| e.0 .0) + .collect(); + fks.sort_unstable(); + assert_eq!(fks, vec![1, 2]); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn legacy_unmarked_head_soft_migrates_without_rescan() { let dir = tmp(); diff --git a/crates/rbitcoin-test/tests/end_of_ibd.rs b/crates/rbitcoin-test/tests/end_of_ibd.rs index 18b118605..734fb7bca 100644 --- a/crates/rbitcoin-test/tests/end_of_ibd.rs +++ b/crates/rbitcoin-test/tests/end_of_ibd.rs @@ -8,8 +8,9 @@ use rbitcoin_consensus::{ChainParams, Milestone}; use rbitcoin_electrum::electrum_scripthash_hex; use rbitcoin_net::{IbdConfig, NetAddr, P2PNode}; use rbitcoin_node::{run_p2p, NodeConfig}; -use rbitcoin_primitives::Network; +use rbitcoin_primitives::{Fk, Height, Network}; use rbitcoin_query::Query; +use rbitcoin_store::{script_hash, ScriptHashRecord}; use rbitcoin_test::mine::{mine_regtest_block, regtest_genesis}; use rbitcoin_test::TestDatadir; use serde_json::{json, Value}; @@ -118,6 +119,16 @@ fn load_chain(miner: &P2PNode, blocks: &[bitcoin::Block]) { } fn syncer_cfg(dir: &Path, miner: SocketAddr, rpc: SocketAddr, electrum: SocketAddr) -> NodeConfig { + syncer_cfg_sh(dir, miner, rpc, electrum, true) +} + +fn syncer_cfg_sh( + dir: &Path, + miner: SocketAddr, + rpc: SocketAddr, + electrum: SocketAddr, + shindex: bool, +) -> NodeConfig { let mut cfg = NodeConfig::default() .with_datadir(dir) .with_network(Network::Regtest) @@ -126,7 +137,7 @@ fn syncer_cfg(dir: &Path, miner: SocketAddr, rpc: SocketAddr, electrum: SocketAd cfg.listen.connect = vec![NetAddr::Ip(miner)]; cfg.listen.use_seeds = false; cfg.listen.electrum = Some(electrum); - cfg.shindex = true; + cfg.shindex = shindex; cfg.rpc.listen = Some(rpc); cfg.max_tip_age_secs = Some(u64::MAX); std::fs::write(dir.join("rpc.token"), "pass").unwrap(); @@ -559,3 +570,268 @@ async fn follow_journey() { ibd_then_restart(miner, miner_addr, &mut synced).await; partial_with_miner_down(miner_dir.path().as_path(), miner_addr, &synced).await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn end_of_ibd_sh_interrupt() { + let _live = live_p2p_lock().await; + let wall = llvm_cov_wall(120, 240); + tokio::time::timeout(wall, sh_interrupt_journey()) + .await + .expect("end_of_ibd_sh_interrupt wall"); +} + +async fn wait_tip_electrum_down(rpc: SocketAddr, electrum: SocketAddr, height: u32, hash: &str) { + let deadline = Instant::now() + Duration::from_secs(40); + loop { + assert!( + TcpStream::connect(electrum).await.is_err(), + "Electrum listened while scripthash indexing was off" + ); + if TcpStream::connect(rpc).await.is_ok() { + let count = jsonrpc(rpc, "getblockcount", json!([])).await; + let best = jsonrpc(rpc, "getbestblockhash", json!([])).await; + if count["result"].as_u64() == Some(u64::from(height)) + && best["result"].as_str() == Some(hash) + { + return; + } + } + if Instant::now() >= deadline { + panic!("shindex-off syncer did not reach height {height} {hash}"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn catch_without_index(dir: &Path, miner: SocketAddr, height: u32, hash: &str) { + let rpc = reserve_addr(); + let electrum = reserve_addr(); + let node = spawn_run_p2p(syncer_cfg_sh(dir, miner, rpc, electrum, false)); + wait_tip_electrum_down(rpc, electrum, height, hash).await; + stop_run_p2p(rpc, node).await; +} + +fn freeze_pass1(store: &Path) { + let query = Query::open_or_create_tiny(store).unwrap(); + let n_shards = query.store().scripthash.head_shard_count(); + let udir = rbitcoin_store::unsorted_shard_dir(query.store().path()); + rbitcoin_store::collect_unsorted_shard_files(query.store(), &udir, n_shards, 1, None).unwrap(); + assert!(udir.join("DONE.keys").is_file(), "pass 1 writes DONE.keys"); + assert!( + !udir.join("DONE.post").is_file(), + "pass 1 stops before DONE.post" + ); + assert!( + !query.store().scripthash.has_durable_index(), + "pass-1 mphf is not a packed head" + ); + assert!(query.store().txs.count() > 1); +} + +struct Resume { + node: tokio::task::JoinHandle>, + rpc: SocketAddr, + electrum: SocketAddr, +} + +async fn resume_until_history( + dir: &Path, + miner: SocketAddr, + height: u32, + hash: &str, + script_a: &BTreeSet, + script_b: &BTreeSet, +) -> Resume { + let rpc = reserve_addr(); + let electrum = reserve_addr(); + let node = spawn_run_p2p(syncer_cfg_sh(dir, miner, rpc, electrum, true)); + let deadline = Instant::now() + Duration::from_secs(60); + loop { + if TcpStream::connect(electrum).await.is_ok() { + let hist_a = history_txids(electrum, &[0x51]).await; + let hist_b = history_txids(electrum, &[0x52]).await; + assert_eq!( + &hist_a, script_a, + "Electrum opened before script A history was complete" + ); + assert_eq!( + &hist_b, script_b, + "Electrum opened before script B history was complete" + ); + let count = jsonrpc(rpc, "getblockcount", json!([])).await; + let best = jsonrpc(rpc, "getbestblockhash", json!([])).await; + assert_eq!(count["result"].as_u64(), Some(u64::from(height)), "{count}"); + assert_eq!(best["result"].as_str(), Some(hash), "{best}"); + assert_tip_view(rpc, height, hash).await; + return Resume { + node, + rpc, + electrum, + }; + } + if Instant::now() >= deadline { + panic!("pass-1 resume never served Electrum at {height} {hash}"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn resume_pass1_and_restart( + dir: &Path, + miner: SocketAddr, + height: u32, + hash: &str, + script_a: &BTreeSet, + script_b: &BTreeSet, +) -> SystemTime { + let resumed = resume_until_history(dir, miner, height, hash, script_a, script_b).await; + let store = dir.join("store"); + let mark = pack_mark(&store); + let packed_at = mark_mtime(&mark); + assert!(!store.join("scripthash.unsorted").is_dir()); + stop_run_p2p(resumed.rpc, resumed.node).await; + + let again = resume_until_history(dir, miner, height, hash, script_a, script_b).await; + assert_eq!(mark_mtime(&mark), packed_at, "restart collected again"); + assert!(!store.join("scripthash.unsorted").is_dir()); + stop_run_p2p(again.rpc, again.node).await; + packed_at +} + +fn mine_one_a(miner: &P2PNode) -> (u32, String, String) { + miner + .hub + .generate_to_script(1, ScriptBuf::from_bytes(vec![0x51]), vec![]) + .unwrap(); + let height = miner.tip_height().unwrap(); + let hash = miner.hub.tip_hash().unwrap().to_string(); + let ids = miner.query.block_txids(Height(height)).unwrap(); + let txid: String = ids[0].iter().rev().map(|b| format!("{b:02x}")).collect(); + (height, hash, txid) +} + +async fn contaminate_writebehind(dir: &Path, miner: &P2PNode, height: u32) { + let block = miner + .query + .reconstruct_block_at_height(Height(height)) + .unwrap(); + let store = dir.join("store"); + let before = Query::open_or_create_tiny(&store) + .unwrap() + .store() + .txs + .count(); + let query = Query::open_or_create_tiny(&store).unwrap(); + let node = P2PNode::start( + "127.0.0.1:0".parse().unwrap(), + query, + ChainParams::regtest(), + Milestone::NONE, + ) + .await + .expect("contaminated syncer"); + node.ingest_block(height, block) + .unwrap_or_else(|e| panic!("write-behind ingest: {e}")); + let count = node.query.store().txs.count(); + assert!(count > before, "ingested block did not grow Class A"); + assert!( + !node.query.store().scripthash.has_durable_index(), + "write-behind on a pass-1 head must not pack it" + ); + if node.query.store().scripthash.include_hwm() < count { + let fk = Fk(count); + let rec = ScriptHashRecord::from_fk(script_hash(&[0x51]), fk); + let mut heads = std::collections::HashMap::new(); + node.query + .store() + .scripthash + .put_create_batch_append(&[rec], &mut heads) + .unwrap(); + std::fs::write( + store.join(rbitcoin_store::INCLUDE_HWM_NAME), + count.to_le_bytes(), + ) + .unwrap(); + } + let udir = store.join("scripthash.unsorted"); + assert!(udir.join("DONE.keys").is_file()); + assert!(!udir.join("DONE.post").is_file()); + node.shutdown().await; + let query = Query::open_or_create_tiny(&store).unwrap(); + assert!(!query.store().scripthash.has_durable_index()); + assert_eq!(query.store().scripthash.include_hwm(), count); +} + +async fn sh_interrupt_journey() { + let (blocks, script_a, script_b) = fixture_chain(); + let miner_dir = TestDatadir::new().unwrap(); + let miner_addr = reserve_addr(); + let miner = start_miner(miner_dir.path().as_path(), miner_addr).await; + load_chain(&miner, &blocks); + let height = miner.tip_height().unwrap(); + let hash = miner.hub.tip_hash().unwrap().to_string(); + assert_eq!(height as usize, blocks.len() - 1); + + let frozen = TestDatadir::new().unwrap(); + catch_without_index(frozen.path().as_path(), miner_addr, height, &hash).await; + freeze_pass1(&frozen.store_path()); + let gap = TestDatadir::new().unwrap(); + let lie = TestDatadir::new().unwrap(); + copy_dir(&frozen.store_path(), &gap.store_path()); + copy_dir(&frozen.store_path(), &lie.store_path()); + + let packed_at = resume_pass1_and_restart( + frozen.path().as_path(), + miner_addr, + height, + &hash, + &script_a, + &script_b, + ) + .await; + + let (new_height, new_hash, txid) = mine_one_a(&miner); + let mut with_new = script_a.clone(); + with_new.insert(txid); + let resumed = resume_until_history( + gap.path().as_path(), + miner_addr, + new_height, + &new_hash, + &with_new, + &script_b, + ) + .await; + stop_run_p2p(resumed.rpc, resumed.node).await; + + contaminate_writebehind(lie.path().as_path(), &miner, new_height).await; + let resumed = resume_until_history( + lie.path().as_path(), + miner_addr, + new_height, + &new_hash, + &with_new, + &script_b, + ) + .await; + stop_run_p2p(resumed.rpc, resumed.node).await; + + let mark = pack_mark(&frozen.store_path()); + let resumed = resume_until_history( + frozen.path().as_path(), + miner_addr, + new_height, + &new_hash, + &with_new, + &script_b, + ) + .await; + assert_eq!( + mark_mtime(&mark), + packed_at, + "write-behind rewrote the pack mark" + ); + assert_eq!(history_txids(resumed.electrum, &[0x51]).await, with_new); + stop_run_p2p(resumed.rpc, resumed.node).await; + miner.shutdown().await; +} diff --git a/docs/crash-recovery.md b/docs/crash-recovery.md index ed958fd82..eb0f41737 100644 --- a/docs/crash-recovery.md +++ b/docs/crash-recovery.md @@ -117,7 +117,7 @@ Private, **not** Class A. RAM graph is source of truth; files may lag. - **Segmented `tx.head`:** directory `tx.head/` with `meta` + open OA `NNNNNN`; sealed `NNNNNN.mphf` + `.fuse8`. Packed BDZ `g` is FdOnly (4 KiB page stream); MPHF output is `rel−1`. Flat `tx.head.meta` / `tx.head.NNNNNN` are **migrated into** `tx.head/` on open. Roll opens the next OA first; seal runs on a sidecar and publishes later. Seal/install of `meta` / `.mphf` is sibling tmp + `sync_all` then rename (empty truncate of the live name is not a seal). Publish persists sealed `meta` **before** unlinking the segment's OA; a leftover OA next to a sealed `.mphf` is discarded on open. Kill mid-seal leaves **at most one** unsealed non-tail OA: open collects fuse keys once from `txid.body` and seals it (does not retain `open_keys`). Two unsealed non-tails is **Corrupt**. Leftover fuse8 v1 and flat `tx.head.meta` **refuse** (`Query::open`); wipe `store/tx.head` (Class A kept) then restart. Unreadable **current** head (empty/truncated `meta`, truncated/missing sealed MPHF) with Class A: wipe+rebuild from `txid.body` (same cost as a clean wipe). Wipe or empty occupancy + Class A: open rebuilds **MPHF+fuse8 directly** from `txid.body` in parallel (default **2²⁵** keys/range; `RBITCOIN_TX_HEAD_REBUILD_WORKERS`); no historical OA. Legacy mono `tx.head` file / `.new` / `.resize` are not opened — reindex. - Scripthash: Direct IBD **defers** SH (no memtable, no confirm enqueue). After the horizon, two Class A `txout` scans: each worker owns a contiguous create-fk span and unsized maps (1.5 GiB estimate cap; spill the largest shard map while over budget; one writer, 1-slot queue) into `scripthash.unsorted/keys/NN/` (`SHKSP01` files, first-fk delta singles; tmp+rename, not a kill-9 barrier — no `DONE.keys` still wipes unsorted); merge folds those spills into one map, one walk to `scripthash.head/NN` + `multi/NN.fuse8`, and unlinks `keys/NN/`; then fuse-hit `post` (`SHPST01` spills under `post/NN/`), pack folds those spills then 2+ bodies, unlink each shard's extract as it seals. A **durable head** on restart stays Tip: write-behind / `recover_sh_writebehind` fills any HWM lag; leftover `scripthash.runs` are discarded (not WarmOnly-merged). - **Full cold** when head empty. **`RBITCOIN_SH_FORCE_REBUILD=1`:** wipe head + full two-scan collect + pack. Empty collect after Class A creates remain is fatal. - - **Cold resume:** the pack commit is `scripthash.head/NN.packed` (sibling of the MPHF base; holes stay). Open loads a shard's MPHF only when that mark exists. `MphfHead::exists` after pass-1 BDZ is **not** pack-done. A complete unmarked head (every shard has `.mphf`+`.val`, and `scripthash.unsorted` has no `DONE.keys` / `DONE.post` / `keys/` / `post/`) is soft-migrated: the marks are written, and a missing `include_hwm` is set from the create count. An extract still on disk never gets a mark, even if `include_hwm` already names the tip. No valid `DONE.keys` (including leftover `SHUNSRT3` / 24 B `NN`, or `keys/NN` / `post/NN` as a file) deletes unsorted and restarts pass 1. A spill whose first 8 B are not `SHKSP01` is Corrupt — wipe unsorted; do not parse or migrate. `DONE.keys` / `DONE.post` record the inclusive Class A `create_fk` scanned (`SHKEYS02` last_fk marker / `SHPOST02`). Missing current `keys/NN/` spills with unsealed shards finishes BDZ from those files. Head files present and no `DONE.post` restarts pass 2 from fk 1 (partial `post/` spills are deleted and recollected). A `post/NN` **file**, or a spill whose first 8 B are not `SHPST01`, is Corrupt (wipe unsorted). If Class A grew after `DONE.post` and **no** shards are packed, append postings. If any shard is already pack-marked, pack remaining unsealed `post/NN/`, then Class A tail-append onto the durable head (Direct write-behind no-ops). After all shards seal, the unsorted dir is removed. + - **Cold resume:** the pack commit is `scripthash.head/NN.packed` (sibling of the MPHF base; holes stay). Open loads a shard's MPHF only when that mark exists. `MphfHead::exists` after pass-1 BDZ is **not** pack-done. A complete unmarked head (every shard has `.mphf`+`.val`, and `scripthash.unsorted` has no `DONE.keys` / `DONE.post` / `keys/` / `post/`) is soft-migrated: the marks are written, and a missing `include_hwm` is set from the create count. An extract still on disk never gets a mark, even if `include_hwm` already names the tip. No valid `DONE.keys` (including leftover `SHUNSRT3` / 24 B `NN`, or `keys/NN` / `post/NN` as a file) deletes unsorted and restarts pass 1. A spill whose first 8 B are not `SHKSP01` is Corrupt — wipe unsorted; do not parse or migrate. `DONE.keys` / `DONE.post` record the inclusive Class A `create_fk` scanned (`SHKEYS02` last_fk marker / `SHPOST02`). Missing current `keys/NN/` spills with unsealed shards finishes BDZ from those files. Head files present and no `DONE.post` restarts pass 2 from fk 1 (partial `post/` spills are deleted and recollected). Packing a shard soft-clears ingest keys that packed main now owns, so a tip append that landed on ingest while the shard was unsealed cannot hide that chain. A `post/NN` **file**, or a spill whose first 8 B are not `SHPST01`, is Corrupt (wipe unsorted). If Class A grew after `DONE.post` and **no** shards are packed, append postings. If any shard is already pack-marked, pack remaining unsealed `post/NN/`, then Class A tail-append onto the durable head (Direct write-behind no-ops). After all shards seal, the unsorted dir is removed. Empty head + leftover catalog: wipe leftover runs + SEAL, then Class A collect (not k-way from `scripthash.runs`). Durable head: leftover runs are discarded, `SEAL` kept; missing `include_hwm` bootstraps from SEAL. Inclusion HWM: `scripthash.include_hwm`. **Leftover live OA** at `scripthash.head` (or non-`SHSR` `ovf/NNNNNN`): refuse — wipe `store/scripthash*` and restart with `--shindex`. **SH head open:** sealed **main** shards load `.idx` only (one entry per 128 records; no fuse). Sealed **ovf** loads `.idx` + BF8R. Occupancy scan is not From 89b37f69e2c3e5839705e46cf7d0fa7df09be5ca Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Fri, 2 Oct 2026 15:29:25 -0700 Subject: [PATCH 5/7] test: pin IBD on the heavier fork when a taller chain has less work Regtest retargeting plus min-difficulty makes one miner taller and weaker. The syncer adopts the heavier tip, keeps it across a reopen, and follows the next heavy block. --- TESTING.md | 3 +- crates/rbitcoin-test/src/mine.rs | 18 ++- crates/rbitcoin-test/tests/end_of_ibd.rs | 177 ++++++++++++++++++++++- 3 files changed, 193 insertions(+), 5 deletions(-) diff --git a/TESTING.md b/TESTING.md index 4f4a6ff6c..b7e10ed50 100644 --- a/TESTING.md +++ b/TESTING.md @@ -179,7 +179,7 @@ test bytes are RAM. | Remining 100-block maturity pads with `confirm_wire_run` | `pad_empty_from` / `build_mature_regtest_with_spend` **once per binary journey** (not once per skinny test) | | Wall-time multi-round microbenches in default suite | Deterministic structure / chunk-load asserts; demote wall arms to `#[ignore]` | -**P2P walls:** `two_node_header_and_block_sync`, `three_node_relay_path`, `ibd_two_peers`, `tip_follow_after_ibd`, `tip_follow_getheaders_catches_missed_blocks`, and `node_run_p2p_short` 60s wall (180s under `coverage.sh` / llvm-cov). `serve_after_restart_via_reconstruct` and `end_of_ibd_follow` 90s wall (180s under llvm-cov). `end_of_ibd_sh_interrupt` 120s wall (240s under llvm-cov). `p2p_compact_hb_getblocktxn_and_orphan` 30s wall (90s under llvm-cov). `p2p_timeout_getaddr_and_keepalive_ping`, `p2p_feeler_completes_and_closes`, and `p2p_inbound_full_rejects_extra` 20s wall. Live `P2PNode` tests in `integration_multinode` take a process mutex (shared `rbtc-scripts` pool / confirm OS threads); hub-only reorgs do not. +**P2P walls:** `two_node_header_and_block_sync`, `three_node_relay_path`, `ibd_two_peers`, `tip_follow_after_ibd`, `tip_follow_getheaders_catches_missed_blocks`, and `node_run_p2p_short` 60s wall (180s under `coverage.sh` / llvm-cov). `serve_after_restart_via_reconstruct` and `end_of_ibd_follow` 90s wall (180s under llvm-cov). `end_of_ibd_sh_interrupt` 120s wall (240s under llvm-cov). `end_of_ibd_work_fork` 150s wall (300s under llvm-cov). `p2p_compact_hb_getblocktxn_and_orphan` 30s wall (90s under llvm-cov). `p2p_timeout_getaddr_and_keepalive_ping`, `p2p_feeler_completes_and_closes`, and `p2p_inbound_full_rejects_extra` 20s wall. Live `P2PNode` tests in `integration_multinode` take a process mutex (shared `rbtc-scripts` pool / confirm OS threads); hub-only reorgs do not. **Speed / reliability (default suite):** prefer `pad_empty_from` / `build_mature_regtest_with_spend` **once per journey** (tx_relay live hub, Electrum protocol, core_analogs assumevalid+mempool) over remine pads; SH run-builder sleeps are 1 ms under `cfg(test)` (40 ms in production). `pin_compose_multi_pack_timed` keeps functional + layout/covered short-circuit gates (multi-ms floor); sticky vs cold assemble is log-only (not a hard timing assert). Schema-13 wire rebuild must stamp create identity from `txid.body` — zero batch identity is treated as missing (regression covered by the spend reconstruct in `consensus_mature_chain_spend_reconstruct_and_scripthash` + multi-vout confirm scenarios). Coverage vs speed: prefer **one** scenario at the real entry over N micro-opens that only paint lines; when adding coverage for reduce/materialize, use a **tiny** target, not production stream depth. @@ -372,6 +372,7 @@ Prefer **one high-level scenario** per behavior cluster. Delete lower-level test | `tip_follow_getheaders_catches_missed_blocks` | P2P (**default**) | Blocks mined while disconnected fill via post-connect `getheaders` | | `end_of_ibd_follow` | P2P (**default**) | Miner plus `--sh-index` syncer. One mature regtest: coinbases pay script A, one spend pays script B. IBD reaches that tip, leaves IBD, and Electrum history matches. The next block tip-follows. Restart does not rewrite the scripthash pack mark; one more block arrives by write-behind. Cancelling IBD once the height is below the miner, then `run_p2p`, finishes the same tip and history. With the syncer caught up, dropping the miner leaves tip follow (not IBD). A partial datadir whose miner is down does not open Electrum and stays short of that tip; the same miner address coming back lets that datadir finish | | `end_of_ibd_sh_interrupt` | P2P (**default**) | Same miner and scripts. The syncer first catches up with `--sh-index` off, then the datadir is frozen after pass 1 (`DONE.keys`, no `DONE.post`). Resume builds the index and Electrum's first answer is the full A/B history; restart does not rewrite the pack mark. A block mined after the freeze is in that history. A copy that already has the block, with `include_hwm` at the new tip and that create appended on the unsealed head, still serves the same history and does not open Electrum early | +| `end_of_ibd_work_fork` | P2P (**default**) | Two miners on a regtest with retargeting and min-difficulty. One chain retargets harder and stops shorter. The other forks after that retarget, resets to the pow limit, and grows taller with less work. The syncer IBD-adopts the heavier tip, keeps it across a reopen, and follows one more heavy block while the tall chain is still ahead | | `node_run_p2p_short` | Node (**default**) | Product `run_p2p` `--blocks-only` `--connect` to a live seeder (`--max-tip-age` so the 3-block pad is not stale IBD); process `getpeerinfo` / `getconnectioncount` / `getnetworkinfo` / `getnettotals` / `ping` while connected (v2 outbound-full-relay; handshake `startingheight` equals the seeder tip; `timeoffset` present; `synced_headers`/`synced_blocks` stay `-1` until the peer announces a header hash (empty getheaders at tip does not copy VERSION height); `servicesnames` present; `getnetworkinfo.timeoffset` present); after catch-up `localrelay` / mempool `relay_enabled` stay false and `sendrawtransaction` is not `relay disabled`; Electrum `broadcast` and Esplora `POST /tx` admit decode/consensus errors (not hub-missing / not `relay disabled`); `addconnection inbound` refuses; `disconnectnode` unknown `nodeid` / empty params error then a real addr drops that row from the next `getpeerinfo`, the seeder sees it go, and a second `disconnectnode` is `-29`; `addnode onetry` reconnects as `manual` and the seeder sees the inbound; seeder inbound `tx` then disconnects. Exit via `stop`. `max_run_secs=0` is `node_listen_and_exit`. Mock-clock `timeoffset` median (odd N, even N upper-middle, inbound-only 0, peer clock behind), connecting dummy `-1`, header-only vs connected `synced_blocks`, query-without-chain, `pingwait` / `NETWORK_LIMITED` / `noban` stay RPC guts | Removed (covered by the rows above): `confirm_cross_block_prevout_without_tx_head`, diff --git a/crates/rbitcoin-test/src/mine.rs b/crates/rbitcoin-test/src/mine.rs index 657f77db8..ce43c014a 100644 --- a/crates/rbitcoin-test/src/mine.rs +++ b/crates/rbitcoin-test/src/mine.rs @@ -122,12 +122,28 @@ pub fn mine_regtest_block( prev_hash: BlockHash, time: u32, height: u32, + extra_txs: Vec, +) -> Block { + mine_regtest_block_at( + prev_hash, + time, + height, + CompactTarget::from_consensus(0x207f_ffff), + extra_txs, + ) +} + +/// Mine a regtest block at an explicit `nBits` and timestamp. +pub fn mine_regtest_block_at( + prev_hash: BlockHash, + time: u32, + height: u32, + bits: CompactTarget, mut extra_txs: Vec, ) -> Block { let mut txdata = vec![coinbase_tx(height, Amount::from_sat(50_0000_0000))]; txdata.append(&mut extra_txs); - let bits = CompactTarget::from_consensus(0x207f_ffff); let header = Header { version: Version::from_consensus(4), prev_blockhash: prev_hash, diff --git a/crates/rbitcoin-test/tests/end_of_ibd.rs b/crates/rbitcoin-test/tests/end_of_ibd.rs index 734fb7bca..fb6a57042 100644 --- a/crates/rbitcoin-test/tests/end_of_ibd.rs +++ b/crates/rbitcoin-test/tests/end_of_ibd.rs @@ -3,15 +3,18 @@ use bitcoin::absolute::LockTime; use bitcoin::script::ScriptBuf; use bitcoin::transaction::Version as TxVersion; -use bitcoin::{Amount, OutPoint, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; -use rbitcoin_consensus::{ChainParams, Milestone}; +use bitcoin::{ + Amount, BlockHash, CompactTarget, OutPoint, Sequence, Transaction, TxIn, TxOut, Txid, Witness, + Work, +}; +use rbitcoin_consensus::{next_work_bits, ChainParams, Milestone}; use rbitcoin_electrum::electrum_scripthash_hex; use rbitcoin_net::{IbdConfig, NetAddr, P2PNode}; use rbitcoin_node::{run_p2p, NodeConfig}; use rbitcoin_primitives::{Fk, Height, Network}; use rbitcoin_query::Query; use rbitcoin_store::{script_hash, ScriptHashRecord}; -use rbitcoin_test::mine::{mine_regtest_block, regtest_genesis}; +use rbitcoin_test::mine::{mine_regtest_block, mine_regtest_block_at, regtest_genesis}; use rbitcoin_test::TestDatadir; use serde_json::{json, Value}; use std::collections::BTreeSet; @@ -835,3 +838,171 @@ async fn sh_interrupt_journey() { stop_run_p2p(resumed.rpc, resumed.node).await; miner.shutdown().await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn end_of_ibd_work_fork() { + let _live = live_p2p_lock().await; + let wall = llvm_cov_wall(150, 300); + tokio::time::timeout(wall, work_fork_journey()) + .await + .expect("end_of_ibd_work_fork wall"); +} + +fn fork_params() -> ChainParams { + let mut params = ChainParams::regtest(); + let spacing = params.btc.pow_target_spacing; + params.btc.no_pow_retargeting = false; + params.btc.allow_min_difficulty_blocks = true; + params.btc.pow_target_timespan = spacing.saturating_mul(20); + params +} + +fn expected_bits( + params: &ChainParams, + height: u32, + chain: &[bitcoin::Block], + time: u32, +) -> CompactTarget { + let prev = &chain[(height - 1) as usize].header; + let interval = params.difficulty_adjustment_interval(); + let first = if interval > 0 && height.is_multiple_of(interval) && !params.no_pow_retargeting() { + Some(chain[(height - interval) as usize].header.time) + } else { + None + }; + next_work_bits(params, height, prev.bits, prev.time, time, first, |h| { + chain.get(h as usize).map(|block| block.header.bits) + }) + .expect("next bits") +} + +fn chain_work(blocks: &[bitcoin::Block]) -> Work { + blocks + .iter() + .fold(Work::from_be_bytes([0u8; 32]), |acc, block| { + acc + block.header.work() + }) +} + +fn push_block(params: &ChainParams, chain: &mut Vec, time: u32) { + let height = chain.len() as u32; + let bits = expected_bits(params, height, chain, time); + let prev = chain.last().unwrap().block_hash(); + chain.push(mine_regtest_block_at(prev, time, height, bits, vec![])); +} + +fn build_forks(params: &ChainParams) -> (Vec, Vec) { + let mut heavy = vec![regtest_genesis()]; + let mut time = heavy[0].header.time; + for _ in 1..=25 { + time = time.saturating_add(1); + push_block(params, &mut heavy, time); + } + let mut light = heavy[..=20].to_vec(); + let step = (params.btc.pow_target_spacing as u32).saturating_mul(2) + 1; + for _ in 21..=32 { + let prev_time = light.last().unwrap().header.time; + push_block(params, &mut light, prev_time.saturating_add(step)); + } + (heavy, light) +} + +async fn start_node(dir: &Path, addr: SocketAddr, params: ChainParams) -> P2PNode { + let query = Query::open_or_create_tiny(dir.join("store")).unwrap(); + let node = P2PNode::start(addr, query, params, Milestone::NONE) + .await + .expect("listen"); + node.hub.set_max_tip_age_secs(u64::MAX); + node +} + +async fn sync_heavy( + dir: &Path, + params: ChainParams, + peers: [SocketAddr; 2], + height: u32, + hash: BlockHash, +) { + let node = start_node(dir, reserve_addr(), params).await; + node.sync( + &[NetAddr::Ip(peers[0]), NetAddr::Ip(peers[1])], + IbdConfig::for_test(), + ) + .await + .expect("sync heavier fork"); + assert_eq!( + node.tip_height(), + Some(height), + "syncer left the heavier tip" + ); + assert_eq!(node.hub.tip_hash().unwrap(), hash); + node.shutdown().await; +} + +async fn work_fork_journey() { + let params = fork_params(); + let (mut heavy_blocks, mut light_blocks) = build_forks(¶ms); + let heavy_h = (heavy_blocks.len() - 1) as u32; + let light_h = (light_blocks.len() - 1) as u32; + assert!(light_h > heavy_h); + assert!(chain_work(&light_blocks) < chain_work(&heavy_blocks)); + assert_ne!( + heavy_blocks.last().unwrap().header.bits, + light_blocks.last().unwrap().header.bits + ); + + let heavy_dir = TestDatadir::new().unwrap(); + let light_dir = TestDatadir::new().unwrap(); + let heavy_addr = reserve_addr(); + let light_addr = reserve_addr(); + let heavy = start_node(heavy_dir.path().as_path(), heavy_addr, params.clone()).await; + let light = start_node(light_dir.path().as_path(), light_addr, params.clone()).await; + load_chain(&heavy, &heavy_blocks); + load_chain(&light, &light_blocks); + assert!( + light.hub.work_through_height(light_h).unwrap() + < heavy.hub.work_through_height(heavy_h).unwrap() + ); + let heavy_hash = heavy.hub.tip_hash().unwrap(); + let syncer = TestDatadir::new().unwrap(); + let peers = [heavy_addr, light_addr]; + sync_heavy( + syncer.path().as_path(), + params.clone(), + peers, + heavy_h, + heavy_hash, + ) + .await; + sync_heavy( + syncer.path().as_path(), + params.clone(), + peers, + heavy_h, + heavy_hash, + ) + .await; + + let heavy_time = heavy_blocks.last().unwrap().header.time.saturating_add(1); + push_block(¶ms, &mut heavy_blocks, heavy_time); + heavy + .ingest_block(heavy_h + 1, heavy_blocks.last().unwrap().clone()) + .unwrap(); + let step = (params.btc.pow_target_spacing as u32).saturating_mul(2) + 1; + for _ in 0..3 { + let prev_time = light_blocks.last().unwrap().header.time; + let height = light_blocks.len() as u32; + push_block(¶ms, &mut light_blocks, prev_time.saturating_add(step)); + light + .ingest_block(height, light_blocks.last().unwrap().clone()) + .unwrap(); + } + let heavy_h = (heavy_blocks.len() - 1) as u32; + let light_h = (light_blocks.len() - 1) as u32; + assert!(light_h > heavy_h); + assert!(chain_work(&light_blocks) < chain_work(&heavy_blocks)); + let heavy_hash = heavy.hub.tip_hash().unwrap(); + sync_heavy(syncer.path().as_path(), params, peers, heavy_h, heavy_hash).await; + heavy.shutdown().await; + light.shutdown().await; +} From ecd6762f61c951ec900b935e65a26146f0dfe248 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Fri, 2 Oct 2026 15:58:19 -0700 Subject: [PATCH 6/7] test: shindex-off catch-up waits on the RPC tip #862 binds Electrum when --sh-index is off. The pass-1 freeze chapter only needs that catch-up to reach the miner tip before the datadir is copied. --- crates/rbitcoin-test/tests/end_of_ibd.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/rbitcoin-test/tests/end_of_ibd.rs b/crates/rbitcoin-test/tests/end_of_ibd.rs index fb6a57042..d6013263a 100644 --- a/crates/rbitcoin-test/tests/end_of_ibd.rs +++ b/crates/rbitcoin-test/tests/end_of_ibd.rs @@ -583,13 +583,9 @@ async fn end_of_ibd_sh_interrupt() { .expect("end_of_ibd_sh_interrupt wall"); } -async fn wait_tip_electrum_down(rpc: SocketAddr, electrum: SocketAddr, height: u32, hash: &str) { +async fn wait_tip(rpc: SocketAddr, height: u32, hash: &str) { let deadline = Instant::now() + Duration::from_secs(40); loop { - assert!( - TcpStream::connect(electrum).await.is_err(), - "Electrum listened while scripthash indexing was off" - ); if TcpStream::connect(rpc).await.is_ok() { let count = jsonrpc(rpc, "getblockcount", json!([])).await; let best = jsonrpc(rpc, "getbestblockhash", json!([])).await; @@ -610,7 +606,7 @@ async fn catch_without_index(dir: &Path, miner: SocketAddr, height: u32, hash: & let rpc = reserve_addr(); let electrum = reserve_addr(); let node = spawn_run_p2p(syncer_cfg_sh(dir, miner, rpc, electrum, false)); - wait_tip_electrum_down(rpc, electrum, height, hash).await; + wait_tip(rpc, height, hash).await; stop_run_p2p(rpc, node).await; } From 05683e06877080640f67b28bd458aa2fb69474d8 Mon Sep 17 00:00:00 2001 From: Brandon Black Date: Fri, 2 Oct 2026 16:17:14 -0700 Subject: [PATCH 7/7] node: pass electrum max subs on the late Electrum bind The tip-follow bind still called the seven-argument helper. #866 added the per-connection subscription cap, so that call did not compile. --- crates/rbitcoin-node/src/run.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index a8c9cb1fc..7348742fc 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -996,6 +996,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { true, config.listen.electrum, config.sptweaks_dust, + config.electrum_max_subs, &shutdown, &node.hub, ¶ms,