use std::io::Write;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Mutex as StdMutex};
use anyhow::{anyhow, bail, Result};
use chrono::Utc;
use dashmap::DashMap;
use nexo_config::types::repl::ReplConfig;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct ReplSession {
pub id: String,
pub runtime: String,
pub cwd: String,
pub spawned_at: chrono::DateTime<Utc>,
pub output_len: u64,
pub exit_code: Option<i32>,
}
#[derive(Debug, Clone)]
pub struct ReplOutput {
pub stdout: String,
pub stderr: String,
pub timed_out: bool,
pub exit_code: Option<i32>,
}
struct ReplProcess {
session: ReplSession,
child: StdMutex<Child>,
stdin: StdMutex<ChildStdin>,
stdout_buf: StdMutex<Vec<u8>>,
stderr_buf: StdMutex<Vec<u8>>,
#[allow(dead_code)]
max_output_bytes: u64,
}
pub struct ReplRegistry {
sessions: DashMap<String, Arc<ReplProcess>>,
config: ReplConfig,
workspace_dir: String,
}
impl ReplRegistry {
pub fn new(config: ReplConfig, workspace_dir: String) -> Self {
Self {
sessions: DashMap::new(),
config,
workspace_dir,
}
}
pub async fn spawn(&self, runtime: &str, cwd: Option<&str>) -> Result<String> {
if !self.config.allowed_runtimes.iter().any(|r| r == runtime) {
bail!(
"runtime '{}' not in allowed list: {:?}",
runtime,
self.config.allowed_runtimes
);
}
if self.sessions.len() >= self.config.max_sessions as usize {
bail!(
"session limit reached ({}) — kill an existing session first",
self.config.max_sessions
);
}
let cwd = cwd.unwrap_or(&self.workspace_dir).to_string();
let (program, args) = match runtime {
"python" => ("python3", vec!["-u", "-q", "-i"]),
"node" => ("node", vec!["-i", "--no-warnings"]),
"bash" => ("bash", vec!["--norc", "--noprofile"]),
other => bail!("unknown runtime: {}", other),
};
let mut child = Command::new(program)
.args(&args)
.current_dir(&cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| anyhow!("failed to spawn {} REPL: {}", runtime, e))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow!("no stdin handle"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("no stdout handle"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| anyhow!("no stderr handle"))?;
let session_id = Uuid::new_v4().to_string();
let session = ReplSession {
id: session_id.clone(),
runtime: runtime.to_string(),
cwd,
spawned_at: Utc::now(),
output_len: 0,
exit_code: None,
};
let proc = Arc::new(ReplProcess {
session,
child: StdMutex::new(child),
stdin: StdMutex::new(stdin),
stdout_buf: StdMutex::new(Vec::new()),
stderr_buf: StdMutex::new(Vec::new()),
max_output_bytes: self.config.max_output_bytes,
});
let cap = self.config.max_output_bytes;
{
let bg = proc.clone();
std::thread::spawn(move || Self::read_stream::<_>(bg, stdout, cap, true));
}
{
let bg = proc.clone();
std::thread::spawn(move || Self::read_stream::<_>(bg, stderr, cap, false));
}
tokio::task::yield_now().await;
self.sessions.insert(session_id.clone(), proc.clone());
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
let _ = proc.read_buffers();
Ok(session_id)
}
fn read_stream<R: std::io::Read>(
proc: Arc<ReplProcess>,
mut stream: R,
cap: u64,
is_stdout: bool,
) {
let mut buf = [0u8; 4096];
loop {
match stream.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let mut guard = if is_stdout {
proc.stdout_buf.lock().unwrap()
} else {
proc.stderr_buf.lock().unwrap()
};
guard.extend_from_slice(&buf[..n]);
let cap_usize = cap as usize;
if guard.len() > cap_usize {
let excess = guard.len() - cap_usize;
guard.drain(..excess);
}
}
}
}
}
pub async fn exec(&self, session_id: &str, code: &str) -> Result<ReplOutput> {
let proc = self
.sessions
.get(session_id)
.ok_or_else(|| anyhow!("session not found: {}", session_id))?;
proc.check_alive()?;
let (out_before, err_before) = proc.read_buffers();
{
let mut stdin = proc.stdin.lock().unwrap();
stdin
.write_all(code.as_bytes())
.map_err(|e| anyhow!("write to REPL stdin failed: {}", e))?;
stdin
.write_all(b"\n")
.map_err(|e| anyhow!("write newline to REPL stdin failed: {}", e))?;
stdin
.flush()
.map_err(|e| anyhow!("flush REPL stdin failed: {}", e))?;
}
let deadline = tokio::time::Duration::from_secs(self.config.timeout_secs as u64);
let result = tokio::time::timeout(
deadline,
proc.wait_for_new_output(out_before.len(), err_before.len()),
)
.await;
match result {
Ok(output) => Ok(output),
Err(_elapsed) => {
let (stdout, stderr) = proc.read_buffers();
Ok(ReplOutput {
stdout: String::from_utf8_lossy(diff(&stdout, out_before.len())).into_owned(),
stderr: String::from_utf8_lossy(diff(&stderr, err_before.len())).into_owned(),
timed_out: true,
exit_code: None,
})
}
}
}
pub async fn read(&self, session_id: &str) -> Result<ReplOutput> {
let proc = self
.sessions
.get(session_id)
.ok_or_else(|| anyhow!("session not found: {}", session_id))?;
proc.check_alive()?;
let (stdout, stderr) = proc.read_buffers();
Ok(ReplOutput {
stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
timed_out: false,
exit_code: None,
})
}
pub async fn kill(&self, session_id: &str) -> Result<()> {
let (_id, proc) = self
.sessions
.remove(session_id)
.ok_or_else(|| anyhow!("session not found: {}", session_id))?;
let mut child = proc.child.lock().unwrap();
child
.kill()
.map_err(|e| anyhow!("kill failed for session {}: {}", session_id, e))?;
Ok(())
}
pub fn list(&self) -> Vec<ReplSession> {
self.sessions
.iter()
.map(|entry| entry.value().session.clone())
.collect()
}
}
impl ReplProcess {
fn check_alive(&self) -> Result<()> {
let mut child = self.child.lock().unwrap();
match child.try_wait() {
Ok(Some(status)) => {
let code = status.code();
bail!(
"session {} terminated (exit code {})",
self.session.id,
code.map_or(-1, |c| c)
);
}
Ok(None) => Ok(()),
Err(e) => Err(anyhow!("process check failed: {}", e)),
}
}
fn read_buffers(&self) -> (Vec<u8>, Vec<u8>) {
let out = self.stdout_buf.lock().unwrap().clone();
let err = self.stderr_buf.lock().unwrap().clone();
(out, err)
}
async fn wait_for_new_output(
&self,
out_before_len: usize,
err_before_len: usize,
) -> ReplOutput {
loop {
{
let mut child = self.child.lock().unwrap();
if let Ok(Some(status)) = child.try_wait() {
let (stdout, stderr) = self.read_buffers();
return ReplOutput {
stdout: String::from_utf8_lossy(diff(&stdout, out_before_len)).into_owned(),
stderr: String::from_utf8_lossy(diff(&stderr, err_before_len)).into_owned(),
timed_out: false,
exit_code: status.code(),
};
}
}
let (stdout, stderr) = self.read_buffers();
if stdout.len() > out_before_len || stderr.len() > err_before_len {
return ReplOutput {
stdout: String::from_utf8_lossy(diff(&stdout, out_before_len)).into_owned(),
stderr: String::from_utf8_lossy(diff(&stderr, err_before_len)).into_owned(),
timed_out: false,
exit_code: None,
};
}
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
}
}
fn diff(data: &[u8], before_len: usize) -> &[u8] {
if data.len() >= before_len {
&data[before_len..]
} else {
data
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command as StdCommand;
fn has_runtime(name: &str) -> bool {
match name {
"python" => StdCommand::new("python3")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false),
"node" => StdCommand::new("node")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false),
"bash" => StdCommand::new("bash")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false),
_ => false,
}
}
fn test_config() -> ReplConfig {
ReplConfig {
enabled: true,
allowed_runtimes: vec!["python".into(), "node".into(), "bash".into()],
max_sessions: 3,
timeout_secs: 5,
max_output_bytes: 65536,
}
}
#[tokio::test]
async fn test_spawn_python_hello_world() {
if !has_runtime("python") {
eprintln!("SKIP: python3 not found");
return;
}
let reg = ReplRegistry::new(test_config(), "/tmp".into());
let sid = reg.spawn("python", None).await.expect("spawn python");
let out = reg
.exec(&sid, "print('hello from repl')")
.await
.expect("exec");
assert!(out.stdout.contains("hello from repl"), "got: {:?}", out);
reg.kill(&sid).await.ok();
}
#[tokio::test]
async fn test_spawn_node_eval() {
if !has_runtime("node") {
eprintln!("SKIP: node not found");
return;
}
let reg = ReplRegistry::new(test_config(), "/tmp".into());
let sid = reg.spawn("node", None).await.expect("spawn node");
let out = reg.exec(&sid, "console.log(1+1)").await.expect("exec");
assert!(out.stdout.contains("2"), "got: {:?}", out);
reg.kill(&sid).await.ok();
}
#[tokio::test]
async fn test_spawn_bash_echo() {
if !has_runtime("bash") {
eprintln!("SKIP: bash not found");
return;
}
let reg = ReplRegistry::new(test_config(), "/tmp".into());
let sid = reg.spawn("bash", None).await.expect("spawn bash");
let out = reg.exec(&sid, "echo okbash").await.expect("exec");
assert!(out.stdout.contains("okbash"), "got: {}", out.stdout);
reg.kill(&sid).await.ok();
}
#[tokio::test]
async fn test_spawn_rejects_disallowed_runtime() {
let mut cfg = test_config();
cfg.allowed_runtimes = vec!["python".into()];
let reg = ReplRegistry::new(cfg, "/tmp".into());
let err = reg.spawn("node", None).await.unwrap_err();
assert!(
err.to_string().contains("not in allowed list"),
"got: {}",
err
);
}
#[tokio::test]
async fn test_session_limit() {
if !has_runtime("bash") {
eprintln!("SKIP: bash not found");
return;
}
let mut cfg = test_config();
cfg.max_sessions = 2;
let reg = ReplRegistry::new(cfg, "/tmp".into());
let s1 = reg.spawn("bash", None).await.expect("spawn 1");
let s2 = reg.spawn("bash", None).await.expect("spawn 2");
let err = reg.spawn("bash", None).await.unwrap_err();
assert!(err.to_string().contains("session limit"), "got: {}", err);
reg.kill(&s1).await.ok();
reg.kill(&s2).await.ok();
}
#[tokio::test]
async fn test_kill_session_then_exec_fails() {
if !has_runtime("bash") {
eprintln!("SKIP: bash not found");
return;
}
let reg = ReplRegistry::new(test_config(), "/tmp".into());
let sid = reg.spawn("bash", None).await.expect("spawn");
reg.kill(&sid).await.expect("kill");
let err = reg.exec(&sid, "echo nope").await.unwrap_err();
assert!(
err.to_string().contains("not found"),
"expected 'session not found', got: {}",
err
);
}
#[tokio::test]
async fn test_list_sessions() {
if !has_runtime("bash") {
eprintln!("SKIP: bash not found");
return;
}
let reg = ReplRegistry::new(test_config(), "/tmp".into());
let s1 = reg.spawn("bash", None).await.expect("spawn 1");
let s2 = reg.spawn("bash", None).await.expect("spawn 2");
let list = reg.list();
assert_eq!(list.len(), 2);
assert!(list.iter().any(|s| s.id == s1));
assert!(list.iter().any(|s| s.id == s2));
reg.kill(&s1).await.ok();
reg.kill(&s2).await.ok();
}
#[tokio::test]
async fn test_timeout_returns_partial_output() {
if !has_runtime("python") {
eprintln!("SKIP: python3 not found");
return;
}
let mut cfg = test_config();
cfg.timeout_secs = 2;
let reg = ReplRegistry::new(cfg, "/tmp".into());
let sid = reg.spawn("python", None).await.expect("spawn python");
let _ = reg.exec(&sid, "x = 1").await.expect("preamble exec");
let out = reg
.exec(&sid, "import time; time.sleep(10)")
.await
.expect("exec");
assert!(out.timed_out, "expected timeout, got: {:?}", out);
reg.kill(&sid).await.ok();
}
#[tokio::test]
async fn test_output_buffer_cap_truncates() {
if !has_runtime("python") {
eprintln!("SKIP: python3 not found");
return;
}
let mut cfg = test_config();
cfg.max_output_bytes = 100;
let reg = ReplRegistry::new(cfg, "/tmp".into());
let sid = reg.spawn("python", None).await.expect("spawn python");
let out = reg.exec(&sid, "print('x' * 500)").await.expect("exec");
let len = out.stdout.len();
assert!(len <= 200, "output should be capped, got {} bytes", len);
reg.kill(&sid).await.ok();
}
}