diff --git a/Cargo.lock b/Cargo.lock index c7f498c..dbba9bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -787,7 +787,7 @@ dependencies = [ [[package]] name = "chia-protocol" version = "0.26.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=dbb03430f2a9c5cebd5b6f97ac86b03012be5a41#dbb03430f2a9c5cebd5b6f97ac86b03012be5a41" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=3e352d91fbda830e517c914cf4f6afa6a9a7e121#3e352d91fbda830e517c914cf4f6afa6a9a7e121" dependencies = [ "chia-bls 0.26.0", "chia-serde", @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "chia-sdk-client" version = "0.28.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=dbb03430f2a9c5cebd5b6f97ac86b03012be5a41#dbb03430f2a9c5cebd5b6f97ac86b03012be5a41" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=3e352d91fbda830e517c914cf4f6afa6a9a7e121#3e352d91fbda830e517c914cf4f6afa6a9a7e121" dependencies = [ "aws-lc-rs", "chia-protocol", @@ -1992,9 +1992,9 @@ dependencies = [ [[package]] name = "dig-dht" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dcc1c46d868c6fd308bf34a00b96d4874b8f0f20a1a6b701093940e38fd820f" +checksum = "3e0effb120e4932d05b28e3211d3f7a47dff62aea394a3bed239334ea8ee558a" dependencies = [ "async-trait", "dig-ip", @@ -2010,9 +2010,9 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f550127432a34f6bec7743bc3e8e68ed964362bea99f6324706ec3a05be6e5a2" +checksum = "9d6ef799dea375447c379e76b3c442835cdc9aa9e6b97cfc9ab737a7ef503080" dependencies = [ "async-trait", "dig-constants 0.8.0", @@ -2031,8 +2031,8 @@ dependencies = [ [[package]] name = "dig-gossip" -version = "0.16.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=b065e70c5bc4ad8f343d363e71c605870a4923d2#b065e70c5bc4ad8f343d363e71c605870a4923d2" +version = "0.17.12" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=3e352d91fbda830e517c914cf4f6afa6a9a7e121#3e352d91fbda830e517c914cf4f6afa6a9a7e121" dependencies = [ "bincode", "chia-protocol", @@ -2181,9 +2181,9 @@ dependencies = [ [[package]] name = "dig-nat" -version = "0.13.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2093ca487bf797e12bacff33a0cc92484879d53d0087ea8e3503e6b2ffc26f7" +checksum = "2bee4606bb5fc35a27c20d342c443b9b2f7924c116bbd8511a07d2a413eb0a41" dependencies = [ "arc-swap", "async-trait", @@ -2266,7 +2266,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.65.1" +version = "0.66.0" dependencies = [ "async-trait", "axum", @@ -2304,9 +2304,9 @@ dependencies = [ [[package]] name = "dig-peer" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "500f0090661cc9a6fe476e44fa2cf5436633e1cced658ef03b4c13cbc2bda79d" +checksum = "d6de94e87ca139b5a61a09c6d22c94d825ac9280bd0b1fe91b9ea3daa3451c7a" dependencies = [ "chia-protocol", "chia-traits 0.26.0", @@ -2338,9 +2338,9 @@ dependencies = [ [[package]] name = "dig-peer-selector" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c78396bc4384367eab08e2a31aaac072bc8863a3f119b2f27fc5b03be13ff344" +checksum = "b5e08046d2d2f5eb470930df4eeffabacb13f5424922838b895d6d3b7cda00f8" dependencies = [ "dig-dht", "dig-nat", diff --git a/Cargo.toml b/Cargo.toml index 4254996..3756a40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.65.1" +version = "0.66.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over @@ -54,8 +54,8 @@ overflow-checks = true # standalone. `native-tls` is NOT patched: dig-gossip is pulled with # `default-features = false, features = ["rustls", "relay"]`, so the OpenSSL/native-tls path is off. [patch.crates-io] -chia-protocol = { git = "https://github.com/DIG-Network/dig-gossip", rev = "dbb03430f2a9c5cebd5b6f97ac86b03012be5a41" } -chia-sdk-client = { git = "https://github.com/DIG-Network/dig-gossip", rev = "dbb03430f2a9c5cebd5b6f97ac86b03012be5a41" } +chia-protocol = { git = "https://github.com/DIG-Network/dig-gossip", rev = "3e352d91fbda830e517c914cf4f6afa6a9a7e121" } +chia-sdk-client = { git = "https://github.com/DIG-Network/dig-gossip", rev = "3e352d91fbda830e517c914cf4f6afa6a9a7e121" } # The dig-nat unification patch is RETIRED (#1280 crates.io cascade): dig-nat 0.7 is on crates.io and # the ENTIRE peer stack (dig-gossip 0.7.1, dig-dht 0.2.2, dig-download 0.2.1, dig-peer-selector 0.2.1, diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index e994257..964c745 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -4,6 +4,40 @@ High-signal realizations from debugging/development: non-obvious cross-system co sharp edges, and gotchas. Concise durable facts with context — NOT a change diary. See `CLAUDE.md` §4.5 for the maintenance contract (a curator periodically re-verifies + prunes). +## A git-pinned peer-stack dep hides shipped fixes indefinitely — the network looked dead for 11 minors (#1771) + +dig-gossip is a **git dependency pinned by rev** (it cannot go to crates.io until dig-peer-protocol +#681 lands), so it does not participate in any automated dependency cascade: nothing bumps it, and +`cargo update` will not move it. It sat at a v0.16.0-era rev while eleven minors accumulated upstream, +including all THREE duplicate-connection fixes (#1691 inbound, #1703 `connect_to`, #1762 +`adopt_nat_connection`). A live EC2 run reproduced all three symptoms *simultaneously* — the fixes were +real, released, tested, and simply not present in the binary. **A rev-pinned dep needs a deliberate, +scheduled bump; treat "the fix is released" as unproven until the pin says so.** + +Two couplings make that bump non-trivial, and both are structural rather than incidental: + +- **The peer stack's dig-nat major moves as ONE atomic step.** dig-node-core constructs its own + `NodeCert` / `NatConfig` / `NatRuntime` / `RelayStatus` / `TraversalKind` values and passes them INTO + dig-download, dig-gossip and dig-peer-selector. Two dig-nat instances in the tree therefore do not + merely bloat it — those calls stop typechecking (`E0308 expected dig_nat::relay::RelayStatus, found + dig_nat::RelayStatus`). So bumping dig-gossip across a dig-nat major forces dig-nat + dig-dht + + dig-download + dig-peer + dig-peer-selector in the same commit, and it is blocked outright until every + member of the line is published. `tests/dependency_tree.rs` asserts the single-instance invariant + against the resolved lock, which is what makes a partial cascade fail loudly instead of oddly. +- **The vendored-fork patch revs move in lockstep with the dep rev (the #1529 3-rev rule).** A git + dep's own `[patch.crates-io]` does NOT apply transitively, so the workspace re-declares dig-gossip's + vendored additive `chia-protocol`/`chia-sdk-client` at the SAME rev. Leaving the patch rev behind + resolves two copies of a patched crate — a tree that builds green and behaves oddly. + +**The behavioural half of a bump matters more than the compile half.** The v0.17 pool republishes +`PoolEvent::PeerAdded` when a fresh session supersedes a stale slot for the same `peer_id`, so any +consumer that counts `PeerAdded` events over-counts under reconnect churn — all of dig-node's consumers +are keyed by `peer_id` and idempotent, so they were safe, but the download-side connected pool APPENDED +the new address and left the superseded (typically dead) one leading the dial order, costing a failed +dial per fetch. Likewise `GossipStats::total_connections` is a LIFETIME counter a supersede increments; +only `connected_peers` / `pool_stats().connected` (live keyed-map sizes) are unique-peer counts. When +bumping a dep whose fix removes a REFUSAL, audit what the refusal was silently keeping rare. + ## A mock-transport regression test proves NOTHING about the wire — the read leg died in JSON serde (#1586) `dig.fetchRange` frames are JSON, so the ciphertext window travels as **base64** — the canonical diff --git a/SPEC.md b/SPEC.md index c464867..d0e44a7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2953,7 +2953,13 @@ relayed / isolated network a holder is discovered in the DHT but its advertised addresses the reader cannot dial, so a DHT-only locate yields no REACHABLE source and the read dead-ends at the §21 upstream backfill (404) even though the reader is ALREADY CONNECTED to that holder. Offering every connected pool peer as a fetch candidate lets the download reach the holder over the -established connection. A connected-pool holder is NOT gated behind a separate `dig.getAvailability` +established connection. A pool peer's candidate MUST list its addresses newest-session-first: dig-gossip +republishes `PoolEvent::PeerAdded` when a freshly authenticated session SUPERSEDES a stale slot for the +same `peer_id` (newest-wins re-adoption), and that is typically a MOVE — a dead relay circuit replaced by +a direct dial. The superseded addresses are RETAINED as trailing fallbacks (a still-working older path is +not discarded) but MUST NOT be dialed first. Re-adoption MUST NOT grow the candidate set: the pool is +keyed by `peer_id`, so a republished `PeerAdded` upserts one entry — a re-adopted peer is one candidate, +never two. A connected-pool holder is NOT gated behind a separate `dig.getAvailability` confirm probe: the download transport short-circuits the confirm to *available* for any provider whose `peer_id` is currently in the connected pool (`PoolConfirmTransport`, #836), so the fetch proceeds straight to `dig.fetchRange`. This is required because dig-download's `locate_and_confirm` DROPS any diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 1fba63b..0262599 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -136,30 +136,42 @@ getrandom = "0.2" # RECEIVER (`ChunkLensAssembler`) plus the `split_chunk_lens_pages` helper, so 0.13 carries the whole # serve-side fix for #1640/#1668. # -# 0.13 rather than 0.14 is REQUIRED, not merely current. This crate builds its own `dig_nat` `NodeCert` -# / `NatConfig` / `NatRuntime` / `RelayStatus` / `TraversalKind` values and passes them INTO -# dig-download, dig-gossip and dig-peer-selector, so a second dig-nat instance does not merely bloat -# the tree -- those calls stop typechecking (verified: E0308 `expected dig_nat::relay::RelayStatus, -# found dig_nat::RelayStatus`). dig-download 0.12 is now on dig-nat ^0.14, but dig-gossip 0.16 and -# dig-peer-selector 0.6 are still on ^0.13, so requesting 0.14 resolves TWO dig-nat and TWO dig-dht. -# Moving to the 0.14 line needs dig-gossip and dig-peer-selector published on it FIRST -- tracked as -# DIG-Network/dig_ecosystem#1686 (release-first, -# §4.1); `tests/dependency_tree.rs` asserts the single-instance invariant against the resolved LOCK so -# a manifest edit cannot quietly reintroduce the duplicate. -dig-nat = "0.13" -# dig-gossip is the ONE peer-stack exception: at v0.15.2 (on dig-nat 0.11 + dig-tls 0.3, crates.io -# deps) but NOT yet published to crates.io — its own publish is guarded pending dig-peer-protocol (#681). -# So it stays a git dependency, PINNED to the v0.15.2 tag commit (rev dbb03430f2a9, #1584 — both -# outbound pool-add sites (`connect_to` direct-dial + `adopt_nat_connection` NAT-ladder) now reject -# `peer_id == config.peer_id` before any pool insert / `PeerAdded` publish, closing the self-dial -# read-leg 404: a relay introducer advertising a reader to itself no longer gets adopted as its own -# provider), on top of #1581's `PoolEvent::PeerAdded` (feeding DHT routing/selector/PEX), #1541's -# persistent `nat_identity` NodeCert injection, and #1517's relay-circuit auto-dial. Pulled rustls-only (no -# native-tls). Its dig-nat/dig-tls/dig-constants come from crates.io (same versions as here), so the +# The dig-nat MAJOR must match the whole peer stack's, and it moves as ONE step (#1771). This crate +# builds its own `dig_nat` `NodeCert` / `NatConfig` / `NatRuntime` / `RelayStatus` / `TraversalKind` +# values and passes them INTO dig-download, dig-gossip and dig-peer-selector, so a second dig-nat +# instance does not merely bloat the tree -- those calls stop typechecking (verified: E0308 `expected +# dig_nat::relay::RelayStatus, found dig_nat::RelayStatus`). So dig-nat cannot be bumped alone, and +# dig-gossip/dig-dht/dig-download/dig-peer/dig-peer-selector cannot be bumped past it: the 0.14 line +# (dig-gossip 0.17, dig-dht 0.8, dig-download 0.12, dig-peer 0.7, dig-peer-selector 0.7) is a single +# atomic cascade, which #1686 completed by publishing every member. `tests/dependency_tree.rs` asserts +# the single-instance invariant against the resolved LOCK so a partial cascade cannot quietly +# reintroduce the duplicate. +dig-nat = "0.14" +# dig-gossip is the ONE peer-stack exception: not yet published to crates.io (its publish is guarded +# pending dig-peer-protocol, #681), so it stays a git dependency PINNED to a release commit — here +# v0.17.12 (rev 3e352d91). +# +# v0.17.12 is REQUIRED, not merely current (#1771): it carries all THREE duplicate-connection fixes, +# and until it landed here the node's pool refused connections that would have worked. Every pool-add +# path now supersedes a stale slot for an already-authenticated `peer_id` instead of refusing the newer +# session — inbound (#1691), `connect_to` (#1703), and `adopt_nat_connection` (#1762), the last being +# the path EVERY dig-nat connection is adopted through, where a dead relay circuit's leftover slot +# refused the direct dial while the peer reported zero connections. `DuplicateConnection` still exists +# as an error variant but is no longer raised on those paths. Consequence for this crate: `PeerAdded` is +# REPUBLISHED on a supersede, so every consumer of it must be keyed by `peer_id` and idempotent (the DHT +# routing feed, the selector registry, and the download-side connected pool all are — see +# `download.rs::on_pool_event`), and `GossipStats::total_connections` is a LIFETIME counter that a +# supersede increments, so it must never be read as a count of unique peers (this crate reads +# `connected_peers` / `pool_stats().connected`, which are the live keyed-map sizes). +# +# Also on 0.17: the outbound /16+AS diversity caps (#1710/#1716) apply to the adoption path, so a +# test fixture adopting two peers must place them in DIFFERENT /16 groups. +# Pulled rustls-only (no native-tls) -- note `--all-features` is NOT a runnable configuration for +# dig-gossip: with both `ring` and `aws-lc-rs` reachable rustls cannot pick a provider. Its dig-nat/dig-tls/dig-constants come from crates.io (same versions as here), so the # graph still unifies to ONE of each. The vendored chia-protocol/chia-sdk-client forks it path-patches # are re-declared at this SAME rev in the workspace `[patch.crates-io]` (a git dep's own patches do not # apply transitively — the #1529 3-rev lockstep: this dep rev + the two patch revs move together). -dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "b065e70c5bc4ad8f343d363e71c605870a4923d2", default-features = false, features = ["rustls", "relay"] } +dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "3e352d91fbda830e517c914cf4f6afa6a9a7e121", default-features = false, features = ["rustls", "relay"] } # The canonical DIG network id (genesis challenge) for the gossip handshake / introducer scope. From # crates.io: dig-nat 0.7 requires `>=0.4, <0.6` and dig-gossip 0.7.1 requires `>=0.2, <0.5`, so `0.4` # sits in both windows and the whole graph resolves to ONE crates.io dig-constants 0.4.x. @@ -175,7 +187,7 @@ dig-constants = "0.4" # so cargo unifies to ONE dig-dht instance — dig-dht's `PeerId` / `Contact` / `DhtTransport` share the # exact types `dig_nat::connect` returns. dig-dht 0.5 deps dig-nat "^0.10" (crates.io), the SAME # dig-nat "0.10" this crate pins, so the whole graph shares ONE dig-nat 0.10 instance. -dig-dht = "0.7" +dig-dht = "0.8" # -- L7 Peer Exchange (PEX, #166) -------------------------------------------------------------------- # The node<->node peer-sharing protocol: over each established mTLS peer connection, a dedicated PEX # logical stream exchanges delta-based first-hand known-peer sets (handshake -> snapshot -> deltas), @@ -210,7 +222,7 @@ dig-pex = "0.1" # `seams/dig_peer/module_anchor.rs` (`ChainAnchoredModuleVerifier`). NOTE the `testkit` feature is # deliberately NOT enabled: it is what makes the fail-OPEN `AcceptAnyModuleAnchor` nameable, and this # crate's anchor gate is the reshare path's ONLY root of trust. -dig-download = "0.11" +dig-download = "0.12" # -- The shared peer client (#1283/#1576) ------------------------------------------------------------- # `DigPeer` — the ONE DIG Network peer client: peer_id-pinned mTLS over the full NAT ladder plus typed # RPC. Depended on DIRECTLY (not only transitively through dig-download) because dig-node supplies the @@ -222,7 +234,7 @@ dig-download = "0.11" # module pull's trust boundary — on the fields that drive the whole pull plan. dig-download 0.8.1 is on # dig-peer 0.5 too, so exactly ONE dig-rpc-protocol + ONE dig-peer resolve here (asserted by # `crates/dig-node-core/tests/dependency_tree.rs`). -dig-peer = "0.6" +dig-peer = "0.7" # -- Self-optimizing peer selection (#178) ------------------------------------------------------------ # The decision + learning layer between dig-dht discovery and dig-download execution: it ranks the # providers `find_providers` returns (learning throughput/rtt/reliability + a per-class saturation @@ -239,7 +251,7 @@ dig-peer = "0.6" # dig-nat/dig-dht types). The selector mirrors `dig_gossip::PoolEvent` LOCALLY (it does not depend on # dig-gossip, which lags as a git dep), so the node maps `dig_gossip::PoolEvent` → the selector's local # `PoolEvent` 1:1 (see `download.rs::pool_event_to_selector`). -dig-peer-selector = "0.6" +dig-peer-selector = "0.7" # The canonical DIG mTLS certificate crate (L00, crates.io). The node's PERSISTENT machine identity # is a CA-signed `dig_tls::NodeCert` minted from the node's own BLS identity key and persisted 0600 in # the data dir (#908 identity boundary: this is the MACHINE key, never a user key). Replaces the @@ -289,7 +301,7 @@ rcgen = "0.13" # # Pinned by the `the_fail_open_anchor_verifier_is_not_reachable_from_a_production_build` test, which # fails if `testkit` ever appears on the production entry. -dig-download = { version = "0.11", features = ["testkit"] } +dig-download = { version = "0.12", features = ["testkit"] } # Captures the peer-facing serve's real emitted tracing records into an in-memory buffer, so the # serve-observability tests (#1595) assert what an operator would actually see in the node log — # and that no payload byte or proof ever reaches it. diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index 1bee5bd..064bbd2 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -652,10 +652,10 @@ impl NodeContent { // already self-filtered but otherwise unranked; the selector refines the SOURCE choice at // schedule time, not the discovered set. The same RAW `locator` stays on the engine for the // redirect-on-miss path (a redirect offers ALL known non-self holders). - let config = DownloadConfig { - selector: Some(Arc::new(SelectorAdapter::new(selector.clone()))), - ..DownloadConfig::default() - }; + // dig-download 0.12 marked `DownloadConfig` `#[non_exhaustive]`, so it is built by mutating a + // default rather than by a struct literal — every future field stays a minor bump for us. + let mut config = DownloadConfig::default(); + config.selector = Some(Arc::new(SelectorAdapter::new(selector.clone()))); // The DOWNLOAD locator (#1590) = a [`PoolProviderLocator`] over the live connected-pool set // UNIONed with the raw discovery `locator`. So a fetch's locate step also offers the peers the // node is ALREADY CONNECTED to — reaching a holder whose DHT provider record is unreachable on @@ -828,10 +828,15 @@ impl NodeContent { .unwrap_or_else(|poisoned| poisoned.into_inner()); match event { PoolEvent::PeerAdded { peer_id, addr } => { + // The NEWEST session's address leads the candidate's dial order, older ones trailing + // as fallbacks (#1771). dig-gossip republishes `PeerAdded` when a fresh verified + // session supersedes a stale slot for the same identity (#1691/#1703/#1762), which is + // typically a MOVE — a dead relay circuit replaced by a direct dial. Appending would + // leave the dead address first and spend a failed dial on it for every later fetch, + // and dropping the older ones would discard a still-working fallback. let addrs = pool.entry(peer_id.to_hex()).or_default(); - if !addrs.contains(addr) { - addrs.push(*addr); - } + addrs.retain(|known| known != addr); + addrs.insert(0, *addr); } PoolEvent::PeerRemoved { peer_id, .. } => { pool.remove(&peer_id.to_hex()); diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index cc609c7..815dd37 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -2991,6 +2991,156 @@ mod tests { ); } + /// Build a `NatPeerConnection` over a loopback duplex with a chosen `peer_id`, remote address and + /// traversal tier, so the node's adoption path can be exercised WITHOUT a real network (a real + /// yamux session, just not over TLS). The returned [`dig_nat::PeerSession`] is the SERVER half: + /// hold it to keep the session live, **drop it to kill the session** — which is how the test below + /// makes a relay circuit genuinely dead rather than merely asserting about one. + fn loopback_nat_conn( + peer_id_bytes: [u8; 32], + remote: std::net::SocketAddr, + method: dig_nat::TraversalKind, + ) -> (dig_gossip::NatPeerConnection, dig_nat::PeerSession) { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let inner = dig_nat::PeerConnection { + peer_id: dig_nat::PeerId::from_bytes(peer_id_bytes), + method, + remote_addr: remote, + peer_bls_pub: None, + session: dig_nat::PeerSession::client(client_io), + }; + ( + dig_gossip::NatPeerConnection::new(inner), + dig_nat::PeerSession::server(server_io), + ) + } + + /// **dig_ecosystem#1771 — the supersession the dig-gossip v0.17.12 bump delivers is reachable from + /// the node, and the node's own peer views report the re-adopted peer ONCE.** + /// + /// `adopt_nat_connection` is the single path every dig-nat connection is adopted through. Before + /// v0.17.12 it refused a held slot outright, so a relay circuit that registered a slot and then + /// died refused the DIRECT adoption that would have worked — the node reported `duplicate + /// connection` while the peer reported zero connections (#1762, with #1691 inbound and #1703 + /// `connect_to` the two siblings). This test pins the property from the node's side: a stale + /// RELAYED adoption followed by a DIRECT adoption of the SAME `peer_id` succeeds, and the two + /// surfaces the node derives from the pool — [`pool_stats_json`] and [`connected_peers_json`] — + /// each report exactly one peer, at the NEWEST address, over the NEWEST tier. + /// + /// Fixture design: only ONE actor varies (the tier + address of the same identity), and the second + /// peer is a truthful control proving the surfaces can still count to two — a test that only + /// asserted "1" would pass against a pool that had silently dropped the peer entirely. The `via` + /// assertion is what distinguishes a genuine supersede from a guard that returns `Ok` while + /// leaving the dead relayed slot in place, and every assertion is on observable pool state rather + /// than a log line, which prints on the broken path too. + #[tokio::test] + async fn a_stale_relayed_slot_does_not_refuse_the_direct_adoption() { + let handle = fresh_pool_handle("readopt-supersede", [11u8; 32]).await; + let peer = [0xAB; 32]; + + // A relayed adoption lands first, then its session DIES (the server half is dropped) — the + // #1761 dead-circuit condition, with no reap to notice it. + let (relayed, relayed_server) = loopback_nat_conn( + peer, + "203.0.113.7:9445".parse().unwrap(), + dig_nat::TraversalKind::Relayed, + ); + handle + .adopt_nat_connection(relayed) + .await + .expect("the first adoption is uncontested"); + drop(relayed_server); + + // The direct dial that the pre-0.17.12 pool refused. + let (direct, _direct_server) = loopback_nat_conn( + peer, + "203.0.113.7:9444".parse().unwrap(), + dig_nat::TraversalKind::Direct, + ); + let adopted = handle + .adopt_nat_connection(direct) + .await + .expect("a stale slot must not refuse a newer verified session for the same identity"); + assert_eq!(adopted, dig_gossip::PeerId::from(peer)); + + // The node's operator view: ONE peer, not two — the supersede replaced the slot. + let stats = pool_stats_json(&handle); + assert_eq!(stats["connected"], 1, "one identity holds one slot"); + + // The node's per-peer view: the NEWEST session's address and tier, proving the dead relayed + // slot was replaced rather than merely tolerated. + let peers = connected_peers_json(&handle); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0]["address"], "203.0.113.7:9444"); + assert_eq!(peers[0]["via"], "direct"); + + // Control: a genuinely DIFFERENT identity still occupies its own slot, so the assertions above + // pin supersession and not a pool that quietly stopped admitting peers. + let (other, _other_server) = loopback_nat_conn( + [0xCD; 32], + // A DIFFERENT /16: the outbound INT-006 diversity cap allows one outbound connection per + // /16 group, so a control peer in 203.0.113.0/16 would be filtered for the wrong reason. + "198.51.100.8:9444".parse().unwrap(), + dig_nat::TraversalKind::Direct, + ); + handle + .adopt_nat_connection(other) + .await + .expect("a net-new identity is admitted"); + assert_eq!(pool_stats_json(&handle)["connected"], 2); + } + + /// **#1771 — the node must not COUNT a re-adopted peer twice.** v0.17.12 republishes + /// `PoolEvent::PeerAdded` on a supersede, so any consumer treating each `PeerAdded` as a distinct + /// peer over-counts under reconnect churn. The node's download-side feed keys candidates by + /// `peer_id`, so a second `PeerAdded` for the same identity UPSERTS: one candidate, at the newest + /// address. The second identity is the control that proves the feed still counts distinct peers. + #[tokio::test] + async fn the_selector_pool_feed_upserts_a_readopted_peer_instead_of_counting_it_twice() { + use dig_download::testkit::{MockContent, MockProviderLocator, MockRangeTransport}; + use dig_download::ProviderLocator; + + let td = tempfile::tempdir().unwrap(); + let pc = crate::download::NodeContent::new( + std::sync::Arc::new(MockProviderLocator::fixed(vec![])), + std::sync::Arc::new(MockRangeTransport::new(MockContent::even(10, 1))), + crate::download::MissMode::FetchThrough, + None, + td.path(), + ); + let peer_id = dig_gossip::PeerId::from([0xAB; 32]); + + // The relayed adoption's PeerAdded, then the supersede's republished PeerAdded for the SAME + // identity at a new address — driven through the real live-feed translation. + for host in ["203.0.113.7", "203.0.113.70"] { + pc.on_pool_event(&map_gossip_pool_event(&dig_gossip::PoolEvent::PeerAdded { + peer_id, + addr: format!("{host}:9445").parse().unwrap(), + })); + } + + let locator = crate::seams::dig_peer::PoolProviderLocator::new(pc.connected_pool()); + let content = dig_dht::ContentId::resource([9u8; 32], [8u8; 32], [7u8; 32]); + let found = locator.find_providers(&content).await.expect("locate ok"); + assert_eq!( + found.len(), + 1, + "a re-adopted peer is ONE candidate, not two (PeerAdded upserts by peer_id)" + ); + assert_eq!( + found[0].addresses[0].host, "203.0.113.70", + "the upsert carries the newest session's address" + ); + + // Control: a distinct identity is a distinct candidate. + pc.on_pool_event(&map_gossip_pool_event(&dig_gossip::PoolEvent::PeerAdded { + peer_id: dig_gossip::PeerId::from([0xCD; 32]), + addr: "198.51.100.8:9445".parse().unwrap(), + })); + let found = locator.find_providers(&content).await.expect("locate ok"); + assert_eq!(found.len(), 2, "two identities are two candidates"); + } + /// A fresh pool has no connected peers, so the per-peer array is empty (the honest "count only" /// state before any peer connects). Uses a real `GossipHandle` — the same type the node retains. #[tokio::test]