diff --git a/TESTING.md b/TESTING.md index e6c587e0..c846281a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -106,7 +106,7 @@ reads it; rustup users export it). Override coverage dir: | 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]` | -**Tier A timeouts:** `two_node_header_and_block_sync` 60s wall (default + job). `p2p_timeout_getaddr_and_keepalive_ping`, `p2p_hb_compact_tip_follow`, and `p2p_orphan_child_getdatas_parent` 20s wall (default). Reconstruct / dead-peer are **multinode job only** (`#[ignore]`; job passes `--ignored`). `coverage.sh` also `--skip`s those names plus `two_node`. Heavier topology stays `#[ignore]` (`scripts/integration.sh`). +**Tier A timeouts:** `two_node_header_and_block_sync` 60s wall (default + job). `p2p_timeout_getaddr_and_keepalive_ping`, `p2p_hb_compact_tip_follow`, `p2p_orphan_child_getdatas_parent`, and `p2p_compact_getblocktxn_missing_extra_tx` 20s wall (default). Reconstruct / dead-peer are **multinode job only** (`#[ignore]`; job passes `--ignored`). `coverage.sh` also `--skip`s those names plus `two_node`. Heavier topology stays `#[ignore]` (`scripts/integration.sh`). **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 `reconstruct_and_connect_error_arms` + 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. @@ -238,6 +238,7 @@ Prefer **one high-level scenario** per behavior cluster. Delete lower-level test | `two_node_header_and_block_sync` | P2P (**default + multinode CI**) | Seeder → peer 8-block IBD; peer `last_write` meter. **Not** re-run under `coverage.sh`. | | `p2p_timeout_getaddr_and_keepalive_ping` | P2P (**default**) | One pad: v1-magic inbound drops at `peertimeout=1`, AddrFetch `getaddr`/`addrv2` (no `getheaders`), one keepalive ping/pong | | `p2p_hb_compact_tip_follow` | P2P (**default**) | Genesis follow: first tip via headers/inv, then HB `sendcmpct` + `cmpctblock` reconstruct (coinbase-only, no `getblocktxn`) | +| `p2p_compact_getblocktxn_missing_extra_tx` | P2P (**default**) | Follow session: 2-tx compact (coinbase prefilled, extra not in mempool) → `getblocktxn`. Does **not** pin depth-10 full-block serve | | `p2p_orphan_child_getdatas_parent` | P2P (**default**) | Follow session: missing-parent child parks; peer `GetData`s the parent. Does **not** pin log-not-reject or tokio-worker lock | | `badprev_orphan_does_not_blacklist_then_reorg_reconstructs` | P2P/chain (default) | Orphan whose prev is not on the tip is held (not `BLOCK_FAILED`); winner branch reconstructs | | `serve_after_restart_via_reconstruct` | P2P (**multinode job only**) | Cold serve via reconstruct | @@ -256,7 +257,7 @@ Removed (covered by the rows above): `confirm_cross_block_prevout_without_tx_hea ### Integration / multi-node -Default `cargo test` runs `two_node_header_and_block_sync` (8-block), `p2p_timeout_getaddr_and_keepalive_ping`, `p2p_hb_compact_tip_follow`, `p2p_orphan_child_getdatas_parent`, and `badprev_orphan_does_not_blacklist_then_reorg_reconstructs`. The required **multinode** job also runs reconstruct and slim dead-peer (`--ignored` filters in `ci.yml`). +Default `cargo test` runs `two_node_header_and_block_sync` (8-block), `p2p_timeout_getaddr_and_keepalive_ping`, `p2p_hb_compact_tip_follow`, `p2p_orphan_child_getdatas_parent`, `p2p_compact_getblocktxn_missing_extra_tx`, and `badprev_orphan_does_not_blacklist_then_reorg_reconstructs`. The required **multinode** job also runs reconstruct and slim dead-peer (`--ignored` filters in `ci.yml`). Heavy topology (3-hop, 48-block, mesh, `run_p2p`) stays `#[ignore]` for `scripts/integration.sh`: ```bash diff --git a/crates/rbitcoin-test/tests/integration_multinode.rs b/crates/rbitcoin-test/tests/integration_multinode.rs index 09ea8254..59b3fcd4 100644 --- a/crates/rbitcoin-test/tests/integration_multinode.rs +++ b/crates/rbitcoin-test/tests/integration_multinode.rs @@ -3,8 +3,9 @@ //! **Tier A (default + CI `multinode` job):** single-hop IBD (8 blocks), cold //! reconstruct serve (10 blocks). Hard wall timeouts; hang-free on CI-class hosts. //! **Tier B (default suite):** handshake timeout / GetAddr / keepalive ping, -//! HB compact tip-follow, mempool orphan child GetData of parent, hub reorg -//! (including leftover/BadPrev orphan that must not blacklist). +//! HB compact tip-follow, compact `getblocktxn` for a missing extra tx, +//! mempool orphan child GetData of parent, hub reorg (including leftover/BadPrev +//! orphan that must not blacklist). //! **Tier C (`#[ignore]`):** multi-hop, tip-follow, 48-block dual seeder, mesh — //! `scripts/integration.sh` or `-- --ignored` only. @@ -459,6 +460,99 @@ async fn p2p_orphan_child_getdatas_parent() { .expect("p2p_orphan_child_getdatas_parent wall timeout (20s)"); } +/// Compact of a 2-tx tip: coinbase prefilled, extra tx absent from mempool → getblocktxn. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn p2p_compact_getblocktxn_missing_extra_tx() { + use bitcoin::bip152::HeaderAndShortIds; + use bitcoin::p2p::message::NetworkMessage; + use bitcoin::p2p::message_compact_blocks::CmpctBlock; + use bitcoin::Amount; + use rbitcoin_test::mine::spend_anyone_can_spend; + + let fut = async { + let seed_dir = TempDir::new().unwrap(); + let peer_dir = TempDir::new().unwrap(); + let seed = start_node(&seed_dir).await; + let mut peer = start_node(&peer_dir).await; + attach_relay_mempool(&peer, &peer_dir); + tokio::time::timeout(Duration::from_secs(5), peer.follow_from(seed.local_addr)) + .await + .expect("follow_from handshake") + .expect("follow"); + assert!( + peer.follow_live_count() >= 1, + "outbound follow must stay live" + ); + + let extra = spend_anyone_can_spend( + bitcoin::Txid::from_byte_array([0x33; 32]), + 0, + Amount::from_sat(1000), + ); + let tip = seed.hub.tip_hash().expect("genesis"); + let tip_time = seed.hub.tip_header().expect("genesis header").time; + let block = mine_regtest_block(tip, tip_time + 600, 1, vec![extra]); + assert_eq!(block.txdata.len(), 2, "coinbase + extra"); + let hsi = HeaderAndShortIds::from_block(&block, 1, 2, &[0]).expect("compact hsi"); + assert_eq!( + hsi.short_ids.len(), + 1, + "coinbase prefilled; extra is a short-id" + ); + + let writer_deadline = tokio::time::Instant::now() + Duration::from_secs(3); + loop { + let queued = seed.peers.live_peers().into_iter().any(|p| { + p.inbound + && p.handshake_complete() + && p.queue_msg(NetworkMessage::CmpctBlock(CmpctBlock { + compact_block: hsi.clone(), + })) + }); + if queued { + break; + } + if tokio::time::Instant::now() >= writer_deadline { + panic!( + "seed inbound writer must take cmpctblock (seed={:?} peer={:?})", + seed.peers.snapshot(), + peer.peers.snapshot() + ); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let getblocktxn = seed + .peers + .snapshot() + .into_iter() + .find(|p| p.inbound) + .map(|p| p.bytesrecv_per_msg.get("getblocktxn").copied().unwrap_or(0)) + .unwrap_or(0); + if getblocktxn > 0 { + break; + } + if tokio::time::Instant::now() >= deadline { + panic!( + "follower must GetBlockTxn the missing extra tx \ + (seed={:?} peer={:?})", + seed.peers.snapshot(), + peer.peers.snapshot() + ); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + + seed.shutdown().await; + peer.shutdown().await; + }; + tokio::time::timeout(Duration::from_secs(20), fut) + .await + .expect("p2p_compact_getblocktxn_missing_extra_tx wall timeout (20s)"); +} + /// Phase 4: seeder restarts with empty RAM cache; peer IBD-syncs via reconstruct /// (CI **multinode** job only — `coverage.sh` also skips this name). #[tokio::test(flavor = "multi_thread", worker_threads = 4)]