Skip to main content

zakura_network/
zakura.rs

1//! Zakura P2P dependency, identity, handshake, and protocol-handler scaffolding.
2//!
3//! This module reserves the iroh dependency, privacy-preserving endpoint posture,
4//! persistent identity storage surface, and bounded Zakura handshake wire types.
5
6use std::time::Duration;
7
8use iroh::{endpoint, Endpoint, NodeAddr, NodeId, RelayMode, SecretKey};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11use tokio::sync::watch;
12
13use crate::{
14    meta_addr::{MetaAddr, MetaAddrChange},
15    PeerSocketAddr,
16};
17
18mod block_sync;
19mod discovery;
20mod exchange;
21mod handler;
22mod handshake;
23mod header_sync;
24mod legacy_gossip;
25#[cfg(any(test, feature = "zakura-testkit"))]
26pub mod testkit;
27mod trace;
28pub mod transport;
29
30pub use block_sync::*;
31pub use discovery::*;
32pub use exchange::*;
33pub use handler::*;
34pub use handshake::*;
35pub use header_sync::*;
36pub use legacy_gossip::*;
37pub use trace::{
38    commit_state_trace, peer_label as zakura_trace_peer_label,
39    reject_reason_label as zakura_trace_reject_reason_label, ZakuraTrace, ZakuraTraceEvent,
40    BLOCK_SYNC_TABLE, COMMIT_STATE_TABLE, CONN_TABLE, HANDSHAKE_TABLE, HEADER_SYNC_TABLE,
41    LEGACY_REQUEST_TABLE, QUEUE_SEND_TABLE, RATELIMIT_TABLE, STREAM_TABLE,
42};
43pub use transport::*;
44
45#[cfg(any(test, feature = "zakura-testkit"))]
46pub(crate) use handler::run_native_initiator_handshake_without_trace as run_native_initiator_handshake;
47
48#[cfg(test)]
49use std::sync::{
50    atomic::{AtomicUsize, Ordering},
51    Arc,
52};
53
54/// The pinned iroh version the Zakura P2P plan was verified against.
55pub const IROH_VERSION: &str = "0.92.0";
56
57/// Capability bit for the legacy gossip compatibility service.
58pub const ZAKURA_CAP_LEGACY_GOSSIP: u64 = 1 << 0;
59
60/// Capability bit for the native discovery service.
61pub const ZAKURA_CAP_DISCOVERY: u64 = 1 << 2;
62
63/// Capability bit for the native header-sync service.
64pub const ZAKURA_CAP_HEADER_SYNC: u64 = 1 << 4;
65
66// Bit `1 << 1` is retired: it advertised the pre-request-id header-sync stream, which
67// correlated responses by arrival order and is no longer spoken. Do not lease it to a new
68// service. (`1 << 3` is block sync, declared in `block_sync::wire`.)
69
70/// Production default for per-service peer caps.
71pub const DEFAULT_SERVICE_MAX_PEERS: usize = 256;
72/// Production default for per-service bounded inbound work queues.
73pub const DEFAULT_SERVICE_INBOUND_QUEUE_DEPTH: usize = 128;
74/// Production default for per-service bounded outbound work queues.
75pub const DEFAULT_SERVICE_OUTBOUND_QUEUE_DEPTH: usize = 128;
76/// Production default for demand-gated service escalations.
77pub const DEFAULT_SERVICE_MAX_PENDING_ESCALATIONS: usize = 32;
78
79/// Per-service peer and queue limits owned by a Zakura service reactor.
80#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields, default)]
82pub struct ServicePeerLimits {
83    /// Maximum inbound peers this service admits.
84    pub max_inbound_peers: usize,
85    /// Maximum outbound peers this service admits.
86    pub max_outbound_peers: usize,
87    /// Inbound queue depth reserved for this service.
88    ///
89    /// Reserved for future transport queue wiring; not enforced in this phase.
90    pub inbound_queue_depth: usize,
91    /// Outbound queue depth reserved for this service.
92    ///
93    /// Reserved for future transport queue wiring; not enforced in this phase.
94    pub outbound_queue_depth: usize,
95    /// Maximum service escalations that may be pending admission.
96    ///
97    /// Reserved for future lazy service escalation; not enforced in this phase.
98    pub max_pending_escalations: usize,
99}
100
101impl Default for ServicePeerLimits {
102    fn default() -> Self {
103        Self {
104            max_inbound_peers: DEFAULT_SERVICE_MAX_PEERS,
105            max_outbound_peers: DEFAULT_SERVICE_MAX_PEERS,
106            inbound_queue_depth: DEFAULT_SERVICE_INBOUND_QUEUE_DEPTH,
107            outbound_queue_depth: DEFAULT_SERVICE_OUTBOUND_QUEUE_DEPTH,
108            max_pending_escalations: DEFAULT_SERVICE_MAX_PENDING_ESCALATIONS,
109        }
110    }
111}
112
113/// Local service admission result.
114#[derive(Copy, Clone, Debug, Eq, PartialEq)]
115pub enum ServiceAdmissionDecision {
116    /// The service accepted the typed peer session.
117    Admit,
118    /// The local service-specific peer cap is full.
119    RejectFull,
120    /// The peer is not useful for this service right now.
121    RejectNotUseful,
122    /// The peer is still in a local retry backoff window.
123    RejectBackoff,
124    /// The peer does not support this service.
125    RejectUnsupported,
126}
127
128/// Direction of the underlying Zakura connection.
129#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)]
130pub enum ServicePeerDirection {
131    /// The remote peer dialed this node.
132    Inbound,
133    /// This node dialed the remote peer.
134    Outbound,
135}
136
137/// Monotonically increasing transport connection generation.
138pub type ZakuraConnId = u64;
139
140impl ServicePeerDirection {
141    pub(crate) fn trace_label(self) -> &'static str {
142        match self {
143            Self::Inbound => "inbound",
144            Self::Outbound => "outbound",
145        }
146    }
147}
148
149/// Current admitted peer counts and slot availability for one service.
150#[derive(Copy, Clone, Debug, Eq, PartialEq)]
151pub struct ServicePeerSnapshot {
152    /// Currently admitted inbound peers.
153    pub inbound_peers: usize,
154    /// Currently admitted outbound peers.
155    pub outbound_peers: usize,
156    /// Free inbound slots under the configured cap.
157    pub inbound_slots_free: usize,
158    /// Free outbound slots under the configured cap.
159    pub outbound_slots_free: usize,
160}
161
162impl ServicePeerSnapshot {
163    /// Build a snapshot from current admitted counts.
164    pub fn new(inbound_peers: usize, outbound_peers: usize, limits: ServicePeerLimits) -> Self {
165        Self {
166            inbound_peers,
167            outbound_peers,
168            inbound_slots_free: limits.max_inbound_peers.saturating_sub(inbound_peers),
169            outbound_slots_free: limits.max_outbound_peers.saturating_sub(outbound_peers),
170        }
171    }
172}
173
174impl Default for ServicePeerSnapshot {
175    fn default() -> Self {
176        Self::new(0, 0, ServicePeerLimits::default())
177    }
178}
179
180/// How long the legacy->Zakura liveness keeper waits for the upgraded QUIC
181/// connection to register with the supervisor before giving up.
182///
183/// The native dial spawned by the upgrade is asynchronous, so the peer only
184/// appears in the supervisor's registered set a little later. If it never
185/// registers (the dial failed), the keeper exits without marking the peer live,
186/// so the outbound crawler reconnects to it normally.
187const ZAKURA_LIVENESS_APPEAR_TIMEOUT: Duration = Duration::from_secs(15);
188
189/// How often the keeper refreshes an upgraded peer's legacy address-book entry.
190///
191/// Must stay below [`constants::MIN_PEER_RECONNECTION_DELAY`](crate::constants::MIN_PEER_RECONNECTION_DELAY)
192/// so the `Responded` liveness never ages into a reconnection candidate while
193/// the Zakura connection is alive.
194const ZAKURA_LIVENESS_REFRESH_INTERVAL: Duration = Duration::from_secs(45);
195
196/// Returns an iroh endpoint builder with relays and external address lookup disabled.
197///
198/// Callers must add direct bind addresses before binding if they do not want the
199/// endpoint to listen on iroh's default unspecified sockets.
200pub fn direct_endpoint_builder(secret_key: SecretKey) -> endpoint::Builder {
201    Endpoint::builder()
202        .relay_mode(RelayMode::Disabled)
203        .clear_discovery()
204        .secret_key(secret_key)
205}
206
207/// The result of routing a mutually P2P-v2-capable legacy handshake.
208#[derive(Clone, Debug, Eq, PartialEq)]
209pub enum ZakuraUpgradeOutcome {
210    /// The peer was authenticated and registered with the Zakura supervisor.
211    Upgraded {
212        /// The authenticated Zakura/Iroh peer identity.
213        peer_id: ZakuraPeerId,
214    },
215
216    /// The peer was authenticated, but a better duplicate connection already exists.
217    Duplicate {
218        /// The authenticated Zakura/Iroh peer identity.
219        peer_id: ZakuraPeerId,
220    },
221
222    /// The upgrade was rejected neutrally.
223    Rejected {
224        /// The neutral rejection reason.
225        reason: ZakuraRejectReason,
226    },
227}
228
229/// An error from the Zakura handshake upgrade hook.
230#[derive(Error, Debug)]
231pub enum ZakuraUpgradeError {
232    /// Local config and the remote service bit selected Zakura, but no supervisor exists yet.
233    #[error("Zakura P2P v2 upgrade selected but no Zakura handshake connector is available")]
234    Unavailable,
235}
236
237/// Handle used by the legacy handshake to enter the Zakura P2P upgrade path.
238///
239/// After a mutually P2P-v2-capable legacy `version`/`verack` exchange, the legacy
240/// handshake swaps a bounded upgrade prelude over the TCP stream to learn the
241/// peer's Zakura node address, then uses this connector to dial it over QUIC.
242#[derive(Clone, Debug, Default)]
243pub struct ZakuraHandshakeConnector {
244    /// The live Zakura endpoint, used to read our own dial hints and to dial a
245    /// peer over QUIC once the legacy upgrade prelude exchange reveals its
246    /// Zakura node address.
247    endpoint: Option<ZakuraEndpoint>,
248    #[cfg(test)]
249    test_outcome: Option<(Arc<AtomicUsize>, ZakuraUpgradeOutcome)>,
250}
251
252impl ZakuraHandshakeConnector {
253    /// Create a placeholder connector for a node that can advertise P2P v2, but
254    /// has no live Zakura endpoint, so it cannot complete a handoff.
255    pub fn unavailable() -> Self {
256        Self {
257            endpoint: None,
258            #[cfg(test)]
259            test_outcome: None,
260        }
261    }
262
263    /// Create a connector backed by the live Zakura endpoint, so the legacy
264    /// handshake can exchange dial hints and connect to the peer over QUIC.
265    pub(crate) fn new_with_endpoint(endpoint: ZakuraEndpoint) -> Self {
266        Self {
267            endpoint: Some(endpoint),
268            #[cfg(test)]
269            test_outcome: None,
270        }
271    }
272
273    /// Returns our local Zakura dial hints (iroh node id, and direct addresses
274    /// each encoded as a `SocketAddr` string) for the legacy upgrade prelude, or
275    /// `None` if this node has no live Zakura endpoint.
276    pub(crate) async fn local_iroh_hints(&self) -> Option<(Vec<u8>, Vec<Vec<u8>>)> {
277        let endpoint = self.endpoint.as_ref()?;
278        Some(endpoint.local_upgrade_hints().await)
279    }
280
281    /// Dial a peer over Zakura QUIC using the node id and direct-address hints it
282    /// advertised in the legacy upgrade prelude, then wait until the connection
283    /// registers with the local supervisor.
284    ///
285    /// The legacy side drops its TCP connection once the upgrade is selected, so
286    /// the Zakura request adapter must have a usable outbound handle before the
287    /// handoff reports success.
288    pub(crate) async fn spawn_zakura_dial_to_hints_and_wait(
289        &self,
290        peer_id: &ZakuraPeerId,
291        node_id: &[u8],
292        direct_addresses: &[Vec<u8>],
293    ) -> bool {
294        let Some(endpoint) = self.endpoint.as_ref() else {
295            return false;
296        };
297        let Some(node_addr) = node_addr_from_hints(node_id, direct_addresses) else {
298            return false;
299        };
300        let mut registered = endpoint.supervisor().subscribe();
301        if !endpoint.ensure_upgrade_native_dial(node_addr) {
302            return false;
303        }
304        if wait_for_zakura_peer(&mut registered, peer_id, ZAKURA_LIVENESS_APPEAR_TIMEOUT).await {
305            return true;
306        }
307
308        // The hand-off did not complete within the wait window. The dial spawned
309        // by `ensure_upgrade_native_dial` uses `RedialPolicy::maintain`, so it
310        // would keep redialing this peer-supplied address forever and retain its
311        // `upgrade_dials` entry. Unless the peer registered in the meantime
312        // (keep its maintained dial as the recovery path), cancel the dial and
313        // drop the entry so a malicious legacy responder cannot leak unbounded
314        // maintained dials and outbound QUIC traffic by repeating failed
315        // upgrades with distinct node ids.
316        if !registered.borrow().iter().any(|id| id == peer_id) {
317            endpoint.cancel_upgrade_native_dial(peer_id);
318        }
319        false
320    }
321
322    /// Wait until the upgraded peer's inbound native QUIC connection registers
323    /// with the local supervisor.
324    ///
325    /// Used by the inbound legacy responder, which does not dial: after sending
326    /// `Accept` the remote peer is expected to dial our advertised Zakura
327    /// endpoint, and our iroh router registers that connection separately. The
328    /// outer handshake drops the legacy TCP connection once the upgrade is
329    /// reported, so the responder must confirm a usable Zakura replacement
330    /// exists first. Returns `false` (keep legacy) if the peer never registers
331    /// within [`ZAKURA_LIVENESS_APPEAR_TIMEOUT`] or this node has no live
332    /// endpoint, so a peer that sends a valid `Init` and then never completes
333    /// the native dial cannot make us silently drop a working legacy peer.
334    pub(crate) async fn wait_for_zakura_registration(&self, peer_id: &ZakuraPeerId) -> bool {
335        let Some(endpoint) = self.endpoint.as_ref() else {
336            return false;
337        };
338        let mut registered = endpoint.supervisor().subscribe();
339        wait_for_zakura_peer(&mut registered, peer_id, ZAKURA_LIVENESS_APPEAR_TIMEOUT).await
340    }
341
342    /// Keep an upgraded peer's legacy address-book entry live for the lifetime
343    /// of its Zakura connection, so the outbound crawler does not re-dial it.
344    ///
345    /// After a legacy->Zakura upgrade the legacy TCP connection is dropped, so
346    /// nothing else refreshes the peer's `Responded` liveness. Without this, the
347    /// crawler re-dials the peer once its entry ages past
348    /// [`constants::MIN_PEER_RECONNECTION_DELAY`](crate::constants::MIN_PEER_RECONNECTION_DELAY),
349    /// re-running the upgrade and churning the QUIC connection. While the peer
350    /// is registered with the supervisor the keeper marks it `Responded`; once
351    /// it deregisters the keeper stops, so a genuinely gone peer becomes a
352    /// reconnection candidate again.
353    ///
354    /// Only meaningful for outbound connections, where `book_addr` is the
355    /// dialable remote address the crawler would otherwise reconnect to. Does
356    /// nothing without a live endpoint.
357    pub(crate) fn spawn_legacy_liveness_keeper(
358        &self,
359        peer_id: ZakuraPeerId,
360        book_addr: PeerSocketAddr,
361        address_book_updater: tokio::sync::mpsc::Sender<MetaAddrChange>,
362    ) {
363        let Some(endpoint) = self.endpoint.as_ref() else {
364            return;
365        };
366        let registered = endpoint.supervisor().subscribe();
367        tokio::spawn(run_legacy_liveness_keeper(
368            registered,
369            peer_id,
370            book_addr,
371            address_book_updater,
372            ZAKURA_LIVENESS_APPEAR_TIMEOUT,
373            ZAKURA_LIVENESS_REFRESH_INTERVAL,
374        ));
375    }
376
377    /// Returns the deterministic upgrade outcome injected for handshake routing
378    /// tests (incrementing the call counter), if one was configured.
379    #[cfg(test)]
380    pub(crate) fn consume_test_outcome(&self) -> Option<ZakuraUpgradeOutcome> {
381        let (calls, outcome) = self.test_outcome.as_ref()?;
382        calls.fetch_add(1, Ordering::SeqCst);
383        Some(outcome.clone())
384    }
385
386    /// Create a deterministic connector for handshake routing tests.
387    #[cfg(test)]
388    pub(crate) fn for_test(calls: Arc<AtomicUsize>, outcome: ZakuraUpgradeOutcome) -> Self {
389        Self {
390            endpoint: None,
391            test_outcome: Some((calls, outcome)),
392        }
393    }
394}
395
396/// Builds an iroh dial address from the node id and direct-address hints a peer
397/// advertised in a legacy upgrade prelude.
398///
399/// Direct addresses are carried as `SocketAddr` strings (the same encoding used
400/// by configured bootstrap peers), so each entry is parsed back into a
401/// `SocketAddr`. Returns `None` if the node id is malformed or no direct address
402/// parses, since a peer with no reachable address cannot be dialed.
403fn node_addr_from_hints(node_id: &[u8], direct_addresses: &[Vec<u8>]) -> Option<NodeAddr> {
404    let node_id_bytes: [u8; 32] = node_id.try_into().ok()?;
405    let node_id = NodeId::from_bytes(&node_id_bytes).ok()?;
406
407    let direct: Vec<std::net::SocketAddr> = direct_addresses
408        .iter()
409        .filter_map(|address| std::str::from_utf8(address).ok()?.parse().ok())
410        .collect();
411
412    if direct.is_empty() {
413        return None;
414    }
415
416    Some(NodeAddr::new(node_id).with_direct_addresses(direct))
417}
418
419/// Refresh an upgraded peer's legacy `Responded` liveness while it stays
420/// registered with the Zakura supervisor.
421///
422/// See [`ZakuraHandshakeConnector::spawn_legacy_liveness_keeper`]. Exits when
423/// the peer never registers within `appear_timeout`, when it deregisters, or
424/// when the address book updater closes (node shutdown).
425async fn run_legacy_liveness_keeper(
426    mut registered: watch::Receiver<Vec<ZakuraPeerId>>,
427    peer_id: ZakuraPeerId,
428    book_addr: PeerSocketAddr,
429    address_book_updater: tokio::sync::mpsc::Sender<MetaAddrChange>,
430    appear_timeout: Duration,
431    refresh_interval: Duration,
432) {
433    if !wait_for_zakura_peer(&mut registered, &peer_id, appear_timeout).await {
434        return;
435    }
436
437    loop {
438        // Refresh the peer's `Responded` liveness so the crawler treats it as a
439        // live (Zakura) peer instead of re-dialing it over legacy TCP.
440        if address_book_updater
441            .send(MetaAddr::new_responded(book_addr, None))
442            .await
443            .is_err()
444        {
445            // The address book updater is gone: the node is shutting down.
446            break;
447        }
448
449        // Wait for the next refresh, but wake early if the peer set changes so
450        // we react to deregistration promptly.
451        tokio::select! {
452            changed = registered.changed() => {
453                if changed.is_err() {
454                    break;
455                }
456            }
457            _ = tokio::time::sleep(refresh_interval) => {}
458        }
459
460        if !registered.borrow().iter().any(|id| id == &peer_id) {
461            // The Zakura connection deregistered: stop refreshing so the entry
462            // ages out and the peer can be reconnected over legacy.
463            break;
464        }
465    }
466}
467
468/// Wait until `peer_id` appears in the supervisor's registered set, or
469/// `appear_timeout` elapses. Returns whether the peer is registered.
470async fn wait_for_zakura_peer(
471    registered: &mut watch::Receiver<Vec<ZakuraPeerId>>,
472    peer_id: &ZakuraPeerId,
473    appear_timeout: Duration,
474) -> bool {
475    tokio::time::timeout(appear_timeout, async {
476        loop {
477            if registered.borrow().iter().any(|id| id == peer_id) {
478                return true;
479            }
480            if registered.changed().await.is_err() {
481                return false;
482            }
483        }
484    })
485    .await
486    .unwrap_or(false)
487}
488
489#[cfg(test)]
490mod tests {
491    use std::{
492        error::Error,
493        net::{Ipv4Addr, SocketAddrV4},
494    };
495
496    use iroh::{
497        endpoint::Connection,
498        protocol::{AcceptError, ProtocolHandler, Router},
499        SecretKey, Watcher as _,
500    };
501
502    use super::*;
503    use crate::{CacheDir, Config};
504
505    #[derive(Debug, Clone)]
506    struct SmokeProtocolHandler;
507
508    impl ProtocolHandler for SmokeProtocolHandler {
509        async fn accept(&self, _connection: Connection) -> Result<(), AcceptError> {
510            Ok(())
511        }
512    }
513
514    #[tokio::test]
515    async fn iroh_endpoint_starts_without_relay_or_discovery() -> Result<(), Box<dyn Error>> {
516        let secret_key = SecretKey::from_bytes(&[7; 32]);
517
518        let endpoint = direct_endpoint_builder(secret_key)
519            .bind_addr_v4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
520            .bind()
521            .await?;
522
523        let router = Router::builder(endpoint)
524            .accept(b"/zakura/smoke/0", SmokeProtocolHandler)
525            .spawn();
526
527        let addr = router.endpoint().node_addr().initialized().await;
528
529        assert_eq!(addr.node_id, router.endpoint().node_id());
530        assert!(addr.direct_addresses().next().is_some());
531        assert!(addr.relay_url().is_none());
532        assert!(router.endpoint().discovery().is_none());
533
534        router.shutdown().await?;
535
536        Ok(())
537    }
538
539    #[test]
540    fn zakura_secret_key_path_uses_identity_dir() {
541        let config = Config {
542            cache_dir: CacheDir::custom_path("/tmp/zakura-cache"),
543            identity_dir: "/tmp/zakura-identities".into(),
544            ..Config::default()
545        };
546        let key_file =
547            crate::config::zakura_secret_key_file_path(&config.identity_dir, &config.network);
548
549        assert!(
550            !key_file.starts_with("/tmp/zakura-cache"),
551            "Zakura identity keys must not be stored under the peer cache directory",
552        );
553        assert_eq!(
554            key_file
555                .parent()
556                .and_then(|path| path.file_name())
557                .and_then(|name| name.to_str()),
558            Some("zakura-identities"),
559            "Zakura identity keys must be stored under network.identity_dir",
560        );
561        assert_eq!(
562            key_file.file_name().and_then(|name| name.to_str()),
563            Some("mainnet.zakura-iroh-secret-key"),
564        );
565    }
566
567    fn test_peer_id() -> ZakuraPeerId {
568        ZakuraPeerId::new(vec![7u8; 32]).expect("32-byte node id is within bounds")
569    }
570
571    fn test_book_addr() -> PeerSocketAddr {
572        "127.0.0.1:18233"
573            .parse::<std::net::SocketAddr>()
574            .expect("valid socket addr")
575            .into()
576    }
577
578    /// While the peer stays registered, the keeper repeatedly refreshes its
579    /// `Responded` liveness; once it deregisters, the keeper stops.
580    #[tokio::test]
581    async fn legacy_liveness_keeper_refreshes_until_deregistered() {
582        let peer_id = test_peer_id();
583        let (registered_tx, registered_rx) = watch::channel(vec![peer_id.clone()]);
584        let (updater_tx, mut updater_rx) = tokio::sync::mpsc::channel(16);
585
586        let keeper = tokio::spawn(run_legacy_liveness_keeper(
587            registered_rx,
588            peer_id.clone(),
589            test_book_addr(),
590            updater_tx,
591            Duration::from_secs(5),
592            Duration::from_millis(20),
593        ));
594
595        // The keeper should keep marking the peer `Responded` while it is registered.
596        for _ in 0..2 {
597            let change = tokio::time::timeout(Duration::from_secs(1), updater_rx.recv())
598                .await
599                .expect("keeper refreshes liveness on a registered peer")
600                .expect("the keeper holds the sender open");
601            assert!(
602                matches!(change, MetaAddrChange::UpdateResponded { addr, .. } if addr == test_book_addr()),
603                "keeper should refresh the upgraded peer's responded liveness, got {change:?}",
604            );
605        }
606
607        // Deregister the peer: the keeper must observe the change and exit.
608        registered_tx.send(Vec::new()).expect("receiver is alive");
609        tokio::time::timeout(Duration::from_secs(2), keeper)
610            .await
611            .expect("keeper exits after the peer deregisters")
612            .expect("keeper task does not panic");
613    }
614
615    /// If the upgraded connection never registers, the keeper gives up without
616    /// marking the peer live, so the crawler can reconnect normally.
617    #[tokio::test]
618    async fn legacy_liveness_keeper_exits_when_peer_never_registers() {
619        let peer_id = test_peer_id();
620        let (_registered_tx, registered_rx) = watch::channel(Vec::new());
621        let (updater_tx, mut updater_rx) = tokio::sync::mpsc::channel(16);
622
623        let keeper = tokio::spawn(run_legacy_liveness_keeper(
624            registered_rx,
625            peer_id,
626            test_book_addr(),
627            updater_tx,
628            Duration::from_millis(50),
629            Duration::from_millis(20),
630        ));
631
632        tokio::time::timeout(Duration::from_secs(2), keeper)
633            .await
634            .expect("keeper exits after the appear timeout")
635            .expect("keeper task does not panic");
636
637        assert!(
638            updater_rx.try_recv().is_err(),
639            "keeper must not refresh liveness for a peer that never registered",
640        );
641    }
642}