Skip to main content

kaish_kernel/scheduler/
stream.rs

1//! Bounded streams for external command 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    /// Fires on every accepted write and on close, so a reader can await new
39    /// data instead of poll-looping — see [`BoundedStream::changed_since`].
40    /// Held beside the lock, not inside it, so a waiter can register without
41    /// contending with the writer it is waiting for.
42    notify: Arc<tokio::sync::Notify>,
43}
44
45struct BoundedStreamInner {
46    /// Ring buffer holding the data.
47    buffer: VecDeque<u8>,
48    /// Maximum buffer size in bytes.
49    max_size: usize,
50    /// Total bytes written (lifetime counter, for diagnostics).
51    total_written: u64,
52    /// Number of bytes evicted due to overflow.
53    bytes_evicted: u64,
54    /// Whether the stream has been closed (no more writes expected).
55    closed: bool,
56}
57
58impl BoundedStream {
59    /// Create a new bounded stream with the specified maximum size.
60    pub fn new(max_size: usize) -> Self {
61        Self {
62            inner: Arc::new(RwLock::new(BoundedStreamInner {
63                buffer: VecDeque::with_capacity(max_size.min(8192)), // Don't preallocate huge buffers
64                max_size,
65                total_written: 0,
66                bytes_evicted: 0,
67                closed: false,
68            })),
69            notify: Arc::new(tokio::sync::Notify::new()),
70        }
71    }
72
73    /// Create a new bounded stream with the default max size (10MB).
74    pub fn default_size() -> Self {
75        Self::new(DEFAULT_STREAM_MAX_SIZE)
76    }
77
78    /// Write data to the stream.
79    ///
80    /// If the write would exceed capacity, the oldest data is evicted first.
81    /// Writing to a closed stream is silently ignored.
82    pub async fn write(&self, data: &[u8]) {
83        {
84            let mut inner = self.inner.write().await;
85
86            if inner.closed {
87                return;
88            }
89
90            inner.total_written += data.len() as u64;
91
92            // If data itself is larger than max_size, only keep the tail
93            if data.len() >= inner.max_size {
94                let start = data.len() - inner.max_size;
95                inner.bytes_evicted += inner.buffer.len() as u64 + start as u64;
96                inner.buffer.clear();
97                inner.buffer.extend(&data[start..]);
98            } else {
99                // Evict oldest data if needed to make room
100                let needed = data.len();
101                let available = inner.max_size.saturating_sub(inner.buffer.len());
102
103                if needed > available {
104                    let to_evict = needed - available;
105                    let actual_evict = to_evict.min(inner.buffer.len());
106                    inner.buffer.drain(..actual_evict);
107                    inner.bytes_evicted += actual_evict as u64;
108                }
109
110                // Append new data
111                inner.buffer.extend(data);
112            }
113        }
114        // Outside the lock: a woken waiter reads `stats()`, which takes it.
115        self.notify.notify_waiters();
116    }
117
118    /// Read a snapshot of the current buffer contents.
119    ///
120    /// Returns a copy of all data currently in the buffer.
121    /// The buffer is not modified.
122    pub async fn read(&self) -> Vec<u8> {
123        let inner = self.inner.read().await;
124        inner.buffer.iter().copied().collect()
125    }
126
127    /// Read the current buffer as a string (lossy UTF-8 conversion).
128    pub async fn read_string(&self) -> String {
129        let data = self.read().await;
130        String::from_utf8_lossy(&data).into_owned()
131    }
132
133    /// Close the stream, indicating no more writes are expected.
134    ///
135    /// Subsequent writes will be silently ignored.
136    pub async fn close(&self) {
137        {
138            let mut inner = self.inner.write().await;
139            inner.closed = true;
140        }
141        // A waiter blocked on `changed_since` must not park forever on a
142        // stream that will never produce another byte.
143        self.notify.notify_waiters();
144    }
145
146    /// Check if the stream has been closed.
147    pub async fn is_closed(&self) -> bool {
148        let inner = self.inner.read().await;
149        inner.closed
150    }
151
152    /// Get the current buffer size in bytes.
153    pub async fn len(&self) -> usize {
154        let inner = self.inner.read().await;
155        inner.buffer.len()
156    }
157
158    /// Check if the buffer is empty.
159    pub async fn is_empty(&self) -> bool {
160        self.len().await == 0
161    }
162
163    /// Whether this stream has ever evicted data due to overflow.
164    ///
165    /// `write` silently drops the oldest bytes once the ring fills — this is
166    /// the hot-path check capture sites use to detect that loss so they can
167    /// surface it instead of reporting clean success (GH #191). Equivalent to
168    /// `stats().await.bytes_evicted > 0`, but avoids building the full
169    /// `StreamStats` when the caller only needs the boolean.
170    pub async fn has_overflowed(&self) -> bool {
171        let inner = self.inner.read().await;
172        inner.bytes_evicted > 0
173    }
174
175    /// Wait until this stream has written more than `seen_total_written`
176    /// lifetime bytes, or has closed. Returns the stats that ended the wait,
177    /// so the caller's next call passes back `stats.total_written`.
178    ///
179    /// This is the alternative to a poll loop for an embedder tailing a
180    /// running job's output. Pass `0` on the first call to wake on the first
181    /// byte. **A closed stream returns immediately, every time** — the caller
182    /// checks `stats.closed` and stops, rather than looping on a stream that
183    /// can never change again.
184    ///
185    /// The registration happens before the read, not after: `Notify` only
186    /// reaches waiters that are already registered, so reading first would
187    /// drop a write that landed in between and park until the *next* one.
188    pub async fn changed_since(&self, seen_total_written: u64) -> StreamStats {
189        loop {
190            let notified = self.notify.notified();
191            tokio::pin!(notified);
192            notified.as_mut().enable();
193
194            let stats = self.stats().await;
195            if stats.closed || stats.total_written > seen_total_written {
196                return stats;
197            }
198
199            notified.await;
200        }
201    }
202
203    /// Get stream statistics.
204    pub async fn stats(&self) -> StreamStats {
205        let inner = self.inner.read().await;
206        StreamStats {
207            current_size: inner.buffer.len(),
208            max_size: inner.max_size,
209            total_written: inner.total_written,
210            bytes_evicted: inner.bytes_evicted,
211            closed: inner.closed,
212        }
213    }
214}
215
216impl std::fmt::Debug for BoundedStream {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        f.debug_struct("BoundedStream")
219            .field("inner", &"<locked>")
220            .finish()
221    }
222}
223
224/// Statistics about a bounded stream.
225#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
226#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
227pub struct StreamStats {
228    /// Current bytes in buffer.
229    pub current_size: usize,
230    /// Maximum buffer size.
231    pub max_size: usize,
232    /// Total bytes written (lifetime).
233    pub total_written: u64,
234    /// Bytes evicted due to overflow.
235    pub bytes_evicted: u64,
236    /// Whether the stream is closed.
237    pub closed: bool,
238}
239
240impl StreamStats {
241    /// Build a loud marker describing this stream's overflow. Call only when
242    /// `bytes_evicted > 0` — the caller is expected to gate on
243    /// [`BoundedStream::has_overflowed`] first.
244    ///
245    /// `label` names the stream ("stdout"/"stderr") in the marker text.
246    /// Centralized here — not hand-written at each capture site — so the two
247    /// external-command spawn sites that must stay in sync
248    /// (`kernel.rs::try_execute_external` and the test-only twin
249    /// `dispatch.rs::BackendDispatcher::try_external`, see CLAUDE.md's "two
250    /// spawn sites" gotcha) can't drift in wording (GH #191).
251    pub fn overflow_marker(&self, label: &str) -> String {
252        let max_mb = self.max_size as f64 / (1024.0 * 1024.0);
253        format!(
254            "[{label} truncated: output exceeded the {max_mb:.0}MB capture buffer \
255             — first {} bytes lost ({} bytes total written); enable output-limit \
256             to spill to disk]\n",
257            self.bytes_evicted, self.total_written,
258        )
259    }
260}
261
262/// Drain an async reader into a bounded stream.
263///
264/// This is useful for capturing process output without blocking the pipe.
265/// The function reads until EOF, then closes the stream.
266pub async fn drain_to_stream<R>(reader: R, stream: Arc<BoundedStream>)
267where
268    R: tokio::io::AsyncRead + Unpin,
269{
270    drain_to_stream_teed(reader, stream, None).await
271}
272
273/// Drain an async reader into `stream`, copying every chunk into `tee` as well.
274///
275/// The tee is what makes `/v/jobs/{id}/stdout` live: a background job's stream
276/// outlives the single command being drained here, so it receives each 8 KiB
277/// chunk as the child emits it and is **not** closed at EOF — only `stream`,
278/// which belongs to this one command, is. Closing the job's stream is the job's
279/// own business, once every command in it has finished.
280pub async fn drain_to_stream_teed<R>(
281    mut reader: R,
282    stream: Arc<BoundedStream>,
283    tee: Option<Arc<BoundedStream>>,
284) where
285    R: tokio::io::AsyncRead + Unpin,
286{
287    use tokio::io::AsyncReadExt;
288
289    let mut buf = [0u8; 8192];
290    loop {
291        match reader.read(&mut buf).await {
292            Ok(0) => break, // EOF
293            Ok(n) => {
294                stream.write(&buf[..n]).await;
295                if let Some(tee) = &tee {
296                    tee.write(&buf[..n]).await;
297                }
298            }
299            Err(e) => {
300                tracing::warn!("drain_to_stream read error: {}", e);
301                break;
302            }
303        }
304    }
305    stream.close().await;
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    /// The wake-up must reach a waiter that registered before the write —
313    /// the whole point of enabling the `Notified` future ahead of the read.
314    #[tokio::test]
315    async fn changed_since_wakes_on_a_later_write() {
316        let stream = Arc::new(BoundedStream::new(1024));
317        let writer = stream.clone();
318        tokio::spawn(async move {
319            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
320            writer.write(b"late").await;
321        });
322
323        let stats = tokio::time::timeout(
324            std::time::Duration::from_secs(5),
325            stream.changed_since(0),
326        )
327        .await
328        .expect("changed_since parked instead of waking on the write");
329        assert_eq!(stats.total_written, 4);
330        assert!(!stats.closed);
331    }
332
333    /// Data already present must return immediately — a caller that polls
334    /// once, then waits, must not miss what landed in between.
335    #[tokio::test]
336    async fn changed_since_returns_at_once_when_data_already_arrived() {
337        let stream = BoundedStream::new(1024);
338        stream.write(b"early").await;
339        let stats = tokio::time::timeout(
340            std::time::Duration::from_millis(500),
341            stream.changed_since(0),
342        )
343        .await
344        .expect("already-written data must not block");
345        assert_eq!(stats.total_written, 5);
346    }
347
348    /// A closed stream can never change again, so waiting on one returns
349    /// rather than parking forever.
350    #[tokio::test]
351    async fn changed_since_returns_on_close() {
352        let stream = Arc::new(BoundedStream::new(1024));
353        let closer = stream.clone();
354        tokio::spawn(async move {
355            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
356            closer.close().await;
357        });
358
359        let stats = tokio::time::timeout(
360            std::time::Duration::from_secs(5),
361            stream.changed_since(0),
362        )
363        .await
364        .expect("close must wake a waiter");
365        assert!(stats.closed, "the caller stops on this flag, not on a timeout");
366        assert_eq!(stats.total_written, 0);
367    }
368
369    /// The tee gets every chunk the primary does, and is NOT closed at EOF —
370    /// a job's stream outlives the one command being drained into it.
371    #[tokio::test]
372    async fn drain_to_stream_teed_copies_to_both_and_closes_only_the_primary() {
373        let primary = Arc::new(BoundedStream::new(1024));
374        let tee = Arc::new(BoundedStream::new(1024));
375
376        let reader = std::io::Cursor::new(b"hello tee".to_vec());
377        drain_to_stream_teed(reader, primary.clone(), Some(tee.clone())).await;
378
379        assert_eq!(primary.read().await, b"hello tee");
380        assert_eq!(tee.read().await, b"hello tee");
381        assert!(primary.is_closed().await, "the drained command's own stream is done");
382        assert!(
383            !tee.is_closed().await,
384            "the job's stream must stay open for the next command in the job"
385        );
386    }
387
388    #[tokio::test]
389    async fn test_basic_write_read() {
390        let stream = BoundedStream::new(100);
391        stream.write(b"hello").await;
392        assert_eq!(stream.read().await, b"hello");
393    }
394
395    #[tokio::test]
396    async fn test_multiple_writes() {
397        let stream = BoundedStream::new(100);
398        stream.write(b"hello ").await;
399        stream.write(b"world").await;
400        assert_eq!(stream.read().await, b"hello world");
401    }
402
403    #[tokio::test]
404    async fn test_eviction_on_overflow() {
405        let stream = BoundedStream::new(10);
406        stream.write(b"12345").await;
407        stream.write(b"67890").await;
408        assert_eq!(stream.len().await, 10);
409
410        // Write 5 more bytes - should evict first 5
411        stream.write(b"ABCDE").await;
412        assert_eq!(stream.read().await, b"67890ABCDE");
413
414        let stats = stream.stats().await;
415        assert_eq!(stats.bytes_evicted, 5);
416        assert_eq!(stats.total_written, 15);
417    }
418
419    #[tokio::test]
420    async fn test_large_write_exceeds_buffer() {
421        let stream = BoundedStream::new(10);
422        // Write more than max_size - should only keep last 10 bytes
423        stream.write(b"0123456789ABCDEFGHIJ").await;
424        assert_eq!(stream.read().await, b"ABCDEFGHIJ");
425    }
426
427    #[tokio::test]
428    async fn test_close_prevents_writes() {
429        let stream = BoundedStream::new(100);
430        stream.write(b"before").await;
431        stream.close().await;
432        stream.write(b"after").await;
433        assert_eq!(stream.read().await, b"before");
434    }
435
436    #[tokio::test]
437    async fn test_read_string() {
438        let stream = BoundedStream::new(100);
439        stream.write(b"hello world").await;
440        assert_eq!(stream.read_string().await, "hello world");
441    }
442
443    #[tokio::test]
444    async fn test_concurrent_writes() {
445        use std::sync::Arc;
446
447        let stream = Arc::new(BoundedStream::new(1000));
448
449        let handles: Vec<_> = (0..10)
450            .map(|i| {
451                let s = stream.clone();
452                tokio::spawn(async move {
453                    for j in 0..10 {
454                        s.write(format!("[{}-{}]", i, j).as_bytes()).await;
455                    }
456                })
457            })
458            .collect();
459
460        for h in handles {
461            h.await.expect("task should not panic");
462        }
463
464        // All writes should complete without panic
465        // Order is non-deterministic, but total length should be consistent
466        let data = stream.read().await;
467        assert!(!data.is_empty());
468    }
469
470    #[tokio::test]
471    async fn test_stats() {
472        let stream = BoundedStream::new(10);
473        stream.write(b"1234567890").await;
474
475        let stats = stream.stats().await;
476        assert_eq!(stats.current_size, 10);
477        assert_eq!(stats.max_size, 10);
478        assert_eq!(stats.total_written, 10);
479        assert_eq!(stats.bytes_evicted, 0);
480        assert!(!stats.closed);
481    }
482
483    #[tokio::test]
484    async fn test_empty_stream() {
485        let stream = BoundedStream::new(100);
486        assert!(stream.is_empty().await);
487        assert_eq!(stream.len().await, 0);
488        assert_eq!(stream.read().await, Vec::<u8>::new());
489    }
490
491    #[tokio::test]
492    async fn test_drain_to_stream() {
493        use std::io::Cursor;
494
495        let data = b"test data from reader";
496        let cursor = Cursor::new(data.to_vec());
497        let stream = Arc::new(BoundedStream::new(100));
498
499        drain_to_stream(cursor, stream.clone()).await;
500
501        assert_eq!(stream.read().await, data);
502        assert!(stream.is_closed().await);
503    }
504
505    #[tokio::test]
506    async fn test_default_size() {
507        let stream = BoundedStream::default_size();
508        let stats = stream.stats().await;
509        assert_eq!(stats.max_size, DEFAULT_STREAM_MAX_SIZE);
510    }
511
512    #[tokio::test]
513    async fn test_has_overflowed() {
514        let stream = BoundedStream::new(10);
515        assert!(!stream.has_overflowed().await, "empty stream has not overflowed");
516
517        stream.write(b"1234567890").await;
518        assert!(
519            !stream.has_overflowed().await,
520            "exactly filling the buffer is not an overflow"
521        );
522
523        stream.write(b"more").await; // forces eviction of the oldest 4 bytes
524        assert!(
525            stream.has_overflowed().await,
526            "writing past capacity must flip has_overflowed"
527        );
528    }
529
530    #[test]
531    fn stream_stats_round_trips_through_serde() {
532        // GH #241 (folded in): StreamStats was flagged alongside the job types
533        // as another kaish type family with no serde — bytes-evicted/
534        // truncation warnings are useful for an embedder surfacing job output
535        // health, same spirit as the rest of this PR.
536        let stats = StreamStats {
537            current_size: 42,
538            max_size: 100,
539            total_written: 142,
540            bytes_evicted: 100,
541            closed: true,
542        };
543        let json = serde_json::to_string(&stats).unwrap();
544        let back: StreamStats = serde_json::from_str(&json).unwrap();
545        assert_eq!(back.current_size, stats.current_size);
546        assert_eq!(back.max_size, stats.max_size);
547        assert_eq!(back.total_written, stats.total_written);
548        assert_eq!(back.bytes_evicted, stats.bytes_evicted);
549        assert_eq!(back.closed, stats.closed);
550    }
551
552    #[test]
553    fn test_overflow_marker_wording() {
554        let stats = StreamStats {
555            current_size: 10 * 1024 * 1024,
556            max_size: 10 * 1024 * 1024,
557            total_written: 15 * 1024 * 1024,
558            bytes_evicted: 5 * 1024 * 1024,
559            closed: true,
560        };
561        let marker = stats.overflow_marker("stdout");
562        assert!(marker.starts_with("[stdout truncated:"), "got: {marker}");
563        assert!(marker.contains("10MB"), "got: {marker}");
564        assert!(marker.contains(&(5 * 1024 * 1024).to_string()), "got: {marker}");
565        assert!(marker.contains(&(15 * 1024 * 1024).to_string()), "got: {marker}");
566        assert!(marker.contains("output-limit"), "got: {marker}");
567    }
568}