use std::io::BufRead;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
fn parse_ipc_json(output: &IpcOutput) -> serde_json::Value {
serde_json::from_str(&output.stdout).unwrap_or_else(|e| {
panic!(
"failed to parse JSON: {e}\nstdout: {}\nstderr: {}",
output.stdout, output.stderr
)
})
}
struct HeadlessProcess {
child: Child,
pid: u32,
_stderr_thread: Option<thread::JoinHandle<()>>,
_stdout_thread: Option<thread::JoinHandle<()>>,
shutdown: Arc<AtomicBool>,
stderr_output: Arc<Mutex<String>>,
stdout_output: Arc<Mutex<String>>,
}
impl HeadlessProcess {
fn spawn() -> Result<Self, String> {
Self::spawn_with_args(&[])
}
fn spawn_with_args(extra_args: &[&str]) -> Result<Self, String> {
Self::spawn_inner(extra_args, None)
}
#[cfg(windows)]
fn spawn_with_creation_flags(extra_args: &[&str], flags: u32) -> Result<Self, String> {
Self::spawn_inner(extra_args, Some(flags))
}
fn spawn_inner(
extra_args: &[&str],
#[allow(unused)] creation_flags: Option<u32>,
) -> Result<Self, String> {
let bin_path = env!("CARGO_BIN_EXE_arf");
let poll_for_readiness = extra_args.contains(&"--quiet")
|| extra_args.contains(&"--json")
|| extra_args.contains(&"--log-file");
let mut cmd = Command::new(bin_path);
cmd.arg("headless");
for arg in extra_args {
cmd.arg(arg);
}
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
#[cfg(windows)]
if let Some(flags) = creation_flags {
use std::os::windows::process::CommandExt;
cmd.creation_flags(flags);
}
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn arf headless: {e}"))?;
let pid = child.id();
let stderr = child.stderr.take().expect("stderr should be piped");
let stdout = child.stdout.take().expect("stdout should be piped");
let stderr_output = Arc::new(Mutex::new(String::new()));
let stdout_output = Arc::new(Mutex::new(String::new()));
let stderr_clone = Arc::clone(&stderr_output);
let stdout_clone = Arc::clone(&stdout_output);
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_clone = Arc::clone(&shutdown);
let shutdown_clone2 = Arc::clone(&shutdown);
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
let mut ready_tx = Some(ready_tx);
let stderr_thread = thread::spawn(move || {
let reader = std::io::BufReader::new(stderr);
for line in reader.lines() {
if shutdown_clone.load(Ordering::Relaxed) {
break;
}
match line {
Ok(line) => {
if line.contains("IPC server listening on:")
&& let Some(tx) = ready_tx.take()
{
let _ = tx.send(());
}
if let Ok(mut output) = stderr_clone.lock() {
output.push_str(&line);
output.push('\n');
}
}
Err(_) => break,
}
}
});
let stdout_thread = thread::spawn(move || {
let mut reader = std::io::BufReader::new(stdout);
let mut buf = String::new();
loop {
if shutdown_clone2.load(Ordering::Relaxed) {
break;
}
buf.clear();
match reader.read_line(&mut buf) {
Ok(0) => break,
Ok(_) => {
if let Ok(mut output) = stdout_clone.lock() {
output.push_str(&buf);
}
}
Err(_) => break,
}
}
});
if poll_for_readiness {
let start = std::time::Instant::now();
let mut last_probe_err = String::new();
loop {
if start.elapsed() > STARTUP_TIMEOUT {
let _ = child.kill();
let server_stderr = stderr_output.lock().map(|s| s.clone()).unwrap_or_default();
return Err(format!(
"Timeout waiting for IPC eval to succeed (polling mode).\n\
Server stderr:\n{server_stderr}\n\
Last probe error:\n{last_probe_err}"
));
}
if let Ok(Some(status)) = child.try_wait() {
let output = stderr_output.lock().map(|s| s.clone()).unwrap_or_default();
return Err(format!(
"Headless process exited early with {status}. Stderr:\n{output}"
));
}
let probe = Command::new(bin_path)
.args([
"ipc",
"eval",
"1",
"--pid",
&pid.to_string(),
"--timeout",
"500",
])
.output();
match probe {
Ok(output) if output.status.success() => break,
Ok(output) => {
last_probe_err = String::from_utf8_lossy(&output.stderr).into_owned();
}
Err(e) => {
last_probe_err = e.to_string();
}
}
std::thread::sleep(Duration::from_millis(100));
}
} else {
match ready_rx.recv_timeout(STARTUP_TIMEOUT) {
Ok(()) => {}
Err(_) => {
let _ = child.kill();
let output = stderr_output.lock().map(|s| s.clone()).unwrap_or_default();
return Err(format!(
"Timeout waiting for headless IPC server to start. Stderr:\n{output}"
));
}
}
}
Ok(HeadlessProcess {
child,
pid,
_stderr_thread: Some(stderr_thread),
_stdout_thread: Some(stdout_thread),
shutdown,
stderr_output,
stdout_output,
})
}
fn ipc_eval(&self, code: &str) -> Result<IpcOutput, String> {
let bin_path = env!("CARGO_BIN_EXE_arf");
let output = Command::new(bin_path)
.args(["ipc", "eval", code, "--pid", &self.pid.to_string()])
.output()
.map_err(|e| format!("Failed to run arf ipc eval: {e}"))?;
Ok(IpcOutput {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
success: output.status.success(),
exit_code: output.status.code(),
})
}
fn ipc_eval_visible(&self, code: &str) -> Result<IpcOutput, String> {
let bin_path = env!("CARGO_BIN_EXE_arf");
let output = Command::new(bin_path)
.args([
"ipc",
"eval",
code,
"--pid",
&self.pid.to_string(),
"--visible",
])
.output()
.map_err(|e| format!("Failed to run arf ipc eval --visible: {e}"))?;
Ok(IpcOutput {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
success: output.status.success(),
exit_code: output.status.code(),
})
}
fn ipc_eval_with_timeout(&self, code: &str, timeout_ms: u64) -> Result<IpcOutput, String> {
let bin_path = env!("CARGO_BIN_EXE_arf");
let output = Command::new(bin_path)
.args([
"ipc",
"eval",
code,
"--pid",
&self.pid.to_string(),
"--timeout",
&timeout_ms.to_string(),
])
.output()
.map_err(|e| format!("Failed to run arf ipc eval --timeout: {e}"))?;
Ok(IpcOutput {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
success: output.status.success(),
exit_code: output.status.code(),
})
}
fn ipc_send(&self, code: &str) -> Result<IpcOutput, String> {
let bin_path = env!("CARGO_BIN_EXE_arf");
let output = Command::new(bin_path)
.args(["ipc", "send", code, "--pid", &self.pid.to_string()])
.output()
.map_err(|e| format!("Failed to run arf ipc send: {e}"))?;
Ok(IpcOutput {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
success: output.status.success(),
exit_code: output.status.code(),
})
}
fn ipc_session(&self) -> Result<IpcOutput, String> {
let bin_path = env!("CARGO_BIN_EXE_arf");
let output = Command::new(bin_path)
.args(["ipc", "session", "--pid", &self.pid.to_string()])
.output()
.map_err(|e| format!("Failed to run arf ipc session: {e}"))?;
Ok(IpcOutput {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
success: output.status.success(),
exit_code: output.status.code(),
})
}
fn ipc_history(&self, extra_args: &[&str]) -> Result<IpcOutput, String> {
let bin_path = env!("CARGO_BIN_EXE_arf");
let pid_str = self.pid.to_string();
let mut args = vec!["ipc", "history", "--pid", &pid_str];
args.extend_from_slice(extra_args);
let output = Command::new(bin_path)
.args(&args)
.output()
.map_err(|e| format!("Failed to run arf ipc history: {e}"))?;
Ok(IpcOutput {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
success: output.status.success(),
exit_code: output.status.code(),
})
}
fn ipc_shutdown(&self) -> Result<IpcOutput, String> {
let bin_path = env!("CARGO_BIN_EXE_arf");
let output = Command::new(bin_path)
.args(["ipc", "shutdown", "--pid", &self.pid.to_string()])
.output()
.map_err(|e| format!("Failed to run arf ipc shutdown: {e}"))?;
Ok(IpcOutput {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
success: output.status.success(),
exit_code: output.status.code(),
})
}
fn wait_for_exit(&mut self, timeout: Duration) -> Result<std::process::ExitStatus, String> {
let start = std::time::Instant::now();
loop {
match self.child.try_wait() {
Ok(Some(status)) => return Ok(status),
Ok(None) => {
if start.elapsed() > timeout {
return Err("Process did not exit within timeout".to_string());
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => return Err(format!("Error waiting for process: {e}")),
}
}
}
fn stderr_output(&self) -> String {
self.stderr_output
.lock()
.map(|s| s.clone())
.unwrap_or_default()
}
fn stdout_output(&self) -> String {
self.stdout_output
.lock()
.map(|s| s.clone())
.unwrap_or_default()
}
fn server_output(&self) -> String {
format!("{}{}", self.stdout_output(), self.stderr_output())
}
}
impl Drop for HeadlessProcess {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Relaxed);
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[derive(Debug)]
struct IpcOutput {
stdout: String,
stderr: String,
success: bool,
exit_code: Option<i32>,
}
#[test]
fn test_headless_starts_and_ipc_ready() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process.ipc_session().expect("ipc session should run");
assert!(
result.success,
"ipc session should succeed. stdout: {}, stderr: {}",
result.stdout, result.stderr
);
let json = parse_ipc_json(&result);
assert_eq!(
json["pid"].as_u64(),
Some(process.pid as u64),
"session should show correct PID: {}",
result.stdout
);
}
#[test]
fn test_headless_eval_value() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process.ipc_eval("1 + 1").expect("eval should run");
assert!(
result.success,
"eval should succeed. stderr: {}",
result.stderr
);
assert!(
result.stdout.contains("[1] 2"),
"should capture R value: {}",
result.stdout
);
}
#[test]
fn test_headless_eval_stdout() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process
.ipc_eval("cat('hello_headless\\n')")
.expect("eval should run");
assert!(
result.success,
"eval should succeed. stderr: {}",
result.stderr
);
assert!(
result.stdout.contains("hello_headless"),
"should capture stdout: {}",
result.stdout
);
}
#[test]
fn test_headless_eval_error() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process
.ipc_eval("stop('headless_error')")
.expect("eval should run");
assert!(
result.success,
"eval should succeed (R errors are in JSON, not exit code). stderr: {}",
result.stderr
);
let json = parse_ipc_json(&result);
assert!(
json["error"]
.as_str()
.is_some_and(|s| s.contains("headless_error")),
"should report error in JSON: {}",
result.stdout
);
}
#[test]
fn test_headless_eval_sequential() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let r1 = process.ipc_eval("x <- 42").expect("first eval should run");
assert!(r1.success, "first eval should succeed");
let r2 = process.ipc_eval("x * 2").expect("second eval should run");
assert!(r2.success, "second eval should succeed");
assert!(
r2.stdout.contains("[1] 84"),
"should see variable from first eval: {}",
r2.stdout
);
}
#[test]
fn test_headless_eval_mixed_output() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process
.ipc_eval("cat('before\\n'); 42")
.expect("eval should run");
assert!(result.success, "eval should succeed");
assert!(
result.stdout.contains("before"),
"should capture stdout: {}",
result.stdout
);
assert!(
result.stdout.contains("[1] 42"),
"should capture value: {}",
result.stdout
);
}
#[test]
fn test_headless_user_input() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process
.ipc_send("invisible(NULL)")
.expect("send should run");
assert!(
result.success,
"send should succeed. stderr: {}",
result.stderr
);
let json = parse_ipc_json(&result);
assert_eq!(
json["accepted"].as_bool(),
Some(true),
"should report acceptance: {}",
result.stdout
);
}
#[test]
fn test_headless_eval_multiline() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let code = r#"f <- function(x) x + 1; f(10)"#;
let result = process.ipc_eval(code).expect("eval should run");
assert!(result.success, "eval should succeed");
assert!(
result.stdout.contains("[1] 11"),
"should evaluate multiline code: {}",
result.stdout
);
}
#[test]
fn test_headless_eval_visible() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process
.ipc_eval_visible("cat('vis_marker_42\\n')")
.expect("visible eval should run");
assert!(
result.success,
"visible eval should succeed. stderr: {}",
result.stderr
);
assert!(
result.stdout.contains("vis_marker_42"),
"JSON-RPC response should capture stdout: {}",
result.stdout
);
std::thread::sleep(std::time::Duration::from_millis(200));
let server_output = process.server_output();
assert!(
server_output.contains("vis_marker_42"),
"visible eval output should appear on headless process output: {}",
server_output
);
}
#[test]
fn test_headless_eval_silent_no_server_output() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process
.ipc_eval("cat('silent_marker_99\\n')")
.expect("eval should run");
assert!(result.success, "eval should succeed");
assert!(
result.stdout.contains("silent_marker_99"),
"JSON-RPC response should capture stdout: {}",
result.stdout
);
std::thread::sleep(std::time::Duration::from_millis(200));
let server_output = process.server_output();
assert!(
!server_output.contains("silent_marker_99"),
"silent eval output should NOT appear on headless process output: {}",
server_output
);
}
#[test]
fn test_headless_vanilla_flag() {
let process =
HeadlessProcess::spawn_with_args(&["--vanilla"]).expect("Failed to spawn with --vanilla");
let result = process.ipc_eval("1 + 1").expect("eval should run");
assert!(
result.success,
"eval should succeed with --vanilla. stderr: {}",
result.stderr
);
assert!(
result.stdout.contains("[1] 2"),
"should return result: {}",
result.stdout
);
}
#[test]
fn test_headless_eval_timeout_sufficient() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process
.ipc_eval_with_timeout("1 + 1", 30000)
.expect("eval with timeout should run");
assert!(result.success, "should succeed: {}", result.stderr);
assert!(
result.stdout.contains("[1] 2"),
"should return result: {}",
result.stdout
);
}
#[test]
fn test_headless_eval_timeout_exceeded() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process
.ipc_eval_with_timeout("Sys.sleep(10)", 1000)
.expect("eval with timeout should run");
assert!(
!result.success,
"should fail due to timeout. stdout: {}, stderr: {}",
result.stdout, result.stderr
);
assert!(
result.stderr.contains("timed out"),
"should mention timeout: {}",
result.stderr
);
}
#[test]
fn test_headless_shutdown_via_ipc() {
let mut process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let session = process.ipc_session().expect("session should work");
assert!(session.success, "should be running");
let result = process.ipc_shutdown().expect("shutdown should run");
assert!(
result.success,
"shutdown should succeed. stderr: {}",
result.stderr
);
let json = parse_ipc_json(&result);
assert_eq!(
json["accepted"].as_bool(),
Some(true),
"should report acceptance: {}",
result.stdout
);
process
.wait_for_exit(Duration::from_secs(10))
.expect("headless process should exit after shutdown");
}
#[test]
fn test_headless_help_does_not_hang() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process
.ipc_eval_with_timeout("?mean", 15000)
.expect("help eval should run");
assert!(
result.success,
"help should succeed without hanging. stderr: {}",
result.stderr
);
assert!(
result.stdout.to_lowercase().contains("mean"),
"help output should contain 'mean': {}",
result.stdout
);
}
#[test]
fn test_headless_plot_does_not_hang() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process
.ipc_eval_with_timeout(
"plot(1:10); dev_name <- names(dev.cur()); dev.off(); cat(dev_name)",
15000,
)
.expect("plot eval should run");
assert!(
result.success,
"plot should succeed without hanging. stderr: {}",
result.stderr
);
let stdout = &result.stdout;
assert!(
stdout.contains("png") || stdout.contains("pdf") || stdout.contains("quartz_off_screen"),
"graphics device should be non-interactive, got: {}",
stdout
);
}
#[test]
fn test_headless_browse_url_does_not_hang() {
let process = HeadlessProcess::spawn().expect("Failed to spawn headless");
let result = process
.ipc_eval_with_timeout("browseURL('https://example.com')", 15000)
.expect("browseURL eval should run");
assert!(
result.success,
"browseURL should succeed without hanging. stderr: {}",
result.stderr
);
assert!(
result.stdout.contains("https://example.com"),
"URL should be captured in stdout: {}",
result.stdout
);
}
#[cfg(unix)]
#[test]
fn test_headless_bind_custom_socket() {
let tmp = tempfile::TempDir::new().expect("create temp dir");
let sock_path = tmp.path().join("custom.sock");
let sock_str = sock_path.display().to_string();
let process = HeadlessProcess::spawn_with_args(&["--bind", &sock_str])
.expect("Failed to spawn headless with --bind");
assert!(
sock_path.exists(),
"custom socket file should exist at: {}",
sock_str
);
let result = process.ipc_eval("1 + 1").expect("eval should work");
assert!(result.success, "eval should succeed: {}", result.stderr);
assert!(
result.stdout.contains("[1] 2"),
"should return result: {}",
result.stdout
);
let stderr = process.stderr_output();
assert!(
stderr.contains(&sock_str),
"stderr should mention custom socket path: {}",
stderr
);
}
#[test]
fn test_headless_pid_file() {
let tmp = tempfile::TempDir::new().expect("create temp dir");
let pid_path = tmp.path().join("arf.pid");
let pid_str = pid_path.display().to_string();
let mut process = HeadlessProcess::spawn_with_args(&["--pid-file", &pid_str])
.expect("Failed to spawn headless with --pid-file");
let start = std::time::Instant::now();
let pid_content = loop {
assert!(
start.elapsed() < Duration::from_secs(5),
"PID file should appear with content at: {}",
pid_str
);
if let Ok(content) = std::fs::read_to_string(&pid_path)
&& !content.is_empty()
{
break content;
}
std::thread::sleep(Duration::from_millis(50));
};
let expected_pid = process.pid.to_string();
assert_eq!(
pid_content.trim(),
expected_pid,
"PID file should contain process PID"
);
let result = process.ipc_shutdown().expect("shutdown should run");
assert!(result.success, "shutdown should succeed");
process
.wait_for_exit(Duration::from_secs(10))
.expect("headless process should exit after shutdown");
assert!(
!pid_path.exists(),
"PID file should be removed after shutdown"
);
}
#[test]
fn test_headless_quiet_mode() {
let process = HeadlessProcess::spawn_with_args(&["--quiet"])
.expect("Failed to spawn headless with --quiet");
let result = process.ipc_eval("1 + 1").expect("eval should work");
assert!(result.success, "eval should succeed: {}", result.stderr);
assert!(
result.stdout.contains("[1] 2"),
"should return result: {}",
result.stdout
);
let stderr = process.stderr_output();
assert!(
!stderr.contains("IPC server listening on:"),
"quiet mode should suppress IPC listening message, got: {}",
stderr
);
assert!(
!stderr.contains("Headless mode ready"),
"quiet mode should suppress ready message, got: {}",
stderr
);
}
#[test]
fn test_headless_log_file() {
let tmp = tempfile::TempDir::new().expect("create temp dir");
let log_path = tmp.path().join("arf.log");
let log_str = log_path.display().to_string();
let process = HeadlessProcess::spawn_with_args(&["--log-file", &log_str])
.expect("Failed to spawn headless with --log-file");
let result = process.ipc_eval("1 + 1").expect("eval should work");
assert!(result.success, "eval should succeed: {}", result.stderr);
assert!(log_path.exists(), "log file should exist at: {}", log_str);
let log_content = std::fs::read_to_string(&log_path).unwrap_or_default();
assert!(
log_content.contains("Headless mode ready"),
"log file should contain status messages (stderr is redirected): {}",
log_content
);
let stderr = process.stderr_output();
assert!(
stderr.trim().is_empty(),
"stderr pipe should be empty when --log-file redirects stderr, but got: {}",
stderr
);
}
#[cfg(unix)]
fn assert_signal_graceful_shutdown(signal: nix::sys::signal::Signal) {
use nix::sys::signal;
use nix::unistd::Pid;
let tmp = tempfile::TempDir::new().expect("create temp dir");
let pid_path = tmp.path().join("arf.pid");
let pid_str = pid_path.display().to_string();
let mut process = HeadlessProcess::spawn_with_args(&["--pid-file", &pid_str])
.expect("Failed to spawn headless with --pid-file");
let start = std::time::Instant::now();
loop {
if start.elapsed() > Duration::from_secs(10) {
panic!(
"Headless mode should become ready.\nServer output:\n{}",
process.server_output()
);
}
if let Ok(Some(status)) = process.child.try_wait() {
panic!(
"Headless process exited early with {status}.\nServer output:\n{}",
process.server_output()
);
}
if process.stderr_output().contains("Headless mode ready") {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(pid_path.exists(), "PID file should exist at: {}", pid_str);
signal::kill(Pid::from_raw(process.pid as i32), signal)
.unwrap_or_else(|e| panic!("failed to send {signal}: {e}"));
let status = process
.wait_for_exit(Duration::from_secs(10))
.unwrap_or_else(|e| panic!("headless process should exit after {signal}: {e}"));
assert!(
status.success(),
"headless process should exit cleanly after {signal}, got: {status}"
);
assert!(
!pid_path.exists(),
"PID file should be removed after {signal} shutdown"
);
}
#[cfg(unix)]
#[test]
fn test_headless_sigterm_shutdown() {
assert_signal_graceful_shutdown(nix::sys::signal::Signal::SIGTERM);
}
#[cfg(unix)]
#[test]
fn test_headless_sighup_shutdown() {
assert_signal_graceful_shutdown(nix::sys::signal::Signal::SIGHUP);
}
#[cfg(unix)]
#[test]
fn test_headless_ctrlc_shutdown() {
assert_signal_graceful_shutdown(nix::sys::signal::Signal::SIGINT);
}
#[cfg(windows)]
#[test]
fn test_headless_ctrlc_shutdown() {
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
let tmp = tempfile::TempDir::new().expect("create temp dir");
let pid_path = tmp.path().join("arf.pid");
let pid_str = pid_path.display().to_string();
let mut process = HeadlessProcess::spawn_with_creation_flags(
&["--pid-file", &pid_str],
CREATE_NEW_PROCESS_GROUP,
)
.expect("Failed to spawn headless with --pid-file");
let start = std::time::Instant::now();
loop {
if start.elapsed() > Duration::from_secs(10) {
panic!(
"Headless mode should become ready.\nServer output:\n{}",
process.server_output()
);
}
if let Ok(Some(status)) = process.child.try_wait() {
panic!(
"Headless process exited early with {status}.\nServer output:\n{}",
process.server_output()
);
}
if process.stderr_output().contains("Headless mode ready") {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(pid_path.exists(), "PID file should exist");
let result = unsafe {
windows_sys::Win32::System::Console::GenerateConsoleCtrlEvent(
windows_sys::Win32::System::Console::CTRL_BREAK_EVENT,
process.pid,
)
};
assert!(
result != 0,
"GenerateConsoleCtrlEvent failed: {}",
std::io::Error::last_os_error()
);
let status = process
.wait_for_exit(Duration::from_secs(10))
.unwrap_or_else(|e| {
panic!(
"headless process should exit after CTRL_BREAK: {e}\nServer output:\n{}",
process.server_output()
)
});
assert!(
status.success(),
"headless process should exit cleanly after CTRL_BREAK, got: {status}\n\
Server output:\n{}",
process.server_output()
);
assert!(
!pid_path.exists(),
"PID file should be removed after CTRL_BREAK shutdown\nServer output:\n{}",
process.server_output()
);
}
#[test]
fn test_headless_history_persistence() {
let tmp = tempfile::TempDir::new().expect("create temp dir");
let history_dir = tmp.path().to_str().unwrap();
let process = HeadlessProcess::spawn_with_args(&["--history-dir", history_dir])
.expect("Failed to spawn headless with --history-dir");
let r1 = process.ipc_eval("1 + 1").expect("eval should run");
assert!(r1.success, "first eval should succeed");
let r2 = process
.ipc_eval("stop('test_error')")
.expect("error eval should run");
assert!(r2.success, "eval should succeed (R error is in JSON)");
let r3 = process
.ipc_send("invisible(NULL)")
.expect("send should run");
assert!(r3.success, "send should succeed");
let _ = process
.ipc_eval(" \n")
.expect("whitespace eval should run");
let _ = process
.ipc_send(" \t ")
.expect("whitespace send should run");
std::thread::sleep(Duration::from_millis(200));
let db_path = tmp.path().join("r.db");
assert!(db_path.exists(), "history database should exist");
let conn =
rusqlite::Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.expect("open history db");
let mut stmt = conn
.prepare(
"SELECT command_line, exit_status, hostname, cwd \
FROM history ORDER BY id",
)
.expect("prepare query");
let rows: Vec<_> = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<i64>>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
))
})
.expect("query")
.collect::<Result<Vec<_>, _>>()
.expect("collect rows");
let success_row = rows.iter().find(|r| r.0 == "1 + 1");
let error_row = rows.iter().find(|r| r.0 == "stop('test_error')");
let send_row = rows.iter().find(|r| r.0 == "invisible(NULL)");
let success_row = success_row.expect("should find '1 + 1' in history");
assert_eq!(
success_row.1,
Some(0),
"successful eval should have exit_status=0"
);
assert!(success_row.2.is_some(), "hostname should be populated");
assert!(success_row.3.is_some(), "cwd should be populated");
let error_row = error_row.expect("should find error command in history");
assert_eq!(error_row.1, Some(1), "error eval should have exit_status=1");
let send_row = send_row.expect("should find send command in history");
assert_eq!(send_row.1, Some(0), "send should have exit_status=0");
let whitespace_rows: Vec<_> = rows.iter().filter(|r| r.0.trim().is_empty()).collect();
assert!(
whitespace_rows.is_empty(),
"whitespace-only commands should not be persisted to history"
);
}
#[test]
fn test_headless_no_history_flag() {
let tmp = tempfile::TempDir::new().expect("create temp dir");
let history_dir = tmp.path().to_str().unwrap();
let process = HeadlessProcess::spawn_with_args(&["--history-dir", history_dir, "--no-history"])
.expect("Failed to spawn headless with --no-history");
let result = process.ipc_eval("1 + 1").expect("eval should run");
assert!(result.success, "eval should succeed");
let db_path = tmp.path().join("r.db");
assert!(
!db_path.exists(),
"history database should not exist with --no-history"
);
}
#[test]
fn test_headless_json_output() {
let process =
HeadlessProcess::spawn_with_args(&["--json"]).expect("Failed to spawn with --json");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while process.stdout_output().trim().is_empty() {
assert!(
std::time::Instant::now() < deadline,
"Timed out waiting for JSON on stdout"
);
std::thread::sleep(Duration::from_millis(50));
}
let stdout = process.stdout_output();
let json: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("Invalid JSON: {e}\nstdout: {stdout}"));
assert_eq!(
json["pid"].as_u64().unwrap() as u32,
process.pid,
"JSON pid should match process PID"
);
assert!(
json["socket_path"].is_string(),
"JSON should have socket_path: {json}"
);
assert!(
json["r_version"].is_string() || json["r_version"].is_null(),
"JSON r_version should be a string or null: {json}"
);
assert!(json["cwd"].is_string(), "JSON should have cwd: {json}");
assert!(
json["started_at"].is_string(),
"JSON should have started_at: {json}"
);
assert!(
json["log_file"].is_null(),
"JSON log_file should be null without --log-file: {json}"
);
assert!(
json["warnings"].is_array(),
"JSON should have warnings array: {json}"
);
let result = process.ipc_eval("1 + 1").expect("eval should work");
assert!(result.success, "eval should succeed: {}", result.stderr);
let stderr = process.stderr_output();
assert!(
!stderr.contains("IPC server listening on:"),
"json mode should suppress IPC listening message, got: {}",
stderr
);
assert!(
!stderr.contains("Headless mode ready"),
"json mode should suppress ready message, got: {}",
stderr
);
}
#[test]
fn test_ipc_history_basic() {
let tmp = tempfile::TempDir::new().expect("create temp dir");
let history_dir = tmp.path().to_str().unwrap();
let process =
HeadlessProcess::spawn_with_args(&["--history-dir", history_dir]).expect("spawn headless");
let r1 = process.ipc_eval("1 + 1").expect("eval 1");
assert!(r1.success);
let r2 = process.ipc_eval("cat('hello')").expect("eval 2");
assert!(r2.success);
std::thread::sleep(Duration::from_millis(200));
let result = process.ipc_history(&[]).expect("history query");
assert!(result.success, "history should succeed: {}", result.stderr);
let json = parse_ipc_json(&result);
let entries = json["entries"].as_array().expect("entries should be array");
assert!(
entries.len() >= 2,
"should have at least 2 entries, got {}: {json}",
entries.len()
);
let commands: Vec<&str> = entries
.iter()
.filter_map(|e| e["command"].as_str())
.collect();
assert!(
commands.contains(&"1 + 1"),
"should contain '1 + 1': {commands:?}"
);
assert!(
commands.contains(&"cat('hello')"),
"should contain cat('hello'): {commands:?}"
);
assert!(
json["session_id"].is_number(),
"should have session_id: {json}"
);
}
#[test]
fn test_ipc_history_limit() {
let tmp = tempfile::TempDir::new().expect("create temp dir");
let history_dir = tmp.path().to_str().unwrap();
let process =
HeadlessProcess::spawn_with_args(&["--history-dir", history_dir]).expect("spawn headless");
for i in 1..=3 {
let r = process
.ipc_eval(&format!("{i} + {i}"))
.expect("eval should run");
assert!(r.success);
}
std::thread::sleep(Duration::from_millis(200));
let result = process
.ipc_history(&["--limit", "2"])
.expect("history query");
assert!(result.success);
let json = parse_ipc_json(&result);
let entries = json["entries"].as_array().expect("entries array");
assert_eq!(entries.len(), 2, "should return exactly 2 entries: {json}");
}
#[test]
fn test_ipc_history_grep() {
let tmp = tempfile::TempDir::new().expect("create temp dir");
let history_dir = tmp.path().to_str().unwrap();
let process =
HeadlessProcess::spawn_with_args(&["--history-dir", history_dir]).expect("spawn headless");
let r1 = process.ipc_eval("print('apple')").expect("eval 1");
assert!(r1.success);
let r2 = process.ipc_eval("cat('banana')").expect("eval 2");
assert!(r2.success);
let r3 = process.ipc_eval("print('apricot')").expect("eval 3");
assert!(r3.success);
std::thread::sleep(Duration::from_millis(200));
let result = process
.ipc_history(&["--grep", "apple"])
.expect("history grep");
assert!(result.success);
let json = parse_ipc_json(&result);
let entries = json["entries"].as_array().expect("entries array");
let commands: Vec<&str> = entries
.iter()
.filter_map(|e| e["command"].as_str())
.collect();
assert!(
commands.iter().all(|c| c.contains("apple")),
"all results should contain 'apple': {commands:?}"
);
assert!(
!commands.iter().any(|c| c.contains("banana")),
"should not contain 'banana': {commands:?}"
);
}
#[test]
fn test_ipc_history_default_session_scoped() {
let tmp = tempfile::TempDir::new().expect("create temp dir");
let history_dir = tmp.path().to_str().unwrap();
let process =
HeadlessProcess::spawn_with_args(&["--history-dir", history_dir]).expect("spawn headless");
let r1 = process.ipc_eval("42").expect("eval");
assert!(r1.success);
std::thread::sleep(Duration::from_millis(200));
let result = process.ipc_history(&[]).expect("history default");
assert!(result.success);
let json = parse_ipc_json(&result);
let entries = json["entries"].as_array().expect("entries array");
assert!(
!entries.is_empty(),
"default query should find entries: {json}"
);
let session_id = json["session_id"].as_i64().expect("session_id");
for entry in entries {
assert_eq!(
entry["session_id"].as_i64(),
Some(session_id),
"all entries should match session_id: {entry}"
);
}
}
#[test]
fn test_ipc_history_metadata() {
let tmp = tempfile::TempDir::new().expect("create temp dir");
let history_dir = tmp.path().to_str().unwrap();
let process =
HeadlessProcess::spawn_with_args(&["--history-dir", history_dir]).expect("spawn headless");
let r1 = process.ipc_eval("1 + 1").expect("eval success");
assert!(r1.success);
let r2 = process.ipc_eval("stop('oops')").expect("eval error");
assert!(r2.success, "eval should succeed (R error is in JSON)");
std::thread::sleep(Duration::from_millis(200));
let result = process.ipc_history(&[]).expect("history query");
assert!(result.success);
let json = parse_ipc_json(&result);
let entries = json["entries"].as_array().expect("entries array");
let success_entry = entries
.iter()
.find(|e| e["command"].as_str() == Some("1 + 1"))
.expect("should find success entry");
assert!(
success_entry["timestamp"].is_string(),
"should have timestamp: {success_entry}"
);
assert!(
success_entry["cwd"].is_string(),
"should have cwd: {success_entry}"
);
assert_eq!(
success_entry["exit_status"].as_i64(),
Some(0),
"success should have exit_status=0: {success_entry}"
);
let error_entry = entries
.iter()
.find(|e| e["command"].as_str() == Some("stop('oops')"))
.expect("should find error entry");
assert_eq!(
error_entry["exit_status"].as_i64(),
Some(1),
"error should have exit_status=1: {error_entry}"
);
}
#[test]
fn test_ipc_history_disabled() {
let tmp = tempfile::TempDir::new().expect("create temp dir");
let history_dir = tmp.path().to_str().unwrap();
let process = HeadlessProcess::spawn_with_args(&["--history-dir", history_dir, "--no-history"])
.expect("spawn headless");
let result = process.ipc_history(&[]).expect("history query");
assert!(
!result.success,
"history should fail when disabled: stdout={}, stderr={}",
result.stdout, result.stderr
);
}
#[test]
fn test_ipc_exit_code_session_not_found() {
let bin_path = env!("CARGO_BIN_EXE_arf");
let fake_pid = std::process::id().saturating_add(900_000).to_string();
let output = Command::new(bin_path)
.args(["ipc", "eval", "1", "--pid", &fake_pid])
.output()
.expect("should run");
assert_eq!(
output.status.code(),
Some(3),
"exit code should be 3 (session)"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json: serde_json::Value = serde_json::from_str(&stderr)
.unwrap_or_else(|e| panic!("stderr should be JSON: {e}\nstderr: {stderr}"));
assert_eq!(json["error"]["code"].as_str(), Some("SESSION_NOT_FOUND"));
assert!(json["error"]["message"].as_str().is_some());
assert!(json["error"]["hint"].as_str().is_some());
}
#[test]
fn test_ipc_list_empty_json() {
let bin_path = env!("CARGO_BIN_EXE_arf");
let output = Command::new(bin_path)
.args(["ipc", "list"])
.output()
.expect("should run");
assert!(output.status.success(), "list should always succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout should be JSON: {e}\nstdout: {stdout}"));
assert!(json["sessions"].is_array(), "should have sessions array");
}
#[test]
fn test_ipc_exit_code_protocol_error() {
let process = HeadlessProcess::spawn().expect("spawn headless");
let result = process
.ipc_eval_with_timeout("Sys.sleep(10)", 500)
.expect("eval should run");
assert!(!result.success, "should fail due to timeout");
assert_eq!(
result.exit_code,
Some(4),
"exit code should be 4 (protocol)"
);
let json: serde_json::Value = serde_json::from_str(&result.stderr)
.unwrap_or_else(|e| panic!("stderr should be JSON: {e}\nstderr: {}", result.stderr));
assert!(
json["error"]["code"].as_str().is_some(),
"should have string error code"
);
assert!(
json["error"]["message"].as_str().is_some(),
"should have message"
);
}