Skip to main content

trustformers_debug/dashboard_ws/
websocket.rs

1//! Streaming dashboard server using Server-Sent Events (SSE).
2//!
3//! The [`DashboardServer`] binds a TCP port and streams [`DashboardEvent`]s
4//! to HTTP clients that issue a `GET /events` request (standard SSE
5//! handshake).  Events are produced via [`DashboardServer::push_event`] and
6//! buffered in a [`LockFreeRingBuffer`] before being forwarded to each
7//! connected client through a `tokio::sync::broadcast` channel.
8//!
9//! # Example
10//!
11//! ```no_run
12//! use trustformers_debug::dashboard_ws::websocket::{
13//!     DashboardConfig, DashboardEvent, DashboardServer,
14//! };
15//! use std::net::SocketAddr;
16//!
17//! # #[tokio::main]
18//! # async fn main() -> anyhow::Result<()> {
19//! let config = DashboardConfig {
20//!     bind_addr: "127.0.0.1:7878".parse()?,
21//!     max_clients: 8,
22//!     event_buffer_size: 256,
23//! };
24//! let server = DashboardServer::new(config)?;
25//! server.start().await?;
26//!
27//! server.push_event(DashboardEvent::TrainingStep {
28//!     step: 1,
29//!     loss: 0.5,
30//!     learning_rate: 1e-4,
31//!     throughput_tokens_per_sec: 3200.0,
32//! })?;
33//! # Ok(())
34//! # }
35//! ```
36
37use std::net::SocketAddr;
38use std::sync::atomic::{AtomicBool, Ordering};
39use std::sync::Arc;
40
41use anyhow::Result;
42use serde::{Deserialize, Serialize};
43use serde_json::Value;
44use tokio::io::{AsyncReadExt, AsyncWriteExt};
45use tokio::net::{TcpListener, TcpStream};
46use tokio::sync::broadcast;
47
48use crate::ring_buffer::LockFreeRingBuffer;
49
50// ─────────────────────────────────────────────────────────────
51// DashboardEvent
52// ─────────────────────────────────────────────────────────────
53
54/// Events that can be pushed to connected dashboard clients.
55///
56/// All variants are serialised to JSON and delivered via SSE
57/// `data:` lines.
58///
59/// # Example
60///
61/// ```
62/// use trustformers_debug::dashboard_ws::websocket::DashboardEvent;
63///
64/// let ev = DashboardEvent::TrainingStep {
65///     step: 10,
66///     loss: 0.35,
67///     learning_rate: 1e-4,
68///     throughput_tokens_per_sec: 2048.0,
69/// };
70/// let json = serde_json::to_string(&ev).unwrap();
71/// assert!(json.contains("TrainingStep"));
72/// ```
73#[derive(Serialize, Deserialize, Clone, Debug)]
74pub enum DashboardEvent {
75    /// Progress update emitted after each training step.
76    TrainingStep {
77        /// Global optimiser step counter.
78        step: u64,
79        /// Training loss value.
80        loss: f32,
81        /// Current learning rate.
82        learning_rate: f32,
83        /// Throughput measured in tokens processed per second.
84        throughput_tokens_per_sec: f32,
85    },
86    /// Snapshot of host-side memory usage.
87    MemorySnapshot {
88        /// Wall-clock milliseconds since epoch when this snapshot was taken.
89        timestamp_ms: u64,
90        /// Currently allocated heap bytes.
91        allocated_bytes: u64,
92        /// Peak allocation since the process started.
93        peak_bytes: u64,
94    },
95    /// Per-layer profiling data.
96    LayerProfile {
97        /// Layer identifier string.
98        layer_name: String,
99        /// Wall-clock execution time of the layer in microseconds.
100        duration_us: u64,
101        /// Memory consumed by the layer's activations.
102        memory_bytes: u64,
103    },
104    /// Arbitrary user-defined event.
105    Custom {
106        /// Event category name.
107        name: String,
108        /// Payload — any JSON-serialisable value.
109        value: Value,
110    },
111}
112
113// ─────────────────────────────────────────────────────────────
114// DashboardConfig
115// ─────────────────────────────────────────────────────────────
116
117/// Configuration for the SSE streaming dashboard server.
118///
119/// # Example
120///
121/// ```
122/// use trustformers_debug::dashboard_ws::websocket::DashboardConfig;
123///
124/// let cfg = DashboardConfig {
125///     bind_addr: "127.0.0.1:0".parse().unwrap(),
126///     max_clients: 4,
127///     event_buffer_size: 64,
128/// };
129/// assert_eq!(cfg.max_clients, 4);
130/// ```
131pub struct DashboardConfig {
132    /// TCP address to bind the server to.
133    pub bind_addr: SocketAddr,
134    /// Maximum number of simultaneously connected SSE clients.
135    pub max_clients: usize,
136    /// Capacity of the in-process event ring buffer (rounded to next power of 2).
137    pub event_buffer_size: usize,
138}
139
140// ─────────────────────────────────────────────────────────────
141// DashboardServer
142// ─────────────────────────────────────────────────────────────
143
144/// SSE streaming dashboard server.
145///
146/// Pushes [`DashboardEvent`]s to all connected HTTP clients that opened
147/// `GET /events`.  Events are first written to an internal
148/// [`LockFreeRingBuffer`] and then forwarded to each client through a
149/// `tokio::sync::broadcast` channel.
150///
151/// # Thread safety
152///
153/// The server is `Send + Sync`.  `push_event` may be called from any thread
154/// or async task.
155pub struct DashboardServer {
156    config: DashboardConfig,
157    event_buffer: Arc<LockFreeRingBuffer<u64>>,
158    /// Broadcast channel used to forward events to active client tasks.
159    sender: broadcast::Sender<String>,
160    /// Set to `true` once `start()` has been called.
161    running: Arc<AtomicBool>,
162}
163
164impl DashboardServer {
165    /// Creates a new server but does **not** start listening yet.
166    ///
167    /// Call [`start`](Self::start) to begin accepting connections.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if `event_buffer_size` is 0.
172    ///
173    /// # Example
174    ///
175    /// ```
176    /// use trustformers_debug::dashboard_ws::websocket::{DashboardConfig, DashboardServer};
177    ///
178    /// let cfg = DashboardConfig {
179    ///     bind_addr: "127.0.0.1:0".parse().unwrap(),
180    ///     max_clients: 4,
181    ///     event_buffer_size: 32,
182    /// };
183    /// let server = DashboardServer::new(cfg).unwrap();
184    /// assert!(!server.is_running());
185    /// ```
186    pub fn new(config: DashboardConfig) -> Result<Self> {
187        let buf_size = config.event_buffer_size;
188        if buf_size == 0 {
189            anyhow::bail!("event_buffer_size must be at least 1");
190        }
191        let event_buffer = Arc::new(LockFreeRingBuffer::new(buf_size));
192        // `max_clients` determines the broadcast channel capacity.
193        let channel_cap = config.max_clients.max(1);
194        let (sender, _) = broadcast::channel(channel_cap * 8);
195        Ok(Self {
196            config,
197            event_buffer,
198            sender,
199            running: Arc::new(AtomicBool::new(false)),
200        })
201    }
202
203    /// Returns `true` if [`start`](Self::start) has been called.
204    pub fn is_running(&self) -> bool {
205        self.running.load(Ordering::Acquire)
206    }
207
208    /// Pushes a [`DashboardEvent`] to all connected clients.
209    ///
210    /// The event is serialised to JSON and written to the internal ring
211    /// buffer as a sequence of bytes (the SSE-framed JSON string length is
212    /// stored as an index rather than raw bytes — see implementation notes).
213    ///
214    /// If no clients are connected the broadcast send silently drops the
215    /// message (all receivers have been dropped), which is acceptable
216    /// behaviour for a monitoring stream.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if JSON serialisation fails.
221    ///
222    /// # Example
223    ///
224    /// ```no_run
225    /// # use trustformers_debug::dashboard_ws::websocket::{DashboardConfig, DashboardServer, DashboardEvent};
226    /// # let server = DashboardServer::new(DashboardConfig {
227    /// #     bind_addr: "127.0.0.1:0".parse().unwrap(),
228    /// #     max_clients: 4,
229    /// #     event_buffer_size: 32,
230    /// # }).unwrap();
231    /// server.push_event(DashboardEvent::Custom {
232    ///     name: "epoch_end".to_string(),
233    ///     value: serde_json::json!({"epoch": 1}),
234    /// }).unwrap();
235    /// ```
236    pub fn push_event(&self, event: DashboardEvent) -> Result<()> {
237        let json = serde_json::to_string(&event)?;
238        let sse_frame = format!("data: {}\n\n", json);
239
240        // Store a fingerprint (length as u64) in the ring buffer so we can
241        // track how many events have been pushed even when no client is
242        // connected.
243        let _ = self.event_buffer.push(sse_frame.len() as u64);
244
245        // Forward to active clients; ignore send errors (no receivers is fine).
246        let _ = self.sender.send(sse_frame);
247        Ok(())
248    }
249
250    /// Starts the SSE server in a background tokio task.
251    ///
252    /// The method binds the TCP listener immediately (so binding errors are
253    /// returned synchronously) and then spawns a task to accept connections.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if the TCP listener cannot bind to
258    /// `config.bind_addr`.
259    ///
260    /// # Example
261    ///
262    /// ```no_run
263    /// # #[tokio::main]
264    /// # async fn main() -> anyhow::Result<()> {
265    /// use trustformers_debug::dashboard_ws::websocket::{DashboardConfig, DashboardServer};
266    ///
267    /// let cfg = DashboardConfig {
268    ///     bind_addr: "127.0.0.1:0".parse()?,
269    ///     max_clients: 2,
270    ///     event_buffer_size: 16,
271    /// };
272    /// let server = DashboardServer::new(cfg)?;
273    /// server.start().await?;
274    /// # Ok(())
275    /// # }
276    /// ```
277    pub async fn start(&self) -> Result<()> {
278        let listener = TcpListener::bind(self.config.bind_addr).await?;
279        tracing::debug!(
280            "SSE dashboard listening on {}",
281            listener.local_addr().unwrap_or(self.config.bind_addr)
282        );
283
284        let sender = self.sender.clone();
285        let running = Arc::clone(&self.running);
286        running.store(true, Ordering::Release);
287        let running_flag = Arc::clone(&self.running);
288
289        tokio::spawn(async move {
290            while running_flag.load(Ordering::Acquire) {
291                match listener.accept().await {
292                    Ok((stream, peer)) => {
293                        tracing::debug!("dashboard: accepted connection from {}", peer);
294                        let rx = sender.subscribe();
295                        tokio::spawn(handle_client(stream, rx));
296                    },
297                    Err(e) => {
298                        tracing::warn!("dashboard: accept error: {}", e);
299                        break;
300                    },
301                }
302            }
303        });
304
305        Ok(())
306    }
307
308    /// Signals the server to stop accepting new connections.
309    ///
310    /// Already-running client handler tasks are left to drain and close
311    /// naturally.
312    ///
313    /// # Errors
314    ///
315    /// Currently infallible; returns `Ok(())` always.
316    pub fn stop(&self) -> Result<()> {
317        self.running.store(false, Ordering::Release);
318        tracing::debug!("dashboard server stopping");
319        Ok(())
320    }
321}
322
323// ─────────────────────────────────────────────────────────────
324// Internal helpers
325// ─────────────────────────────────────────────────────────────
326
327/// Handles one SSE client connection.
328///
329/// Reads the HTTP request line, sends back SSE headers, then streams events
330/// received on `rx` until the connection is closed.
331async fn handle_client(mut stream: TcpStream, mut rx: broadcast::Receiver<String>) {
332    // Read enough of the request to identify the path, then ignore the rest.
333    let mut buf = [0u8; 512];
334    match stream.read(&mut buf).await {
335        Ok(0) | Err(_) => return,
336        Ok(_) => {},
337    }
338
339    let request = std::str::from_utf8(&buf).unwrap_or("");
340    let is_events = request.starts_with("GET /events") || request.contains("GET /events");
341
342    let (status, body) = if is_events {
343        (
344            "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
345            None,
346        )
347    } else {
348        (
349            "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n",
350            Some(DASHBOARD_HTML),
351        )
352    };
353
354    if stream.write_all(status.as_bytes()).await.is_err() {
355        return;
356    }
357
358    if let Some(html) = body {
359        let _ = stream.write_all(html.as_bytes()).await;
360        return;
361    }
362
363    // Stream events
364    loop {
365        match rx.recv().await {
366            Ok(frame) => {
367                if stream.write_all(frame.as_bytes()).await.is_err() {
368                    break;
369                }
370            },
371            Err(broadcast::error::RecvError::Lagged(n)) => {
372                tracing::warn!("dashboard client lagged by {} events", n);
373            },
374            Err(broadcast::error::RecvError::Closed) => break,
375        }
376    }
377}
378
379/// Minimal HTML page that auto-connects to the SSE event stream.
380const DASHBOARD_HTML: &str = r#"<!DOCTYPE html>
381<html lang="en">
382<head><meta charset="utf-8"><title>TrustformeRS Dashboard</title></head>
383<body>
384<h1>TrustformeRS Streaming Dashboard</h1>
385<pre id="log"></pre>
386<script>
387  const log = document.getElementById('log');
388  const es = new EventSource('/events');
389  es.onmessage = e => {
390    const line = document.createTextNode(e.data + '\n');
391    log.appendChild(line);
392  };
393</script>
394</body>
395</html>"#;
396
397// ─────────────────────────────────────────────────────────────
398// Tests
399// ─────────────────────────────────────────────────────────────
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    // ── Serialisation tests (no network) ─────────────────────
406
407    #[test]
408    fn test_training_step_serialises() {
409        let ev = DashboardEvent::TrainingStep {
410            step: 5,
411            loss: 0.5,
412            learning_rate: 1e-4,
413            throughput_tokens_per_sec: 1024.0,
414        };
415        let json = serde_json::to_string(&ev).unwrap();
416        assert!(json.contains("TrainingStep"));
417        assert!(json.contains("\"step\":5"));
418    }
419
420    #[test]
421    fn test_memory_snapshot_serialises() {
422        let ev = DashboardEvent::MemorySnapshot {
423            timestamp_ms: 1000,
424            allocated_bytes: 1_073_741_824,
425            peak_bytes: 2_147_483_648,
426        };
427        let json = serde_json::to_string(&ev).unwrap();
428        assert!(json.contains("MemorySnapshot"));
429        assert!(json.contains("allocated_bytes"));
430    }
431
432    #[test]
433    fn test_layer_profile_serialises() {
434        let ev = DashboardEvent::LayerProfile {
435            layer_name: "attention".to_string(),
436            duration_us: 1500,
437            memory_bytes: 256 * 1024 * 1024,
438        };
439        let json = serde_json::to_string(&ev).unwrap();
440        assert!(json.contains("LayerProfile"));
441        assert!(json.contains("\"layer_name\":\"attention\""));
442    }
443
444    #[test]
445    fn test_custom_event_serialises() {
446        let ev = DashboardEvent::Custom {
447            name: "epoch_end".to_string(),
448            value: serde_json::json!({"epoch": 3, "val_loss": 0.22}),
449        };
450        let json = serde_json::to_string(&ev).unwrap();
451        assert!(json.contains("Custom"));
452        assert!(json.contains("epoch_end"));
453    }
454
455    #[test]
456    fn test_event_roundtrip_deserialise() {
457        let ev = DashboardEvent::TrainingStep {
458            step: 42,
459            loss: 0.1,
460            learning_rate: 3e-5,
461            throughput_tokens_per_sec: 4096.0,
462        };
463        let json = serde_json::to_string(&ev).unwrap();
464        let ev2: DashboardEvent = serde_json::from_str(&json).unwrap();
465        if let DashboardEvent::TrainingStep { step, .. } = ev2 {
466            assert_eq!(step, 42);
467        } else {
468            panic!("unexpected variant after roundtrip");
469        }
470    }
471
472    // ── Server creation and push_event tests ─────────────────
473
474    #[test]
475    fn test_server_creation() {
476        let cfg = DashboardConfig {
477            bind_addr: "127.0.0.1:0".parse().unwrap(),
478            max_clients: 4,
479            event_buffer_size: 32,
480        };
481        let server = DashboardServer::new(cfg).unwrap();
482        assert!(!server.is_running());
483    }
484
485    #[test]
486    fn test_push_event_without_clients() {
487        let cfg = DashboardConfig {
488            bind_addr: "127.0.0.1:0".parse().unwrap(),
489            max_clients: 4,
490            event_buffer_size: 32,
491        };
492        let server = DashboardServer::new(cfg).unwrap();
493        // Pushing without any client connected should not error.
494        server
495            .push_event(DashboardEvent::TrainingStep {
496                step: 1,
497                loss: 0.4,
498                learning_rate: 1e-4,
499                throughput_tokens_per_sec: 2048.0,
500            })
501            .unwrap();
502        // Ring buffer should have recorded the event size.
503        assert!(!server.event_buffer.is_empty());
504    }
505
506    #[test]
507    fn test_stop_before_start() {
508        let cfg = DashboardConfig {
509            bind_addr: "127.0.0.1:0".parse().unwrap(),
510            max_clients: 2,
511            event_buffer_size: 8,
512        };
513        let server = DashboardServer::new(cfg).unwrap();
514        server.stop().unwrap();
515        assert!(!server.is_running());
516    }
517
518    #[tokio::test]
519    async fn test_start_and_stop() {
520        let cfg = DashboardConfig {
521            bind_addr: "127.0.0.1:0".parse().unwrap(),
522            max_clients: 2,
523            event_buffer_size: 16,
524        };
525        let server = DashboardServer::new(cfg).unwrap();
526        server.start().await.unwrap();
527        assert!(server.is_running());
528        server.stop().unwrap();
529        assert!(!server.is_running());
530    }
531
532    #[tokio::test]
533    async fn test_push_event_with_subscriber() {
534        let cfg = DashboardConfig {
535            bind_addr: "127.0.0.1:0".parse().unwrap(),
536            max_clients: 4,
537            event_buffer_size: 32,
538        };
539        let server = DashboardServer::new(cfg).unwrap();
540
541        // Subscribe before pushing
542        let mut rx = server.sender.subscribe();
543
544        server
545            .push_event(DashboardEvent::Custom {
546                name: "test".to_string(),
547                value: serde_json::json!(42),
548            })
549            .unwrap();
550
551        let frame = rx.try_recv().expect("should receive frame");
552        assert!(frame.starts_with("data: "));
553        assert!(frame.contains("\"test\""));
554    }
555
556    // ── additional DashboardEvent tests ────────────────────────────────────
557
558    #[test]
559    fn test_dashboard_event_training_step_serialization() {
560        let ev = DashboardEvent::TrainingStep {
561            step: 42,
562            loss: 0.25,
563            learning_rate: 3e-4,
564            throughput_tokens_per_sec: 1500.0,
565        };
566        let json = serde_json::to_string(&ev).expect("serialize should succeed");
567        assert!(json.contains("TrainingStep"));
568        assert!(json.contains("42"));
569    }
570
571    #[test]
572    fn test_dashboard_event_memory_snapshot_serialization() {
573        let ev = DashboardEvent::MemorySnapshot {
574            timestamp_ms: 12345,
575            allocated_bytes: 1024 * 1024,
576            peak_bytes: 2 * 1024 * 1024,
577        };
578        let json = serde_json::to_string(&ev).expect("serialize should succeed");
579        assert!(json.contains("MemorySnapshot"));
580    }
581
582    #[test]
583    fn test_dashboard_event_layer_profile_serialization() {
584        let ev = DashboardEvent::LayerProfile {
585            layer_name: "attention".to_string(),
586            duration_us: 1500,
587            memory_bytes: 4096,
588        };
589        let json = serde_json::to_string(&ev).expect("serialize should succeed");
590        assert!(json.contains("attention"));
591        assert!(json.contains("1500"));
592    }
593
594    #[test]
595    fn test_dashboard_event_custom_roundtrip() {
596        let ev = DashboardEvent::Custom {
597            name: "my_event".to_string(),
598            value: serde_json::json!({"key": "value"}),
599        };
600        let json = serde_json::to_string(&ev).expect("serialize should succeed");
601        let decoded: DashboardEvent =
602            serde_json::from_str(&json).expect("deserialize should succeed");
603        if let DashboardEvent::Custom { name, .. } = decoded {
604            assert_eq!(name, "my_event");
605        } else {
606            panic!("expected Custom variant");
607        }
608    }
609
610    #[test]
611    fn test_dashboard_config_fields() {
612        let cfg = DashboardConfig {
613            bind_addr: "0.0.0.0:8080".parse().expect("parse addr"),
614            max_clients: 16,
615            event_buffer_size: 128,
616        };
617        assert_eq!(cfg.max_clients, 16);
618        assert_eq!(cfg.event_buffer_size, 128);
619    }
620
621    #[test]
622    fn test_dashboard_server_zero_buffer_errors() {
623        let cfg = DashboardConfig {
624            bind_addr: "127.0.0.1:0".parse().expect("parse addr"),
625            max_clients: 4,
626            event_buffer_size: 0, // invalid
627        };
628        let result = DashboardServer::new(cfg);
629        assert!(result.is_err());
630    }
631
632    #[test]
633    fn test_push_multiple_events_fills_buffer() {
634        let cfg = DashboardConfig {
635            bind_addr: "127.0.0.1:0".parse().expect("parse addr"),
636            max_clients: 4,
637            event_buffer_size: 32,
638        };
639        let server = DashboardServer::new(cfg).expect("create server");
640        for i in 0..5_u64 {
641            server
642                .push_event(DashboardEvent::TrainingStep {
643                    step: i,
644                    loss: 0.5,
645                    learning_rate: 1e-4,
646                    throughput_tokens_per_sec: 1000.0,
647                })
648                .expect("push should succeed");
649        }
650        assert!(!server.event_buffer.is_empty());
651    }
652
653    #[test]
654    fn test_dashboard_event_deserialize_training_step() {
655        let json = r#"{"TrainingStep":{"step":10,"loss":0.3,"learning_rate":0.001,"throughput_tokens_per_sec":2048.0}}"#;
656        let ev: DashboardEvent = serde_json::from_str(json).expect("deserialize should succeed");
657        if let DashboardEvent::TrainingStep { step, .. } = ev {
658            assert_eq!(step, 10);
659        } else {
660            panic!("expected TrainingStep");
661        }
662    }
663}