Skip to main content

sqlite_graphrag/
stdin_helper.rs

1//! Stdin reader with timeout to prevent indefinite blocking when the
2//! upstream pipe is held open without sending data.
3//!
4//! Used by `remember --body-stdin` and `edit` body input to enforce a
5//! deadline (default 60s). When the timeout fires, the spawned reader
6//! thread is leaked because `std::io::stdin()` cannot be cancelled
7//! from outside; this is acceptable in error scenarios because the
8//! process is about to exit anyway.
9
10use crate::errors::AppError;
11use std::io::Read;
12use std::sync::mpsc;
13use std::thread;
14use std::time::Duration;
15
16/// Reads stdin to a `String` with a hard deadline.
17///
18/// # Errors
19/// Returns `AppError::Internal` when the read does not finish within
20/// `secs` seconds, or `AppError::Io` when the underlying read fails.
21pub fn read_stdin_with_timeout(secs: u64) -> Result<String, AppError> {
22    let (tx, rx) = mpsc::channel::<std::io::Result<String>>();
23    thread::spawn(move || {
24        let mut buf = String::new();
25        let result = std::io::stdin().read_to_string(&mut buf).map(|_| buf);
26        let _ = tx.send(result);
27    });
28    match rx.recv_timeout(Duration::from_secs(secs)) {
29        Ok(Ok(buf)) => Ok(buf),
30        Ok(Err(e)) => Err(AppError::Io(e)),
31        Err(mpsc::RecvTimeoutError::Timeout) => Err(AppError::Internal(anyhow::anyhow!(
32            "stdin read timed out after {secs}s; pipe must close within timeout window"
33        ))),
34        Err(mpsc::RecvTimeoutError::Disconnected) => Err(AppError::Internal(anyhow::anyhow!(
35            "stdin reader thread disconnected unexpectedly"
36        ))),
37    }
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43    use std::time::Instant;
44
45    // Note: we cannot easily test the success path because tests inherit stdin
46    // from the test runner. We only assert the timeout path here.
47    #[test]
48    fn read_stdin_with_timeout_returns_internal_error_on_timeout() {
49        // 1s is enough — stdin in test runner is typically a tty or pipe with no input.
50        let start = Instant::now();
51        let result = read_stdin_with_timeout(1);
52        let elapsed = start.elapsed();
53        // We expect either a timeout (most cases) or a successful EOF read (rare).
54        match result {
55            Err(AppError::Internal(e)) => {
56                assert!(e.to_string().contains("timed out"), "unexpected error: {e}");
57                assert!(elapsed.as_secs_f64() >= 0.9 && elapsed.as_secs_f64() < 2.5);
58            }
59            Ok(_) | Err(AppError::Io(_)) => {
60                // EOF reached before timeout — also acceptable in CI environments.
61            }
62            Err(other) => panic!("unexpected error variant: {other:?}"),
63        }
64    }
65}