Skip to main content

loopctl/stream/
heartbeat.rs

1//! Composable heartbeat and timeout wrapper for any stream.
2//!
3//! [`HeartbeatStream`] wraps any `Stream<Item = Result<StreamEvent, ApiError>>`
4//! and adds two behaviours:
5//!
6//! 1. **Heartbeat callbacks** — Fires a callback at regular intervals to report
7//!    elapsed time and timeout status.
8//! 2. **Hard timeout** — Returns an [`ApiError`] if the stream exceeds a
9//!    configured maximum duration.
10//!
11//! It does **not** retry or fall back — that's [`StreamHandler`](super::handler::StreamHandler)'s
12//! job. Use this when you need heartbeat/timeout on a stream you've already opened.
13//!
14//! # Architecture
15//!
16//! ```text
17//! ┌────────────────────────────────────┐
18//! │        HeartbeatStream<S>          │
19//! │                                    │
20//! │  poll_next():                      │
21//! │    1. Check heartbeat interval     │
22//! │       └─ fire callback if elapsed  │
23//! │    2. Check hard timeout           │
24//! │       └─ return ApiError if hit    │
25//! │    3. Delegate to inner stream     │
26//! └────────────────────────────────────┘
27//! ```
28//!
29//! # Quick Start
30//!
31//! ```rust
32//! use loopctl::stream::heartbeat::{HeartbeatStream, HeartbeatConfig, HeartbeatData};
33//! use std::time::Duration;
34//! use std::sync::{Arc, Mutex};
35//!
36//! let callbacks = Arc::new(Mutex::new(Vec::new()));
37//! let cb = callbacks.clone();
38//!
39//! let config = HeartbeatConfig::new(
40//!     Duration::from_secs(30),  // heartbeat_interval
41//!     Duration::from_secs(600), // timeout
42//!     Box::new(move |data: HeartbeatData| {
43//!         cb.lock().unwrap().push(data.elapsed);
44//!     }),
45//! );
46//! ```
47
48use crate::api::error::ApiError;
49use crate::stream::StreamEvent;
50use futures::Stream;
51use std::pin::Pin;
52use std::task::{Context, Poll};
53use std::time::{Duration, Instant};
54
55// ===================================================
56// HeartbeatData
57// ===================================================
58
59/// Data emitted on each heartbeat callback.
60///
61/// Passed to the callback registered in [`HeartbeatConfig`] at each
62/// heartbeat interval.
63///
64/// # Example
65///
66/// ```rust
67/// use loopctl::stream::heartbeat::HeartbeatData;
68/// use std::time::Duration;
69///
70/// let data = HeartbeatData {
71///     elapsed: Duration::from_secs(45),
72///     is_timeout: false,
73/// };
74/// assert!(!data.is_timeout);
75/// ```
76#[derive(Debug, Clone)]
77pub struct HeartbeatData {
78    /// Time elapsed since stream start.
79    pub elapsed: Duration,
80    /// Whether the stream has exceeded its configured timeout.
81    pub is_timeout: bool,
82}
83
84// ===================================================
85// HeartbeatCallback
86// ===================================================
87
88/// Callback type for heartbeat events.
89///
90/// A `Box<dyn Fn(HeartbeatData) + Send + Sync>` that is called at each
91/// heartbeat interval with the current stream status.
92pub type HeartbeatCallback = Box<dyn Fn(HeartbeatData) + Send + Sync>;
93
94// ===================================================
95// HeartbeatConfig
96// ===================================================
97
98/// Configuration for a [`HeartbeatStream`].
99///
100/// Holds the heartbeat interval, hard timeout, and callback function.
101/// Created via [`HeartbeatConfig::new()`].
102///
103/// # Example
104///
105/// ```rust
106/// use loopctl::stream::heartbeat::{HeartbeatConfig, HeartbeatData};
107/// use std::time::Duration;
108///
109/// let config = HeartbeatConfig::new(
110///     Duration::from_secs(30),
111///     Duration::from_secs(600),
112///     Box::new(|_data: HeartbeatData| {}),
113/// );
114/// assert_eq!(config.heartbeat_interval(), Duration::from_secs(30));
115/// assert_eq!(config.timeout(), Duration::from_secs(600));
116/// ```
117pub struct HeartbeatConfig {
118    /// Interval between heartbeat callbacks.
119    heartbeat_interval: Duration,
120    /// Maximum total stream duration before triggering a hard timeout.
121    timeout: Duration,
122    /// Callback fired at each heartbeat interval.
123    on_heartbeat: HeartbeatCallback,
124}
125
126impl std::fmt::Debug for HeartbeatConfig {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("HeartbeatConfig")
129            .field("heartbeat_interval", &self.heartbeat_interval)
130            .field("timeout", &self.timeout)
131            .finish_non_exhaustive()
132    }
133}
134
135impl HeartbeatConfig {
136    /// Create a new heartbeat configuration.
137    ///
138    /// # Arguments
139    ///
140    /// - `heartbeat_interval` — How often to fire the callback.
141    /// - `timeout` — Maximum stream duration before returning an error.
142    /// - `on_heartbeat` — Callback invoked at each interval.
143    ///
144    /// # Example
145    ///
146    /// ```rust
147    /// use loopctl::stream::heartbeat::{HeartbeatConfig, HeartbeatData};
148    /// use std::time::Duration;
149    ///
150    /// let config = HeartbeatConfig::new(
151    ///     Duration::from_secs(15),
152    ///     Duration::from_secs(300),
153    ///     Box::new(|data: HeartbeatData| {
154    ///         println!("heartbeat: {:.1}s elapsed", data.elapsed.as_secs_f64());
155    ///     }),
156    /// );
157    /// ```
158    #[must_use]
159    pub fn new(
160        heartbeat_interval: Duration,
161        timeout: Duration,
162        on_heartbeat: HeartbeatCallback,
163    ) -> Self {
164        Self {
165            heartbeat_interval,
166            timeout,
167            on_heartbeat,
168        }
169    }
170
171    /// Returns the configured heartbeat interval.
172    #[must_use]
173    pub fn heartbeat_interval(&self) -> Duration {
174        self.heartbeat_interval
175    }
176
177    /// Returns the configured hard timeout.
178    #[must_use]
179    pub fn timeout(&self) -> Duration {
180        self.timeout
181    }
182}
183
184// ===================================================
185// HeartbeatStream
186// ===================================================
187
188/// A stream wrapper that emits heartbeat callbacks and enforces a hard timeout.
189///
190/// Wraps any `Stream<Item = Result<StreamEvent, ApiError>>` and adds:
191/// - Periodic heartbeat callbacks via [`HeartbeatConfig`].
192/// - A hard timeout that returns an [`ApiError`] when exceeded.
193///
194/// Does **not** retry or fallback — use [`StreamHandler`](super::handler::StreamHandler) instead.
195///
196/// # Composability
197///
198/// `HeartbeatStream` implements `Stream` directly, so it composes with
199/// any other stream wrapper. Use it on any stream you've already opened
200/// when you need heartbeat/timeout without the full handler lifecycle.
201///
202/// # Example
203///
204/// ```rust
205/// use loopctl::stream::heartbeat::{HeartbeatStream, HeartbeatConfig, HeartbeatData};
206/// use std::time::Duration;
207///
208/// let config = HeartbeatConfig::new(
209///     Duration::from_secs(30),
210///     Duration::from_secs(600),
211///     Box::new(|_data: HeartbeatData| {}),
212/// );
213///
214/// // Wrap any stream:
215/// // let heartbeat_stream = HeartbeatStream::new(inner_stream, config);
216/// // while let Some(result) = futures::StreamExt::next(&mut heartbeat_stream).await {
217/// //     // ...
218/// // }
219/// ```
220pub struct HeartbeatStream<S> {
221    /// The inner stream being wrapped.
222    inner: S,
223    /// Heartbeat and timeout configuration.
224    config: HeartbeatConfig,
225    /// Time of the last heartbeat callback.
226    last_heartbeat: Instant,
227    /// Time the stream was created.
228    start: Instant,
229    /// A Sleep that fires at the hard timeout deadline.
230    ///
231    /// Ensures the runtime wakes this task when the timeout expires,
232    /// even if the inner stream is Pending and nobody re-polls.
233    timeout_sleep: std::pin::Pin<Box<tokio::time::Sleep>>,
234}
235
236impl<S> HeartbeatStream<S> {
237    /// Create a new heartbeat stream wrapping the given inner stream.
238    ///
239    /// The heartbeat timer starts immediately upon construction.
240    /// The first heartbeat callback fires after `heartbeat_interval`
241    /// elapses (checked on each `poll_next`).
242    ///
243    /// # Example
244    ///
245    /// ```rust
246    /// use loopctl::stream::heartbeat::{HeartbeatStream, HeartbeatConfig, HeartbeatData};
247    /// use std::time::Duration;
248    ///
249    /// let config = HeartbeatConfig::new(
250    ///     Duration::from_secs(30),
251    ///     Duration::from_secs(600),
252    ///     Box::new(|_data: HeartbeatData| {}),
253    /// );
254    /// // let stream = HeartbeatStream::new(inner_stream, config);
255    /// ```
256    pub fn new(inner: S, config: HeartbeatConfig) -> Self {
257        /// 30 years in seconds — used as a far-future deadline fallback.
258        /// Computed as a const so the compiler verifies no overflow.
259        const THIRTY_YEARS_SECS: u64 = 86400 * 365 * 30;
260
261        let now = Instant::now();
262        // checked_add returns None only for extreme Duration values (hundreds of years).
263        // Fallback: 30 years from now, which is effectively infinite.
264        let far_future = || {
265            Instant::now()
266                .checked_add(Duration::from_secs(THIRTY_YEARS_SECS))
267                .unwrap_or(Instant::now())
268        };
269        let deadline = now.checked_add(config.timeout).unwrap_or_else(far_future);
270        let timeout_sleep = Box::pin(tokio::time::sleep_until(tokio::time::Instant::from_std(
271            deadline,
272        )));
273        Self {
274            inner,
275            config,
276            last_heartbeat: now,
277            start: now,
278            timeout_sleep,
279        }
280    }
281}
282
283impl<S> Stream for HeartbeatStream<S>
284where
285    S: Stream<Item = Result<StreamEvent, ApiError>> + Unpin,
286{
287    type Item = Result<StreamEvent, ApiError>;
288
289    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
290        let this = self.get_mut();
291
292        // Check heartbeat interval — fire callback if elapsed.
293        if this.last_heartbeat.elapsed() >= this.config.heartbeat_interval {
294            let elapsed = this.start.elapsed();
295            let data = HeartbeatData {
296                elapsed,
297                is_timeout: elapsed > this.config.timeout,
298            };
299            (this.config.on_heartbeat)(data);
300            this.last_heartbeat = Instant::now();
301        }
302
303        // Hard timeout — check the Sleep first (proactive wake-up),
304        // then fall back to elapsed() for the sync case.
305        if this.timeout_sleep.as_mut().poll(cx).is_ready()
306            || this.start.elapsed() > this.config.timeout
307        {
308            return Poll::Ready(Some(Err(ApiError::Api(format!(
309                "Stream timeout after {}s",
310                this.config.timeout.as_secs()
311            )))));
312        }
313
314        // Delegate to inner stream.
315        Pin::new(&mut this.inner).poll_next(cx)
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use futures::StreamExt;
323
324    struct VecStream {
325        items: Vec<Result<StreamEvent, ApiError>>,
326    }
327
328    impl Stream for VecStream {
329        type Item = Result<StreamEvent, ApiError>;
330
331        fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
332            Poll::Ready(self.get_mut().items.pop())
333        }
334    }
335
336    fn make_config(
337        callbacks: &std::sync::Arc<std::sync::Mutex<Vec<HeartbeatData>>>,
338    ) -> HeartbeatConfig {
339        let cb = callbacks.clone();
340        HeartbeatConfig::new(
341            Duration::from_millis(10),
342            Duration::from_secs(60),
343            Box::new(move |data: HeartbeatData| {
344                cb.lock().unwrap().push(data);
345            }),
346        )
347    }
348
349    #[test]
350    fn heartbeat_data_fields() {
351        let data = HeartbeatData {
352            elapsed: Duration::from_secs(30),
353            is_timeout: true,
354        };
355        assert_eq!(data.elapsed, Duration::from_secs(30));
356        assert!(data.is_timeout);
357    }
358
359    #[test]
360    fn config_accessors() {
361        let config = HeartbeatConfig::new(
362            Duration::from_secs(15),
363            Duration::from_secs(300),
364            Box::new(|_| {}),
365        );
366        assert_eq!(config.heartbeat_interval(), Duration::from_secs(15));
367        assert_eq!(config.timeout(), Duration::from_secs(300));
368    }
369
370    #[test]
371    fn config_debug() {
372        let config = HeartbeatConfig::new(
373            Duration::from_secs(30),
374            Duration::from_secs(600),
375            Box::new(|_| {}),
376        );
377        let debug = format!("{config:?}");
378        assert!(debug.contains("HeartbeatConfig"));
379        assert!(debug.contains("heartbeat_interval"));
380        assert!(debug.contains("timeout"));
381    }
382
383    #[tokio::test]
384    async fn passes_through_events() {
385        let callbacks: std::sync::Arc<std::sync::Mutex<Vec<HeartbeatData>>> =
386            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
387        let config = make_config(&callbacks);
388
389        let inner = VecStream {
390            items: vec![Ok(StreamEvent::Ping), Ok(StreamEvent::Ping)],
391        };
392
393        let mut stream = HeartbeatStream::new(inner, config);
394        let first = stream.next().await;
395        assert!(first.is_some());
396
397        let second = stream.next().await;
398        assert!(second.is_some());
399
400        let third = stream.next().await;
401        assert!(third.is_none());
402    }
403
404    #[tokio::test]
405    async fn passes_through_errors() {
406        let callbacks: std::sync::Arc<std::sync::Mutex<Vec<HeartbeatData>>> =
407            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
408        let config = make_config(&callbacks);
409
410        let inner = VecStream {
411            items: vec![Err(ApiError::Api("test error".to_string()))],
412        };
413
414        let mut stream = HeartbeatStream::new(inner, config);
415        let result = stream.next().await;
416        assert!(matches!(result, Some(Err(ApiError::Api(_)))));
417    }
418
419    #[tokio::test]
420    async fn fires_heartbeat_on_interval_sync() {
421        let callbacks: std::sync::Arc<std::sync::Mutex<Vec<HeartbeatData>>> =
422            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
423        let config = make_config(&callbacks);
424        let inner = VecStream {
425            items: vec![Ok(StreamEvent::Ping)],
426        };
427        let mut stream = HeartbeatStream::new(inner, config);
428
429        stream.last_heartbeat = Instant::now().checked_sub(Duration::from_secs(1)).unwrap();
430
431        let waker = futures::task::noop_waker();
432        let mut cx = Context::from_waker(&waker);
433        let result = Pin::new(&mut stream).poll_next(&mut cx);
434
435        assert!(matches!(result, Poll::Ready(Some(Ok(StreamEvent::Ping)))));
436
437        let cbs = callbacks.lock().unwrap();
438        assert_eq!(cbs.len(), 1);
439        assert!(cbs[0].elapsed > Duration::ZERO);
440    }
441
442    #[tokio::test]
443    async fn timeout_returns_error_sync() {
444        // Verify that poll_next returns a timeout error when the timeout
445        // has elapsed. We construct the stream, manually advance time by
446        // setting start into the past, then poll.
447        let callbacks: std::sync::Arc<std::sync::Mutex<Vec<HeartbeatData>>> =
448            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
449        let config = HeartbeatConfig::new(
450            Duration::from_millis(10),
451            Duration::from_millis(1),
452            Box::new(move |data: HeartbeatData| {
453                callbacks.lock().unwrap().push(data);
454            }),
455        );
456
457        // VecStream that returns Pending on first poll (simulates waiting).
458        let inner = VecStream { items: vec![] };
459        let mut stream = HeartbeatStream::new(inner, config);
460
461        // Manually set start into the past so timeout has elapsed.
462        stream.start = Instant::now().checked_sub(Duration::from_secs(10)).unwrap();
463
464        // Use a no-op waker to poll manually.
465        let waker = futures::task::noop_waker();
466        let mut cx = Context::from_waker(&waker);
467        let result = Pin::new(&mut stream).poll_next(&mut cx);
468
469        assert!(
470            matches!(result, Poll::Ready(Some(Err(ApiError::Api(msg)))) if msg.contains("timeout"))
471        );
472    }
473}