Skip to main content

laminar_core/cluster/discovery/
static_discovery.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);
33
34struct AbortTaskOnDrop(tokio::task::AbortHandle);
35
36impl AbortTaskOnDrop {
37    fn abort(&self) {
38        self.0.abort();
39    }
40}
41
42impl Drop for AbortTaskOnDrop {
43    fn drop(&mut self) {
44        self.abort();
45    }
46}
47
48async fn join_task_bounded<T>(
49    mut task: tokio::task::JoinHandle<T>,
50    timeout: Duration,
51    task_name: &'static str,
52) -> Option<Result<T, tokio::task::JoinError>> {
53    let abort_on_drop = AbortTaskOnDrop(task.abort_handle());
54    if let Ok(result) = tokio::time::timeout(timeout, &mut task).await {
55        Some(result)
56    } else {
57        tracing::warn!(
58            task = task_name,
59            ?timeout,
60            "Discovery task did not stop in time"
61        );
62        abort_on_drop.abort();
63        let _ = tokio::time::timeout(timeout.min(Duration::from_secs(1)), &mut task).await;
64        None
65    }
66}
67
68/// Configuration for static discovery.
69#[derive(Debug, Clone)]
70pub struct StaticDiscoveryConfig {
71    /// This node's info.
72    pub local_node: NodeInfo,
73    /// Seed addresses to connect to.
74    pub seeds: Vec<String>,
75    /// Heartbeat interval.
76    pub heartbeat_interval: Duration,
77    /// Number of missed heartbeats before marking as `Suspected`.
78    pub suspect_threshold: u32,
79    /// Number of missed heartbeats before marking as `Left`.
80    pub dead_threshold: u32,
81    /// Address to bind the heartbeat listener.
82    pub listen_address: String,
83    /// Durable, monotonically increasing process term for this stable node ID.
84    pub process_generation: u64,
85    /// Unique identity of this process incarnation.
86    pub process_incarnation: uuid::Uuid,
87}
88
89impl Default for StaticDiscoveryConfig {
90    fn default() -> Self {
91        Self {
92            local_node: NodeInfo {
93                id: NodeId(1),
94                name: "node-1".into(),
95                rpc_address: "127.0.0.1:9000".into(),
96                raft_address: "127.0.0.1:9001".into(),
97                state: NodeState::Active,
98                metadata: NodeMetadata::default(),
99                last_heartbeat_ms: 0,
100            },
101            seeds: Vec::new(),
102            heartbeat_interval: Duration::from_secs(1),
103            suspect_threshold: 3,
104            dead_threshold: 10,
105            listen_address: "127.0.0.1:9002".into(),
106            process_generation: 1,
107            process_incarnation: uuid::Uuid::from_u128(1),
108        }
109    }
110}
111
112#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
113struct StaticHeartbeat {
114    node: NodeInfo,
115    process_generation: u64,
116    process_incarnation: [u8; 16],
117}
118
119impl StaticHeartbeat {
120    fn new(node: NodeInfo, process_generation: u64, process_incarnation: uuid::Uuid) -> Self {
121        Self {
122            node,
123            process_generation,
124            process_incarnation: process_incarnation.into_bytes(),
125        }
126    }
127
128    fn identity(&self) -> ProcessIdentity {
129        ProcessIdentity {
130            generation: self.process_generation,
131            incarnation: self.process_incarnation,
132        }
133    }
134
135    fn validate(&self) -> Result<(), DiscoveryError> {
136        if self.process_generation == 0 {
137            return Err(DiscoveryError::Serialization(
138                "static discovery process generation must be nonzero".into(),
139            ));
140        }
141        if self.process_incarnation == [0; 16] {
142            return Err(DiscoveryError::Serialization(
143                "static discovery process incarnation must be a non-nil UUID".into(),
144            ));
145        }
146        Ok(())
147    }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151struct ProcessIdentity {
152    generation: u64,
153    incarnation: [u8; 16],
154}
155
156/// Internal per-peer tracking state.
157#[derive(Debug)]
158struct PeerState {
159    info: NodeInfo,
160    identity: ProcessIdentity,
161    /// An equal-term/different-incarnation collision has no safe winner.
162    excluded: bool,
163    /// Hidden after the `Left` grace period while retaining the process-term watermark.
164    reaped: bool,
165    /// Missed *outbound* heartbeats (managed exclusively by the heartbeater).
166    missed_heartbeats: u32,
167    /// Counter that keeps incrementing after `Left` state. Used for reaping.
168    left_ticks: u32,
169}
170
171#[derive(Debug, Default)]
172struct StaticState {
173    peers: HashMap<u64, PeerState>,
174}
175
176impl StaticState {
177    fn peer_list(&self) -> Vec<NodeInfo> {
178        self.peers
179            .values()
180            .filter(|peer| !peer.excluded && !peer.reaped)
181            .map(|peer| peer.info.clone())
182            .collect()
183    }
184
185    fn update_identity(info: &mut NodeInfo, remote: &NodeInfo, now: i64) {
186        info.rpc_address.clone_from(&remote.rpc_address);
187        info.raft_address.clone_from(&remote.raft_address);
188        info.name.clone_from(&remote.name);
189        info.metadata = remote.metadata.clone();
190        info.last_heartbeat_ms = now;
191    }
192
193    fn new_peer(
194        heartbeat: StaticHeartbeat,
195        now: i64,
196        direction: ObservationDirection,
197    ) -> PeerState {
198        let identity = heartbeat.identity();
199        let mut info = heartbeat.node;
200        info.last_heartbeat_ms = now;
201        if direction == ObservationDirection::Inbound {
202            info.state = match info.state {
203                NodeState::Draining => NodeState::Draining,
204                NodeState::Left => NodeState::Left,
205                _ => NodeState::Joining,
206            };
207        }
208        PeerState {
209            info,
210            identity,
211            excluded: false,
212            reaped: false,
213            missed_heartbeats: 0,
214            left_ticks: 0,
215        }
216    }
217
218    fn observe(
219        &mut self,
220        heartbeat: StaticHeartbeat,
221        now: i64,
222        direction: ObservationDirection,
223    ) -> ObservationResult {
224        let id = heartbeat.node.id.0;
225        let incoming_identity = heartbeat.identity();
226        let Some(peer) = self.peers.get_mut(&id) else {
227            self.peers
228                .insert(id, Self::new_peer(heartbeat, now, direction));
229            return ObservationResult::Accepted(incoming_identity);
230        };
231
232        match incoming_identity.generation.cmp(&peer.identity.generation) {
233            std::cmp::Ordering::Less => return ObservationResult::Ignored,
234            std::cmp::Ordering::Greater => {
235                *peer = Self::new_peer(heartbeat, now, direction);
236                return ObservationResult::Accepted(incoming_identity);
237            }
238            std::cmp::Ordering::Equal => {}
239        }
240
241        if incoming_identity.incarnation != peer.identity.incarnation {
242            peer.excluded = true;
243            return ObservationResult::Collision;
244        }
245        if peer.excluded {
246            return ObservationResult::Collision;
247        }
248
249        let remote = heartbeat.node;
250        Self::update_identity(&mut peer.info, &remote, now);
251        peer.info.state = match (peer.info.state, remote.state) {
252            (NodeState::Left, _) | (_, NodeState::Left) => NodeState::Left,
253            (NodeState::Draining, _) | (_, NodeState::Draining) => NodeState::Draining,
254            (state, _) if direction == ObservationDirection::Inbound => state,
255            (_, advertised) => advertised,
256        };
257        if direction == ObservationDirection::Outbound {
258            peer.missed_heartbeats = 0;
259            if peer.info.state != NodeState::Left {
260                peer.left_ticks = 0;
261            }
262        }
263        ObservationResult::Accepted(incoming_identity)
264    }
265
266    fn observe_inbound(&mut self, heartbeat: StaticHeartbeat, now: i64) -> ObservationResult {
267        self.observe(heartbeat, now, ObservationDirection::Inbound)
268    }
269
270    fn observe_outbound(&mut self, heartbeat: StaticHeartbeat, now: i64) -> ObservationResult {
271        self.observe(heartbeat, now, ObservationDirection::Outbound)
272    }
273
274    fn record_missed_heartbeat(
275        &mut self,
276        expected: SeedPeer,
277        suspect_threshold: u32,
278        dead_threshold: u32,
279    ) {
280        let Some(peer) = self.peers.get_mut(&expected.node_id) else {
281            return;
282        };
283        if peer.identity != expected.identity || peer.excluded || peer.reaped {
284            return;
285        }
286        peer.missed_heartbeats = peer.missed_heartbeats.saturating_add(1);
287        if peer.missed_heartbeats >= dead_threshold {
288            peer.info.state = NodeState::Left;
289        } else if peer.missed_heartbeats >= suspect_threshold {
290            peer.info.state = NodeState::Suspected;
291        }
292    }
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296enum ObservationDirection {
297    Inbound,
298    Outbound,
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302enum ObservationResult {
303    Accepted(ProcessIdentity),
304    Ignored,
305    Collision,
306}
307
308/// Static discovery implementation with TCP heartbeats.
309#[derive(Debug)]
310pub struct StaticDiscovery {
311    config: StaticDiscoveryConfig,
312    local_info: Arc<RwLock<NodeInfo>>,
313    state: Arc<RwLock<StaticState>>,
314    membership_tx: watch::Sender<Vec<NodeInfo>>,
315    membership_rx: watch::Receiver<Vec<NodeInfo>>,
316    cancel: CancellationToken,
317    listener_handle: Option<tokio::task::JoinHandle<Result<(), DiscoveryError>>>,
318    heartbeater_handle: Option<tokio::task::JoinHandle<()>>,
319    started: bool,
320}
321
322impl StaticDiscovery {
323    /// Create a new static discovery instance.
324    #[must_use]
325    pub fn new(config: StaticDiscoveryConfig) -> Self {
326        debug_assert!(
327            config.suspect_threshold < config.dead_threshold,
328            "suspect_threshold ({}) must be less than dead_threshold ({})",
329            config.suspect_threshold,
330            config.dead_threshold,
331        );
332        let (tx, rx) = watch::channel(Vec::new());
333        Self {
334            local_info: Arc::new(RwLock::new(config.local_node.clone())),
335            config,
336            state: Arc::new(RwLock::new(StaticState::default())),
337            membership_tx: tx,
338            membership_rx: rx,
339            cancel: CancellationToken::new(),
340            listener_handle: None,
341            heartbeater_handle: None,
342            started: false,
343        }
344    }
345
346    /// Serialize a heartbeat for transmission.
347    fn serialize_heartbeat(heartbeat: &StaticHeartbeat) -> Result<Vec<u8>, DiscoveryError> {
348        heartbeat.validate()?;
349        rkyv::to_bytes::<rkyv::rancor::Error>(heartbeat)
350            .map(|v| v.to_vec())
351            .map_err(|e| DiscoveryError::Serialization(e.to_string()))
352    }
353
354    /// Deserialize and validate a heartbeat received from the network.
355    fn deserialize_heartbeat(data: &[u8]) -> Result<StaticHeartbeat, DiscoveryError> {
356        let heartbeat = rkyv::from_bytes::<StaticHeartbeat, rkyv::rancor::Error>(data)
357            .map_err(|e| DiscoveryError::Serialization(e.to_string()))?;
358        heartbeat.validate()?;
359        Ok(heartbeat)
360    }
361
362    /// Send a heartbeat to a single seed address with connect + I/O timeouts.
363    #[allow(clippy::cast_possible_truncation)]
364    async fn send_heartbeat(address: &str, data: &[u8]) -> Result<Option<Vec<u8>>, DiscoveryError> {
365        use tokio::io::{AsyncReadExt, AsyncWriteExt};
366
367        // Connect with timeout (C2 fix)
368        let mut stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(address))
369            .await
370            .map_err(|_| DiscoveryError::Connection {
371                address: address.into(),
372                reason: "connect timeout".into(),
373            })?
374            .map_err(|e| DiscoveryError::Connection {
375                address: address.into(),
376                reason: e.to_string(),
377            })?;
378
379        // Validate message size before sending (W7 symmetric limit)
380        if data.len() > MAX_MESSAGE_SIZE {
381            return Err(DiscoveryError::Serialization(
382                "message too large to send".into(),
383            ));
384        }
385
386        // Write length-prefixed message with I/O timeout (W1 fix)
387        let len = data.len() as u32;
388        tokio::time::timeout(IO_TIMEOUT, async {
389            stream.write_all(&len.to_be_bytes()).await?;
390            stream.write_all(data).await
391        })
392        .await
393        .map_err(|_| DiscoveryError::Connection {
394            address: address.into(),
395            reason: "write timeout".into(),
396        })?
397        .map_err(|e| DiscoveryError::Connection {
398            address: address.into(),
399            reason: e.to_string(),
400        })?;
401
402        // Read response with I/O timeout (W1 fix)
403        let resp = tokio::time::timeout(IO_TIMEOUT, async {
404            let mut len_buf = [0u8; 4];
405            if stream.read_exact(&mut len_buf).await.is_err() {
406                return Ok(None);
407            }
408
409            let resp_len = u32::from_be_bytes(len_buf) as usize;
410            if resp_len > MAX_MESSAGE_SIZE {
411                return Err(DiscoveryError::Serialization("response too large".into()));
412            }
413            let mut resp = vec![0u8; resp_len];
414            stream.read_exact(&mut resp).await?;
415            Ok(Some(resp))
416        })
417        .await
418        .map_err(|_| DiscoveryError::Connection {
419            address: address.into(),
420            reason: "read timeout".into(),
421        })?;
422
423        resp.map_err(|e: DiscoveryError| e)
424    }
425
426    /// Run the heartbeat listener (accepts incoming heartbeats).
427    ///
428    /// Inbound traffic refreshes peer identity and propagates advertised
429    /// draining/left states, but does not reset outbound failure counters.
430    #[allow(clippy::cast_possible_truncation)]
431    async fn run_listener(
432        listener: TcpListener,
433        local_info: Arc<RwLock<NodeInfo>>,
434        local_identity: ProcessIdentity,
435        state: Arc<RwLock<StaticState>>,
436        membership_tx: watch::Sender<Vec<NodeInfo>>,
437        cancel: CancellationToken,
438    ) -> Result<(), DiscoveryError> {
439        use tokio::io::{AsyncReadExt, AsyncWriteExt};
440
441        // Bound concurrent handler tasks (W3 fix)
442        let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_HANDLER_TASKS));
443        let mut handlers = tokio::task::JoinSet::new();
444
445        loop {
446            tokio::select! {
447                () = cancel.cancelled() => {
448                    handlers.shutdown().await;
449                    break;
450                },
451                result = handlers.join_next(), if !handlers.is_empty() => {
452                    if let Some(Err(error)) = result {
453                        tracing::debug!(%error, "Static discovery heartbeat handler stopped");
454                    }
455                },
456                accept = listener.accept() => {
457                    let (mut stream, _) = match accept {
458                        Ok(accepted) => accepted,
459                        Err(error) => {
460                            handlers.shutdown().await;
461                            return Err(error.into());
462                        }
463                    };
464                    let local_info = Arc::clone(&local_info);
465                    let state = Arc::clone(&state);
466                    let membership_tx = membership_tx.clone();
467                    let permit = Arc::clone(&semaphore);
468
469                    handlers.spawn(async move {
470                        // Acquire semaphore permit — drop-guard releases on exit
471                        let Ok(_permit) = permit.try_acquire() else {
472                            return; // at capacity, drop connection
473                        };
474
475                        // Wrap all handler I/O in a timeout (W1 fix)
476                        let result = tokio::time::timeout(IO_TIMEOUT, async {
477                            let mut len_buf = [0u8; 4];
478                            if stream.read_exact(&mut len_buf).await.is_err() {
479                                return;
480                            }
481                            let msg_len = u32::from_be_bytes(len_buf) as usize;
482                            if msg_len > MAX_MESSAGE_SIZE {
483                                return;
484                            }
485                            let mut data = vec![0u8; msg_len];
486                            if stream.read_exact(&mut data).await.is_err() {
487                                return;
488                            }
489
490                            if let Ok(remote_heartbeat) = Self::deserialize_heartbeat(&data) {
491                                let local_snapshot = local_info.read().clone();
492                                // Skip self — don't add ourselves to the peer list
493                                if remote_heartbeat.node.id == local_snapshot.id {
494                                    // Still respond so the heartbeater gets a reply
495                                    let heartbeat = StaticHeartbeat {
496                                        node: local_snapshot,
497                                        process_generation: local_identity.generation,
498                                        process_incarnation: local_identity.incarnation,
499                                    };
500                                    if let Ok(resp) = Self::serialize_heartbeat(&heartbeat) {
501                                        let len = resp.len() as u32;
502                                        let _ = stream.write_all(&len.to_be_bytes()).await;
503                                        let _ = stream.write_all(&resp).await;
504                                    }
505                                    return;
506                                }
507
508                                let peer_list = {
509                                    let now = chrono::Utc::now().timestamp_millis();
510                                    let mut guard = state.write();
511                                    guard.observe_inbound(remote_heartbeat, now);
512                                    guard.peer_list()
513                                };
514                                publish_if_changed(&membership_tx, peer_list);
515                            }
516
517                            let local_snapshot = local_info.read().clone();
518                            let heartbeat = StaticHeartbeat {
519                                node: local_snapshot,
520                                process_generation: local_identity.generation,
521                                process_incarnation: local_identity.incarnation,
522                            };
523                            if let Ok(resp) = Self::serialize_heartbeat(&heartbeat) {
524                                let len = resp.len() as u32;
525                                let _ = stream.write_all(&len.to_be_bytes()).await;
526                                let _ = stream.write_all(&resp).await;
527                            }
528                        })
529                        .await;
530
531                        if result.is_err() {
532                            // Handler timed out — connection dropped
533                        }
534                    });
535                }
536            }
537        }
538
539        Ok(())
540    }
541
542    /// Run the periodic heartbeat sender.
543    ///
544    /// Sends heartbeats concurrently to all seeds and uses the responses
545    /// to track failure state.
546    async fn run_heartbeater(config: StaticDiscoveryConfig, ctx: HeartbeatContext) {
547        let mut interval = tokio::time::interval(config.heartbeat_interval);
548        // Don't burst missed ticks — skip them to avoid thundering herd (W5 fix)
549        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
550
551        // Track the exact process identity reached through each seed so a stale
552        // address cannot mark a newer replacement as failed.
553        // Protected by a Mutex since concurrent heartbeat tasks need it.
554        let seed_to_peer = Arc::new(parking_lot::Mutex::new(HashMap::<String, SeedPeer>::new()));
555
556        loop {
557            tokio::select! {
558                () = ctx.cancel.cancelled() => break,
559                _ = interval.tick() => {
560                    let local_info = ctx.local_info.read().clone();
561                    let local_id = local_info.id;
562                    let heartbeat = StaticHeartbeat::new(
563                        local_info,
564                        config.process_generation,
565                        config.process_incarnation,
566                    );
567                    let Ok(data) = Self::serialize_heartbeat(&heartbeat) else {
568                        continue;
569                    };
570                    let data = Arc::new(data);
571
572                    // Send heartbeats to all seeds concurrently (W5 fix)
573                    let mut tasks = tokio::task::JoinSet::new();
574                    for seed in &config.seeds {
575                        let seed = seed.clone();
576                        let data = Arc::clone(&data);
577                        tasks.spawn(async move {
578                            let result = Self::send_heartbeat(&seed, &data).await;
579                            (seed, result)
580                        });
581                    }
582
583                    // Collect results and update peer state
584                    while !tasks.is_empty() {
585                        let task = tokio::select! {
586                            biased;
587                            () = ctx.cancel.cancelled() => {
588                                tasks.shutdown().await;
589                                return;
590                            }
591                            task = tasks.join_next() => task,
592                        };
593                        let Some(Ok((seed, result))) = task else {
594                            continue; // task panicked
595                        };
596
597                        if let Ok(Some(resp_data)) = result {
598                            if let Ok(remote_heartbeat) = Self::deserialize_heartbeat(&resp_data) {
599                                // Skip self
600                                if remote_heartbeat.node.id == local_id {
601                                    continue;
602                                }
603
604                                let now = chrono::Utc::now().timestamp_millis();
605                                let remote_id = remote_heartbeat.node.id.0;
606                                let observation =
607                                    ctx.state.write().observe_outbound(remote_heartbeat, now);
608                                match observation {
609                                    ObservationResult::Accepted(identity) => {
610                                        seed_to_peer.lock().insert(
611                                            seed,
612                                            SeedPeer {
613                                                node_id: remote_id,
614                                                identity,
615                                            },
616                                        );
617                                    }
618                                    ObservationResult::Ignored => {
619                                        let expected = {
620                                            seed_to_peer.lock().get(&seed).copied()
621                                        };
622                                        if let Some(expected) = expected {
623                                            ctx.state.write().record_missed_heartbeat(
624                                                expected,
625                                                config.suspect_threshold,
626                                                config.dead_threshold,
627                                            );
628                                        }
629                                    }
630                                    ObservationResult::Collision => {
631                                        seed_to_peer.lock().remove(&seed);
632                                    }
633                                }
634                            }
635                        } else {
636                            // Heartbeat failed — increment missed counter
637                            let map = seed_to_peer.lock();
638                            if let Some(seed_peer) = map.get(seed.as_str()).copied() {
639                                drop(map);
640                                ctx.state.write().record_missed_heartbeat(
641                                    seed_peer,
642                                    config.suspect_threshold,
643                                    config.dead_threshold,
644                                );
645                            }
646                        }
647                    }
648
649                    // Hide peers stuck in Left state while retaining their process-term
650                    // watermark so a stale incarnation cannot reappear as current.
651                    {
652                        let mut state = ctx.state.write();
653                        for peer in state.peers.values_mut() {
654                            if peer.info.state == NodeState::Left && !peer.excluded {
655                                peer.left_ticks = peer.left_ticks.saturating_add(1);
656                                if peer.left_ticks >= LEFT_REAP_THRESHOLD {
657                                    peer.reaped = true;
658                                }
659                            }
660                        }
661                    }
662
663                    // Also clean up seed_to_peer for reaped peers (W2 fix)
664                    {
665                        let state = ctx.state.read();
666                        let mut map = seed_to_peer.lock();
667                        map.retain(|_, seed_peer| {
668                            state.peers.get(&seed_peer.node_id).is_some_and(|peer| {
669                                peer.identity == seed_peer.identity
670                                    && !peer.excluded
671                                    && !peer.reaped
672                            })
673                        });
674                    }
675
676                    let peer_list = ctx.state.read().peer_list();
677                    publish_if_changed(&ctx.membership_tx, peer_list);
678                }
679            }
680        }
681    }
682
683    fn start_with_bound_listener(&mut self, listener: TcpListener) -> Result<(), DiscoveryError> {
684        let local_heartbeat = StaticHeartbeat::new(
685            self.local_info.read().clone(),
686            self.config.process_generation,
687            self.config.process_incarnation,
688        );
689        local_heartbeat.validate()?;
690        let local_identity = local_heartbeat.identity();
691
692        // Create a fresh cancellation token so restart after stop() works (W4 fix)
693        self.cancel = CancellationToken::new();
694
695        let local_info = Arc::clone(&self.local_info);
696        let state = Arc::clone(&self.state);
697        let membership_tx = self.membership_tx.clone();
698        let cancel = self.cancel.clone();
699
700        // Binding is complete before either task starts, so startup cannot succeed with a
701        // listener task that is already destined to fail on an address conflict.
702        self.listener_handle = Some(tokio::spawn(Self::run_listener(
703            listener,
704            Arc::clone(&local_info),
705            local_identity,
706            Arc::clone(&state),
707            membership_tx.clone(),
708            cancel.clone(),
709        )));
710        self.heartbeater_handle = Some(tokio::spawn(Self::run_heartbeater(
711            self.config.clone(),
712            HeartbeatContext {
713                local_info,
714                state,
715                membership_tx,
716                cancel,
717            },
718        )));
719
720        self.started = true;
721        Ok(())
722    }
723
724    async fn stop_with_timeout(&mut self, timeout: Duration) {
725        self.cancel.cancel();
726        self.started = false;
727
728        let listener_handle = self.listener_handle.take();
729        let heartbeater_handle = self.heartbeater_handle.take();
730        let stop_listener = async move {
731            if let Some(handle) = listener_handle {
732                match join_task_bounded(handle, timeout, "static-listener").await {
733                    Some(Ok(Ok(()))) | None => {}
734                    Some(Ok(Err(error))) => {
735                        tracing::warn!(%error, "Static discovery listener stopped with an error");
736                    }
737                    Some(Err(error)) => {
738                        tracing::debug!(%error, "Static discovery listener task stopped unexpectedly");
739                    }
740                }
741            }
742        };
743        let stop_heartbeater = async move {
744            if let Some(handle) = heartbeater_handle {
745                if let Some(Err(error)) =
746                    join_task_bounded(handle, timeout, "static-heartbeater").await
747                {
748                    tracing::debug!(%error, "Static discovery heartbeater task stopped unexpectedly");
749                }
750            }
751        };
752        tokio::join!(stop_listener, stop_heartbeater);
753    }
754}
755
756#[derive(Debug, Clone, Copy)]
757struct SeedPeer {
758    node_id: u64,
759    identity: ProcessIdentity,
760}
761
762/// Shared context for the heartbeater background task.
763struct HeartbeatContext {
764    local_info: Arc<RwLock<NodeInfo>>,
765    state: Arc<RwLock<StaticState>>,
766    membership_tx: watch::Sender<Vec<NodeInfo>>,
767    cancel: CancellationToken,
768}
769
770impl Discovery for StaticDiscovery {
771    async fn start(&mut self) -> Result<(), DiscoveryError> {
772        if self.started {
773            return Ok(());
774        }
775        let listener = TcpListener::bind(&self.config.listen_address)
776            .await
777            .map_err(|error| DiscoveryError::Bind(error.to_string()))?;
778        self.start_with_bound_listener(listener)
779    }
780
781    async fn peers(&self) -> Result<Vec<NodeInfo>, DiscoveryError> {
782        if !self.started {
783            return Err(DiscoveryError::NotStarted);
784        }
785        Ok(self.state.read().peer_list())
786    }
787
788    async fn announce(&self, info: NodeInfo) -> Result<(), DiscoveryError> {
789        if !self.started {
790            return Err(DiscoveryError::NotStarted);
791        }
792        let mut local = self.local_info.write();
793        let current_state = local.state;
794        *local = info;
795        local.state = match (current_state, local.state) {
796            (NodeState::Left, _) | (_, NodeState::Left) => NodeState::Left,
797            (NodeState::Draining, _) | (_, NodeState::Draining) => NodeState::Draining,
798            (_, next) => next,
799        };
800        Ok(())
801    }
802
803    fn membership_watch(&self) -> watch::Receiver<Vec<NodeInfo>> {
804        self.membership_rx.clone()
805    }
806
807    async fn stop(&mut self) -> Result<(), DiscoveryError> {
808        self.stop_with_timeout(DISCOVERY_SHUTDOWN_TIMEOUT).await;
809        Ok(())
810    }
811}
812
813impl Drop for StaticDiscovery {
814    fn drop(&mut self) {
815        self.cancel.cancel();
816        self.started = false;
817        if let Some(handle) = self.listener_handle.take() {
818            handle.abort();
819        }
820        if let Some(handle) = self.heartbeater_handle.take() {
821            handle.abort();
822        }
823    }
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    async fn bound_listeners() -> (TcpListener, TcpListener) {
831        let listener1 = TcpListener::bind("127.0.0.1:0").await.unwrap();
832        let listener2 = TcpListener::bind("127.0.0.1:0").await.unwrap();
833        (listener1, listener2)
834    }
835
836    fn test_node(id: u64, address: &str, state: NodeState) -> NodeInfo {
837        NodeInfo {
838            id: NodeId(id),
839            name: format!("node-{id}"),
840            rpc_address: address.into(),
841            raft_address: address.into(),
842            state,
843            metadata: NodeMetadata::default(),
844            last_heartbeat_ms: 0,
845        }
846    }
847
848    fn test_heartbeat(node: NodeInfo, generation: u64, incarnation: u128) -> StaticHeartbeat {
849        StaticHeartbeat::new(node, generation, uuid::Uuid::from_u128(incarnation))
850    }
851
852    async fn two_nodes(
853        heartbeat_interval: Duration,
854    ) -> (
855        StaticDiscovery,
856        StaticDiscovery,
857        TcpListener,
858        TcpListener,
859        String,
860        NodeInfo,
861    ) {
862        let (listener1, listener2) = bound_listeners().await;
863        let addr1 = listener1.local_addr().unwrap().to_string();
864        let addr2 = listener2.local_addr().unwrap().to_string();
865        let node1 = test_node(1, &addr1, NodeState::Active);
866        let node2 = test_node(2, &addr2, NodeState::Active);
867        let config1 = StaticDiscoveryConfig {
868            local_node: node1,
869            seeds: vec![addr2.clone()],
870            heartbeat_interval,
871            suspect_threshold: 1_000,
872            dead_threshold: 2_000,
873            listen_address: addr1.clone(),
874            process_generation: 1,
875            process_incarnation: uuid::Uuid::from_u128(1),
876        };
877        let config2 = StaticDiscoveryConfig {
878            local_node: node2.clone(),
879            seeds: vec![addr1.clone()],
880            heartbeat_interval,
881            suspect_threshold: 1_000,
882            dead_threshold: 2_000,
883            listen_address: addr2,
884            process_generation: 1,
885            process_incarnation: uuid::Uuid::from_u128(2),
886        };
887        (
888            StaticDiscovery::new(config1),
889            StaticDiscovery::new(config2),
890            listener1,
891            listener2,
892            addr1,
893            node2,
894        )
895    }
896
897    async fn wait_for_peer_state(
898        discovery: &StaticDiscovery,
899        peer_id: NodeId,
900        expected: Option<NodeState>,
901    ) {
902        tokio::time::timeout(Duration::from_secs(2), async {
903            loop {
904                let state = discovery
905                    .peers()
906                    .await
907                    .unwrap()
908                    .into_iter()
909                    .find(|peer| peer.id == peer_id)
910                    .map(|peer| peer.state);
911                if state == expected {
912                    return;
913                }
914                tokio::time::sleep(Duration::from_millis(5)).await;
915            }
916        })
917        .await
918        .unwrap_or_else(|_| panic!("peer {peer_id:?} did not reach state {expected:?}"));
919    }
920
921    #[test]
922    fn test_config_default() {
923        let config = StaticDiscoveryConfig::default();
924        assert_eq!(config.heartbeat_interval, Duration::from_secs(1));
925        assert_eq!(config.suspect_threshold, 3);
926        assert_eq!(config.dead_threshold, 10);
927    }
928
929    #[test]
930    fn test_serialize_round_trip() {
931        let info = NodeInfo {
932            id: NodeId(42),
933            name: "test".into(),
934            rpc_address: "127.0.0.1:9000".into(),
935            raft_address: "127.0.0.1:9001".into(),
936            state: NodeState::Active,
937            metadata: NodeMetadata::default(),
938            last_heartbeat_ms: 1000,
939        };
940
941        let heartbeat = test_heartbeat(info, 7, 42);
942        let data = StaticDiscovery::serialize_heartbeat(&heartbeat).unwrap();
943        let back = StaticDiscovery::deserialize_heartbeat(&data).unwrap();
944        assert_eq!(back.node.id, NodeId(42));
945        assert_eq!(back.node.name, "test");
946        assert_eq!(back.process_generation, 7);
947        assert_eq!(
948            back.process_incarnation,
949            uuid::Uuid::from_u128(42).into_bytes()
950        );
951    }
952
953    #[test]
954    fn test_deserialize_invalid() {
955        let result = StaticDiscovery::deserialize_heartbeat(&[0xff, 0xff]);
956        assert!(result.is_err());
957    }
958
959    #[test]
960    fn higher_term_replacement_escapes_graceful_drain() {
961        let mut state = StaticState::default();
962        let mut draining = test_node(9, "old:9000", NodeState::Draining);
963        let first = test_heartbeat(draining.clone(), 4, 40);
964        assert!(matches!(
965            state.observe_outbound(first, 1),
966            ObservationResult::Accepted(_)
967        ));
968
969        draining.state = NodeState::Active;
970        draining.rpc_address = "new:9000".into();
971        let replacement = test_heartbeat(draining, 5, 50);
972        assert!(matches!(
973            state.observe_outbound(replacement, 2),
974            ObservationResult::Accepted(_)
975        ));
976
977        let peer = state.peers.get(&9).unwrap();
978        assert_eq!(peer.identity.generation, 5);
979        assert_eq!(peer.info.state, NodeState::Active);
980        assert_eq!(peer.info.rpc_address, "new:9000");
981        assert!(!peer.excluded);
982    }
983
984    #[test]
985    fn outbound_heartbeat_preserves_advertised_joining_state() {
986        let mut state = StaticState::default();
987        let joining = test_heartbeat(test_node(9, "node:9000", NodeState::Joining), 4, 40);
988
989        state.observe_outbound(joining, 1);
990
991        assert_eq!(state.peers.get(&9).unwrap().info.state, NodeState::Joining);
992    }
993
994    #[test]
995    fn lower_term_stale_heartbeat_is_ignored() {
996        let mut state = StaticState::default();
997        let current = test_heartbeat(test_node(9, "new:9000", NodeState::Active), 5, 50);
998        state.observe_outbound(current, 2);
999
1000        let stale = test_heartbeat(test_node(9, "old:9000", NodeState::Left), 4, 40);
1001        assert_eq!(state.observe_outbound(stale, 3), ObservationResult::Ignored);
1002
1003        let peer = state.peers.get(&9).unwrap();
1004        assert_eq!(peer.identity.generation, 5);
1005        assert_eq!(peer.info.state, NodeState::Active);
1006        assert_eq!(peer.info.rpc_address, "new:9000");
1007    }
1008
1009    #[test]
1010    fn equal_term_incarnation_collision_is_excluded_until_higher_term() {
1011        let mut state = StaticState::default();
1012        let incumbent = test_heartbeat(test_node(9, "one:9000", NodeState::Active), 5, 50);
1013        state.observe_outbound(incumbent.clone(), 1);
1014
1015        let collision = test_heartbeat(test_node(9, "two:9000", NodeState::Active), 5, 51);
1016        assert_eq!(
1017            state.observe_outbound(collision, 2),
1018            ObservationResult::Collision
1019        );
1020        assert!(state.peer_list().is_empty());
1021        assert_eq!(
1022            state.observe_outbound(incumbent, 3),
1023            ObservationResult::Collision
1024        );
1025        assert!(state.peer_list().is_empty());
1026
1027        let replacement = test_heartbeat(test_node(9, "three:9000", NodeState::Joining), 6, 60);
1028        state.observe_outbound(replacement, 4);
1029        assert_eq!(state.peer_list()[0].state, NodeState::Joining);
1030    }
1031
1032    #[tokio::test]
1033    async fn zero_process_generation_is_rejected_before_start() {
1034        let mut config = StaticDiscoveryConfig::default();
1035        config.listen_address = "127.0.0.1:0".into();
1036        config.process_generation = 0;
1037        let mut discovery = StaticDiscovery::new(config);
1038
1039        let error = discovery.start().await.unwrap_err();
1040        assert!(error.to_string().contains("generation must be nonzero"));
1041    }
1042
1043    #[tokio::test]
1044    async fn test_not_started_errors() {
1045        let config = StaticDiscoveryConfig::default();
1046        let disc = StaticDiscovery::new(config);
1047        assert!(disc.peers().await.is_err());
1048    }
1049
1050    #[tokio::test]
1051    async fn test_start_stop() {
1052        let config = StaticDiscoveryConfig {
1053            listen_address: "127.0.0.1:0".into(),
1054            ..StaticDiscoveryConfig::default()
1055        };
1056        let mut disc = StaticDiscovery::new(config);
1057        disc.start().await.unwrap();
1058        assert!(disc.started);
1059        disc.stop().await.unwrap();
1060        assert!(!disc.started);
1061    }
1062
1063    #[tokio::test]
1064    async fn start_reports_occupied_listener_address() {
1065        let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap();
1066        let address = occupied.local_addr().unwrap().to_string();
1067        let config = StaticDiscoveryConfig {
1068            listen_address: address,
1069            ..StaticDiscoveryConfig::default()
1070        };
1071        let mut discovery = StaticDiscovery::new(config);
1072
1073        assert!(matches!(
1074            discovery.start().await,
1075            Err(DiscoveryError::Bind(_))
1076        ));
1077        assert!(!discovery.started);
1078        assert!(discovery.listener_handle.is_none());
1079        assert!(discovery.heartbeater_handle.is_none());
1080
1081        drop(occupied);
1082        discovery.start().await.unwrap();
1083        discovery.stop().await.unwrap();
1084    }
1085
1086    #[tokio::test]
1087    async fn test_double_start_ok() {
1088        let config = StaticDiscoveryConfig {
1089            listen_address: "127.0.0.1:0".into(),
1090            ..StaticDiscoveryConfig::default()
1091        };
1092        let mut disc = StaticDiscovery::new(config);
1093        disc.start().await.unwrap();
1094        disc.start().await.unwrap(); // Should be idempotent
1095        disc.stop().await.unwrap();
1096    }
1097
1098    #[tokio::test]
1099    async fn test_membership_watch() {
1100        let config = StaticDiscoveryConfig {
1101            listen_address: "127.0.0.1:0".into(),
1102            ..StaticDiscoveryConfig::default()
1103        };
1104        let disc = StaticDiscovery::new(config);
1105        let rx = disc.membership_watch();
1106        assert!(rx.borrow().is_empty());
1107    }
1108
1109    #[tokio::test]
1110    async fn test_announce_updates_local_advertisement() {
1111        let config = StaticDiscoveryConfig {
1112            listen_address: "127.0.0.1:0".into(),
1113            ..StaticDiscoveryConfig::default()
1114        };
1115        let mut disc = StaticDiscovery::new(config);
1116        disc.start().await.unwrap();
1117
1118        let mut local = disc.local_info.read().clone();
1119        local.state = NodeState::Draining;
1120        disc.announce(local).await.unwrap();
1121
1122        let peers = disc.peers().await.unwrap();
1123        assert!(peers.is_empty());
1124        assert_eq!(disc.local_info.read().state, NodeState::Draining);
1125
1126        disc.stop().await.unwrap();
1127    }
1128
1129    #[tokio::test]
1130    async fn test_two_node_heartbeat() {
1131        let listener1 = TcpListener::bind("127.0.0.1:0").await.unwrap();
1132        let addr1 = listener1.local_addr().unwrap().to_string();
1133
1134        let listener2 = TcpListener::bind("127.0.0.1:0").await.unwrap();
1135        let addr2 = listener2.local_addr().unwrap().to_string();
1136
1137        let config1 = StaticDiscoveryConfig {
1138            local_node: NodeInfo {
1139                id: NodeId(1),
1140                name: "node-1".into(),
1141                rpc_address: addr1.clone(),
1142                raft_address: addr1.clone(),
1143                state: NodeState::Active,
1144                metadata: NodeMetadata::default(),
1145                last_heartbeat_ms: 0,
1146            },
1147            seeds: vec![addr2.clone()],
1148            heartbeat_interval: Duration::from_millis(100),
1149            listen_address: addr1.clone(),
1150            ..StaticDiscoveryConfig::default()
1151        };
1152
1153        let config2 = StaticDiscoveryConfig {
1154            local_node: NodeInfo {
1155                id: NodeId(2),
1156                name: "node-2".into(),
1157                rpc_address: addr2.clone(),
1158                raft_address: addr2.clone(),
1159                state: NodeState::Active,
1160                metadata: NodeMetadata::default(),
1161                last_heartbeat_ms: 0,
1162            },
1163            seeds: vec![addr1],
1164            heartbeat_interval: Duration::from_millis(100),
1165            listen_address: addr2,
1166            ..StaticDiscoveryConfig::default()
1167        };
1168
1169        let mut disc1 = StaticDiscovery::new(config1);
1170        let mut disc2 = StaticDiscovery::new(config2);
1171
1172        disc1.start_with_bound_listener(listener1).unwrap();
1173        disc2.start_with_bound_listener(listener2).unwrap();
1174
1175        tokio::time::sleep(Duration::from_millis(500)).await;
1176
1177        let peers1 = disc1.peers().await.unwrap();
1178        let peers2 = disc2.peers().await.unwrap();
1179
1180        assert!(
1181            !peers1.is_empty() || !peers2.is_empty(),
1182            "at least one node should have discovered peers"
1183        );
1184
1185        disc1.stop().await.unwrap();
1186        disc2.stop().await.unwrap();
1187    }
1188
1189    #[tokio::test]
1190    async fn test_two_node_draining_is_monotonic_across_healthy_heartbeats() {
1191        let interval = Duration::from_millis(20);
1192        let (mut disc1, mut disc2, listener1, listener2, addr1, mut node2) =
1193            two_nodes(interval).await;
1194        disc1.start_with_bound_listener(listener1).unwrap();
1195        disc2.start_with_bound_listener(listener2).unwrap();
1196        wait_for_peer_state(&disc1, node2.id, Some(NodeState::Active)).await;
1197
1198        node2.state = NodeState::Draining;
1199        disc2.announce(node2.clone()).await.unwrap();
1200        wait_for_peer_state(&disc1, node2.id, Some(NodeState::Draining)).await;
1201
1202        disc2.stop().await.unwrap();
1203        node2.state = NodeState::Active;
1204        let stale_active = StaticDiscovery::serialize_heartbeat(&StaticHeartbeat::new(
1205            node2.clone(),
1206            disc2.config.process_generation,
1207            disc2.config.process_incarnation,
1208        ))
1209        .unwrap();
1210        StaticDiscovery::send_heartbeat(&addr1, &stale_active)
1211            .await
1212            .unwrap();
1213        tokio::time::sleep(interval * 3).await;
1214        assert_eq!(
1215            disc1
1216                .peers()
1217                .await
1218                .unwrap()
1219                .into_iter()
1220                .find(|peer| peer.id == node2.id)
1221                .map(|peer| peer.state),
1222            Some(NodeState::Draining)
1223        );
1224
1225        disc1.stop().await.unwrap();
1226    }
1227
1228    #[tokio::test]
1229    async fn test_two_node_left_is_monotonic_until_higher_term_rejoins() {
1230        let interval = Duration::from_millis(20);
1231        let (mut disc1, mut disc2, listener1, listener2, addr1, mut node2) =
1232            two_nodes(interval).await;
1233        disc1.start_with_bound_listener(listener1).unwrap();
1234        disc2.start_with_bound_listener(listener2).unwrap();
1235        wait_for_peer_state(&disc1, node2.id, Some(NodeState::Active)).await;
1236
1237        node2.state = NodeState::Left;
1238        disc2.announce(node2.clone()).await.unwrap();
1239        wait_for_peer_state(&disc1, node2.id, Some(NodeState::Left)).await;
1240
1241        let mut replacement_config = disc2.config.clone();
1242        disc2.stop().await.unwrap();
1243        node2.state = NodeState::Active;
1244        let stale_active = StaticDiscovery::serialize_heartbeat(&StaticHeartbeat::new(
1245            node2.clone(),
1246            disc2.config.process_generation,
1247            disc2.config.process_incarnation,
1248        ))
1249        .unwrap();
1250        StaticDiscovery::send_heartbeat(&addr1, &stale_active)
1251            .await
1252            .unwrap();
1253        tokio::time::sleep(interval * 3).await;
1254        assert_eq!(
1255            disc1
1256                .peers()
1257                .await
1258                .unwrap()
1259                .into_iter()
1260                .find(|peer| peer.id == node2.id)
1261                .map(|peer| peer.state),
1262            Some(NodeState::Left)
1263        );
1264
1265        replacement_config.process_generation += 1;
1266        replacement_config.process_incarnation = uuid::Uuid::from_u128(22);
1267        let mut replacement = StaticDiscovery::new(replacement_config);
1268        replacement.start().await.unwrap();
1269        wait_for_peer_state(&disc1, node2.id, Some(NodeState::Active)).await;
1270
1271        replacement.stop().await.unwrap();
1272        disc1.stop().await.unwrap();
1273    }
1274
1275    #[tokio::test]
1276    async fn test_restart_after_stop() {
1277        let config = StaticDiscoveryConfig {
1278            listen_address: "127.0.0.1:0".into(),
1279            ..StaticDiscoveryConfig::default()
1280        };
1281        let mut disc = StaticDiscovery::new(config);
1282
1283        // First start/stop cycle
1284        disc.start().await.unwrap();
1285        disc.stop().await.unwrap();
1286
1287        // Second start should work (fresh CancellationToken)
1288        disc.start().await.unwrap();
1289        assert!(disc.started);
1290        disc.stop().await.unwrap();
1291    }
1292
1293    #[tokio::test]
1294    async fn drop_cancels_background_tasks_and_releases_listener() {
1295        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1296        let address = listener.local_addr().unwrap();
1297        let config = StaticDiscoveryConfig {
1298            listen_address: address.to_string(),
1299            ..StaticDiscoveryConfig::default()
1300        };
1301        let mut discovery = StaticDiscovery::new(config);
1302        discovery.start_with_bound_listener(listener).unwrap();
1303
1304        let cancelled = discovery.cancel.clone();
1305        let listener_task = discovery.listener_handle.as_ref().unwrap().abort_handle();
1306        let heartbeater_task = discovery
1307            .heartbeater_handle
1308            .as_ref()
1309            .unwrap()
1310            .abort_handle();
1311
1312        drop(discovery);
1313        assert!(cancelled.is_cancelled());
1314        tokio::time::timeout(Duration::from_secs(1), async {
1315            while !listener_task.is_finished() || !heartbeater_task.is_finished() {
1316                tokio::task::yield_now().await;
1317            }
1318        })
1319        .await
1320        .expect("dropped static discovery tasks must terminate");
1321
1322        tokio::time::timeout(Duration::from_secs(1), async {
1323            loop {
1324                match TcpListener::bind(address).await {
1325                    Ok(listener) => break listener,
1326                    Err(_) => tokio::time::sleep(Duration::from_millis(5)).await,
1327                }
1328            }
1329        })
1330        .await
1331        .expect("dropped static discovery must release its listener");
1332    }
1333
1334    #[tokio::test]
1335    async fn stop_aborts_background_tasks_that_exceed_the_shutdown_bound() {
1336        let mut discovery = StaticDiscovery::new(StaticDiscoveryConfig::default());
1337        discovery.started = true;
1338        let cancelled = discovery.cancel.clone();
1339
1340        let listener = tokio::spawn(std::future::pending::<Result<(), DiscoveryError>>());
1341        let listener_task = listener.abort_handle();
1342        discovery.listener_handle = Some(listener);
1343        let heartbeater = tokio::spawn(std::future::pending::<()>());
1344        let heartbeater_task = heartbeater.abort_handle();
1345        discovery.heartbeater_handle = Some(heartbeater);
1346
1347        tokio::time::timeout(
1348            Duration::from_secs(1),
1349            discovery.stop_with_timeout(Duration::from_millis(10)),
1350        )
1351        .await
1352        .expect("bounded static discovery shutdown did not return");
1353
1354        assert!(cancelled.is_cancelled());
1355        assert!(!discovery.started);
1356        assert!(discovery.listener_handle.is_none());
1357        assert!(discovery.heartbeater_handle.is_none());
1358        tokio::time::timeout(Duration::from_secs(1), async {
1359            while !listener_task.is_finished() || !heartbeater_task.is_finished() {
1360                tokio::task::yield_now().await;
1361            }
1362        })
1363        .await
1364        .expect("bounded static discovery shutdown left an owned task running");
1365    }
1366}