Skip to main content

fips_core/node/handlers/
rx_loop.rs

1//! RX event loop and dataplane dispatch.
2
3use crate::control::queries;
4use crate::control::{ControlMessage, ControlSenders, ControlSocket, commands};
5use crate::dataplane::DataplaneFastIngressRx;
6use crate::node::{
7    EndpointDataBatchRx, EndpointEventSender, Node, NodeError, endpoint_data_batch_channel,
8};
9use crate::transport::PacketRx;
10use crate::upper::tun::TunOutboundRx;
11use std::time::{Duration, Instant};
12use tokio::sync::mpsc::Receiver;
13use tracing::{debug, info, warn};
14
15mod budget;
16mod dataplane;
17mod drain;
18
19#[cfg(test)]
20mod tests;
21
22use budget::*;
23use drain::*;
24
25pub(in crate::node) struct RxLoopDataplaneIo<'a> {
26    packet_rx: &'a mut PacketRx,
27    dataplane_fast_ingress_rx: &'a mut DataplaneFastIngressRx,
28    endpoint_data_rx: &'a mut EndpointDataBatchRx,
29    tun_outbound_rx: &'a mut TunOutboundRx,
30    endpoint_tx: &'a EndpointEventSender,
31}
32
33struct RxLoopDataplaneRuntime {
34    packet_rx: PacketRx,
35    dataplane_fast_ingress_rx: DataplaneFastIngressRx,
36    endpoint_data_rx: EndpointDataBatchRx,
37    tun_outbound_rx: TunOutboundRx,
38    endpoint_tx: EndpointEventSender,
39}
40
41impl RxLoopDataplaneRuntime {
42    fn io(&mut self) -> RxLoopDataplaneIo<'_> {
43        RxLoopDataplaneIo {
44            packet_rx: &mut self.packet_rx,
45            dataplane_fast_ingress_rx: &mut self.dataplane_fast_ingress_rx,
46            endpoint_data_rx: &mut self.endpoint_data_rx,
47            tun_outbound_rx: &mut self.tun_outbound_rx,
48            endpoint_tx: &self.endpoint_tx,
49        }
50    }
51}
52
53#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54pub(in crate::node) struct RxLoopDataplaneTurnLimits {
55    packet: usize,
56    endpoint: usize,
57    tun: usize,
58    crypto: usize,
59}
60
61impl RxLoopDataplaneTurnLimits {
62    pub(in crate::node) fn new(packet: usize, endpoint: usize, tun: usize, crypto: usize) -> Self {
63        Self {
64            packet,
65            endpoint,
66            tun,
67            crypto,
68        }
69    }
70}
71
72#[cfg(test)]
73pub(in crate::node) fn rx_loop_dataplane_io<'a>(
74    packet_rx: &'a mut PacketRx,
75    dataplane_fast_ingress_rx: &'a mut DataplaneFastIngressRx,
76    endpoint_data_rx: &'a mut EndpointDataBatchRx,
77    tun_outbound_rx: &'a mut TunOutboundRx,
78    endpoint_tx: &'a EndpointEventSender,
79) -> RxLoopDataplaneIo<'a> {
80    RxLoopDataplaneIo {
81        packet_rx,
82        dataplane_fast_ingress_rx,
83        endpoint_data_rx,
84        tun_outbound_rx,
85        endpoint_tx,
86    }
87}
88
89impl Node {
90    /// Run the receive event loop.
91    ///
92    /// Processes packets from all transports, dispatching based on
93    /// the phase field in the 4-byte common prefix:
94    /// - Phase 0x0: Encrypted frame (session data)
95    /// - Phase 0x1: Handshake message 1 (initiator -> responder)
96    /// - Phase 0x2: Handshake message 2 (responder -> initiator)
97    ///
98    /// Also processes outbound IPv6 packets from the TUN reader for session
99    /// encapsulation and routing through the mesh.
100    ///
101    /// Also processes DNS-resolved identities for identity cache population.
102    ///
103    /// Also runs a periodic tick (1s) to clean up stale handshake connections
104    /// that never received a response. This prevents resource leaks when peers
105    /// are unreachable.
106    ///
107    /// This method takes ownership of the packet_rx channel and runs
108    /// until the channel is closed (typically when stop() is called).
109    pub async fn run_rx_loop(&mut self) -> Result<(), NodeError> {
110        let packet_rx = self.packet_rx.take().ok_or(NodeError::NotStarted)?;
111
112        // Take the TUN outbound receiver, or create a dummy channel that never
113        // produces messages (when TUN is disabled). Holding the sender prevents
114        // the channel from closing.
115        let (tun_outbound_rx, _tun_guard) = match self.tun_outbound_rx.take() {
116            Some(rx) => (rx, None),
117            None => {
118                let (tx, rx) = crate::upper::tun::tun_outbound_channel(1);
119                (rx, Some(tx))
120            }
121        };
122
123        // Take the DNS identity receiver, or create a dummy channel (when DNS
124        // is disabled). Same pattern as TUN outbound.
125        let (mut dns_identity_rx, _dns_guard) = match self.dns_identity_rx.take() {
126            Some(rx) => (rx, None),
127            None => {
128                let (tx, rx) = tokio::sync::mpsc::channel(1);
129                (rx, Some(tx))
130            }
131        };
132
133        // Take the endpoint control receiver, or create a dummy channel
134        // when the embedded endpoint API is not in use.
135        let (mut endpoint_control_rx, _endpoint_control_guard) =
136            match self.endpoint_control_rx.take() {
137                Some(rx) => (rx, None),
138                None => {
139                    let (tx, rx) = tokio::sync::mpsc::channel(1);
140                    (rx, Some(tx))
141                }
142            };
143        let (endpoint_data_rx, _endpoint_data_guard) = match self.endpoint_data_rx.take() {
144            Some(rx) => (rx, None),
145            None => {
146                let (tx, rx) = endpoint_data_batch_channel(1);
147                (rx, Some(tx))
148            }
149        };
150        let (dataplane_fast_ingress_rx, _dataplane_fast_ingress_guard) =
151            match self.dataplane_fast_ingress_rx.take() {
152                Some(rx) => (rx, None),
153                None => {
154                    let (tx, rx) = tokio::sync::mpsc::channel(1);
155                    (rx, Some(tx))
156                }
157            };
158
159        let mut tick =
160            tokio::time::interval(Duration::from_secs(self.config.node.tick_interval_secs));
161        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
162        let mut maintenance_state = RxLoopMaintenanceState::default();
163
164        // Set up control socket channels. Read-only queries are separated
165        // from mutating commands so operator status reads can get reserved
166        // progress while a command awaits slower discovery/transport work.
167        let (control_query_tx, mut control_query_rx) =
168            tokio::sync::mpsc::channel::<ControlMessage>(32);
169        let (control_command_tx, mut control_command_rx) =
170            tokio::sync::mpsc::channel::<ControlMessage>(32);
171
172        if self.config.node.control.enabled {
173            let config = self.config.node.control.clone();
174            let senders = ControlSenders::new(control_query_tx.clone(), control_command_tx.clone());
175            tokio::spawn(async move {
176                match ControlSocket::bind(&config) {
177                    Ok(socket) => {
178                        socket.accept_loop_split(senders).await;
179                    }
180                    Err(e) => {
181                        warn!(error = %e, "Failed to bind control socket");
182                    }
183                }
184            });
185        }
186        // Drop unused sender to avoid keeping channel open if control is disabled
187        drop(control_query_tx);
188        drop(control_command_tx);
189
190        let dataplane_endpoint_tx = self.endpoint_events.sender().unwrap_or_else(|| {
191            let (tx, rx) = EndpointEventSender::channel(1);
192            drop(rx);
193            tx
194        });
195        let dataplane_completion_notify = self.dataplane.completion_notify();
196        let mut dataplane_runtime = RxLoopDataplaneRuntime {
197            packet_rx,
198            dataplane_fast_ingress_rx,
199            endpoint_data_rx,
200            tun_outbound_rx,
201            endpoint_tx: dataplane_endpoint_tx,
202        };
203
204        info!("RX event loop started");
205        // Optional perf profiler (FIPS_PERF=1). No-op otherwise.
206        crate::perf_profile::maybe_spawn_reporter();
207        // Tokio intervals tick immediately on first poll. Consume that startup
208        // tick so the reserved-progress branch below represents a due periodic
209        // maintenance turn, not an eager pre-data maintenance pass.
210        tick.tick().await;
211
212        loop {
213            tokio::select! {
214                biased;
215                // Timer-driven liveness is a reserved-progress branch. It
216                // performs bounded pre/post data drains and timeboxes slow
217                // discovery/status work, so hot packet or endpoint/TUN queues
218                // cannot indefinitely postpone heartbeat, rekey, MMP, route
219                // aging, or path maintenance.
220                _ = tick.tick() => {
221                    let drained = {
222                        let mut dataplane_io = dataplane_runtime.io();
223                        self.drain_rx_loop_data_queues(
224                            &mut dataplane_io,
225                            ENDPOINT_DRAIN_BUDGET,
226                        ).await
227                    };
228                    if drained.has_drained() {
229                        maintenance_state.record_data_activity(Instant::now());
230                        debug!(
231                            drained = drained.total(),
232                            drained_packets = drained.packets,
233                            drained_tun = drained.tun,
234                            drained_endpoint = drained.endpoint,
235                            "Drained queued packets before rx-loop maintenance"
236                        );
237                    }
238                    let maintenance_plan = maintenance_state.plan_maintenance(
239                        drained,
240                        Instant::now(),
241                        RX_LOOP_RECENT_DATA_ACTIVITY_WINDOW,
242                        RX_LOOP_SLOW_MAINTENANCE_IDLE_TIMEOUT,
243                        RX_LOOP_SLOW_MAINTENANCE_BUSY_TIMEOUT,
244                    );
245
246                    let slow_timed_out = self.run_rx_loop_maintenance_tick(
247                        maintenance_plan,
248                    ).await;
249                    maintenance_state.record_maintenance_result(
250                        maintenance_plan.data_pressure(),
251                        slow_timed_out,
252                    );
253
254                    let post_drained = {
255                        let mut dataplane_io = dataplane_runtime.io();
256                        self.drain_rx_loop_data_queues(
257                            &mut dataplane_io,
258                            PACKET_DRAIN_BUDGET,
259                        ).await
260                    };
261                    if post_drained.has_drained() {
262                        maintenance_state.record_data_activity(Instant::now());
263                        debug!(
264                            drained = post_drained.total(),
265                            drained_packets = post_drained.packets,
266                            drained_tun = post_drained.tun,
267                            drained_endpoint = post_drained.endpoint,
268                            "Drained queued packets after rx-loop maintenance"
269                        );
270                    }
271                }
272                Some(message) = control_query_rx.recv() => {
273                    self.drain_control_queries(
274                        &mut control_query_rx,
275                        Some(message),
276                        ENDPOINT_DRAIN_BUDGET,
277                    ).await;
278                }
279                // Endpoint control carries management/lifecycle commands.
280                // Endpoint payload batches stay on the data lane; this branch
281                // keeps control work from waiting behind hot raw receive.
282                // Endpoint data batches intentionally remain below packet_rx.
283                Some(command) = endpoint_control_rx.recv() => {
284                    self.handle_endpoint_control(command).await;
285                }
286                packet = dataplane_runtime.packet_rx.recv() => {
287                    match packet {
288                        Some(p) => {
289                            let latency_packet = p.is_transport_priority();
290                            let mut firsts = crate::dataplane::DataplaneLiveTurnFirsts {
291                                raw_packet: Some(p),
292                                ..Default::default()
293                            };
294                            if let Ok(packet) = dataplane_runtime.tun_outbound_rx.try_recv() {
295                                firsts.tun_packet = Some(packet);
296                            }
297                            let latency_work_ready = latency_packet
298                                || dataplane_runtime.packet_rx.priority_ready_packets() > 0;
299                            if latency_work_ready {
300                                let packet_budget = packet_drain_budget(true);
301                                let endpoint_budget = endpoint_drain_budget(packet_budget);
302                                let tun_budget = tun_drain_budget(packet_budget);
303                                let crypto_budget = mixed_dataplane_crypto_budget(
304                                    packet_budget,
305                                    endpoint_budget,
306                                    tun_budget,
307                                );
308                                let mut turn = {
309                                    let mut dataplane_io = dataplane_runtime.io();
310                                    self.drain_dataplane_turn_with_firsts(
311                                        &mut dataplane_io,
312                                        firsts,
313                                        RxLoopDataplaneTurnLimits::new(
314                                            packet_budget,
315                                            endpoint_budget,
316                                            tun_budget,
317                                            crypto_budget,
318                                        ),
319                                    ).await
320                                };
321                                self.finish_dataplane_turn(
322                                    &mut turn,
323                                    &mut maintenance_state,
324                                    &mut control_query_rx,
325                                    CONTROL_QUERY_INTERLEAVE_BUDGET,
326                                ).await;
327                            } else {
328                                firsts.raw_ingress_prefetch = true;
329                                let mut dataplane_io = dataplane_runtime.io();
330                                self.service_dataplane_bulk_turns(
331                                    &mut dataplane_io,
332                                    firsts,
333                                    &mut maintenance_state,
334                                    &mut control_query_rx,
335                                ).await;
336                            }
337                        }
338                        None => break, // channel closed
339                    }
340                }
341                Some(fast_ingress) = dataplane_runtime.dataplane_fast_ingress_rx.recv() => {
342                    let mut dataplane_io = dataplane_runtime.io();
343                    self.service_dataplane_bulk_turns(
344                        &mut dataplane_io,
345                        crate::dataplane::DataplaneLiveTurnFirsts {
346                            fast_ingress: Some(fast_ingress),
347                            ..Default::default()
348                        },
349                        &mut maintenance_state,
350                        &mut control_query_rx,
351                    ).await;
352                }
353                _ = dataplane_completion_notify.notified() => {
354                    let mut turn = {
355                        let mut dataplane_io = dataplane_runtime.io();
356                        self.drain_dataplane_completion_turn(
357                            &mut dataplane_io,
358                            LATENCY_PACKET_DRAIN_BUDGET,
359                        ).await
360                    };
361                    self.finish_dataplane_turn(
362                        &mut turn,
363                        &mut maintenance_state,
364                        &mut control_query_rx,
365                        0,
366                    ).await;
367                }
368                Some(ipv6_packet) = dataplane_runtime.tun_outbound_rx.recv() => {
369                    let tun_budget = tun_drain_budget(LATENCY_PACKET_DRAIN_BUDGET);
370                    let mut turn = {
371                        let mut dataplane_io = dataplane_runtime.io();
372                        self.drain_dataplane_turn_with_firsts(
373                            &mut dataplane_io,
374                            crate::dataplane::DataplaneLiveTurnFirsts {
375                                tun_packet: Some(ipv6_packet),
376                                ..Default::default()
377                            },
378                            RxLoopDataplaneTurnLimits::new(0, 0, tun_budget, tun_budget),
379                        ).await
380                    };
381                    self.finish_dataplane_turn(
382                        &mut turn,
383                        &mut maintenance_state,
384                        &mut control_query_rx,
385                        0,
386                    ).await;
387                }
388                Some(identity) = dns_identity_rx.recv() => {
389                    debug!(
390                        node_addr = %identity.node_addr,
391                        "Registering identity from DNS resolution"
392                    );
393                    self.register_identity(identity.node_addr, identity.pubkey);
394                }
395                Some(batch) = dataplane_runtime.endpoint_data_rx.recv() => {
396                    let mut turn = {
397                        let mut dataplane_io = dataplane_runtime.io();
398                        self.drain_dataplane_turn_with_firsts(
399                            &mut dataplane_io,
400                            crate::dataplane::DataplaneLiveTurnFirsts {
401                                endpoint_data_batch: Some(batch),
402                                ..Default::default()
403                            },
404                            RxLoopDataplaneTurnLimits::new(
405                                0,
406                                ENDPOINT_DRAIN_BUDGET,
407                                0,
408                                PACKET_DRAIN_BUDGET,
409                            ),
410                        ).await
411                    };
412                    self.finish_dataplane_turn(
413                        &mut turn,
414                        &mut maintenance_state,
415                        &mut control_query_rx,
416                        0,
417                    ).await;
418                }
419                Some((request, response_tx)) = control_command_rx.recv() => {
420                    let response = commands::dispatch(
421                        self,
422                        &request.command,
423                        request.params.as_ref(),
424                    ).await;
425                    let _ = response_tx.send(response);
426                }
427            }
428        }
429
430        info!("RX event loop stopped (channel closed)");
431        Ok(())
432    }
433
434    async fn drain_rx_loop_data_queues(
435        &mut self,
436        io: &mut RxLoopDataplaneIo<'_>,
437        budget: usize,
438    ) -> RxLoopDataDrainStats {
439        let fast_ingress =
440            Self::take_dataplane_fast_ingress_batch(io.dataplane_fast_ingress_rx, budget);
441        let packet_budget = budget.max(
442            fast_ingress
443                .as_ref()
444                .map_or(0, |fast_ingress| fast_ingress.len()),
445        );
446        let endpoint_budget = endpoint_drain_budget(packet_budget);
447        let tun_budget = tun_drain_budget(packet_budget);
448        let crypto_budget =
449            mixed_dataplane_crypto_budget(packet_budget, endpoint_budget, tun_budget);
450        let mut turn = self
451            .drain_dataplane_turn_with_firsts(
452                io,
453                crate::dataplane::DataplaneLiveTurnFirsts {
454                    fast_ingress,
455                    ..Default::default()
456                },
457                RxLoopDataplaneTurnLimits::new(
458                    packet_budget,
459                    endpoint_budget,
460                    tun_budget,
461                    crypto_budget,
462                ),
463            )
464            .await;
465        let drained_packets = Self::dataplane_packet_activity(&turn);
466        let control_drained = self.process_dataplane_control_ingress(&mut turn).await;
467        RxLoopDataDrainStats::new(
468            drained_packets,
469            turn.tun_source_drained(),
470            turn.endpoint_source_drained(),
471            control_drained,
472        )
473    }
474
475    fn take_dataplane_fast_ingress_batch(
476        dataplane_fast_ingress_rx: &mut crate::dataplane::DataplaneFastIngressRx,
477        limit: usize,
478    ) -> Option<crate::dataplane::DataplaneFastIngressBatch> {
479        let fast_ingress = dataplane_fast_ingress_rx.try_recv().ok()?;
480        Some(Self::coalesce_dataplane_fast_ingress(
481            fast_ingress,
482            dataplane_fast_ingress_rx,
483            limit,
484        ))
485    }
486
487    fn coalesce_dataplane_fast_ingress(
488        mut fast_ingress: crate::dataplane::DataplaneFastIngressBatch,
489        dataplane_fast_ingress_rx: &mut crate::dataplane::DataplaneFastIngressRx,
490        limit: usize,
491    ) -> crate::dataplane::DataplaneFastIngressBatch {
492        while fast_ingress.len() < limit {
493            let Ok(next) = dataplane_fast_ingress_rx.try_recv() else {
494                break;
495            };
496            fast_ingress.absorb(next);
497        }
498        fast_ingress
499    }
500
501    async fn service_dataplane_bulk_turns(
502        &mut self,
503        io: &mut RxLoopDataplaneIo<'_>,
504        firsts: crate::dataplane::DataplaneLiveTurnFirsts,
505        maintenance_state: &mut RxLoopMaintenanceState,
506        control_query_rx: &mut Receiver<ControlMessage>,
507    ) {
508        let started = Instant::now();
509        let mut firsts = Some(firsts);
510        let mut turns = 0usize;
511
512        loop {
513            if turns > 0
514                && (turns >= RX_LOOP_BULK_SERVICE_MAX_TURNS
515                    || started.elapsed() >= RX_LOOP_BULK_SERVICE_MAX_ELAPSED
516                    || io.packet_rx.priority_ready_packets() > 0)
517            {
518                break;
519            }
520
521            let packet_budget = PACKET_DRAIN_BUDGET;
522            let mut turn_firsts = firsts.take().unwrap_or_default();
523            turn_firsts.raw_ingress_prefetch = true;
524            turn_firsts.fast_ingress = match turn_firsts.fast_ingress.take() {
525                Some(fast_ingress) => Some(Self::coalesce_dataplane_fast_ingress(
526                    fast_ingress,
527                    io.dataplane_fast_ingress_rx,
528                    packet_budget,
529                )),
530                None => Self::take_dataplane_fast_ingress_batch(
531                    io.dataplane_fast_ingress_rx,
532                    packet_budget,
533                ),
534            };
535            let packet_budget = packet_budget.max(
536                turn_firsts
537                    .fast_ingress
538                    .as_ref()
539                    .map_or(0, |fast_ingress| fast_ingress.len()),
540            );
541            let endpoint_budget = endpoint_drain_budget(packet_budget);
542            let tun_budget = tun_drain_budget(packet_budget);
543            let crypto_budget =
544                mixed_dataplane_crypto_budget(packet_budget, endpoint_budget, tun_budget);
545
546            let mut turn = self
547                .drain_dataplane_turn_with_firsts(
548                    io,
549                    turn_firsts,
550                    RxLoopDataplaneTurnLimits::new(
551                        packet_budget,
552                        endpoint_budget,
553                        tun_budget,
554                        crypto_budget,
555                    ),
556                )
557                .await;
558            let raw_drained = Self::dataplane_raw_ingress_activity(&turn);
559            let control_activity = Self::dataplane_control_activity(&turn);
560            let completions_drained = turn.summary().completions();
561            let admission_dropped =
562                turn.summary().inbound_dropped() > 0 || turn.summary().outbound_dropped() > 0;
563            let keep_servicing = !admission_dropped
564                && (raw_drained >= packet_budget
565                    || completions_drained >= crypto_budget
566                    || turn.tun_source_drained() >= tun_budget
567                    || turn.endpoint_source_drained() >= endpoint_budget);
568            let control_drained = self
569                .finish_dataplane_turn(
570                    &mut turn,
571                    maintenance_state,
572                    control_query_rx,
573                    CONTROL_QUERY_INTERLEAVE_BUDGET,
574                )
575                .await;
576            turns += 1;
577
578            if !keep_servicing || control_activity > 0 || control_drained > 0 {
579                break;
580            }
581        }
582    }
583
584    async fn finish_dataplane_turn(
585        &mut self,
586        turn: &mut crate::dataplane::DataplaneLiveNodeTurn,
587        maintenance_state: &mut RxLoopMaintenanceState,
588        control_query_rx: &mut Receiver<ControlMessage>,
589        control_query_budget: usize,
590    ) -> usize {
591        let had_activity = turn.has_activity();
592        let control_drained = self.process_dataplane_control_ingress(turn).await;
593        if control_drained > 0 && self.dataplane.has_deferred_raw_ingress() {
594            self.dataplane.completion_notify().notify_one();
595        }
596        let query_drained = if control_query_budget > 0 {
597            self.drain_control_queries(control_query_rx, None, control_query_budget)
598                .await
599        } else {
600            0
601        };
602        if had_activity || control_drained > 0 {
603            maintenance_state.record_data_activity(Instant::now());
604        }
605        control_drained.saturating_add(query_drained)
606    }
607
608    async fn drain_control_queries(
609        &mut self,
610        control_query_rx: &mut Receiver<ControlMessage>,
611        first_message: Option<ControlMessage>,
612        budget: usize,
613    ) -> usize {
614        let mut drain = SingleLaneDrainCursor::new(first_message, budget);
615        while let Some((request, response_tx)) = drain.next(control_query_rx) {
616            let response = queries::dispatch(self, &request.command, request.params.as_ref());
617            let _ = response_tx.send(response);
618        }
619
620        drain.drained()
621    }
622
623    async fn run_rx_loop_maintenance_tick(&mut self, plan: RxLoopMaintenancePlan) -> bool {
624        self.check_timeouts();
625        let now_ms = Self::now_ms();
626        // Link/session liveness must run before slower retry/discovery work:
627        // under bulk send pressure a late heartbeat or MMP report is
628        // indistinguishable from a dead direct path on the remote peer.
629        self.check_link_heartbeats().await;
630        self.reload_peer_acl();
631        self.resend_pending_handshakes(now_ms).await;
632        self.resend_pending_rekeys(now_ms).await;
633        self.resend_pending_session_handshakes(now_ms).await;
634        self.resend_pending_session_msg3(now_ms).await;
635        self.purge_idle_sessions(now_ms);
636        self.purge_learned_routes(now_ms);
637        self.check_mmp_reports().await;
638        self.check_session_mmp_reports().await;
639        self.check_rekey().await;
640        self.check_session_rekey().await;
641        self.check_pending_lookups(now_ms).await;
642        self.poll_pending_connects().await;
643        self.process_pending_retries(now_ms).await;
644        self.poll_transport_discovery().await;
645        self.sample_transport_congestion();
646
647        let Some(slow_timeout) = plan.slow_timeout() else {
648            crate::perf_profile::record_event(
649                crate::perf_profile::Event::RxLoopSlowMaintenanceSkipped,
650            );
651            return false;
652        };
653
654        if tokio::time::timeout(slow_timeout, self.run_rx_loop_slow_maintenance_tick())
655            .await
656            .is_err()
657        {
658            crate::perf_profile::record_event(
659                crate::perf_profile::Event::RxLoopSlowMaintenanceTimeout,
660            );
661            self.mark_rx_loop_maintenance_timeout();
662            warn!(
663                timeout_ms = slow_timeout.as_millis() as u64,
664                data_pressure = plan.data_pressure(),
665                "RX loop slow maintenance timed out; continuing packet processing"
666            );
667            return true;
668        }
669        false
670    }
671
672    async fn run_rx_loop_slow_maintenance_tick(&mut self) {
673        if let Some(delay) = rx_loop_slow_maintenance_fault_delay() {
674            tokio::time::sleep(delay).await;
675        }
676
677        // Discovery and graph/stat maintenance can involve relay work or
678        // larger scans. Keep it bounded after direct-path liveness and session
679        // upkeep so a slow Nostr/LAN tick degrades discovery freshness, not
680        // packet flow.
681        self.poll_nostr_discovery().await;
682        self.poll_lan_discovery().await;
683        self.poll_local_instance_discovery().await;
684        self.check_tree_state().await;
685        self.check_bloom_state().await;
686        self.compute_mesh_size();
687        self.record_stats_history();
688    }
689}