1use super::{EvalKernel, EvalLanguage, EvalOutput};
18use async_trait::async_trait;
19use parking_lot::Mutex;
20use std::sync::Arc;
21use std::time::{Duration, Instant};
22use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
23use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout};
24
25const MARKER: &str = "__OXI_EVAL_END__";
26const DEFAULT_MAX_OUTPUT: usize = 256 * 1024;
27const STDERR_TAIL_LIMIT: usize = 16 * 1024;
28
29struct KernelProc {
30 child: Child,
31 stdin: ChildStdin,
32 stdout: BufReader<ChildStdout>,
33}
34
35struct KernelShared {
38 proc: Mutex<Option<KernelProc>>,
39 stderr_tail: Arc<Mutex<String>>,
40 program: &'static str,
41 args: &'static [&'static str],
42}
43
44impl std::fmt::Debug for KernelShared {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct("KernelShared")
47 .field("program", &self.program)
48 .field("alive", &self.proc.lock().is_some())
49 .finish()
50 }
51}
52
53impl KernelShared {
54 fn new(program: &'static str, args: &'static [&'static str]) -> Self {
55 Self {
56 proc: Mutex::new(None),
57 stderr_tail: Arc::new(Mutex::new(String::new())),
58 program,
59 args,
60 }
61 }
62
63 async fn take(&self) -> Result<KernelProc, String> {
66 let existing = self.proc.lock().take();
67 match existing {
68 Some(p) => {
69 let mut p = p;
70 let alive = p.child.try_wait().map_or(true, |s| s.is_none());
71 if alive { Ok(p) } else { self.spawn().await }
72 }
73 None => self.spawn().await,
74 }
75 }
76
77 async fn spawn(&self) -> Result<KernelProc, String> {
78 use tokio::process::Command;
79 let mut cmd = Command::new(self.program);
80 cmd.args(self.args)
81 .stdin(std::process::Stdio::piped())
82 .stdout(std::process::Stdio::piped())
83 .stderr(std::process::Stdio::piped())
84 .kill_on_drop(true);
85 let mut child = cmd
86 .spawn()
87 .map_err(|e| format!("spawn {}: {e}", self.program))?;
88 let stdin = child.stdin.take().ok_or("interpreter: no stdin")?;
89 let stdout = child.stdout.take().ok_or("interpreter: no stdout")?;
90 if let Some(stderr) = child.stderr.take() {
91 drain_stderr(Arc::clone(&self.stderr_tail), stderr);
92 }
93 Ok(KernelProc {
94 child,
95 stdin,
96 stdout: BufReader::new(stdout),
97 })
98 }
99
100 fn clear_tail(&self) {
101 self.stderr_tail.lock().clear();
102 }
103
104 fn snapshot_tail(&self) -> String {
105 self.stderr_tail.lock().clone()
106 }
107}
108
109fn drain_stderr(tail: Arc<Mutex<String>>, stderr: ChildStderr) {
112 tokio::spawn(async move {
113 let mut reader = BufReader::new(stderr);
114 let mut line = String::new();
115 loop {
116 match reader.read_line(&mut line).await {
117 Ok(0) | Err(_) => break,
118 Ok(_) => {
119 let mut buf = tail.lock();
120 if buf.len() + line.len() > STDERR_TAIL_LIMIT {
121 buf.clear();
122 }
123 buf.push_str(&line);
124 drop(buf);
125 line.clear();
126 }
127 }
128 }
129 });
130}
131
132fn last_nonempty(s: &str) -> &str {
133 s.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("")
134}
135
136async fn write_and_read(
139 shared: &KernelShared,
140 proc: &mut KernelProc,
141 cell: &str,
142 timeout: Duration,
143 max_output: usize,
144) -> Result<(String, bool, bool), String> {
145 let deadline = Instant::now() + timeout;
146 if let Err(e) = proc.stdin.write_all(cell.as_bytes()).await {
147 let msg = format!("{} stdin write: {e}", shared.program);
148 let _ = proc.child.kill().await;
149 return Err(msg);
150 }
151 if let Err(e) = proc.stdin.flush().await {
152 let msg = format!("{} stdin flush: {e}", shared.program);
153 let _ = proc.child.kill().await;
154 return Err(msg);
155 }
156 let mut stdout = String::new();
157 let mut truncated = false;
158 loop {
159 if Instant::now() >= deadline {
160 return Ok((stdout, truncated, false));
161 }
162 let mut line = String::new();
163 let read = tokio::time::timeout_at(
164 tokio::time::Instant::from(deadline),
165 proc.stdout.read_line(&mut line),
166 )
167 .await;
168 match read {
169 Err(_elapsed) => return Ok((stdout, truncated, false)),
170 Ok(Err(e)) => {
171 let msg = format!("{} stdout read: {e}", shared.program);
172 let _ = proc.child.kill().await;
173 return Err(msg);
174 }
175 Ok(Ok(0)) => {
176 let msg = format!("{} exited mid-cell", shared.program);
177 let _ = proc.child.kill().await;
178 return Err(msg);
179 }
180 Ok(Ok(_)) => {
181 if line.trim_end().ends_with(MARKER) {
182 return Ok((stdout, truncated, true));
183 }
184 if stdout.len() + line.len() > max_output {
185 truncated = true;
186 } else {
187 stdout.push_str(&line);
188 }
189 }
190 }
191 }
192}
193
194#[derive(Debug)]
198pub struct PythonEvalKernel {
199 shared: KernelShared,
200 max_output: usize,
201}
202
203impl Default for PythonEvalKernel {
204 fn default() -> Self {
205 Self::new()
206 }
207}
208
209impl PythonEvalKernel {
210 pub fn new() -> Self {
212 Self {
213 shared: KernelShared::new("python3", &["-q", "-u", "-i"]),
214 max_output: DEFAULT_MAX_OUTPUT,
215 }
216 }
217
218 pub fn with_max_output(mut self, max: usize) -> Self {
220 self.max_output = max;
221 self
222 }
223}
224
225#[async_trait]
226impl EvalKernel for PythonEvalKernel {
227 fn language(&self) -> EvalLanguage {
228 EvalLanguage::Python
229 }
230
231 async fn execute(&self, code: &str, timeout: Duration) -> Result<EvalOutput, String> {
232 let escaped = code.replace('\\', "\\\\").replace("'''", "\\'\\'\\'");
236 let cell = format!(
237 "exec(compile('''{escaped}'''.encode('utf-8'), 'cell', 'exec'))\nprint(\"{MARKER}\")\n"
238 );
239 self.shared.clear_tail();
240
241 let mut proc = self.shared.take().await?;
242 let (stdout, truncated, completed) =
243 write_and_read(&self.shared, &mut proc, &cell, timeout, self.max_output).await?;
244 tokio::time::sleep(Duration::from_millis(30)).await;
247 let stderr = self.shared.snapshot_tail();
248 if !completed {
249 let _ = proc.child.kill().await;
250 *self.shared.proc.lock() = None;
251 return Err(if stderr.trim().is_empty() {
252 "python cell timed out before completing".to_string()
253 } else {
254 format!("python cell failed: {}", last_nonempty(&stderr))
255 });
256 }
257 let stderr = self.shared.snapshot_tail();
258 *self.shared.proc.lock() = Some(proc);
259 let error = stderr
260 .contains("Traceback")
261 .then(|| last_nonempty(&stderr).to_string());
262 Ok(EvalOutput {
263 result: String::new(),
264 stdout,
265 stderr,
266 error,
267 truncated,
268 })
269 }
270
271 async fn reset(&self) -> Result<(), String> {
272 let taken = self.shared.proc.lock().take();
273 if let Some(mut p) = taken {
274 let _ = p.child.kill().await;
275 }
276 Ok(())
277 }
278}
279
280#[derive(Debug)]
284pub struct JavaScriptEvalKernel {
285 shared: KernelShared,
286 max_output: usize,
287}
288
289impl Default for JavaScriptEvalKernel {
290 fn default() -> Self {
291 Self::new()
292 }
293}
294
295impl JavaScriptEvalKernel {
296 pub fn new() -> Self {
298 let (program, args): (&'static str, &'static [&'static str]) = if runtime_present("node") {
299 ("node", &["-i", "--no-warnings"])
300 } else {
301 ("bun", &["-i"])
302 };
303 Self {
304 shared: KernelShared::new(program, args),
305 max_output: DEFAULT_MAX_OUTPUT,
306 }
307 }
308
309 pub fn with_max_output(mut self, max: usize) -> Self {
311 self.max_output = max;
312 self
313 }
314}
315
316fn runtime_present(program: &str) -> bool {
317 std::process::Command::new(program)
318 .arg("--version")
319 .stdout(std::process::Stdio::null())
320 .stderr(std::process::Stdio::null())
321 .status()
322 .map(|s| s.success())
323 .unwrap_or(false)
324}
325
326#[async_trait]
327impl EvalKernel for JavaScriptEvalKernel {
328 fn language(&self) -> EvalLanguage {
329 EvalLanguage::JavaScript
330 }
331 async fn execute(&self, code: &str, timeout: Duration) -> Result<EvalOutput, String> {
332 let cell = format!("{code}\nconsole.log(\"{MARKER}\");\n");
333 self.shared.clear_tail();
334
335 let mut proc = self.shared.take().await?;
336 let (stdout, truncated, completed) =
337 write_and_read(&self.shared, &mut proc, &cell, timeout, self.max_output).await?;
338 if !completed {
339 let _ = proc.child.kill().await;
340 *self.shared.proc.lock() = None;
341 return Err(format!(
342 "js cell timed out before completing ({})",
343 self.shared.program
344 ));
345 }
346 let stderr = self.shared.snapshot_tail();
347 *self.shared.proc.lock() = Some(proc);
348 let error = if stderr.contains("Uncaught") {
351 Some(last_nonempty(&stderr).to_string())
352 } else if stdout.contains("Uncaught") {
353 Some(
354 stdout
355 .lines()
356 .rev()
357 .find(|l| l.contains("Uncaught"))
358 .unwrap_or_default()
359 .to_string(),
360 )
361 } else {
362 None
363 };
364 Ok(EvalOutput {
365 result: String::new(),
366 stdout,
367 stderr,
368 error,
369 truncated,
370 })
371 }
372
373 async fn reset(&self) -> Result<(), String> {
374 let taken = self.shared.proc.lock().take();
375 if let Some(mut p) = taken {
376 let _ = p.child.kill().await;
377 }
378 Ok(())
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[tokio::test]
387 async fn python_state_persists_and_reports_errors() {
388 let kernel = PythonEvalKernel::new();
389 let first = kernel
390 .execute("fixture_x = 41", Duration::from_secs(15))
391 .await
392 .unwrap();
393 assert!(first.error.is_none(), "cell 1 errored: {:?}", first.stderr);
394 let out = kernel
395 .execute("print(fixture_x + 1)", Duration::from_secs(15))
396 .await
397 .unwrap();
398 assert!(out.error.is_none(), "cell 2 errored: {:?}", out.stderr);
399 assert!(
400 out.stdout.contains("42"),
401 "state must persist: {}",
402 out.stdout
403 );
404 let err = kernel
405 .execute("raise ValueError('oxi-boom')", Duration::from_secs(15))
406 .await
407 .unwrap();
408 assert!(
409 err.error
410 .as_deref()
411 .unwrap_or_default()
412 .contains("oxi-boom")
413 );
414 kernel.reset().await.unwrap();
415 let gone = kernel
416 .execute("print(fixture_x)", Duration::from_secs(15))
417 .await
418 .unwrap();
419 assert!(gone.error.is_some(), "reset must clear the namespace");
420 }
421
422 #[tokio::test]
423 async fn javascript_state_persists() {
424 let kernel = JavaScriptEvalKernel::new();
425 kernel
426 .execute("globalThis.fixture_y = 41", Duration::from_secs(15))
427 .await
428 .unwrap();
429 let out = kernel
430 .execute("console.log(fixture_y + 1)", Duration::from_secs(15))
431 .await
432 .unwrap();
433 assert!(
434 out.stdout.contains("42"),
435 "state must persist: {}",
436 out.stdout
437 );
438 }
439}