1use std::process::Stdio;
17use std::sync::Arc;
18use std::time::Duration;
19
20use async_trait::async_trait;
21use thiserror::Error;
22use tokio::io::AsyncWriteExt;
23use tokio::process::Command;
24
25#[derive(Debug, Clone, PartialEq, Eq, Default)]
37pub struct RunCellOutcome {
38 pub print_output: Vec<String>,
39 pub error: Option<String>,
40}
41
42#[derive(Debug, Error)]
49pub enum RunnerError {
50 #[error("python interpreter not found: {0}")]
52 InterpreterNotFound(String),
53
54 #[error("failed to spawn interpreter: {0}")]
56 Spawn(String),
57
58 #[error("subprocess I/O failed: {0}")]
60 Io(String),
61}
62
63#[async_trait]
70pub trait NotebookRunner: Send + Sync + 'static {
71 async fn run_cell(&self, code: &str, timeout: Duration) -> Result<RunCellOutcome, RunnerError>;
78}
79
80#[derive(Debug, Clone)]
96pub struct SubprocessRunner {
97 pub python_path: String,
99 pub stdout_cap_bytes: usize,
101 pub stderr_cap_bytes: usize,
103 pub memory_cap_bytes: Option<u64>,
105 pub cpu_seconds_cap: Option<u64>,
108}
109
110impl Default for SubprocessRunner {
111 fn default() -> Self {
112 Self {
113 python_path: "python3".to_owned(),
114 stdout_cap_bytes: 64 * 1024,
115 stderr_cap_bytes: 16 * 1024,
116 memory_cap_bytes: Some(512 * 1024 * 1024),
117 cpu_seconds_cap: Some(60),
118 }
119 }
120}
121
122impl SubprocessRunner {
123 pub fn new() -> Self {
125 Self::default()
126 }
127
128 pub fn into_dyn(self) -> Arc<dyn NotebookRunner> {
131 Arc::new(self) as Arc<dyn NotebookRunner>
132 }
133}
134
135const PYTHON_WRAPPER: &str = r#"
142import sys, traceback
143src = sys.stdin.read()
144print_output = []
145def _custom_print(*args, **kwargs):
146 sep = kwargs.get('sep', ' ')
147 print_output.append(sep.join(str(a) for a in args))
148env = {'print': _custom_print, '__name__': '__cognee_cell__'}
149try:
150 exec(compile(src, '<cell>', 'exec'), env)
151except SystemExit:
152 raise
153except BaseException:
154 sys.stderr.write(traceback.format_exc())
155for line in print_output:
156 sys.__stdout__.write(line)
157 sys.__stdout__.write('\n')
158sys.__stdout__.flush()
159"#;
160
161#[async_trait]
162impl NotebookRunner for SubprocessRunner {
163 async fn run_cell(&self, code: &str, timeout: Duration) -> Result<RunCellOutcome, RunnerError> {
164 let mut cmd = Command::new(&self.python_path);
165 cmd.arg("-I") .arg("-c")
167 .arg(PYTHON_WRAPPER)
168 .stdin(Stdio::piped())
169 .stdout(Stdio::piped())
170 .stderr(Stdio::piped())
171 .kill_on_drop(true)
172 .env_clear()
174 .env("PATH", "/usr/bin:/bin")
175 .env("LANG", "C.UTF-8");
176
177 if let Ok(tmpdir) = std::env::var("TMPDIR") {
178 cmd.env("TMPDIR", tmpdir);
179 }
180
181 #[cfg(unix)]
182 {
183 let mem_cap = self.memory_cap_bytes;
184 let cpu_cap = self.cpu_seconds_cap;
185 unsafe {
186 cmd.pre_exec(move || {
187 if let Some(mem) = mem_cap {
188 let lim = libc::rlimit {
189 rlim_cur: mem as libc::rlim_t,
190 rlim_max: mem as libc::rlim_t,
191 };
192 let _ = libc::setrlimit(libc::RLIMIT_AS, &lim);
194 }
195 if let Some(cpu) = cpu_cap {
196 let lim = libc::rlimit {
197 rlim_cur: cpu as libc::rlim_t,
198 rlim_max: cpu as libc::rlim_t,
199 };
200 let _ = libc::setrlimit(libc::RLIMIT_CPU, &lim);
201 }
202 Ok(())
203 });
204 }
205 }
206
207 let mut child = match cmd.spawn() {
208 Ok(c) => c,
209 Err(e) => {
210 if e.kind() == std::io::ErrorKind::NotFound {
211 return Err(RunnerError::InterpreterNotFound(self.python_path.clone()));
212 }
213 return Err(RunnerError::Spawn(e.to_string()));
214 }
215 };
216
217 let mut stdin = child
219 .stdin
220 .take()
221 .ok_or_else(|| RunnerError::Io("child stdin missing".to_owned()))?;
222 let code_owned = code.to_owned();
223 let write_handle = tokio::spawn(async move {
224 let res = stdin.write_all(code_owned.as_bytes()).await;
225 drop(stdin);
227 res
228 });
229
230 let wait_result = tokio::time::timeout(timeout, child.wait_with_output()).await;
232
233 let _ = write_handle.await;
236
237 let output = match wait_result {
238 Ok(Ok(output)) => output,
239 Ok(Err(e)) => return Err(RunnerError::Io(format!("wait_with_output: {e}"))),
240 Err(_) => {
241 return Ok(RunCellOutcome {
243 print_output: Vec::new(),
244 error: Some(format!(
245 "Cell execution timed out after {} ms",
246 timeout.as_millis()
247 )),
248 });
249 }
250 };
251
252 let stdout_truncated = output.stdout.len() > self.stdout_cap_bytes;
254 let stderr_truncated = output.stderr.len() > self.stderr_cap_bytes;
255 let stdout_bytes = &output.stdout[..output.stdout.len().min(self.stdout_cap_bytes)];
256 let stderr_bytes = &output.stderr[..output.stderr.len().min(self.stderr_cap_bytes)];
257
258 let stdout = String::from_utf8_lossy(stdout_bytes).into_owned();
259 let mut stderr = String::from_utf8_lossy(stderr_bytes).into_owned();
260
261 if stdout_truncated {
262 stderr.push_str("\n[stdout truncated by server: exceeded cap]\n");
263 }
264 if stderr_truncated {
265 stderr.push_str("\n[stderr truncated by server: exceeded cap]\n");
266 }
267
268 let print_output: Vec<String> = stdout
269 .split('\n')
270 .filter(|s| !s.is_empty())
271 .map(str::to_owned)
272 .collect();
273
274 let error = if !output.status.success() && !stderr.is_empty() {
275 Some(stderr.trim_end().to_owned())
276 } else if !stderr.is_empty() {
277 Some(stderr.trim_end().to_owned())
281 } else if !output.status.success() {
282 Some(format!(
284 "Python interpreter exited with status {}",
285 output.status.code().unwrap_or(-1)
286 ))
287 } else {
288 None
289 };
290
291 Ok(RunCellOutcome {
292 print_output,
293 error,
294 })
295 }
296}
297
298#[cfg(test)]
301#[allow(
302 clippy::unwrap_used,
303 clippy::expect_used,
304 reason = "test code — panics are acceptable failures"
305)]
306mod tests {
307 use super::*;
308 use std::sync::Mutex;
309
310 pub struct MockRunner {
314 pub calls: Mutex<Vec<(String, Duration)>>,
315 pub outcome: Mutex<Result<RunCellOutcome, RunnerErrorStub>>,
316 }
317
318 #[derive(Debug, Clone)]
320 pub enum RunnerErrorStub {
321 InterpreterNotFound(String),
322 Spawn(String),
323 Io(String),
324 }
325
326 impl From<&RunnerErrorStub> for RunnerError {
327 fn from(s: &RunnerErrorStub) -> Self {
328 match s {
329 RunnerErrorStub::InterpreterNotFound(s) => Self::InterpreterNotFound(s.clone()),
330 RunnerErrorStub::Spawn(s) => Self::Spawn(s.clone()),
331 RunnerErrorStub::Io(s) => Self::Io(s.clone()),
332 }
333 }
334 }
335
336 impl MockRunner {
337 pub fn with_outcome(outcome: RunCellOutcome) -> Self {
338 Self {
339 calls: Mutex::new(Vec::new()),
340 outcome: Mutex::new(Ok(outcome)),
341 }
342 }
343
344 pub fn with_error(err: RunnerErrorStub) -> Self {
345 Self {
346 calls: Mutex::new(Vec::new()),
347 outcome: Mutex::new(Err(err)),
348 }
349 }
350 }
351
352 #[async_trait]
353 impl NotebookRunner for MockRunner {
354 async fn run_cell(
355 &self,
356 code: &str,
357 timeout: Duration,
358 ) -> Result<RunCellOutcome, RunnerError> {
359 self.calls
360 .lock()
361 .expect("mock calls lock") .push((code.to_owned(), timeout));
363 match &*self.outcome.lock().expect("mock outcome lock") {
364 Ok(o) => Ok(o.clone()),
366 Err(e) => Err(e.into()),
367 }
368 }
369 }
370
371 #[tokio::test]
374 async fn mock_runner_happy_path() {
375 let mock = MockRunner::with_outcome(RunCellOutcome {
376 print_output: vec!["2".to_owned()],
377 error: None,
378 });
379 let outcome = mock
380 .run_cell("print(1+1)", Duration::from_secs(5))
381 .await
382 .expect("ok");
383 assert_eq!(outcome.print_output, vec!["2".to_owned()]);
384 assert_eq!(outcome.error, None);
385
386 let calls = mock.calls.lock().expect("calls");
387 assert_eq!(calls.len(), 1);
388 assert_eq!(calls[0].0, "print(1+1)");
389 assert_eq!(calls[0].1, Duration::from_secs(5));
390 }
391
392 #[tokio::test]
393 async fn mock_runner_simulated_timeout_outcome() {
394 let mock = MockRunner::with_outcome(RunCellOutcome {
398 print_output: Vec::new(),
399 error: Some("Cell execution timed out after 1000 ms".to_owned()),
400 });
401 let outcome = mock
402 .run_cell("import time; time.sleep(60)", Duration::from_millis(1000))
403 .await
404 .expect("ok");
405 assert!(outcome.print_output.is_empty());
406 assert!(
407 outcome
408 .error
409 .as_deref()
410 .unwrap_or_default()
411 .contains("timed out")
412 );
413 }
414
415 #[tokio::test]
416 async fn mock_runner_overflow_outcome() {
417 let mut err = String::from("[stdout truncated by server: exceeded cap]");
419 let mock = MockRunner::with_outcome(RunCellOutcome {
420 print_output: vec!["A".repeat(64).to_string()],
421 error: Some(std::mem::take(&mut err)),
422 });
423 let outcome = mock
424 .run_cell("print('A'*10**9)", Duration::from_secs(5))
425 .await
426 .expect("ok");
427 assert!(outcome.error.unwrap().contains("truncated"));
428 }
429
430 #[tokio::test]
431 async fn mock_runner_error_path() {
432 let mock =
433 MockRunner::with_error(RunnerErrorStub::InterpreterNotFound("python3".to_owned()));
434 let err = mock
435 .run_cell("print(1)", Duration::from_secs(5))
436 .await
437 .expect_err("should error");
438 match err {
439 RunnerError::InterpreterNotFound(p) => assert_eq!(p, "python3"),
440 other => panic!("unexpected variant: {other:?}"),
441 }
442 }
443
444 #[test]
447 fn subprocess_runner_defaults() {
448 let r = SubprocessRunner::new();
449 assert_eq!(r.python_path, "python3");
450 assert_eq!(r.stdout_cap_bytes, 64 * 1024);
451 assert_eq!(r.stderr_cap_bytes, 16 * 1024);
452 assert_eq!(r.memory_cap_bytes, Some(512 * 1024 * 1024));
453 assert_eq!(r.cpu_seconds_cap, Some(60));
454 }
455}