Skip to main content

kaish_kernel/scheduler/
stream.rs

1//! Bounded streams for job output capture.
2//!
3//! Provides ring-buffer-backed streams that:
4//! - Bound memory usage (prevents OOM from large output)
5//! - Evict oldest data when capacity is exceeded
6//! - Support concurrent writes from async tasks
7//! - Provide snapshot reads for observability
8
9use std::collections::VecDeque;
10use std::sync::Arc;
11use tokio::sync::RwLock;
12
13/// Default maximum size for bounded streams (10MB).
14pub const DEFAULT_STREAM_MAX_SIZE: usize = 10 * 1024 * 1024;
15
16/// A bounded stream backed by a ring buffer.
17///
18/// When writes exceed capacity, the oldest data is evicted to make room.
19/// This prevents unbounded memory growth from chatty commands while still
20/// keeping recent output available for inspection.
21///
22/// # Example
23///
24/// ```ignore
25/// use kaish_kernel::scheduler::BoundedStream;
26///
27/// let stream = BoundedStream::new(100); // 100 byte max
28///
29/// stream.write(b"hello ").await;
30/// stream.write(b"world").await;
31///
32/// let snapshot = stream.read().await;
33/// assert_eq!(&snapshot, b"hello world");
34/// ```
35#[derive(Clone)]
36pub struct BoundedStream {
37    inner: Arc<RwLock<BoundedStreamInner>>,
38}
39
40struct BoundedStreamInner {
41    /// Ring buffer holding the data.
42    buffer: VecDeque<u8>,
43    /// Maximum buffer size in bytes.
44    max_size: usize,
45    /// Total bytes written (lifetime counter, for diagnostics).
46    total_written: u64,
47    /// Number of bytes evicted due to overflow.
48    bytes_evicted: u64,
49    /// Whether the stream has been closed (no more writes expected).
50    closed: bool,
51}
52
53impl BoundedStream {
54    /// Create a new bounded stream with the specified maximum size.
55    pub fn new(max_size: usize) -> Self {
56        Self {
57            inner: Arc::new(RwLock::new(BoundedStreamInner {
58                buffer: VecDeque::with_capacity(max_size.min(8192)), // Don't preallocate huge buffers
59                max_size,
60                total_written: 0,
61                bytes_evicted: 0,
62                closed: false,
63            })),
64        }
65    }
66
67    /// Create a new bounded stream with the default max size (10MB).
68    pub fn default_size() -> Self {
69        Self::new(DEFAULT_STREAM_MAX_SIZE)
70    }
71
72    /// Write data to the stream.
73    ///
74    /// If the write would exceed capacity, the oldest data is evicted first.
75    /// Writing to a closed stream is silently ignored.
76    pub async fn write(&self, data: &[u8]) {
77        let mut inner = self.inner.write().await;
78
79        if inner.closed {
80            return;
81        }
82
83        inner.total_written += data.len() as u64;
84
85        // If data itself is larger than max_size, only keep the tail
86        if data.len() >= inner.max_size {
87            let start = data.len() - inner.max_size;
88            inner.bytes_evicted += inner.buffer.len() as u64 + start as u64;
89            inner.buffer.clear();
90            inner.buffer.extend(&data[start..]);
91            return;
92        }
93
94        // Evict oldest data if needed to make room
95        let needed = data.len();
96        let available = inner.max_size.saturating_sub(inner.buffer.len());
97
98        if needed > available {
99            let to_evict = needed - available;
100            let actual_evict = to_evict.min(inner.buffer.len());
101            inner.buffer.drain(..actual_evict);
102            inner.bytes_evicted += actual_evict as u64;
103        }
104
105        // Append new data
106        inner.buffer.extend(data);
107    }
108
109    /// Read a snapshot of the current buffer contents.
110    ///
111    /// Returns a copy of all data currently in the buffer.
112    /// The buffer is not modified.
113    pub async fn read(&self) -> Vec<u8> {
114        let inner = self.inner.read().await;
115        inner.buffer.iter().copied().collect()
116    }
117
118    /// Read the current buffer as a string (lossy UTF-8 conversion).
119    pub async fn read_string(&self) -> String {
120        let data = self.read().await;
121        String::from_utf8_lossy(&data).into_owned()
122    }
123
124    /// Close the stream, indicating no more writes are expected.
125    ///
126    /// Subsequent writes will be silently ignored.
127    pub async fn close(&self) {
128        let mut inner = self.inner.write().await;
129        inner.closed = true;
130    }
131
132    /// Check if the stream has been closed.
133    pub async fn is_closed(&self) -> bool {
134        let inner = self.inner.read().await;
135        inner.closed
136    }
137
138    /// Get the current buffer size in bytes.
139    pub async fn len(&self) -> usize {
140        let inner = self.inner.read().await;
141        inner.buffer.len()
142    }
143
144    /// Check if the buffer is empty.
145    pub async fn is_empty(&self) -> bool {
146        self.len().await == 0
147    }
148
149    /// Whether this stream has ever evicted data due to overflow.
150    ///
151    /// `write` silently drops the oldest bytes once the ring fills — this is
152    /// the hot-path check capture sites use to detect that loss so they can
153    /// surface it instead of reporting clean success (GH #191). Equivalent to
154    /// `stats().await.bytes_evicted > 0`, but avoids building the full
155    /// `StreamStats` when the caller only needs the boolean.
156    pub async fn has_overflowed(&self) -> bool {
157        let inner = self.inner.read().await;
158        inner.bytes_evicted > 0
159    }
160
161    /// Get stream statistics.
162    pub async fn stats(&self) -> StreamStats {
163        let inner = self.inner.read().await;
164        StreamStats {
165            current_size: inner.buffer.len(),
166            max_size: inner.max_size,
167            total_written: inner.total_written,
168            bytes_evicted: inner.bytes_evicted,
169            closed: inner.closed,
170        }
171    }
172}
173
174impl std::fmt::Debug for BoundedStream {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        f.debug_struct("BoundedStream")
177            .field("inner", &"<locked>")
178            .finish()
179    }
180}
181
182/// Statistics about a bounded stream.
183#[derive(Debug, Clone)]
184pub struct StreamStats {
185    /// Current bytes in buffer.
186    pub current_size: usize,
187    /// Maximum buffer size.
188    pub max_size: usize,
189    /// Total bytes written (lifetime).
190    pub total_written: u64,
191    /// Bytes evicted due to overflow.
192    pub bytes_evicted: u64,
193    /// Whether the stream is closed.
194    pub closed: bool,
195}
196
197impl StreamStats {
198    /// Build a loud marker describing this stream's overflow. Call only when
199    /// `bytes_evicted > 0` — the caller is expected to gate on
200    /// [`BoundedStream::has_overflowed`] first.
201    ///
202    /// `label` names the stream ("stdout"/"stderr") in the marker text.
203    /// Centralized here — not hand-written at each capture site — so the two
204    /// external-command spawn sites that must stay in sync
205    /// (`kernel.rs::try_execute_external` and the test-only twin
206    /// `dispatch.rs::BackendDispatcher::try_external`, see CLAUDE.md's "two
207    /// spawn sites" gotcha) can't drift in wording (GH #191).
208    pub fn overflow_marker(&self, label: &str) -> String {
209        let max_mb = self.max_size as f64 / (1024.0 * 1024.0);
210        format!(
211            "[{label} truncated: output exceeded the {max_mb:.0}MB capture buffer \
212             — first {} bytes lost ({} bytes total written); enable output-limit \
213             to spill to disk]\n",
214            self.bytes_evicted, self.total_written,
215        )
216    }
217}
218
219/// Drain an async reader into a bounded stream.
220///
221/// This is useful for capturing process output without blocking the pipe.
222/// The function reads until EOF, then closes the stream.
223pub async fn drain_to_stream<R>(mut reader: R, stream: Arc<BoundedStream>)
224where
225    R: tokio::io::AsyncRead + Unpin,
226{
227    use tokio::io::AsyncReadExt;
228
229    let mut buf = [0u8; 8192];
230    loop {
231        match reader.read(&mut buf).await {
232            Ok(0) => break, // EOF
233            Ok(n) => stream.write(&buf[..n]).await,
234            Err(e) => {
235                tracing::warn!("drain_to_stream read error: {}", e);
236                break;
237            }
238        }
239    }
240    stream.close().await;
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[tokio::test]
248    async fn test_basic_write_read() {
249        let stream = BoundedStream::new(100);
250        stream.write(b"hello").await;
251        assert_eq!(stream.read().await, b"hello");
252    }
253
254    #[tokio::test]
255    async fn test_multiple_writes() {
256        let stream = BoundedStream::new(100);
257        stream.write(b"hello ").await;
258        stream.write(b"world").await;
259        assert_eq!(stream.read().await, b"hello world");
260    }
261
262    #[tokio::test]
263    async fn test_eviction_on_overflow() {
264        let stream = BoundedStream::new(10);
265        stream.write(b"12345").await;
266        stream.write(b"67890").await;
267        assert_eq!(stream.len().await, 10);
268
269        // Write 5 more bytes - should evict first 5
270        stream.write(b"ABCDE").await;
271        assert_eq!(stream.read().await, b"67890ABCDE");
272
273        let stats = stream.stats().await;
274        assert_eq!(stats.bytes_evicted, 5);
275        assert_eq!(stats.total_written, 15);
276    }
277
278    #[tokio::test]
279    async fn test_large_write_exceeds_buffer() {
280        let stream = BoundedStream::new(10);
281        // Write more than max_size - should only keep last 10 bytes
282        stream.write(b"0123456789ABCDEFGHIJ").await;
283        assert_eq!(stream.read().await, b"ABCDEFGHIJ");
284    }
285
286    #[tokio::test]
287    async fn test_close_prevents_writes() {
288        let stream = BoundedStream::new(100);
289        stream.write(b"before").await;
290        stream.close().await;
291        stream.write(b"after").await;
292        assert_eq!(stream.read().await, b"before");
293    }
294
295    #[tokio::test]
296    async fn test_read_string() {
297        let stream = BoundedStream::new(100);
298        stream.write(b"hello world").await;
299        assert_eq!(stream.read_string().await, "hello world");
300    }
301
302    #[tokio::test]
303    async fn test_concurrent_writes() {
304        use std::sync::Arc;
305
306        let stream = Arc::new(BoundedStream::new(1000));
307
308        let handles: Vec<_> = (0..10)
309            .map(|i| {
310                let s = stream.clone();
311                tokio::spawn(async move {
312                    for j in 0..10 {
313                        s.write(format!("[{}-{}]", i, j).as_bytes()).await;
314                    }
315                })
316            })
317            .collect();
318
319        for h in handles {
320            h.await.expect("task should not panic");
321        }
322
323        // All writes should complete without panic
324        // Order is non-deterministic, but total length should be consistent
325        let data = stream.read().await;
326        assert!(!data.is_empty());
327    }
328
329    #[tokio::test]
330    async fn test_stats() {
331        let stream = BoundedStream::new(10);
332        stream.write(b"1234567890").await;
333
334        let stats = stream.stats().await;
335        assert_eq!(stats.current_size, 10);
336        assert_eq!(stats.max_size, 10);
337        assert_eq!(stats.total_written, 10);
338        assert_eq!(stats.bytes_evicted, 0);
339        assert!(!stats.closed);
340    }
341
342    #[tokio::test]
343    async fn test_empty_stream() {
344        let stream = BoundedStream::new(100);
345        assert!(stream.is_empty().await);
346        assert_eq!(stream.len().await, 0);
347        assert_eq!(stream.read().await, Vec::<u8>::new());
348    }
349
350    #[tokio::test]
351    async fn test_drain_to_stream() {
352        use std::io::Cursor;
353
354        let data = b"test data from reader";
355        let cursor = Cursor::new(data.to_vec());
356        let stream = Arc::new(BoundedStream::new(100));
357
358        drain_to_stream(cursor, stream.clone()).await;
359
360        assert_eq!(stream.read().await, data);
361        assert!(stream.is_closed().await);
362    }
363
364    #[tokio::test]
365    async fn test_default_size() {
366        let stream = BoundedStream::default_size();
367        let stats = stream.stats().await;
368        assert_eq!(stats.max_size, DEFAULT_STREAM_MAX_SIZE);
369    }
370
371    #[tokio::test]
372    async fn test_has_overflowed() {
373        let stream = BoundedStream::new(10);
374        assert!(!stream.has_overflowed().await, "empty stream has not overflowed");
375
376        stream.write(b"1234567890").await;
377        assert!(
378            !stream.has_overflowed().await,
379            "exactly filling the buffer is not an overflow"
380        );
381
382        stream.write(b"more").await; // forces eviction of the oldest 4 bytes
383        assert!(
384            stream.has_overflowed().await,
385            "writing past capacity must flip has_overflowed"
386        );
387    }
388
389    #[test]
390    fn test_overflow_marker_wording() {
391        let stats = StreamStats {
392            current_size: 10 * 1024 * 1024,
393            max_size: 10 * 1024 * 1024,
394            total_written: 15 * 1024 * 1024,
395            bytes_evicted: 5 * 1024 * 1024,
396            closed: true,
397        };
398        let marker = stats.overflow_marker("stdout");
399        assert!(marker.starts_with("[stdout truncated:"), "got: {marker}");
400        assert!(marker.contains("10MB"), "got: {marker}");
401        assert!(marker.contains(&(5 * 1024 * 1024).to_string()), "got: {marker}");
402        assert!(marker.contains(&(15 * 1024 * 1024).to_string()), "got: {marker}");
403        assert!(marker.contains("output-limit"), "got: {marker}");
404    }
405}