Skip to main content

laminar_core/cluster/discovery/static_discovery/
mod.rs

1//! Static seed-list discovery with TCP heartbeats.
2
3#![allow(clippy::disallowed_types)] // cold path: static discovery coordination
4use std::collections::HashMap;
5use std::sync::Arc;
6use std::time::Duration;
7
8use parking_lot::RwLock;
9use tokio::net::{TcpListener, TcpStream};
10use tokio::sync::watch;
11use tokio_util::sync::CancellationToken;
12
13use super::{
14    publish_if_changed, Discovery, DiscoveryError, NodeId, NodeInfo, NodeMetadata, NodeState,
15};
16
17/// TCP connect timeout for heartbeat connections.
18const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
19
20/// TCP read/write timeout per I/O operation.
21const IO_TIMEOUT: Duration = Duration::from_secs(5);
22
23/// Maximum concurrent handler tasks in the listener.
24const MAX_HANDLER_TASKS: usize = 64;
25
26/// Maximum message size (1 MB).
27const MAX_MESSAGE_SIZE: usize = 1_048_576;
28
29/// Grace period after which `Left` peers disappear from published membership.
30const LEFT_REAP_THRESHOLD: u32 = 30;
31
32const DISCOVERY_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
33const STATIC_DISCOVERY_PROTOCOL_VERSION: u16 = 2;
34
35fn current_node_metadata() -> NodeMetadata {
36    NodeMetadata {
37        version: env!("CARGO_PKG_VERSION").into(),
38        ..NodeMetadata::default()
39    }
40}
41
42struct AbortTaskOnDrop(tokio::task::AbortHandle);
43
44impl AbortTaskOnDrop {
45    fn abort(&self) {
46        self.0.abort();
47    }
48}
49
50impl Drop for AbortTaskOnDrop {
51    fn drop(&mut self) {
52        self.abort();
53    }
54}
55
56async fn join_task_bounded<T>(
57    mut task: tokio::task::JoinHandle<T>,
58    timeout: Duration,
59    task_name: &'static str,
60) -> Option<Result<T, tokio::task::JoinError>> {
61    let abort_on_drop = AbortTaskOnDrop(task.abort_handle());
62    if let Ok(result) = tokio::time::timeout(timeout, &mut task).await {
63        Some(result)
64    } else {
65        tracing::warn!(
66            task = task_name,
67            ?timeout,
68            "Discovery task did not stop in time"
69        );
70        abort_on_drop.abort();
71        let _ = tokio::time::timeout(timeout.min(Duration::from_secs(1)), &mut task).await;
72        None
73    }
74}
75
76/// Configuration for static discovery.
77#[derive(Debug, Clone)]
78pub struct StaticDiscoveryConfig {
79    /// This node's info.
80    pub local_node: NodeInfo,
81    /// Seed addresses to connect to.
82    pub seeds: Vec<String>,
83    /// Heartbeat interval.
84    pub heartbeat_interval: Duration,
85    /// Number of missed heartbeats before marking as `Suspected`.
86    pub suspect_threshold: u32,
87    /// Number of missed heartbeats before marking as `Left`.
88    pub dead_threshold: u32,
89    /// Address to bind the heartbeat listener.
90    pub listen_address: String,
91    /// Durable, monotonically increasing process term for this stable node ID.
92    pub process_generation: u64,
93    /// Unique identity of this process incarnation.
94    pub process_incarnation: uuid::Uuid,
95}
96
97impl Default for StaticDiscoveryConfig {
98    fn default() -> Self {
99        Self {
100            local_node: NodeInfo {
101                id: NodeId(1),
102                name: "node-1".into(),
103                rpc_address: "127.0.0.1:9000".into(),
104                state: NodeState::Active,
105                metadata: current_node_metadata(),
106                last_heartbeat_ms: 0,
107            },
108            seeds: Vec::new(),
109            heartbeat_interval: Duration::from_secs(1),
110            suspect_threshold: 3,
111            dead_threshold: 10,
112            listen_address: "127.0.0.1:9002".into(),
113            process_generation: 1,
114            process_incarnation: uuid::Uuid::from_u128(1),
115        }
116    }
117}
118
119#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
120struct StaticHeartbeat {
121    protocol_version: u16,
122    node: NodeInfo,
123    process_generation: u64,
124    process_incarnation: [u8; 16],
125}
126
127impl StaticHeartbeat {
128    fn new(node: NodeInfo, process_generation: u64, process_incarnation: uuid::Uuid) -> Self {
129        Self {
130            protocol_version: STATIC_DISCOVERY_PROTOCOL_VERSION,
131            node,
132            process_generation,
133            process_incarnation: process_incarnation.into_bytes(),
134        }
135    }
136
137    fn identity(&self) -> ProcessIdentity {
138        ProcessIdentity {
139            generation: self.process_generation,
140            incarnation: self.process_incarnation,
141        }
142    }
143
144    fn validate(&self) -> Result<(), DiscoveryError> {
145        if self.protocol_version != STATIC_DISCOVERY_PROTOCOL_VERSION {
146            return Err(DiscoveryError::Serialization(format!(
147                "static discovery protocol version must be {STATIC_DISCOVERY_PROTOCOL_VERSION}"
148            )));
149        }
150        if self.process_generation == 0 {
151            return Err(DiscoveryError::Serialization(
152                "static discovery process generation must be nonzero".into(),
153            ));
154        }
155        if self.process_incarnation == [0; 16] {
156            return Err(DiscoveryError::Serialization(
157                "static discovery process incarnation must be a non-nil UUID".into(),
158            ));
159        }
160        if self.node.metadata.version != env!("CARGO_PKG_VERSION") {
161            return Err(DiscoveryError::Serialization(format!(
162                "static discovery node version must be {:?}",
163                env!("CARGO_PKG_VERSION")
164            )));
165        }
166        Ok(())
167    }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171struct ProcessIdentity {
172    generation: u64,
173    incarnation: [u8; 16],
174}
175
176/// Internal per-peer tracking state.
177#[derive(Debug)]
178struct PeerState {
179    info: NodeInfo,
180    identity: ProcessIdentity,
181    /// An equal-term/different-incarnation collision has no safe winner.
182    excluded: bool,
183    /// Hidden after the `Left` grace period while retaining the process-term watermark.
184    reaped: bool,
185    /// Missed *outbound* heartbeats (managed exclusively by the heartbeater).
186    missed_heartbeats: u32,
187    /// Counter that keeps incrementing after `Left` state. Used for reaping.
188    left_ticks: u32,
189}
190
191#[derive(Debug, Default)]
192struct StaticState {
193    peers: HashMap<u64, PeerState>,
194}
195
196impl StaticState {
197    fn peer_list(&self) -> Vec<NodeInfo> {
198        self.peers
199            .values()
200            .filter(|peer| !peer.excluded && !peer.reaped)
201            .map(|peer| peer.info.clone())
202            .collect()
203    }
204
205    fn update_identity(info: &mut NodeInfo, remote: &NodeInfo, now: i64) {
206        info.rpc_address.clone_from(&remote.rpc_address);
207        info.name.clone_from(&remote.name);
208        info.metadata = remote.metadata.clone();
209        info.last_heartbeat_ms = now;
210    }
211
212    fn new_peer(
213        heartbeat: StaticHeartbeat,
214        now: i64,
215        direction: ObservationDirection,
216    ) -> PeerState {
217        let identity = heartbeat.identity();
218        let mut info = heartbeat.node;
219        info.last_heartbeat_ms = now;
220        if direction == ObservationDirection::Inbound {
221            info.state = match info.state {
222                NodeState::Draining => NodeState::Draining,
223                NodeState::Left => NodeState::Left,
224                _ => NodeState::Joining,
225            };
226        }
227        PeerState {
228            info,
229            identity,
230            excluded: false,
231            reaped: false,
232            missed_heartbeats: 0,
233            left_ticks: 0,
234        }
235    }
236
237    fn observe(
238        &mut self,
239        heartbeat: StaticHeartbeat,
240        now: i64,
241        direction: ObservationDirection,
242    ) -> ObservationResult {
243        let id = heartbeat.node.id.0;
244        let incoming_identity = heartbeat.identity();
245        let Some(peer) = self.peers.get_mut(&id) else {
246            self.peers
247                .insert(id, Self::new_peer(heartbeat, now, direction));
248            return ObservationResult::Accepted(incoming_identity);
249        };
250
251        match incoming_identity.generation.cmp(&peer.identity.generation) {
252            std::cmp::Ordering::Less => return ObservationResult::Ignored,
253            std::cmp::Ordering::Greater => {
254                *peer = Self::new_peer(heartbeat, now, direction);
255                return ObservationResult::Accepted(incoming_identity);
256            }
257            std::cmp::Ordering::Equal => {}
258        }
259
260        if incoming_identity.incarnation != peer.identity.incarnation {
261            peer.excluded = true;
262            return ObservationResult::Collision;
263        }
264        if peer.excluded {
265            return ObservationResult::Collision;
266        }
267
268        let remote = heartbeat.node;
269        Self::update_identity(&mut peer.info, &remote, now);
270        peer.info.state = match (peer.info.state, remote.state) {
271            (NodeState::Left, _) | (_, NodeState::Left) => NodeState::Left,
272            (NodeState::Draining, _) | (_, NodeState::Draining) => NodeState::Draining,
273            (state, _) if direction == ObservationDirection::Inbound => state,
274            (_, advertised) => advertised,
275        };
276        if direction == ObservationDirection::Outbound {
277            peer.missed_heartbeats = 0;
278            if peer.info.state != NodeState::Left {
279                peer.left_ticks = 0;
280            }
281        }
282        ObservationResult::Accepted(incoming_identity)
283    }
284
285    fn observe_inbound(&mut self, heartbeat: StaticHeartbeat, now: i64) -> ObservationResult {
286        self.observe(heartbeat, now, ObservationDirection::Inbound)
287    }
288
289    fn observe_outbound(&mut self, heartbeat: StaticHeartbeat, now: i64) -> ObservationResult {
290        self.observe(heartbeat, now, ObservationDirection::Outbound)
291    }
292
293    fn record_missed_heartbeat(
294        &mut self,
295        expected: SeedPeer,
296        suspect_threshold: u32,
297        dead_threshold: u32,
298    ) {
299        let Some(peer) = self.peers.get_mut(&expected.node_id) else {
300            return;
301        };
302        if peer.identity != expected.identity || peer.excluded || peer.reaped {
303            return;
304        }
305        peer.missed_heartbeats = peer.missed_heartbeats.saturating_add(1);
306        if peer.missed_heartbeats >= dead_threshold {
307            peer.info.state = NodeState::Left;
308        } else if peer.missed_heartbeats >= suspect_threshold {
309            peer.info.state = NodeState::Suspected;
310        }
311    }
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315enum ObservationDirection {
316    Inbound,
317    Outbound,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321enum ObservationResult {
322    Accepted(ProcessIdentity),
323    Ignored,
324    Collision,
325}
326
327/// Static discovery implementation with TCP heartbeats.
328#[derive(Debug)]
329pub struct StaticDiscovery {
330    config: StaticDiscoveryConfig,
331    local_info: Arc<RwLock<NodeInfo>>,
332    state: Arc<RwLock<StaticState>>,
333    membership_tx: watch::Sender<Vec<NodeInfo>>,
334    membership_rx: watch::Receiver<Vec<NodeInfo>>,
335    cancel: CancellationToken,
336    listener_handle: Option<tokio::task::JoinHandle<Result<(), DiscoveryError>>>,
337    heartbeater_handle: Option<tokio::task::JoinHandle<()>>,
338    started: bool,
339}
340
341impl StaticDiscovery {
342    /// Create a new static discovery instance.
343    #[must_use]
344    pub fn new(config: StaticDiscoveryConfig) -> Self {
345        debug_assert!(
346            config.suspect_threshold < config.dead_threshold,
347            "suspect_threshold ({}) must be less than dead_threshold ({})",
348            config.suspect_threshold,
349            config.dead_threshold,
350        );
351        let (tx, rx) = watch::channel(Vec::new());
352        Self {
353            local_info: Arc::new(RwLock::new(config.local_node.clone())),
354            config,
355            state: Arc::new(RwLock::new(StaticState::default())),
356            membership_tx: tx,
357            membership_rx: rx,
358            cancel: CancellationToken::new(),
359            listener_handle: None,
360            heartbeater_handle: None,
361            started: false,
362        }
363    }
364
365    /// Serialize a heartbeat for transmission.
366    fn serialize_heartbeat(heartbeat: &StaticHeartbeat) -> Result<Vec<u8>, DiscoveryError> {
367        heartbeat.validate()?;
368        rkyv::to_bytes::<rkyv::rancor::Error>(heartbeat)
369            .map(|v| v.to_vec())
370            .map_err(|e| DiscoveryError::Serialization(e.to_string()))
371    }
372
373    /// Deserialize and validate a heartbeat received from the network.
374    fn deserialize_heartbeat(data: &[u8]) -> Result<StaticHeartbeat, DiscoveryError> {
375        let heartbeat = rkyv::from_bytes::<StaticHeartbeat, rkyv::rancor::Error>(data)
376            .map_err(|e| DiscoveryError::Serialization(e.to_string()))?;
377        heartbeat.validate()?;
378        Ok(heartbeat)
379    }
380
381    /// Send a heartbeat to a single seed address with connect + I/O timeouts.
382    #[allow(clippy::cast_possible_truncation)]
383    async fn send_heartbeat(address: &str, data: &[u8]) -> Result<Option<Vec<u8>>, DiscoveryError> {
384        use tokio::io::{AsyncReadExt, AsyncWriteExt};
385
386        // Connect with timeout (C2 fix)
387        let mut stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(address))
388            .await
389            .map_err(|_| DiscoveryError::Connection {
390                address: address.into(),
391                reason: "connect timeout".into(),
392            })?
393            .map_err(|e| DiscoveryError::Connection {
394                address: address.into(),
395                reason: e.to_string(),
396            })?;
397
398        // Validate message size before sending (W7 symmetric limit)
399        if data.len() > MAX_MESSAGE_SIZE {
400            return Err(DiscoveryError::Serialization(
401                "message too large to send".into(),
402            ));
403        }
404
405        // Write length-prefixed message with I/O timeout (W1 fix)
406        let len = data.len() as u32;
407        tokio::time::timeout(IO_TIMEOUT, async {
408            stream.write_all(&len.to_be_bytes()).await?;
409            stream.write_all(data).await
410        })
411        .await
412        .map_err(|_| DiscoveryError::Connection {
413            address: address.into(),
414            reason: "write timeout".into(),
415        })?
416        .map_err(|e| DiscoveryError::Connection {
417            address: address.into(),
418            reason: e.to_string(),
419        })?;
420
421        // Read response with I/O timeout (W1 fix)
422        let resp = tokio::time::timeout(IO_TIMEOUT, async {
423            let mut len_buf = [0u8; 4];
424            if stream.read_exact(&mut len_buf).await.is_err() {
425                return Ok(None);
426            }
427
428            let resp_len = u32::from_be_bytes(len_buf) as usize;
429            if resp_len > MAX_MESSAGE_SIZE {
430                return Err(DiscoveryError::Serialization("response too large".into()));
431            }
432            let mut resp = vec![0u8; resp_len];
433            stream.read_exact(&mut resp).await?;
434            Ok(Some(resp))
435        })
436        .await
437        .map_err(|_| DiscoveryError::Connection {
438            address: address.into(),
439            reason: "read timeout".into(),
440        })?;
441
442        resp.map_err(|e: DiscoveryError| e)
443    }
444
445    /// Run the heartbeat listener (accepts incoming heartbeats).
446    ///
447    /// Inbound traffic refreshes peer identity and propagates advertised
448    /// draining/left states, but does not reset outbound failure counters.
449    #[allow(clippy::cast_possible_truncation)]
450    async fn run_listener(
451        listener: TcpListener,
452        local_info: Arc<RwLock<NodeInfo>>,
453        local_identity: ProcessIdentity,
454        state: Arc<RwLock<StaticState>>,
455        membership_tx: watch::Sender<Vec<NodeInfo>>,
456        cancel: CancellationToken,
457    ) -> Result<(), DiscoveryError> {
458        use tokio::io::{AsyncReadExt, AsyncWriteExt};
459
460        // Bound concurrent handler tasks (W3 fix)
461        let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_HANDLER_TASKS));
462        let mut handlers = tokio::task::JoinSet::new();
463
464        loop {
465            tokio::select! {
466                () = cancel.cancelled() => {
467                    handlers.shutdown().await;
468                    break;
469                },
470                result = handlers.join_next(), if !handlers.is_empty() => {
471                    if let Some(Err(error)) = result {
472                        tracing::debug!(%error, "Static discovery heartbeat handler stopped");
473                    }
474                },
475                accept = listener.accept() => {
476                    let (mut stream, _) = match accept {
477                        Ok(accepted) => accepted,
478                        Err(error) => {
479                            handlers.shutdown().await;
480                            return Err(error.into());
481                        }
482                    };
483                    let local_info = Arc::clone(&local_info);
484                    let state = Arc::clone(&state);
485                    let membership_tx = membership_tx.clone();
486                    let permit = Arc::clone(&semaphore);
487
488                    handlers.spawn(async move {
489                        // Acquire semaphore permit — drop-guard releases on exit
490                        let Ok(_permit) = permit.try_acquire() else {
491                            return; // at capacity, drop connection
492                        };
493
494                        // Wrap all handler I/O in a timeout (W1 fix)
495                        let result = tokio::time::timeout(IO_TIMEOUT, async {
496                            let mut len_buf = [0u8; 4];
497                            if stream.read_exact(&mut len_buf).await.is_err() {
498                                return;
499                            }
500                            let msg_len = u32::from_be_bytes(len_buf) as usize;
501                            if msg_len > MAX_MESSAGE_SIZE {
502                                return;
503                            }
504                            let mut data = vec![0u8; msg_len];
505                            if stream.read_exact(&mut data).await.is_err() {
506                                return;
507                            }
508
509                            if let Ok(remote_heartbeat) = Self::deserialize_heartbeat(&data) {
510                                let local_snapshot = local_info.read().clone();
511                                // Skip self — don't add ourselves to the peer list
512                                if remote_heartbeat.node.id == local_snapshot.id {
513                                    // Still respond so the heartbeater gets a reply
514                                    let heartbeat = StaticHeartbeat {
515                                        protocol_version: STATIC_DISCOVERY_PROTOCOL_VERSION,
516                                        node: local_snapshot,
517                                        process_generation: local_identity.generation,
518                                        process_incarnation: local_identity.incarnation,
519                                    };
520                                    if let Ok(resp) = Self::serialize_heartbeat(&heartbeat) {
521                                        let len = resp.len() as u32;
522                                        let _ = stream.write_all(&len.to_be_bytes()).await;
523                                        let _ = stream.write_all(&resp).await;
524                                    }
525                                    return;
526                                }
527
528                                let peer_list = {
529                                    let now = chrono::Utc::now().timestamp_millis();
530                                    let mut guard = state.write();
531                                    guard.observe_inbound(remote_heartbeat, now);
532                                    guard.peer_list()
533                                };
534                                publish_if_changed(&membership_tx, peer_list);
535                            }
536
537                            let local_snapshot = local_info.read().clone();
538                            let heartbeat = StaticHeartbeat {
539                                protocol_version: STATIC_DISCOVERY_PROTOCOL_VERSION,
540                                node: local_snapshot,
541                                process_generation: local_identity.generation,
542                                process_incarnation: local_identity.incarnation,
543                            };
544                            if let Ok(resp) = Self::serialize_heartbeat(&heartbeat) {
545                                let len = resp.len() as u32;
546                                let _ = stream.write_all(&len.to_be_bytes()).await;
547                                let _ = stream.write_all(&resp).await;
548                            }
549                        })
550                        .await;
551
552                        if result.is_err() {
553                            // Handler timed out — connection dropped
554                        }
555                    });
556                }
557            }
558        }
559
560        Ok(())
561    }
562
563    /// Run the periodic heartbeat sender.
564    ///
565    /// Sends heartbeats concurrently to all seeds and uses the responses
566    /// to track failure state.
567    async fn run_heartbeater(config: StaticDiscoveryConfig, ctx: HeartbeatContext) {
568        let mut interval = tokio::time::interval(config.heartbeat_interval);
569        // Don't burst missed ticks — skip them to avoid thundering herd (W5 fix)
570        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
571
572        // Track the exact process identity reached through each seed so a stale
573        // address cannot mark a newer replacement as failed.
574        // Protected by a Mutex since concurrent heartbeat tasks need it.
575        let seed_to_peer = Arc::new(parking_lot::Mutex::new(HashMap::<String, SeedPeer>::new()));
576
577        loop {
578            tokio::select! {
579                () = ctx.cancel.cancelled() => break,
580                _ = interval.tick() => {
581                    let local_info = ctx.local_info.read().clone();
582                    let local_id = local_info.id;
583                    let heartbeat = StaticHeartbeat::new(
584                        local_info,
585                        config.process_generation,
586                        config.process_incarnation,
587                    );
588                    let Ok(data) = Self::serialize_heartbeat(&heartbeat) else {
589                        continue;
590                    };
591                    let data = Arc::new(data);
592
593                    // Send heartbeats to all seeds concurrently (W5 fix)
594                    let mut tasks = tokio::task::JoinSet::new();
595                    for seed in &config.seeds {
596                        let seed = seed.clone();
597                        let data = Arc::clone(&data);
598                        tasks.spawn(async move {
599                            let result = Self::send_heartbeat(&seed, &data).await;
600                            (seed, result)
601                        });
602                    }
603
604                    // Collect results and update peer state
605                    while !tasks.is_empty() {
606                        let task = tokio::select! {
607                            biased;
608                            () = ctx.cancel.cancelled() => {
609                                tasks.shutdown().await;
610                                return;
611                            }
612                            task = tasks.join_next() => task,
613                        };
614                        let Some(Ok((seed, result))) = task else {
615                            continue; // task panicked
616                        };
617
618                        if let Ok(Some(resp_data)) = result {
619                            if let Ok(remote_heartbeat) = Self::deserialize_heartbeat(&resp_data) {
620                                // Skip self
621                                if remote_heartbeat.node.id == local_id {
622                                    continue;
623                                }
624
625                                let now = chrono::Utc::now().timestamp_millis();
626                                let remote_id = remote_heartbeat.node.id.0;
627                                let observation =
628                                    ctx.state.write().observe_outbound(remote_heartbeat, now);
629                                match observation {
630                                    ObservationResult::Accepted(identity) => {
631                                        seed_to_peer.lock().insert(
632                                            seed,
633                                            SeedPeer {
634                                                node_id: remote_id,
635                                                identity,
636                                            },
637                                        );
638                                    }
639                                    ObservationResult::Ignored => {
640                                        let expected = {
641                                            seed_to_peer.lock().get(&seed).copied()
642                                        };
643                                        if let Some(expected) = expected {
644                                            ctx.state.write().record_missed_heartbeat(
645                                                expected,
646                                                config.suspect_threshold,
647                                                config.dead_threshold,
648                                            );
649                                        }
650                                    }
651                                    ObservationResult::Collision => {
652                                        seed_to_peer.lock().remove(&seed);
653                                    }
654                                }
655                            }
656                        } else {
657                            // Heartbeat failed — increment missed counter
658                            let map = seed_to_peer.lock();
659                            if let Some(seed_peer) = map.get(seed.as_str()).copied() {
660                                drop(map);
661                                ctx.state.write().record_missed_heartbeat(
662                                    seed_peer,
663                                    config.suspect_threshold,
664                                    config.dead_threshold,
665                                );
666                            }
667                        }
668                    }
669
670                    // Hide peers stuck in Left state while retaining their process-term
671                    // watermark so a stale incarnation cannot reappear as current.
672                    {
673                        let mut state = ctx.state.write();
674                        for peer in state.peers.values_mut() {
675                            if peer.info.state == NodeState::Left && !peer.excluded {
676                                peer.left_ticks = peer.left_ticks.saturating_add(1);
677                                if peer.left_ticks >= LEFT_REAP_THRESHOLD {
678                                    peer.reaped = true;
679                                }
680                            }
681                        }
682                    }
683
684                    // Also clean up seed_to_peer for reaped peers (W2 fix)
685                    {
686                        let state = ctx.state.read();
687                        let mut map = seed_to_peer.lock();
688                        map.retain(|_, seed_peer| {
689                            state.peers.get(&seed_peer.node_id).is_some_and(|peer| {
690                                peer.identity == seed_peer.identity
691                                    && !peer.excluded
692                                    && !peer.reaped
693                            })
694                        });
695                    }
696
697                    let peer_list = ctx.state.read().peer_list();
698                    publish_if_changed(&ctx.membership_tx, peer_list);
699                }
700            }
701        }
702    }
703
704    fn start_with_bound_listener(&mut self, listener: TcpListener) -> Result<(), DiscoveryError> {
705        let local_heartbeat = StaticHeartbeat::new(
706            self.local_info.read().clone(),
707            self.config.process_generation,
708            self.config.process_incarnation,
709        );
710        local_heartbeat.validate()?;
711        let local_identity = local_heartbeat.identity();
712
713        // Create a fresh cancellation token so restart after stop() works (W4 fix)
714        self.cancel = CancellationToken::new();
715
716        let local_info = Arc::clone(&self.local_info);
717        let state = Arc::clone(&self.state);
718        let membership_tx = self.membership_tx.clone();
719        let cancel = self.cancel.clone();
720
721        // Binding is complete before either task starts, so startup cannot succeed with a
722        // listener task that is already destined to fail on an address conflict.
723        self.listener_handle = Some(tokio::spawn(Self::run_listener(
724            listener,
725            Arc::clone(&local_info),
726            local_identity,
727            Arc::clone(&state),
728            membership_tx.clone(),
729            cancel.clone(),
730        )));
731        self.heartbeater_handle = Some(tokio::spawn(Self::run_heartbeater(
732            self.config.clone(),
733            HeartbeatContext {
734                local_info,
735                state,
736                membership_tx,
737                cancel,
738            },
739        )));
740
741        self.started = true;
742        Ok(())
743    }
744
745    async fn stop_with_timeout(&mut self, timeout: Duration) {
746        self.cancel.cancel();
747        self.started = false;
748
749        let listener_handle = self.listener_handle.take();
750        let heartbeater_handle = self.heartbeater_handle.take();
751        let stop_listener = async move {
752            if let Some(handle) = listener_handle {
753                match join_task_bounded(handle, timeout, "static-listener").await {
754                    Some(Ok(Ok(()))) | None => {}
755                    Some(Ok(Err(error))) => {
756                        tracing::warn!(%error, "Static discovery listener stopped with an error");
757                    }
758                    Some(Err(error)) => {
759                        tracing::debug!(%error, "Static discovery listener task stopped unexpectedly");
760                    }
761                }
762            }
763        };
764        let stop_heartbeater = async move {
765            if let Some(handle) = heartbeater_handle {
766                if let Some(Err(error)) =
767                    join_task_bounded(handle, timeout, "static-heartbeater").await
768                {
769                    tracing::debug!(%error, "Static discovery heartbeater task stopped unexpectedly");
770                }
771            }
772        };
773        tokio::join!(stop_listener, stop_heartbeater);
774    }
775}
776
777#[derive(Debug, Clone, Copy)]
778struct SeedPeer {
779    node_id: u64,
780    identity: ProcessIdentity,
781}
782
783/// Shared context for the heartbeater background task.
784struct HeartbeatContext {
785    local_info: Arc<RwLock<NodeInfo>>,
786    state: Arc<RwLock<StaticState>>,
787    membership_tx: watch::Sender<Vec<NodeInfo>>,
788    cancel: CancellationToken,
789}
790
791impl Discovery for StaticDiscovery {
792    async fn start(&mut self) -> Result<(), DiscoveryError> {
793        if self.started {
794            return Ok(());
795        }
796        let listener = TcpListener::bind(&self.config.listen_address)
797            .await
798            .map_err(|error| DiscoveryError::Bind(error.to_string()))?;
799        self.start_with_bound_listener(listener)
800    }
801
802    // INVARIANT: discovery operations stay poll-driven for timeout and select cancellation.
803    #[allow(unknown_lints, clippy::unused_async, clippy::unused_async_trait_impl)]
804    async fn peers(&self) -> Result<Vec<NodeInfo>, DiscoveryError> {
805        if !self.started {
806            return Err(DiscoveryError::NotStarted);
807        }
808        Ok(self.state.read().peer_list())
809    }
810
811    // INVARIANT: announcement mutation starts only when the returned future is polled.
812    #[allow(unknown_lints, clippy::unused_async, clippy::unused_async_trait_impl)]
813    async fn announce(&self, info: NodeInfo) -> Result<(), DiscoveryError> {
814        if !self.started {
815            return Err(DiscoveryError::NotStarted);
816        }
817        StaticHeartbeat::new(
818            info.clone(),
819            self.config.process_generation,
820            self.config.process_incarnation,
821        )
822        .validate()?;
823        let mut local = self.local_info.write();
824        let current_state = local.state;
825        *local = info;
826        local.state = match (current_state, local.state) {
827            (NodeState::Left, _) | (_, NodeState::Left) => NodeState::Left,
828            (NodeState::Draining, _) | (_, NodeState::Draining) => NodeState::Draining,
829            (_, next) => next,
830        };
831        Ok(())
832    }
833
834    fn membership_watch(&self) -> watch::Receiver<Vec<NodeInfo>> {
835        self.membership_rx.clone()
836    }
837
838    async fn stop(&mut self) -> Result<(), DiscoveryError> {
839        self.stop_with_timeout(DISCOVERY_SHUTDOWN_TIMEOUT).await;
840        Ok(())
841    }
842}
843
844impl Drop for StaticDiscovery {
845    fn drop(&mut self) {
846        self.cancel.cancel();
847        self.started = false;
848        if let Some(handle) = self.listener_handle.take() {
849            handle.abort();
850        }
851        if let Some(handle) = self.heartbeater_handle.take() {
852            handle.abort();
853        }
854    }
855}
856
857#[cfg(test)]
858mod tests;