use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use thiserror::Error;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RunCellOutcome {
pub print_output: Vec<String>,
pub error: Option<String>,
}
#[derive(Debug, Error)]
pub enum RunnerError {
#[error("python interpreter not found: {0}")]
InterpreterNotFound(String),
#[error("failed to spawn interpreter: {0}")]
Spawn(String),
#[error("subprocess I/O failed: {0}")]
Io(String),
}
#[async_trait]
pub trait NotebookRunner: Send + Sync + 'static {
async fn run_cell(&self, code: &str, timeout: Duration) -> Result<RunCellOutcome, RunnerError>;
}
#[derive(Debug, Clone)]
pub struct SubprocessRunner {
pub python_path: String,
pub stdout_cap_bytes: usize,
pub stderr_cap_bytes: usize,
pub memory_cap_bytes: Option<u64>,
pub cpu_seconds_cap: Option<u64>,
}
impl Default for SubprocessRunner {
fn default() -> Self {
Self {
python_path: "python3".to_owned(),
stdout_cap_bytes: 64 * 1024,
stderr_cap_bytes: 16 * 1024,
memory_cap_bytes: Some(512 * 1024 * 1024),
cpu_seconds_cap: Some(60),
}
}
}
impl SubprocessRunner {
pub fn new() -> Self {
Self::default()
}
pub fn into_dyn(self) -> Arc<dyn NotebookRunner> {
Arc::new(self) as Arc<dyn NotebookRunner>
}
}
const PYTHON_WRAPPER: &str = r#"
import sys, traceback
src = sys.stdin.read()
print_output = []
def _custom_print(*args, **kwargs):
sep = kwargs.get('sep', ' ')
print_output.append(sep.join(str(a) for a in args))
env = {'print': _custom_print, '__name__': '__cognee_cell__'}
try:
exec(compile(src, '<cell>', 'exec'), env)
except SystemExit:
raise
except BaseException:
sys.stderr.write(traceback.format_exc())
for line in print_output:
sys.__stdout__.write(line)
sys.__stdout__.write('\n')
sys.__stdout__.flush()
"#;
#[async_trait]
impl NotebookRunner for SubprocessRunner {
async fn run_cell(&self, code: &str, timeout: Duration) -> Result<RunCellOutcome, RunnerError> {
let mut cmd = Command::new(&self.python_path);
cmd.arg("-I") .arg("-c")
.arg(PYTHON_WRAPPER)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("LANG", "C.UTF-8");
if let Ok(tmpdir) = std::env::var("TMPDIR") {
cmd.env("TMPDIR", tmpdir);
}
#[cfg(unix)]
{
let mem_cap = self.memory_cap_bytes;
let cpu_cap = self.cpu_seconds_cap;
unsafe {
cmd.pre_exec(move || {
if let Some(mem) = mem_cap {
let lim = libc::rlimit {
rlim_cur: mem as libc::rlim_t,
rlim_max: mem as libc::rlim_t,
};
let _ = libc::setrlimit(libc::RLIMIT_AS, &lim);
}
if let Some(cpu) = cpu_cap {
let lim = libc::rlimit {
rlim_cur: cpu as libc::rlim_t,
rlim_max: cpu as libc::rlim_t,
};
let _ = libc::setrlimit(libc::RLIMIT_CPU, &lim);
}
Ok(())
});
}
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
return Err(RunnerError::InterpreterNotFound(self.python_path.clone()));
}
return Err(RunnerError::Spawn(e.to_string()));
}
};
let mut stdin = child
.stdin
.take()
.ok_or_else(|| RunnerError::Io("child stdin missing".to_owned()))?;
let code_owned = code.to_owned();
let write_handle = tokio::spawn(async move {
let res = stdin.write_all(code_owned.as_bytes()).await;
drop(stdin);
res
});
let wait_result = tokio::time::timeout(timeout, child.wait_with_output()).await;
let _ = write_handle.await;
let output = match wait_result {
Ok(Ok(output)) => output,
Ok(Err(e)) => return Err(RunnerError::Io(format!("wait_with_output: {e}"))),
Err(_) => {
return Ok(RunCellOutcome {
print_output: Vec::new(),
error: Some(format!(
"Cell execution timed out after {} ms",
timeout.as_millis()
)),
});
}
};
let stdout_truncated = output.stdout.len() > self.stdout_cap_bytes;
let stderr_truncated = output.stderr.len() > self.stderr_cap_bytes;
let stdout_bytes = &output.stdout[..output.stdout.len().min(self.stdout_cap_bytes)];
let stderr_bytes = &output.stderr[..output.stderr.len().min(self.stderr_cap_bytes)];
let stdout = String::from_utf8_lossy(stdout_bytes).into_owned();
let mut stderr = String::from_utf8_lossy(stderr_bytes).into_owned();
if stdout_truncated {
stderr.push_str("\n[stdout truncated by server: exceeded cap]\n");
}
if stderr_truncated {
stderr.push_str("\n[stderr truncated by server: exceeded cap]\n");
}
let print_output: Vec<String> = stdout
.split('\n')
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
let error = if !output.status.success() && !stderr.is_empty() {
Some(stderr.trim_end().to_owned())
} else if !stderr.is_empty() {
Some(stderr.trim_end().to_owned())
} else if !output.status.success() {
Some(format!(
"Python interpreter exited with status {}",
output.status.code().unwrap_or(-1)
))
} else {
None
};
Ok(RunCellOutcome {
print_output,
error,
})
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "test code — panics are acceptable failures"
)]
mod tests {
use super::*;
use std::sync::Mutex;
pub struct MockRunner {
pub calls: Mutex<Vec<(String, Duration)>>,
pub outcome: Mutex<Result<RunCellOutcome, RunnerErrorStub>>,
}
#[derive(Debug, Clone)]
pub enum RunnerErrorStub {
InterpreterNotFound(String),
Spawn(String),
Io(String),
}
impl From<&RunnerErrorStub> for RunnerError {
fn from(s: &RunnerErrorStub) -> Self {
match s {
RunnerErrorStub::InterpreterNotFound(s) => Self::InterpreterNotFound(s.clone()),
RunnerErrorStub::Spawn(s) => Self::Spawn(s.clone()),
RunnerErrorStub::Io(s) => Self::Io(s.clone()),
}
}
}
impl MockRunner {
pub fn with_outcome(outcome: RunCellOutcome) -> Self {
Self {
calls: Mutex::new(Vec::new()),
outcome: Mutex::new(Ok(outcome)),
}
}
pub fn with_error(err: RunnerErrorStub) -> Self {
Self {
calls: Mutex::new(Vec::new()),
outcome: Mutex::new(Err(err)),
}
}
}
#[async_trait]
impl NotebookRunner for MockRunner {
async fn run_cell(
&self,
code: &str,
timeout: Duration,
) -> Result<RunCellOutcome, RunnerError> {
self.calls
.lock()
.expect("mock calls lock") .push((code.to_owned(), timeout));
match &*self.outcome.lock().expect("mock outcome lock") {
Ok(o) => Ok(o.clone()),
Err(e) => Err(e.into()),
}
}
}
#[tokio::test]
async fn mock_runner_happy_path() {
let mock = MockRunner::with_outcome(RunCellOutcome {
print_output: vec!["2".to_owned()],
error: None,
});
let outcome = mock
.run_cell("print(1+1)", Duration::from_secs(5))
.await
.expect("ok");
assert_eq!(outcome.print_output, vec!["2".to_owned()]);
assert_eq!(outcome.error, None);
let calls = mock.calls.lock().expect("calls");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "print(1+1)");
assert_eq!(calls[0].1, Duration::from_secs(5));
}
#[tokio::test]
async fn mock_runner_simulated_timeout_outcome() {
let mock = MockRunner::with_outcome(RunCellOutcome {
print_output: Vec::new(),
error: Some("Cell execution timed out after 1000 ms".to_owned()),
});
let outcome = mock
.run_cell("import time; time.sleep(60)", Duration::from_millis(1000))
.await
.expect("ok");
assert!(outcome.print_output.is_empty());
assert!(
outcome
.error
.as_deref()
.unwrap_or_default()
.contains("timed out")
);
}
#[tokio::test]
async fn mock_runner_overflow_outcome() {
let mut err = String::from("[stdout truncated by server: exceeded cap]");
let mock = MockRunner::with_outcome(RunCellOutcome {
print_output: vec!["A".repeat(64).to_string()],
error: Some(std::mem::take(&mut err)),
});
let outcome = mock
.run_cell("print('A'*10**9)", Duration::from_secs(5))
.await
.expect("ok");
assert!(outcome.error.unwrap().contains("truncated"));
}
#[tokio::test]
async fn mock_runner_error_path() {
let mock =
MockRunner::with_error(RunnerErrorStub::InterpreterNotFound("python3".to_owned()));
let err = mock
.run_cell("print(1)", Duration::from_secs(5))
.await
.expect_err("should error");
match err {
RunnerError::InterpreterNotFound(p) => assert_eq!(p, "python3"),
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn subprocess_runner_defaults() {
let r = SubprocessRunner::new();
assert_eq!(r.python_path, "python3");
assert_eq!(r.stdout_cap_bytes, 64 * 1024);
assert_eq!(r.stderr_cap_bytes, 16 * 1024);
assert_eq!(r.memory_cap_bytes, Some(512 * 1024 * 1024));
assert_eq!(r.cpu_seconds_cap, Some(60));
}
}