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}