22//!
33//! Handles command batching and periodic execution.
44
5+ use std:: sync:: atomic:: { AtomicBool , Ordering } ;
56use std:: sync:: Arc ;
67use tokio:: time:: interval;
78use tracing:: { debug, error, info, warn} ;
@@ -66,6 +67,7 @@ pub async fn run_polling_loop(
6667 config : & Config ,
6768 vcontrold : Arc < VcontroldClient > ,
6869 mqtt_client : Arc < MqttClient > ,
70+ mqtt_connected : Arc < AtomicBool > ,
6971) {
7072 if config. commands . is_empty ( ) {
7173 warn ! ( "No commands configured for polling" ) ;
@@ -88,11 +90,32 @@ pub async fn run_polling_loop(
8890 }
8991
9092 let mut poll_interval = interval ( config. interval ) ;
93+ // Skip missed ticks instead of bursting them all at once. This prevents
94+ // overwhelming the MQTT client after a stall (e.g. broker outage where
95+ // publishes hit the timeout and the interval falls behind).
96+ poll_interval. set_missed_tick_behavior ( tokio:: time:: MissedTickBehavior :: Skip ) ;
9197 let publisher = Publisher :: new ( & mqtt_client) ;
9298
99+ let mut was_disconnected = false ;
100+
93101 loop {
94102 poll_interval. tick ( ) . await ;
95103
104+ // Skip entire cycle when the MQTT broker is unreachable. This avoids
105+ // unnecessary vcontrold/Optolink traffic and prevents filling the
106+ // rumqttc internal channel (which would block the polling loop).
107+ if !mqtt_connected. load ( Ordering :: Relaxed ) {
108+ if !was_disconnected {
109+ warn ! ( "MQTT broker disconnected, skipping polling cycles" ) ;
110+ was_disconnected = true ;
111+ }
112+ continue ;
113+ }
114+ if was_disconnected {
115+ info ! ( "MQTT broker reconnected, resuming polling" ) ;
116+ was_disconnected = false ;
117+ }
118+
96119 debug ! ( "Starting polling cycle" ) ;
97120
98121 for ( batch_idx, batch) in batches. iter ( ) . enumerate ( ) {
@@ -183,4 +206,66 @@ mod tests {
183206 assert_eq ! ( batches. len( ) , 1 ) ;
184207 assert_eq ! ( batches[ 0 ] , vec![ "veryLongCommandName" ] ) ;
185208 }
209+
210+ /// Verify that the polling interval uses Skip behavior: after a long stall
211+ /// only one tick fires rather than a burst of all missed ticks.
212+ ///
213+ /// With the default `Burst` behavior, advancing time by 5x the interval
214+ /// would yield 5 immediately-ready ticks. With `Skip`, only the next
215+ /// natural tick fires, so we get exactly 1.
216+ #[ tokio:: test]
217+ async fn test_interval_skips_missed_ticks ( ) {
218+ use std:: time:: Duration ;
219+ use tokio:: time:: { interval, MissedTickBehavior } ;
220+
221+ tokio:: time:: pause ( ) ;
222+
223+ let period = Duration :: from_secs ( 10 ) ;
224+ let mut ivl = interval ( period) ;
225+ ivl. set_missed_tick_behavior ( MissedTickBehavior :: Skip ) ;
226+
227+ // First tick fires immediately (interval semantics)
228+ ivl. tick ( ) . await ;
229+
230+ // Simulate a stall: advance time by 5 periods without ticking
231+ tokio:: time:: advance ( period * 5 ) . await ;
232+
233+ // After the stall, exactly one tick should be ready (Skip discards
234+ // missed ticks and resets to the next future deadline).
235+ ivl. tick ( ) . await ;
236+
237+ // The next tick should NOT be immediately available — it should
238+ // require waiting another full period.
239+ let next = tokio:: time:: timeout ( period / 2 , ivl. tick ( ) ) . await ;
240+ assert ! (
241+ next. is_err( ) ,
242+ "no burst tick should be available; Skip must discard missed ticks"
243+ ) ;
244+ }
245+
246+ #[ test]
247+ fn test_mqtt_connected_flag_state_transitions ( ) {
248+ // Verify the AtomicBool flag behaves correctly across the
249+ // state transitions that run_event_loop and run_polling_loop rely on.
250+ let connected = Arc :: new ( AtomicBool :: new ( false ) ) ;
251+
252+ // Initial state: disconnected (same as main.rs)
253+ assert ! ( !connected. load( Ordering :: Relaxed ) ) ;
254+
255+ // Simulate ConnAck in event loop
256+ connected. store ( true , Ordering :: Relaxed ) ;
257+ assert ! ( connected. load( Ordering :: Relaxed ) ) ;
258+
259+ // Simulate Disconnect
260+ connected. store ( false , Ordering :: Relaxed ) ;
261+ assert ! ( !connected. load( Ordering :: Relaxed ) ) ;
262+
263+ // Simulate reconnect
264+ connected. store ( true , Ordering :: Relaxed ) ;
265+ assert ! ( connected. load( Ordering :: Relaxed ) ) ;
266+
267+ // Simulate event loop error
268+ connected. store ( false , Ordering :: Relaxed ) ;
269+ assert ! ( !connected. load( Ordering :: Relaxed ) ) ;
270+ }
186271}
0 commit comments