use serde::{Deserialize, Serialize};
use std::path::PathBuf;
const ARF_IPC_SESSIONS_DIR: &str = "ARF_IPC_SESSIONS_DIR";
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionInfo {
pub pid: u32,
pub socket_path: String,
pub r_version: Option<String>,
#[serde(default)]
pub r_home: Option<String>,
pub cwd: String,
pub started_at: String,
#[serde(default)]
pub session_type: Option<SessionType>,
#[serde(default)]
pub log_file: Option<String>,
#[serde(default)]
pub history_session_id: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionType {
Headless,
Interactive,
}
pub fn sessions_dir() -> Option<PathBuf> {
if let Some(override_dir) = std::env::var_os(ARF_IPC_SESSIONS_DIR) {
let path = PathBuf::from(override_dir);
if !path.as_os_str().is_empty() {
return Some(path);
}
}
dirs::cache_dir().map(|d| d.join("arf").join("sessions"))
}
pub fn write_session(info: &SessionInfo) -> std::io::Result<()> {
let dir = sessions_dir().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "cache directory not found")
})?;
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&dir)?;
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(&dir)?;
}
let path = dir.join(format!("{}.json", info.pid));
let json = serde_json::to_string_pretty(info).map_err(std::io::Error::other)?;
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
file.write_all(json.as_bytes())?;
}
#[cfg(not(unix))]
{
std::fs::write(&path, &json)?;
}
log::info!("Session file written: {}", path.display());
Ok(())
}
pub fn clear_session_history_id(pid: u32) {
let Some(dir) = sessions_dir() else { return };
let path = dir.join(format!("{pid}.json"));
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
log::debug!("Could not read session file {}: {}", path.display(), e);
return;
}
};
let mut info: SessionInfo = match serde_json::from_str(&contents) {
Ok(i) => i,
Err(e) => {
log::debug!("Could not parse session file {}: {}", path.display(), e);
return;
}
};
info.history_session_id = None;
info.pid = pid;
if let Err(e) = write_session(&info) {
log::debug!("Could not rewrite session file {}: {}", path.display(), e);
}
}
pub fn remove_session(pid: u32) {
if let Some(dir) = sessions_dir() {
let path = dir.join(format!("{pid}.json"));
if let Err(e) = std::fs::remove_file(&path) {
log::debug!("Could not remove session file {}: {}", path.display(), e);
}
}
}
pub fn list_sessions() -> Vec<SessionInfo> {
let dir = match sessions_dir() {
Some(d) if d.exists() => d,
_ => return Vec::new(),
};
let mut sessions = Vec::new();
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json")
&& let Ok(contents) = std::fs::read_to_string(&path)
&& let Ok(info) = serde_json::from_str::<SessionInfo>(&contents)
{
if is_process_alive(info.pid) {
sessions.push(info);
} else {
let _ = std::fs::remove_file(&path);
}
}
}
sessions
}
pub fn find_session(pid: Option<u32>) -> Option<SessionInfo> {
let sessions = list_sessions();
match pid {
Some(target_pid) => sessions.into_iter().find(|s| s.pid == target_pid),
None => {
if sessions.len() == 1 {
sessions.into_iter().next()
} else {
None
}
}
}
}
fn is_process_alive(pid: u32) -> bool {
#[cfg(unix)]
{
let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
if ret == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(windows)]
{
use std::process::Command;
Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
.output()
.is_ok_and(|o| {
let out = String::from_utf8_lossy(&o.stdout);
out.lines().any(|line| {
line.split(',')
.nth(1)
.is_some_and(|f| f.trim_matches('"') == pid.to_string())
})
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
fn session_info(session_type: Option<SessionType>) -> SessionInfo {
SessionInfo {
pid: 12345,
socket_path: "/tmp/arf.sock".to_string(),
r_version: Some("4.4.1".to_string()),
r_home: None,
cwd: "/tmp".to_string(),
started_at: "2026-01-01T00:00:00+00:00".to_string(),
session_type,
log_file: None,
history_session_id: Some(42),
}
}
#[test]
fn session_type_serializes_and_round_trips() {
let mut info = session_info(Some(SessionType::Headless));
info.r_home = Some("/opt/R/4.4.1/lib/R".to_string());
let json = serde_json::to_value(&info).unwrap();
assert_eq!(json["session_type"], "headless");
assert_eq!(json["r_home"], "/opt/R/4.4.1/lib/R");
let restored: SessionInfo = serde_json::from_value(json).unwrap();
assert_eq!(restored.session_type, Some(SessionType::Headless));
assert_eq!(restored.r_home.as_deref(), Some("/opt/R/4.4.1/lib/R"));
}
#[test]
fn legacy_session_without_session_type_deserializes_as_unknown() {
let json = r#"
{
"pid": 12345,
"socket_path": "/tmp/arf.sock",
"r_version": "4.4.1",
"cwd": "/tmp",
"started_at": "2026-01-01T00:00:00+00:00"
}
"#;
let info: SessionInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.session_type, None);
}
#[test]
fn legacy_session_without_r_home_deserializes_as_unknown() {
let json = r#"
{
"pid": 12345,
"socket_path": "/tmp/arf.sock",
"r_version": "4.4.1",
"cwd": "/tmp",
"started_at": "2026-01-01T00:00:00+00:00"
}
"#;
let info: SessionInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.r_home, None);
let listed = serde_json::to_value(&info).unwrap();
assert!(listed.as_object().unwrap().contains_key("r_home"));
assert!(listed["r_home"].is_null());
}
#[test]
#[serial]
fn clear_session_history_id_preserves_session_type() {
let temp_dir = tempfile::tempdir().unwrap();
let _guard = crate::test_utils::lock_env_var(ARF_IPC_SESSIONS_DIR, temp_dir.path());
let pid = std::process::id();
let info = SessionInfo {
pid,
..session_info(Some(SessionType::Interactive))
};
write_session(&info).unwrap();
clear_session_history_id(pid);
let contents =
std::fs::read_to_string(temp_dir.path().join(format!("{pid}.json"))).unwrap();
let cleared: SessionInfo = serde_json::from_str(&contents).unwrap();
assert_eq!(cleared.history_session_id, None);
assert_eq!(cleared.session_type, Some(SessionType::Interactive));
}
}