use std::sync::Arc;
use tokio::io::AsyncReadExt;
use tokio::time::Duration;
pub type LineSink = Arc<dyn Fn(&str) + Send + Sync>;
pub struct OutputWatchdog {
idle_timeout: Duration,
max_bytes: usize,
line_sink: Option<LineSink>,
}
#[derive(Debug, Clone)]
pub struct WatchdogOutput {
pub data: String,
pub truncated: bool,
pub idle_timeout_triggered: bool,
pub bytes_read: usize,
}
impl OutputWatchdog {
pub fn new(idle_timeout: Duration, max_bytes: usize) -> Self {
Self {
idle_timeout,
max_bytes,
line_sink: None,
}
}
pub fn with_line_sink(mut self, sink: LineSink) -> Self {
self.line_sink = Some(sink);
self
}
pub async fn read_with_idle_detection<R: AsyncReadExt + Unpin>(
&self,
reader: &mut R,
) -> WatchdogOutput {
let mut buf = vec![0u8; self.max_bytes + 1];
let mut total = 0usize;
let mut idle_triggered = false;
let mut emitted = 0usize;
let mut clean_eof = false;
loop {
match tokio::time::timeout(self.idle_timeout, reader.read(&mut buf[total..])).await {
Ok(Ok(0)) => {
clean_eof = true;
break; }
Ok(Ok(n)) => {
total += n;
if total > self.max_bytes {
total = self.max_bytes;
break;
}
if let Some(sink) = &self.line_sink {
while let Some(rel) = buf[emitted..total].iter().position(|b| *b == b'\n') {
let end = emitted + rel;
sink(
String::from_utf8_lossy(&buf[emitted..end]).trim_end_matches('\r'),
);
emitted = end + 1;
}
}
}
Ok(Err(_)) => break, Err(_) => {
idle_triggered = true;
break;
}
}
}
if clean_eof && emitted < total {
if let Some(sink) = &self.line_sink {
sink(String::from_utf8_lossy(&buf[emitted..total]).trim_end_matches('\r'));
}
}
let truncated = total == self.max_bytes;
let bytes_read = total;
let output = String::from_utf8_lossy(&buf[..total]).to_string();
let data = if truncated {
format!(
"{}\n... [output truncated at {} bytes]",
output, self.max_bytes
)
} else {
output
};
WatchdogOutput {
data,
truncated,
idle_timeout_triggered: idle_triggered,
bytes_read,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::duplex;
fn recording_sink() -> (LineSink, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let handle = seen.clone();
let sink: LineSink = std::sync::Arc::new(move |line: &str| {
handle.lock().unwrap().push(line.to_string());
});
(sink, seen)
}
#[tokio::test]
async fn line_sink_reassembles_records_split_across_reads() {
let (mut writer, mut reader) = duplex(1024);
let (sink, seen) = recording_sink();
let watchdog = OutputWatchdog::new(Duration::from_secs(5), 4096).with_line_sink(sink);
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
writer.write_all(b"{\"type\":\"assis").await.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
writer
.write_all(b"tant\"}\n{\"type\":\"result\"}\n")
.await
.unwrap();
drop(writer);
});
let out = watchdog.read_with_idle_detection(&mut reader).await;
assert!(!out.idle_timeout_triggered);
let lines = seen.lock().unwrap().clone();
assert_eq!(
lines,
vec![
r#"{"type":"assistant"}"#.to_string(),
r#"{"type":"result"}"#.to_string(),
],
"a record split across reads must be reassembled, not framed on the read boundary"
);
}
#[tokio::test]
async fn line_sink_emits_final_unterminated_line_at_eof() {
let (mut writer, mut reader) = duplex(1024);
let (sink, seen) = recording_sink();
let watchdog = OutputWatchdog::new(Duration::from_secs(5), 4096).with_line_sink(sink);
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
writer.write_all(b"first\nlast-no-newline").await.unwrap();
drop(writer);
});
watchdog.read_with_idle_detection(&mut reader).await;
assert_eq!(
seen.lock().unwrap().clone(),
vec!["first".to_string(), "last-no-newline".to_string()]
);
}
#[tokio::test]
async fn line_sink_drops_partial_line_on_truncation() {
let (mut writer, mut reader) = duplex(1024);
let (sink, seen) = recording_sink();
let watchdog = OutputWatchdog::new(Duration::from_secs(5), 12).with_line_sink(sink);
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let _ = writer.write_all(b"ok\nthis-one-is-cut-off\n").await;
drop(writer);
});
let out = watchdog.read_with_idle_detection(&mut reader).await;
assert!(out.truncated);
let lines = seen.lock().unwrap().clone();
assert!(
!lines.iter().any(|l| l.starts_with("this-one")),
"a partial line must not reach the sink, got: {lines:?}"
);
}
#[tokio::test]
async fn test_continuous_output_no_idle_timeout() {
let (mut writer, mut reader) = duplex(1024);
let watchdog = OutputWatchdog::new(Duration::from_secs(5), 4096);
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
writer.write_all(b"hello world").await.unwrap();
drop(writer);
});
let output = watchdog.read_with_idle_detection(&mut reader).await;
assert!(!output.idle_timeout_triggered);
assert!(!output.truncated);
assert_eq!(output.data, "hello world");
assert_eq!(output.bytes_read, 11);
}
#[tokio::test]
async fn test_idle_timeout_triggers() {
let (_writer, mut reader) = duplex(1024);
let watchdog = OutputWatchdog::new(Duration::from_millis(50), 4096);
let output = watchdog.read_with_idle_detection(&mut reader).await;
assert!(output.idle_timeout_triggered);
assert!(!output.truncated);
assert_eq!(output.bytes_read, 0);
}
#[tokio::test]
async fn test_truncation_with_watchdog() {
let (mut writer, mut reader) = duplex(1024);
let watchdog = OutputWatchdog::new(Duration::from_secs(5), 10);
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
writer.write_all(b"abcdefghijklmnop").await.unwrap();
drop(writer);
});
let output = watchdog.read_with_idle_detection(&mut reader).await;
assert!(output.truncated);
assert!(!output.idle_timeout_triggered);
assert_eq!(output.bytes_read, 10);
assert!(output.data.contains("[output truncated at 10 bytes]"));
}
}