aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
authorFranz Bettag <franz@bett.ag>2026-04-28 19:11:11 +0200
committerGitHub <noreply@github.com>2026-04-28 20:11:11 +0300
commit5baf231e076ed96bb2aaba245eb53ad0e4501d76 (patch)
treea89d28ad26b26f8b8e519106dc304d4e54d3b39e /src/server
parent22d7bef5680a1d55c28ff6246518d96487e95d5e (diff)
Add named persistent sessions (#57)
* feat: add named persistent sessions * feat: add session management commands * fix: tighten named session stop and override precedence * fix: handle session help and stop test race * test: isolate default session env test --------- Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/server')
-rw-r--r--src/server/autodetect.rs73
-rw-r--r--src/server/headless.rs14
2 files changed, 72 insertions, 15 deletions
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs
index c4a07a2d..8f695467 100644
--- a/src/server/autodetect.rs
+++ b/src/server/autodetect.rs
@@ -12,6 +12,7 @@ use std::io;
use std::os::unix::net::UnixStream;
use std::os::unix::process::CommandExt;
use std::path::Path;
+use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
@@ -85,8 +86,9 @@ fn is_server_listening_at(socket_path: &Path) -> bool {
/// The server process is fully detached:
/// - Runs in its own session (setsid) so it survives the client exiting
/// - Stdin/stdout/stderr are redirected to /dev/null
-/// - Inherits all relevant environment variables
-/// (`HERDR_SOCKET_PATH`, `HERDR_CLIENT_SOCKET_PATH`, `XDG_CONFIG_HOME`, etc.)
+/// - Inherits relevant environment variables (`XDG_CONFIG_HOME`, `HERDR_SESSION`,
+/// socket overrides, etc.), except inherited socket overrides are cleared when
+/// this CLI invocation explicitly selected a session.
///
/// Returns the PID of the spawned server process.
pub fn spawn_server_daemon() -> io::Result<u32> {
@@ -99,7 +101,21 @@ pub fn spawn_server_daemon() -> io::Result<u32> {
info!(exe = %exe.display(), "spawning server daemon");
- let child = Command::new(&exe)
+ let mut command = build_server_daemon_command(exe);
+
+ let child = command.spawn().map_err(|err: io::Error| {
+ io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}"))
+ })?;
+
+ let pid = child.id();
+ info!(pid, "server daemon spawned");
+
+ Ok(pid)
+}
+
+fn build_server_daemon_command(exe: PathBuf) -> Command {
+ let mut command = Command::new(&exe);
+ command
.arg("server")
// Create a new process group so the server survives the parent's exit
// and doesn't receive SIGHUP when the client's terminal closes.
@@ -107,16 +123,15 @@ pub fn spawn_server_daemon() -> io::Result<u32> {
// Redirect stdio to /dev/null
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null())
- .spawn()
- .map_err(|err: io::Error| {
- io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}"))
- })?;
+ .stderr(std::process::Stdio::null());
- let pid = child.id();
- info!(pid, "server daemon spawned");
+ if crate::session::explicit_session_requested() {
+ command
+ .env_remove(crate::api::SOCKET_PATH_ENV_VAR)
+ .env_remove("HERDR_CLIENT_SOCKET_PATH");
+ }
- Ok(pid)
+ command
}
// ---------------------------------------------------------------------------
@@ -187,7 +202,14 @@ pub fn auto_detect_launch() -> io::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
+ use std::ffi::OsStr;
use std::os::unix::net::UnixListener;
+ use std::sync::{Mutex, OnceLock};
+
+ fn env_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ }
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
@@ -205,6 +227,35 @@ mod tests {
}
#[test]
+ fn server_daemon_command_clears_socket_overrides_for_explicit_session() {
+ let _guard = env_lock().lock().unwrap();
+ std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/inherited.sock");
+ std::env::set_var("HERDR_CLIENT_SOCKET_PATH", "/tmp/inherited-client.sock");
+ std::env::remove_var(crate::session::SESSION_ENV_VAR);
+ crate::session::clear_explicit_session_for_test();
+ let args = vec![
+ "herdr".to_string(),
+ "--session".to_string(),
+ "work".to_string(),
+ ];
+ crate::session::configure_from_args(&args).unwrap();
+
+ let command = build_server_daemon_command(PathBuf::from("/tmp/herdr-test"));
+ let envs: Vec<_> = command.get_envs().collect();
+
+ assert!(envs.iter().any(|(key, value)| {
+ *key == OsStr::new(crate::api::SOCKET_PATH_ENV_VAR) && value.is_none()
+ }));
+ assert!(envs.iter().any(|(key, value)| {
+ *key == OsStr::new("HERDR_CLIENT_SOCKET_PATH") && value.is_none()
+ }));
+ std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
+ std::env::remove_var("HERDR_CLIENT_SOCKET_PATH");
+ std::env::remove_var(crate::session::SESSION_ENV_VAR);
+ crate::session::clear_explicit_session_for_test();
+ }
+
+ #[test]
fn is_server_listening_returns_true_for_live_socket() {
let dir = unique_test_dir("live");
std::fs::create_dir_all(&dir).unwrap();
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 5f9aad2c..62cf3ba1 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -152,12 +152,16 @@ fn toast_message_from_state_change(
/// Returns the path for the client protocol socket.
///
/// Contract-aligned override behavior:
-/// 1. If `HERDR_SOCKET_PATH` is set, derive the client socket path from it by
+/// 1. If CLI `--session <name>` is active, use that session's client socket.
+/// 2. If `HERDR_SOCKET_PATH` is set, derive the client socket path from it by
/// inserting `-client` before `.sock` (e.g. `herdr.sock` -> `herdr-client.sock`).
/// This keeps JSON API and client socket overrides consistent.
-/// 2. Otherwise, honor `HERDR_CLIENT_SOCKET_PATH` (legacy/testing fallback).
-/// 3. Otherwise, use the app config directory.
+/// 3. Otherwise, honor `HERDR_CLIENT_SOCKET_PATH` (legacy/testing fallback).
+/// 4. Otherwise, use the active session data directory.
pub fn client_socket_path() -> PathBuf {
+ if crate::session::explicit_session_requested() {
+ return crate::session::client_socket_path_for(crate::session::active_name().as_deref());
+ }
client_socket_path_from_overrides(
std::env::var(api::SOCKET_PATH_ENV_VAR).ok().as_deref(),
std::env::var(CLIENT_SOCKET_PATH_ENV_VAR).ok().as_deref(),
@@ -176,7 +180,7 @@ fn client_socket_path_from_overrides(
return PathBuf::from(client_socket_override);
}
- config::config_dir().join("herdr-client.sock")
+ crate::session::client_socket_path_for(crate::session::active_name().as_deref())
}
fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf {
@@ -2178,6 +2182,8 @@ mod tests {
#[test]
fn client_socket_path_defaults_to_config_dir() {
+ std::env::remove_var(crate::session::SESSION_ENV_VAR);
+ crate::session::clear_explicit_session_for_test();
let path = client_socket_path_from_overrides(None, None);
assert_eq!(path, config::config_dir().join("herdr-client.sock"));
}