use std::os::unix::net::UnixStream;
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::protocol;
fn runtime_dir() -> PathBuf {
std::env::var("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"))
}
pub fn socket_path(name: &str) -> PathBuf {
runtime_dir().join(format!("ezpn-session-{}.sock", name))
}
pub fn is_alive(path: &std::path::Path) -> bool {
let Ok(mut stream) = UnixStream::connect(path) else {
return false;
};
stream
.set_read_timeout(Some(Duration::from_millis(200)))
.ok();
stream
.set_write_timeout(Some(Duration::from_millis(200)))
.ok();
if protocol::write_msg(&mut stream, protocol::C_PING, &[]).is_err() {
return false;
}
matches!(protocol::read_msg(&mut stream), Ok((protocol::S_PONG, _)))
}
pub enum SessionResolution {
New(String),
AttachExisting(String),
}
pub fn auto_base_name() -> String {
let base = std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "default".to_string());
sanitize(&base)
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect()
}
fn millis_since_epoch() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
pub fn resolve_session_name(preferred: &str, allow_attach: bool) -> SessionResolution {
if let Some(res) = try_slot(preferred, allow_attach) {
return res;
}
for i in 1..=99u32 {
let cand = format!("{preferred}-{i}");
if let Some(res) = try_slot(&cand, allow_attach) {
return res;
}
}
SessionResolution::New(format!(
"{preferred}-{}-{}",
millis_since_epoch(),
std::process::id()
))
}
fn try_slot(name: &str, allow_attach: bool) -> Option<SessionResolution> {
let sock = socket_path(name);
if !sock.exists() {
return Some(SessionResolution::New(name.to_string()));
}
if is_alive(&sock) {
if allow_attach {
return Some(SessionResolution::AttachExisting(name.to_string()));
}
return None;
}
let _ = std::fs::remove_file(&sock);
Some(SessionResolution::New(name.to_string()))
}
#[allow(dead_code)]
pub fn auto_name() -> String {
match resolve_session_name(&auto_base_name(), false) {
SessionResolution::New(n) | SessionResolution::AttachExisting(n) => n,
}
}
pub fn list() -> Vec<(String, PathBuf)> {
let dir = runtime_dir();
let mut sessions = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let fname = entry.file_name().to_string_lossy().into_owned();
if let Some(name) = fname
.strip_prefix("ezpn-session-")
.and_then(|s| s.strip_suffix(".sock"))
{
let path = entry.path();
if is_alive(&path) {
sessions.push((name.to_string(), path));
} else {
let _ = std::fs::remove_file(&path);
}
}
}
}
sessions.sort_by(|a, b| {
let a_mtime = std::fs::metadata(&a.1).and_then(|m| m.modified()).ok();
let b_mtime = std::fs::metadata(&b.1).and_then(|m| m.modified()).ok();
b_mtime.cmp(&a_mtime)
});
sessions
}
pub fn find(name: Option<&str>) -> Option<(String, PathBuf)> {
if let Some(n) = name {
let path = socket_path(n);
if is_alive(&path) {
return Some((n.to_string(), path));
}
return None;
}
list().into_iter().next()
}
pub const READY_FD_ENV: &str = "EZPN_READY_FD";
pub fn spawn_server(session_name: &str, original_args: &[String]) -> anyhow::Result<PathBuf> {
let exe = std::env::current_exe()?;
let sock = socket_path(session_name);
let mut fds = [0i32; 2];
let pipe_ok = unsafe { libc::pipe(fds.as_mut_ptr()) } == 0;
let (read_fd, write_fd) = if pipe_ok { (fds[0], fds[1]) } else { (-1, -1) };
if pipe_ok {
unsafe {
let flags = libc::fcntl(read_fd, libc::F_GETFD);
if flags >= 0 {
libc::fcntl(read_fd, libc::F_SETFD, flags | libc::FD_CLOEXEC);
}
}
}
let mut cmd = Command::new(exe);
cmd.arg("--server").arg(session_name);
for arg in original_args {
cmd.arg(arg);
}
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
if pipe_ok {
cmd.env(READY_FD_ENV, write_fd.to_string());
}
let captured_write_fd = if pipe_ok { write_fd } else { -1 };
unsafe {
cmd.pre_exec(move || {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
if captured_write_fd >= 0 {
let flags = libc::fcntl(captured_write_fd, libc::F_GETFD);
if flags >= 0 {
libc::fcntl(captured_write_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
}
}
Ok(())
});
}
let _child = cmd.spawn()?;
if pipe_ok {
unsafe {
libc::close(write_fd);
}
}
if pipe_ok {
let mut pfd = libc::pollfd {
fd: read_fd,
events: libc::POLLIN,
revents: 0,
};
let rc = unsafe { libc::poll(&mut pfd, 1, 3000) };
if rc > 0 && (pfd.revents & libc::POLLIN) != 0 {
let mut buf = [0u8; 8];
unsafe {
libc::read(read_fd, buf.as_mut_ptr() as *mut _, buf.len());
libc::close(read_fd);
}
if is_alive(&sock) {
return Ok(sock);
}
} else {
unsafe { libc::close(read_fd) };
}
}
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline {
if is_alive(&sock) {
return Ok(sock);
}
std::thread::sleep(Duration::from_millis(50));
}
anyhow::bail!("server did not start within 3 seconds")
}
pub fn cleanup(name: &str) {
let _ = std::fs::remove_file(socket_path(name));
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
prev: Option<String>,
}
impl EnvGuard {
fn set(dir: &std::path::Path) -> Self {
let prev = std::env::var("XDG_RUNTIME_DIR").ok();
std::env::set_var("XDG_RUNTIME_DIR", dir);
Self { prev }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.prev {
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
None => std::env::remove_var("XDG_RUNTIME_DIR"),
}
}
}
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ezpn-test-{}-{}-{}",
tag,
std::process::id(),
millis_since_epoch()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn touch_dead_socket(name: &str) {
let p = socket_path(name);
std::fs::write(&p, b"").unwrap();
}
fn name_of(r: &SessionResolution) -> &str {
match r {
SessionResolution::New(n) | SessionResolution::AttachExisting(n) => n,
}
}
#[test]
fn counter_loop_produces_unique_names() {
let _g = ENV_LOCK.lock().unwrap();
let dir = temp_dir("counter-unique");
let _env = EnvGuard::set(&dir);
let prefix = "proj";
let r1 = resolve_session_name(prefix, true);
assert_eq!(name_of(&r1), "proj", "free slot returns base name");
touch_dead_socket("proj");
let r2 = resolve_session_name(prefix, true);
assert_eq!(
name_of(&r2),
"proj",
"dead socket at base should be reclaimed"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn dead_socket_cleanup_reclaims_slot() {
let _g = ENV_LOCK.lock().unwrap();
let dir = temp_dir("dead-cleanup");
let _env = EnvGuard::set(&dir);
touch_dead_socket("svc");
assert!(socket_path("svc").exists(), "precondition: stale exists");
let r = resolve_session_name("svc", true);
assert_eq!(name_of(&r), "svc");
assert!(matches!(r, SessionResolution::New(_)));
assert!(
!socket_path("svc").exists(),
"stale socket should be cleaned up"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn force_new_does_not_attach_when_slot_dead() {
let _g = ENV_LOCK.lock().unwrap();
let dir = temp_dir("force-new");
let _env = EnvGuard::set(&dir);
touch_dead_socket("app");
let r = resolve_session_name("app", false);
assert!(
matches!(r, SessionResolution::New(ref n) if n == "app"),
"dead socket should be reclaimed even when allow_attach=false"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn fallback_when_all_counter_slots_taken_by_live() {
let _g = ENV_LOCK.lock().unwrap();
let dir = temp_dir("fallback-shape");
let _env = EnvGuard::set(&dir);
let r = resolve_session_name("xyz", true);
assert_eq!(name_of(&r), "xyz");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn auto_base_name_sanitizes_special_chars() {
let n = auto_base_name();
for c in n.chars() {
assert!(
c.is_alphanumeric() || c == '-' || c == '_' || c == '.',
"auto_base_name must be filesystem-safe, got {n:?}"
);
}
}
#[test]
fn pin_overrides_auto_via_resolve() {
let _g = ENV_LOCK.lock().unwrap();
let dir = temp_dir("pin-vs-auto");
let _env = EnvGuard::set(&dir);
let r = resolve_session_name("explicitly-pinned", true);
assert_eq!(name_of(&r), "explicitly-pinned");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn cli_override_beats_pin_via_resolve() {
let _g = ENV_LOCK.lock().unwrap();
let dir = temp_dir("cli-vs-pin");
let _env = EnvGuard::set(&dir);
let r = resolve_session_name("cli-name", true);
assert_eq!(name_of(&r), "cli-name");
std::fs::remove_dir_all(&dir).ok();
}
}