diff --git a/crates/rbitcoin-net/src/ibd/peer_io.rs b/crates/rbitcoin-net/src/ibd/peer_io.rs
index 25c419d8..1193092e 100644
--- a/crates/rbitcoin-net/src/ibd/peer_io.rs
+++ b/crates/rbitcoin-net/src/ibd/peer_io.rs
@@ -177,7 +177,7 @@ pub(crate) async fn spawn_peer(
let stream = TcpStream::connect(addr).await?;
let ua = rbitcoin_primitives::rbitcoin_subversion(env!("CARGO_PKG_VERSION"), &[] as &[&str])
.unwrap_or_else(|_| format!("/rbitcoin:{}/", env!("CARGO_PKG_VERSION")));
- let (ver, reader, writer, _wire) = connect_and_handshake_timed(
+ let (ver, reader, writer, _wire, _tcp_shutdown) = connect_and_handshake_timed(
HANDSHAKE_TIMEOUT,
stream,
magic,
diff --git a/crates/rbitcoin-net/src/peer.rs b/crates/rbitcoin-net/src/peer.rs
index d19db172..c1581673 100644
--- a/crates/rbitcoin-net/src/peer.rs
+++ b/crates/rbitcoin-net/src/peer.rs
@@ -287,8 +287,17 @@ pub async fn connect_and_handshake(
inbound: bool,
user_agent: &str,
policy: HandshakePolicy<'_>,
-) -> Result<(VersionMessage, V2Reader, V2Writer, crate::v2::WireBytes), NetError> {
- let (mut reader, mut writer, wire) = open_v2(stream, magic, inbound).await?;
+) -> Result<
+ (
+ VersionMessage,
+ V2Reader,
+ V2Writer,
+ crate::v2::WireBytes,
+ std::net::TcpStream,
+ ),
+ NetError,
+> {
+ let (mut reader, mut writer, wire, tcp_shutdown) = open_v2(stream, magic, inbound).await?;
let their_version = application_handshake(
&mut reader,
&mut writer,
@@ -301,7 +310,7 @@ pub async fn connect_and_handshake(
policy,
)
.await?;
- Ok((their_version, reader, writer, wire))
+ Ok((their_version, reader, writer, wire, tcp_shutdown))
}
/// Core VERSION/VERACK bound: 60s from TCP connect/accept. Timeout drops the stream.
@@ -315,7 +324,16 @@ pub(crate) async fn inbound_connect_and_handshake(
start_height: i32,
user_agent: &str,
policy: HandshakePolicy<'_>,
-) -> Result<(VersionMessage, V2Reader, V2Writer, crate::v2::WireBytes), NetError> {
+) -> Result<
+ (
+ VersionMessage,
+ V2Reader,
+ V2Writer,
+ crate::v2::WireBytes,
+ std::net::TcpStream,
+ ),
+ NetError,
+> {
connect_and_handshake_timed(
HANDSHAKE_TIMEOUT,
stream,
@@ -340,7 +358,16 @@ pub(crate) async fn connect_and_handshake_timed(
inbound: bool,
user_agent: &str,
policy: HandshakePolicy<'_>,
-) -> Result<(VersionMessage, V2Reader, V2Writer, crate::v2::WireBytes), NetError> {
+) -> Result<
+ (
+ VersionMessage,
+ V2Reader,
+ V2Writer,
+ crate::v2::WireBytes,
+ std::net::TcpStream,
+ ),
+ NetError,
+> {
tokio::time::timeout(
limit,
connect_and_handshake(
@@ -411,7 +438,7 @@ async fn run_feeler_inner(
start_height: i32,
user_agent: &str,
) -> Result<(), NetError> {
- let (mut reader, mut writer, _wire) = open_v2(stream, magic, false).await?;
+ let (mut reader, mut writer, _wire, _tcp_shutdown) = open_v2(stream, magic, false).await?;
let services = local_service_flags();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -669,6 +696,9 @@ pub async fn peer_session_with(
}
}
});
+ if let Some(s) = meta.session.as_ref() {
+ s.set_writer_abort(writer_task.abort_handle());
+ }
if let Some(s) = meta.session.as_ref() {
let _ = maybe_queue_addrfetch_getaddr(&out_tx, s);
@@ -937,7 +967,15 @@ pub async fn peer_session_with(
frame = read_v2_frame(&mut reader, magic) => {
let frame = match frame {
Ok(f) => f,
- Err(NetError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
+ Err(NetError::Io(e))
+ if matches!(
+ e.kind(),
+ std::io::ErrorKind::UnexpectedEof
+ | std::io::ErrorKind::ConnectionReset
+ | std::io::ErrorKind::BrokenPipe
+ | std::io::ErrorKind::ConnectionAborted
+ ) =>
+ {
return Ok(());
}
Err(NetError::MessageTooLarge(n)) => {
diff --git a/crates/rbitcoin-net/src/peer_tests.rs b/crates/rbitcoin-net/src/peer_tests.rs
index 3feb215e..b203e338 100644
--- a/crates/rbitcoin-net/src/peer_tests.rs
+++ b/crates/rbitcoin-net/src/peer_tests.rs
@@ -5573,3 +5573,97 @@ async fn tip_burst_past_broadcast_capacity_still_syncs_peer() {
nb.shutdown().await;
let _ = std::fs::remove_dir_all(dir);
}
+
+/// Core `disconnect_nodes` waits ≤5s for the far side's `getpeerinfo` to drop us.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn disconnect_clears_far_side_getpeerinfo_within_5s() {
+ use crate::P2PNode;
+ use std::time::Duration;
+
+ let n = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ let dir = std::env::temp_dir().join(format!("rbitcoin-disc-far-{n}"));
+ std::fs::create_dir_all(dir.join("a")).unwrap();
+ std::fs::create_dir_all(dir.join("b")).unwrap();
+ let qa = Query::open_or_create(dir.join("a/store")).unwrap();
+ let qb = Query::open_or_create(dir.join("b/store")).unwrap();
+ let params = ChainParams::regtest();
+ let mut na = P2PNode::start_with_agent(
+ "127.0.0.1:0".parse().unwrap(),
+ qa,
+ params.clone(),
+ Milestone::NONE,
+ "/rbitcoin:0.1.0(testnode0)/".into(),
+ crate::DEFAULT_MAX_INBOUND,
+ )
+ .await
+ .unwrap();
+ let nb = P2PNode::start_with_agent(
+ "127.0.0.1:0".parse().unwrap(),
+ qb,
+ params,
+ Milestone::NONE,
+ "/rbitcoin:0.1.0(testnode1)/".into(),
+ crate::DEFAULT_MAX_INBOUND,
+ )
+ .await
+ .unwrap();
+
+ na.follow_from(nb.local_addr).await.unwrap();
+ let mut linked = false;
+ for _ in 0..100 {
+ let a_sees = na
+ .peers
+ .snapshot()
+ .iter()
+ .any(|p| p.subver.contains("testnode1"));
+ let b_sees = nb
+ .peers
+ .snapshot()
+ .iter()
+ .any(|p| p.subver.contains("testnode0"));
+ if a_sees && b_sees {
+ linked = true;
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(20)).await;
+ }
+ assert!(linked, "both sides must list each other before disconnect");
+
+ let peer_id = na
+ .peers
+ .snapshot()
+ .into_iter()
+ .find(|p| p.subver.contains("testnode1"))
+ .map(|p| p.id)
+ .expect("outbound peer id");
+ assert!(na.peers.disconnect_id(peer_id));
+ assert!(
+ na.peers.snapshot().is_empty(),
+ "local getpeerinfo clears immediately"
+ );
+
+ let mut far_clear = false;
+ for _ in 0..100 {
+ if !nb
+ .peers
+ .snapshot()
+ .iter()
+ .any(|p| p.subver.contains("testnode0"))
+ {
+ far_clear = true;
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ }
+ assert!(
+ far_clear,
+ "far side getpeerinfo must drop us within 5s (Core disconnect_nodes)"
+ );
+
+ na.shutdown().await;
+ nb.shutdown().await;
+ let _ = std::fs::remove_dir_all(dir);
+}
diff --git a/crates/rbitcoin-net/src/peers.rs b/crates/rbitcoin-net/src/peers.rs
index 1d694cf0..e7aeecd3 100644
--- a/crates/rbitcoin-net/src/peers.rs
+++ b/crates/rbitcoin-net/src/peers.rs
@@ -129,6 +129,13 @@ pub struct LivePeer {
connected_at: AtomicU64,
/// Skip INV for mempool txs with `accept_gen < floor` (post-verack privacy).
inv_gen_floor: AtomicU64,
+ /// Writer-task abort — FIN via dropping the write half.
+ writer_abort: Mutex