use serde::{Deserialize, Serialize};
use std::path::{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,
pub session_type: 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") {
continue;
}
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
match serde_json::from_str::<SessionInfo>(&contents) {
Ok(info) if is_process_alive(info.pid) => sessions.push(info),
Ok(_) => {
let _ = std::fs::remove_file(&path);
}
Err(_) => cleanup_invalid_session_file(&path, &contents),
}
}
sessions
}
fn cleanup_invalid_session_file(path: &Path, contents: &str) {
let Ok(json) = serde_json::from_str::<serde_json::Value>(contents) else {
return;
};
let Some(pid) = json
.get("pid")
.and_then(serde_json::Value::as_u64)
.and_then(|pid| u32::try_from(pid).ok())
else {
return;
};
let Some(filename_pid) = path
.file_stem()
.and_then(|stem| stem.to_str())
.and_then(|stem| stem.parse::<u32>().ok())
else {
return;
};
if pid == filename_pid && !is_process_alive(pid) {
let _ = std::fs::remove_file(path);
}
}
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::*;
fn session_info(session_type: 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(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");
insta::assert_snapshot!(serde_json::to_string_pretty(&json).unwrap(), @r###"
{
"cwd": "/tmp",
"history_session_id": 42,
"log_file": null,
"pid": 12345,
"r_home": "/opt/R/4.4.1/lib/R",
"r_version": "4.4.1",
"session_type": "headless",
"socket_path": "/tmp/arf.sock",
"started_at": "2026-01-01T00:00:00+00:00"
}"###);
let restored: SessionInfo = serde_json::from_value(json).unwrap();
assert_eq!(restored.session_type, SessionType::Headless);
assert_eq!(restored.r_home.as_deref(), Some("/opt/R/4.4.1/lib/R"));
}
#[test]
fn session_type_is_required_in_session_files() {
let json = serde_json::json!({
"pid": 12345,
"socket_path": "/tmp/arf.sock",
"r_version": "4.4.1",
"cwd": "/tmp",
"started_at": "2026-01-01T00:00:00+00:00",
});
assert!(serde_json::from_value::<SessionInfo>(json).is_err());
}
#[test]
fn null_session_type_is_rejected() {
let json = serde_json::json!({
"pid": 12345,
"socket_path": "/tmp/arf.sock",
"r_version": "4.4.1",
"cwd": "/tmp",
"started_at": "2026-01-01T00:00:00+00:00",
"session_type": null,
});
assert!(serde_json::from_value::<SessionInfo>(json).is_err());
}
#[test]
fn list_sessions_removes_dead_legacy_session_file() {
let temp_dir = tempfile::tempdir().unwrap();
let mut guard = crate::test_utils::lock_env();
guard.set(ARF_IPC_SESSIONS_DIR, temp_dir.path());
let pid = std::process::id().saturating_add(1_000_000);
let path = temp_dir.path().join(format!("{pid}.json"));
let json = serde_json::json!({
"pid": pid,
"socket_path": "/tmp/arf.sock",
"r_version": null,
"cwd": "/tmp",
"started_at": "1970-01-01T00:00:00Z",
"r_home": null,
"log_file": null,
"history_session_id": null,
});
std::fs::write(&path, serde_json::to_vec(&json).unwrap()).unwrap();
assert!(list_sessions().is_empty());
assert!(!path.exists());
}
#[test]
fn list_sessions_hides_but_keeps_live_legacy_session_file() {
let temp_dir = tempfile::tempdir().unwrap();
let mut guard = crate::test_utils::lock_env();
guard.set(ARF_IPC_SESSIONS_DIR, temp_dir.path());
let pid = std::process::id();
let path = temp_dir.path().join(format!("{pid}.json"));
let json = serde_json::json!({
"pid": pid,
"socket_path": "/tmp/arf.sock",
"r_version": null,
"cwd": "/tmp",
"started_at": "1970-01-01T00:00:00Z",
"r_home": null,
"log_file": null,
"history_session_id": null,
});
std::fs::write(&path, serde_json::to_vec(&json).unwrap()).unwrap();
assert!(list_sessions().is_empty());
assert!(path.exists());
}
#[test]
fn list_sessions_keeps_legacy_file_when_pid_does_not_match_filename() {
let temp_dir = tempfile::tempdir().unwrap();
let mut guard = crate::test_utils::lock_env();
guard.set(ARF_IPC_SESSIONS_DIR, temp_dir.path());
let pid = std::process::id();
let filename_pid = pid.saturating_add(1_000_000);
let path = temp_dir.path().join(format!("{filename_pid}.json"));
let json = serde_json::json!({
"pid": pid,
"socket_path": "/tmp/arf.sock",
"r_version": null,
"cwd": "/tmp",
"started_at": "1970-01-01T00:00:00Z",
"r_home": null,
"log_file": null,
"history_session_id": null,
});
std::fs::write(&path, serde_json::to_vec(&json).unwrap()).unwrap();
assert!(list_sessions().is_empty());
assert!(path.exists());
}
#[test]
fn legacy_session_without_r_home_deserializes_as_unknown() {
let json = serde_json::json!({
"pid": 12345,
"socket_path": "/tmp/arf.sock",
"r_version": "4.4.1",
"cwd": "/tmp",
"started_at": "2026-01-01T00:00:00+00:00",
"session_type": "interactive",
});
let info: SessionInfo = serde_json::from_value(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]
fn clear_session_history_id_preserves_session_type() {
let temp_dir = tempfile::tempdir().unwrap();
let mut guard = crate::test_utils::lock_env();
guard.set(ARF_IPC_SESSIONS_DIR, temp_dir.path());
let pid = std::process::id();
let info = SessionInfo {
pid,
..session_info(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, SessionType::Interactive);
}
}