pipey 0.2.1

A lightweight HTTP-to-WebSocket event delivery service.
Documentation
//! Centralized heartbeat tracking for WebSocket sessions.
//!
//! Instead of a `tokio::time::interval` per connection (expensive at scale:
//! 1M timers = 1M timer-wheel entries + wakeups), we keep one lightweight
//! entry per session in a DashMap and sweep it with a single background task.

use actix_ws::Session;
use dashmap::DashMap;
use std::hash::Hash;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::time;
use tracing::{debug, warn};

struct HeartbeatEntry {
    session: Session,
    last_pong_ms: AtomicI64,
}

#[derive(Clone)]
pub struct HeartbeatRegistry<K: Eq + Hash + Clone + Send + Sync + 'static> {
    inner: Arc<DashMap<K, HeartbeatEntry>>,
}

impl<K: Eq + Hash + Clone + Send + Sync + std::fmt::Display + 'static> HeartbeatRegistry<K> {
    pub fn new() -> Self {
        Self {
            inner: Arc::new(DashMap::new()),
        }
    }

    /// Call once when a connection is established.
    pub fn register(&self, session_id: K, session: Session) {
        self.inner.insert(
            session_id,
            HeartbeatEntry {
                session,
                last_pong_ms: AtomicI64::new(now_ms()),
            },
        );
    }

    /// Call whenever a Pong (or any liveness signal) arrives.
    pub fn touch(&self, session_id: &K) {
        if let Some(entry) = self.inner.get(session_id) {
            entry.last_pong_ms.store(now_ms(), Ordering::Relaxed);
        }
    }

    /// Call when the connection task exits, regardless of reason.
    pub fn remove(&self, session_id: &K) {
        self.inner.remove(session_id);
    }

    /// Spawn the single background sweeper. Call this ONCE at startup,
    /// not per-connection.
    pub fn spawn_sweeper(&self, interval: Duration, timeout: Duration) {
        let registry = self.clone();
        actix_web::rt::spawn(async move {
            let mut ticker = time::interval(interval);
            let timeout_ms = timeout.as_millis() as i64;
            loop {
                ticker.tick().await;
                let now = now_ms();
                let mut stale = Vec::new();

                for entry in registry.inner.iter() {
                    let last = entry.value().last_pong_ms.load(Ordering::Relaxed);
                    if now - last > timeout_ms {
                        stale.push(entry.key().clone());
                    } else {
                        // Fan out pings so a slow client can't block the sweep.
                        let mut session = entry.value().session.clone();
                        actix_web::rt::spawn(async move {
                            let _ = session.ping(b"").await;
                        });
                    }
                }

                for session_id in stale {
                    if let Some((_, entry)) = registry.inner.remove(&session_id) {
                        warn!(%session_id, "heartbeat timeout, force-closing");
                        let session = entry.session;
                        // Just close the socket — the owning connection task's
                        // msg_stream will end and it will run its own cleanup
                        // (unsubscribe/remove_online_user) exactly once.
                        let _ = session.close(None).await;
                    }
                }

                debug!(active = registry.inner.len(), "heartbeat sweep complete");
            }
        });
    }
}

fn now_ms() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_millis() as i64
}