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>
This commit is contained in:
Franz Bettag
2026-04-28 20:11:11 +03:00
committed by GitHub
co-authored by Ogulcan Celik
parent 22d7bef568
commit 5baf231e07
12 changed files with 1102 additions and 31 deletions
+9
View File
@@ -6,6 +6,15 @@ herdr reads config from:
~/.config/herdr/config.toml
```
Named sessions share this config file. Sessions are runtime/socket namespaces, not workspace replacements; per-session sockets and persistent runtime state are separate:
```text
~/.config/herdr/session.json
~/.config/herdr/sessions/<name>/session.json
```
Use `herdr session list`, `herdr session stop <name>`, and `herdr session delete <name>` to inspect and manage named session namespaces.
print the full default config with:
```bash
+11 -1
View File
@@ -41,7 +41,17 @@ herdr update
herdr
```
by default herdr launches or attaches to a background session server. `ctrl+b q` detaches the client. agents keep running. use `herdr server stop` to stop the server. use `--no-session` for the old single-process mode.
by default herdr launches or attaches to one background session server. `ctrl+b q` detaches the client. agents keep running. use `herdr server stop` to stop the default server. use `--no-session` for the old single-process mode.
named sessions are runtime/socket namespaces for separate persistent herdr servers. they do not replace workspaces; each named session has its own panes, tabs, workspaces, sockets, and session state while sharing the same global config file.
```bash
herdr --session work
herdr --session side-project
herdr session list
herdr session stop work
herdr session delete side-project
```
1. press `n` to create a workspace
2. run an agent in the root pane
+13 -5
View File
@@ -23,13 +23,21 @@ important difference: `pane.run` and `wait agent-status` are **cli conveniences*
- request/response: send one json request per line, read one json response per line
- subscriptions: send `events.subscribe`, receive an ack, then keep the same connection open and continue reading pushed events
named sessions are runtime/socket namespaces, not replacements for herdr workspaces. each named session has its own server sockets and persistent runtime state while config remains global.
socket path resolution order:
1. `HERDR_SOCKET_PATH`
2. `$XDG_RUNTIME_DIR/herdr.sock`
3. `$XDG_CONFIG_HOME/herdr/herdr.sock`
4. `$HOME/.config/herdr/herdr.sock`
5. `/tmp/herdr.sock`
1. explicit `herdr --session <name>`:
`$XDG_CONFIG_HOME/herdr/sessions/<name>/herdr.sock` or `$HOME/.config/herdr/sessions/<name>/herdr.sock`
2. `HERDR_SOCKET_PATH`
3. `HERDR_SESSION=<name>`:
`$XDG_CONFIG_HOME/herdr/sessions/<name>/herdr.sock` or `$HOME/.config/herdr/sessions/<name>/herdr.sock`
4. default session path:
`$XDG_CONFIG_HOME/herdr/herdr.sock` or `$HOME/.config/herdr/herdr.sock`
this means `HERDR_SOCKET_PATH` remains an exact low-level socket override, but an explicit cli `--session <name>` still wins when a command runs inside a pane that inherited `HERDR_SOCKET_PATH`.
session names may contain ASCII letters, numbers, `.`, `_`, and `-`. `default` is reserved for the default session. use `herdr session list`, `herdr session stop <name>`, and `herdr session delete <name>` to inspect and manage session namespaces. `session delete` refuses running sessions and does not delete the default session.
## request and response envelopes
+25 -5
View File
@@ -95,11 +95,7 @@ impl EventHub {
}
pub fn socket_path() -> PathBuf {
if let Ok(path) = std::env::var(SOCKET_PATH_ENV_VAR) {
return PathBuf::from(path);
}
crate::config::config_dir().join("herdr.sock")
crate::session::active_api_socket_path()
}
pub struct ServerHandle {
@@ -1064,6 +1060,8 @@ mod tests {
fn socket_path_prefers_explicit_env_override() {
let _guard = env_lock().lock().unwrap();
let unique = format!("/tmp/herdr-test-{}.sock", std::process::id());
std::env::remove_var(crate::session::SESSION_ENV_VAR);
crate::session::clear_explicit_session_for_test();
std::env::set_var(SOCKET_PATH_ENV_VAR, &unique);
assert_eq!(socket_path(), PathBuf::from(&unique));
std::env::remove_var(SOCKET_PATH_ENV_VAR);
@@ -1075,6 +1073,8 @@ mod tests {
let config_home = unique_test_path("socket-default-config-home");
let runtime_dir = unique_test_path("socket-default-runtime");
std::env::remove_var(SOCKET_PATH_ENV_VAR);
std::env::remove_var(crate::session::SESSION_ENV_VAR);
crate::session::clear_explicit_session_for_test();
std::env::set_var("XDG_CONFIG_HOME", &config_home);
std::env::set_var("XDG_RUNTIME_DIR", &runtime_dir);
@@ -1087,6 +1087,26 @@ mod tests {
std::env::remove_var("XDG_RUNTIME_DIR");
}
#[test]
fn socket_path_uses_named_session_dir() {
let _guard = env_lock().lock().unwrap();
let config_home = unique_test_path("socket-named-config-home");
std::env::remove_var(SOCKET_PATH_ENV_VAR);
crate::session::clear_explicit_session_for_test();
std::env::set_var(crate::session::SESSION_ENV_VAR, "work");
std::env::set_var("XDG_CONFIG_HOME", &config_home);
let expected = config_home
.join(crate::config::app_dir_name())
.join("sessions")
.join("work")
.join("herdr.sock");
assert_eq!(socket_path(), expected);
std::env::remove_var(crate::session::SESSION_ENV_VAR);
std::env::remove_var("XDG_CONFIG_HOME");
}
#[test]
fn restrict_socket_permissions_sets_user_only_mode() {
let dir = unique_test_path("socket-perms");
+113
View File
@@ -37,6 +37,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
"pane" => run_pane_command(&args[2..])?,
"wait" => run_wait_command(&args[2..])?,
"integration" => run_integration_command(&args[2..])?,
"session" => run_session_command(&args[2..])?,
_ => return Ok(CommandOutcome::NotCli),
};
@@ -330,6 +331,27 @@ fn run_integration_command(args: &[String]) -> std::io::Result<i32> {
}
}
fn run_session_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_session_help();
return Ok(2);
};
match subcommand {
"list" => session_list(&args[1..]),
"stop" => session_stop(&args[1..]),
"delete" => session_delete(&args[1..]),
"help" | "--help" | "-h" => {
print_session_help();
Ok(0)
}
_ => {
print_session_help();
Ok(2)
}
}
}
fn server_stop(args: &[String]) -> std::io::Result<i32> {
if !args.is_empty() {
eprintln!("usage: herdr server stop");
@@ -351,6 +373,76 @@ fn server_reload_config(args: &[String]) -> std::io::Result<i32> {
})?)
}
fn session_list(args: &[String]) -> std::io::Result<i32> {
if !args.is_empty() {
eprintln!("usage: herdr session list");
return Ok(2);
}
let sessions = crate::session::list_sessions()?;
_print_json(&serde_json::json!({
"sessions": sessions,
}));
Ok(0)
}
fn session_stop(args: &[String]) -> std::io::Result<i32> {
let Some(name) = args.first() else {
eprintln!("usage: herdr session stop <name>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr session stop <name>");
return Ok(2);
}
let target = match crate::session::parse_target_name(name) {
Ok(target) => target,
Err(message) => {
print_session_error("invalid_session_name", &message);
return Ok(1);
}
};
match crate::session::stop_session(target.as_deref()) {
Ok(session) => {
_print_json(&serde_json::json!({
"stopped": true,
"session": session,
}));
Ok(0)
}
Err(message) => {
print_session_error("session_stop_failed", &message);
Ok(1)
}
}
}
fn session_delete(args: &[String]) -> std::io::Result<i32> {
let Some(name) = args.first() else {
eprintln!("usage: herdr session delete <name>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr session delete <name>");
return Ok(2);
}
match crate::session::delete_session(name) {
Ok(session) => {
_print_json(&serde_json::json!({
"deleted": true,
"session": session,
}));
Ok(0)
}
Err(message) => {
print_session_error("session_delete_failed", &message);
Ok(1)
}
}
}
fn workspace_list(args: &[String]) -> std::io::Result<i32> {
if !args.is_empty() {
eprintln!("usage: herdr workspace list");
@@ -1225,6 +1317,19 @@ fn parse_u64_flag(flag: &str, value: &str) -> std::io::Result<u64> {
.map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}")))
}
fn print_session_error(code: &str, message: &str) {
eprintln!(
"{}",
serde_json::to_string(&serde_json::json!({
"error": {
"code": code,
"message": message,
}
}))
.unwrap()
);
}
fn print_server_help() {
eprintln!("herdr server commands:");
eprintln!(" herdr server run as headless server");
@@ -1293,6 +1398,14 @@ fn print_integration_help() {
eprintln!(" herdr integration uninstall opencode");
}
fn print_session_help() {
eprintln!("herdr session commands:");
eprintln!(" herdr session list");
eprintln!(" herdr session stop <name>");
eprintln!(" herdr session delete <name>");
eprintln!(" use 'default' as <name> to target the default session for stop");
}
fn _print_json<T: Serialize>(value: &T) {
println!("{}", serde_json::to_string(value).unwrap());
}
+2 -2
View File
@@ -11,7 +11,7 @@ const DEFAULT_RETAINED_LOG_FILES: usize = 0;
pub(crate) fn init_file_logging(file_name: &str) {
let Ok(make_writer) = RotatingFileMakeWriter::new(
crate::config::config_dir(),
crate::session::data_dir(),
file_name,
DEFAULT_MAX_LOG_BYTES,
DEFAULT_RETAINED_LOG_FILES,
@@ -31,7 +31,7 @@ pub(crate) fn init_file_logging(file_name: &str) {
}
pub(crate) fn help_log_paths_summary() -> String {
let dir = crate::config::config_dir();
let dir = crate::session::data_dir();
format!(
"{} (plus herdr-client.log, herdr-server.log)",
dir.join("herdr.log").display()
+28 -2
View File
@@ -38,6 +38,7 @@ mod raw_input;
mod release_notes;
mod selection;
mod server;
mod session;
mod sound;
mod terminal_notify;
mod terminal_theme;
@@ -160,7 +161,15 @@ fn random_nested_message() -> &'static str {
}
fn main() -> io::Result<()> {
let args: Vec<String> = std::env::args().collect();
let raw_args: Vec<String> = std::env::args().collect();
let args = match session::configure_from_args(&raw_args) {
Ok(args) => args,
Err(err) => {
eprintln!("error: {err}");
eprintln!("run 'herdr --help' for usage");
std::process::exit(2);
}
};
if let cli::CommandOutcome::Handled(code) = cli::maybe_run(&args)? {
std::process::exit(code);
@@ -189,7 +198,17 @@ fn main() -> io::Result<()> {
if args.iter().any(|a| a == "--help" || a == "-h") {
println!("herdr — terminal workspace manager for AI coding agents");
println!();
println!("Usage: herdr [options] [command]");
println!("Usage: herdr [options]");
println!(" herdr --session <name> [options]");
println!(" herdr update");
println!(" herdr server stop");
println!(" herdr server reload-config");
println!(" herdr workspace <subcommand> ...");
println!(" herdr tab <subcommand> ...");
println!(" herdr pane <subcommand> ...");
println!(" herdr wait <subcommand> ...");
println!(" herdr session <subcommand> ...");
println!(" herdr integration <subcommand> ...");
println!();
println!("Common commands:");
println!(
@@ -228,6 +247,10 @@ fn main() -> io::Result<()> {
" {:<32} {}",
"herdr wait <subcommand>", "Blocking wait helpers over the socket API"
);
println!(
" {:<32} {}",
"herdr session <subcommand>", "Manage named persistent sessions"
);
println!(
" {:<32} {}",
"herdr integration <subcommand>", "Manage built-in agent integrations"
@@ -242,6 +265,7 @@ fn main() -> io::Result<()> {
println!();
println!("Options:");
println!(" --no-session Run monolithically (no server/client, escape hatch)");
println!(" --session <name> Use or create a named persistent session");
println!(" --default-config Print default configuration and exit");
println!(" --version, -V Print version and exit");
println!(" --help, -h Show this help");
@@ -266,6 +290,7 @@ fn main() -> io::Result<()> {
// Reject unknown flags
let known_flags = [
"--no-session",
"--session",
"--version",
"-V",
"--default-config",
@@ -287,6 +312,7 @@ fn main() -> io::Result<()> {
"workspace",
"pane",
"wait",
"session",
"integration",
]
.contains(&arg.as_str())
+1 -1
View File
@@ -5,7 +5,7 @@ use tracing::warn;
use super::snapshot::{parse_snapshot, snapshot_file_version, SessionSnapshot, SNAPSHOT_VERSION};
fn session_path() -> PathBuf {
crate::config::config_dir().join("session.json")
crate::session::data_dir().join("session.json")
}
pub(super) fn save_to_path(path: &Path, snapshot: &SessionSnapshot) -> std::io::Result<()> {
+62 -11
View File
@@ -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()
@@ -204,6 +226,35 @@ mod tests {
assert!(!is_server_listening_at(&path));
}
#[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");
+10 -4
View File
@@ -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"));
}
+569
View File
@@ -0,0 +1,569 @@
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
pub const SESSION_ENV_VAR: &str = "HERDR_SESSION";
pub const DEFAULT_SESSION_NAME: &str = "default";
const MAX_SESSION_NAME_LEN: usize = 64;
const STOP_WAIT_TIMEOUT: Duration = Duration::from_secs(2);
const STOP_WAIT_POLL: Duration = Duration::from_millis(25);
static EXPLICIT_SESSION_REQUESTED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct SessionInfo {
pub name: String,
pub default: bool,
pub running: bool,
pub socket_path: String,
pub session_dir: String,
}
pub fn configure_from_args(args: &[String]) -> Result<Vec<String>, String> {
let mut cleaned = Vec::with_capacity(args.len());
if let Some(program) = args.first() {
cleaned.push(program.clone());
}
let mut requested_session = None;
let mut index = 1;
while index < args.len() {
let arg = &args[index];
if arg == "--session" {
let Some(value) = args.get(index + 1) else {
return Err("missing value for --session".to_string());
};
requested_session = Some(value.clone());
index += 2;
continue;
}
if let Some(value) = arg.strip_prefix("--session=") {
requested_session = Some(value.to_string());
index += 1;
continue;
}
cleaned.push(arg.clone());
index += 1;
}
if let Some(session) = requested_session {
let session = normalize_name(&session)?;
if let Some(session) = session {
std::env::set_var(SESSION_ENV_VAR, session);
} else {
std::env::remove_var(SESSION_ENV_VAR);
}
EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed);
} else if std::env::var_os(crate::api::SOCKET_PATH_ENV_VAR).is_some() {
EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed);
} else if let Ok(session) = std::env::var(SESSION_ENV_VAR) {
if normalize_name(&session)?.is_none() {
std::env::remove_var(SESSION_ENV_VAR);
}
EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed);
} else {
EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed);
}
Ok(cleaned)
}
pub fn active_name() -> Option<String> {
std::env::var(SESSION_ENV_VAR)
.ok()
.filter(|name| name != DEFAULT_SESSION_NAME)
.filter(|name| validate_name(name).is_ok())
}
pub fn explicit_session_requested() -> bool {
EXPLICIT_SESSION_REQUESTED.load(Ordering::Relaxed)
}
#[cfg(test)]
pub(crate) fn clear_explicit_session_for_test() {
EXPLICIT_SESSION_REQUESTED.store(false, Ordering::Relaxed);
}
pub fn data_dir() -> PathBuf {
data_dir_for(active_name().as_deref())
}
pub fn data_dir_for(name: Option<&str>) -> PathBuf {
let config_dir = crate::config::config_dir();
match name {
Some(name) => config_dir.join("sessions").join(name),
None => config_dir,
}
}
pub fn api_socket_path_for(name: Option<&str>) -> PathBuf {
data_dir_for(name).join("herdr.sock")
}
pub fn active_api_socket_path() -> PathBuf {
if explicit_session_requested() {
return api_socket_path_for(active_name().as_deref());
}
if let Ok(path) = std::env::var(crate::api::SOCKET_PATH_ENV_VAR) {
return PathBuf::from(path);
}
api_socket_path_for(active_name().as_deref())
}
pub fn client_socket_path_for(name: Option<&str>) -> PathBuf {
data_dir_for(name).join("herdr-client.sock")
}
pub fn list_sessions() -> std::io::Result<Vec<SessionInfo>> {
let mut sessions = vec![session_info(None)];
let sessions_dir = crate::config::config_dir().join("sessions");
let entries = match std::fs::read_dir(&sessions_dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(sessions),
Err(err) => return Err(err),
};
let mut names = Vec::new();
for entry in entries {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if name != DEFAULT_SESSION_NAME && validate_name(&name).is_ok() {
names.push(name);
}
}
names.sort();
sessions.extend(names.iter().map(|name| session_info(Some(name))));
Ok(sessions)
}
pub fn session_info(name: Option<&str>) -> SessionInfo {
let default = name.is_none();
let display_name = name.unwrap_or(DEFAULT_SESSION_NAME).to_string();
let socket_path = api_socket_path_for(name);
let session_dir = data_dir_for(name);
SessionInfo {
name: display_name,
default,
running: is_running_at(&socket_path),
socket_path: socket_path.display().to_string(),
session_dir: session_dir.display().to_string(),
}
}
pub fn parse_target_name(name: &str) -> Result<Option<String>, String> {
normalize_name(name)
}
pub fn stop_session(name: Option<&str>) -> Result<SessionInfo, String> {
stop_session_with_timeout(name, STOP_WAIT_TIMEOUT)
}
fn stop_session_with_timeout(name: Option<&str>, timeout: Duration) -> Result<SessionInfo, String> {
let socket_path = api_socket_path_for(name);
let request = serde_json::json!({
"id": "cli:session:stop",
"method": "server.stop",
"params": {}
});
let mut stream = UnixStream::connect(&socket_path).map_err(|err| {
format!(
"session {} is not running or cannot be reached at {}: {err}",
name.unwrap_or(DEFAULT_SESSION_NAME),
socket_path.display()
)
})?;
stream
.write_all(request.to_string().as_bytes())
.map_err(|err| err.to_string())?;
stream.write_all(b"\n").map_err(|err| err.to_string())?;
stream.flush().map_err(|err| err.to_string())?;
let mut line = String::new();
BufReader::new(stream)
.read_line(&mut line)
.map_err(|err| err.to_string())?;
let response: serde_json::Value = serde_json::from_str(&line).map_err(|err| err.to_string())?;
if let Some(error) = response.get("error") {
return Err(error.to_string());
}
if !wait_until_stopped(&socket_path, timeout) {
return Err(format!(
"session {} did not stop within {}ms; socket is still reachable at {}",
name.unwrap_or(DEFAULT_SESSION_NAME),
timeout.as_millis(),
socket_path.display()
));
}
Ok(session_info(name))
}
pub fn delete_session(name: &str) -> Result<SessionInfo, String> {
if name == DEFAULT_SESSION_NAME {
return Err("deleting the default session is not supported".to_string());
}
validate_name(name)?;
let socket_path = api_socket_path_for(Some(name));
if is_running_at(&socket_path) {
return Err(format!(
"session {name} is running; stop it before deleting"
));
}
let info = session_info(Some(name));
let dir = data_dir_for(Some(name));
match std::fs::remove_dir_all(&dir) {
Ok(()) => Ok(info),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(info),
Err(err) => Err(err.to_string()),
}
}
fn is_running_at(socket_path: &Path) -> bool {
socket_path.exists() && UnixStream::connect(socket_path).is_ok()
}
fn wait_until_stopped(socket_path: &Path, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if !is_running_at(socket_path) {
return true;
}
std::thread::sleep(STOP_WAIT_POLL);
}
!is_running_at(socket_path)
}
pub fn validate_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("session name cannot be empty".to_string());
}
if name.len() > MAX_SESSION_NAME_LEN {
return Err(format!(
"session name cannot be longer than {MAX_SESSION_NAME_LEN} bytes"
));
}
if name == "." || name == ".." {
return Err("session name cannot be . or ..".to_string());
}
if !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err(
"session name may only contain ASCII letters, numbers, '.', '_' and '-'".to_string(),
);
}
Ok(())
}
fn normalize_name(name: &str) -> Result<Option<String>, String> {
if name == DEFAULT_SESSION_NAME {
return Ok(None);
}
validate_name(name)?;
Ok(Some(name.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[test]
fn configure_from_args_removes_global_session_option() {
let _guard = env_lock().lock().unwrap();
std::env::remove_var(SESSION_ENV_VAR);
clear_explicit_session_for_test();
let args = vec![
"herdr".to_string(),
"--session".to_string(),
"work".to_string(),
"workspace".to_string(),
"list".to_string(),
];
let cleaned = configure_from_args(&args).unwrap();
assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("work"));
assert!(explicit_session_requested());
assert_eq!(cleaned, vec!["herdr", "workspace", "list"]);
std::env::remove_var(SESSION_ENV_VAR);
clear_explicit_session_for_test();
}
#[test]
fn configure_from_args_accepts_equals_form() {
let _guard = env_lock().lock().unwrap();
std::env::remove_var(SESSION_ENV_VAR);
clear_explicit_session_for_test();
let args = vec![
"herdr".to_string(),
"server".to_string(),
"stop".to_string(),
"--session=api".to_string(),
];
let cleaned = configure_from_args(&args).unwrap();
assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("api"));
assert!(explicit_session_requested());
assert_eq!(cleaned, vec!["herdr", "server", "stop"]);
std::env::remove_var(SESSION_ENV_VAR);
clear_explicit_session_for_test();
}
#[test]
fn configure_from_args_maps_default_session_name_to_default_path() {
let _guard = env_lock().lock().unwrap();
let config_home =
std::env::temp_dir().join(format!("herdr-session-default-{}", std::process::id()));
std::env::set_var("XDG_CONFIG_HOME", &config_home);
std::env::set_var(SESSION_ENV_VAR, "work");
clear_explicit_session_for_test();
std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/inherited.sock");
let args = vec![
"herdr".to_string(),
"--session".to_string(),
DEFAULT_SESSION_NAME.to_string(),
"workspace".to_string(),
"list".to_string(),
];
let cleaned = configure_from_args(&args).unwrap();
assert_eq!(cleaned, vec!["herdr", "workspace", "list"]);
assert!(std::env::var(SESSION_ENV_VAR).is_err());
assert!(explicit_session_requested());
assert_eq!(
active_api_socket_path(),
config_home
.join(crate::config::app_dir_name())
.join("herdr.sock")
);
std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var(SESSION_ENV_VAR);
clear_explicit_session_for_test();
std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
}
#[test]
fn env_session_does_not_mark_session_explicit() {
let _guard = env_lock().lock().unwrap();
std::env::set_var(SESSION_ENV_VAR, "env-session");
EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed);
let args = vec![
"herdr".to_string(),
"workspace".to_string(),
"list".to_string(),
];
let cleaned = configure_from_args(&args).unwrap();
assert_eq!(cleaned, vec!["herdr", "workspace", "list"]);
assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("env-session"));
assert!(!explicit_session_requested());
std::env::remove_var(SESSION_ENV_VAR);
}
#[test]
fn env_default_session_name_uses_default_path() {
let _guard = env_lock().lock().unwrap();
let config_home =
std::env::temp_dir().join(format!("herdr-env-session-default-{}", std::process::id()));
std::env::set_var("XDG_CONFIG_HOME", &config_home);
std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
std::env::set_var(SESSION_ENV_VAR, DEFAULT_SESSION_NAME);
EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed);
let args = vec![
"herdr".to_string(),
"workspace".to_string(),
"list".to_string(),
];
let cleaned = configure_from_args(&args).unwrap();
assert_eq!(cleaned, vec!["herdr", "workspace", "list"]);
assert!(std::env::var(SESSION_ENV_VAR).is_err());
assert!(!explicit_session_requested());
assert_eq!(
active_api_socket_path(),
config_home
.join(crate::config::app_dir_name())
.join("herdr.sock")
);
std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var(SESSION_ENV_VAR);
std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
clear_explicit_session_for_test();
}
#[test]
fn explicit_session_socket_ignores_inherited_socket_override() {
let _guard = env_lock().lock().unwrap();
let config_home =
std::env::temp_dir().join(format!("herdr-session-precedence-{}", std::process::id()));
std::env::set_var("XDG_CONFIG_HOME", &config_home);
std::env::set_var(SESSION_ENV_VAR, "work");
EXPLICIT_SESSION_REQUESTED.store(true, Ordering::Relaxed);
std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/inherited.sock");
let path = active_api_socket_path();
assert_eq!(
path,
config_home
.join(crate::config::app_dir_name())
.join("sessions")
.join("work")
.join("herdr.sock")
);
std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var(SESSION_ENV_VAR);
clear_explicit_session_for_test();
std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
}
#[test]
fn env_socket_override_wins_without_explicit_session() {
let _guard = env_lock().lock().unwrap();
std::env::set_var(SESSION_ENV_VAR, "work");
clear_explicit_session_for_test();
std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/explicit.sock");
assert_eq!(
active_api_socket_path(),
PathBuf::from("/tmp/explicit.sock")
);
std::env::remove_var(SESSION_ENV_VAR);
clear_explicit_session_for_test();
std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
}
#[test]
fn env_socket_override_skips_invalid_env_session_validation_without_explicit_session() {
let _guard = env_lock().lock().unwrap();
std::env::set_var(SESSION_ENV_VAR, "bad/name");
clear_explicit_session_for_test();
std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/herdr.sock");
let args = vec![
"herdr".to_string(),
"workspace".to_string(),
"list".to_string(),
];
let cleaned = configure_from_args(&args).unwrap();
assert_eq!(cleaned, vec!["herdr", "workspace", "list"]);
assert!(!explicit_session_requested());
assert_eq!(active_api_socket_path(), PathBuf::from("/tmp/herdr.sock"));
assert_eq!(std::env::var(SESSION_ENV_VAR).as_deref(), Ok("bad/name"));
std::env::remove_var(SESSION_ENV_VAR);
clear_explicit_session_for_test();
std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
}
#[test]
fn stop_session_fails_when_socket_remains_reachable_after_timeout() {
let _guard = env_lock().lock().unwrap();
let config_home = PathBuf::from(format!("/tmp/hs-stop-{}", std::process::id()));
std::env::set_var("XDG_CONFIG_HOME", &config_home);
let session_name = "slow";
let socket_path = api_socket_path_for(Some(session_name));
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let _ = std::fs::remove_file(&socket_path);
let listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap();
listener.set_nonblocking(true).unwrap();
let keep_running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
let keep_running_for_thread = keep_running.clone();
let handle = std::thread::spawn(move || {
while keep_running_for_thread.load(Ordering::Relaxed) {
match listener.accept() {
Ok((mut stream, _)) => {
if let Ok(reader_stream) = stream.try_clone() {
let mut request = String::new();
let _ = BufReader::new(reader_stream).read_line(&mut request);
}
let _ = stream.write_all(b"{\"id\":\"cli:session:stop\",\"result\":{}}\n");
let _ = stream.flush();
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
}
Err(_) => break,
}
}
});
let err = stop_session_with_timeout(Some(session_name), Duration::from_millis(75))
.expect_err("still-running session should fail");
assert!(err.contains("did not stop"), "{err}");
assert!(
err.contains(socket_path.to_string_lossy().as_ref()),
"{err}"
);
keep_running.store(false, Ordering::Relaxed);
handle.join().unwrap();
let _ = std::fs::remove_dir_all(&config_home);
std::env::remove_var("XDG_CONFIG_HOME");
}
#[test]
fn invalid_names_are_rejected() {
let _guard = env_lock().lock().unwrap();
assert!(validate_name("../prod").is_err());
assert!(validate_name("").is_err());
assert!(validate_name("work session").is_err());
}
#[test]
fn parse_default_target_name_maps_to_default_session() {
assert_eq!(parse_target_name(DEFAULT_SESSION_NAME).unwrap(), None);
assert_eq!(parse_target_name("work").unwrap(), Some("work".to_string()));
}
#[test]
fn delete_default_session_is_rejected() {
assert!(delete_session(DEFAULT_SESSION_NAME).is_err());
}
#[test]
fn list_sessions_skips_reserved_default_directory() {
let _guard = env_lock().lock().unwrap();
let config_home =
std::env::temp_dir().join(format!("herdr-session-list-{}", std::process::id()));
let sessions_dir = config_home
.join(crate::config::app_dir_name())
.join("sessions");
std::fs::create_dir_all(sessions_dir.join(DEFAULT_SESSION_NAME)).unwrap();
std::fs::create_dir_all(sessions_dir.join("work")).unwrap();
std::env::set_var("XDG_CONFIG_HOME", &config_home);
std::env::remove_var(SESSION_ENV_VAR);
clear_explicit_session_for_test();
let sessions = list_sessions().unwrap();
let names: Vec<_> = sessions
.iter()
.map(|session| session.name.as_str())
.collect();
assert_eq!(names, vec![DEFAULT_SESSION_NAME, "work"]);
std::fs::remove_dir_all(&config_home).unwrap();
std::env::remove_var("XDG_CONFIG_HOME");
}
}
+259
View File
@@ -29,6 +29,19 @@ struct SpawnedHerdr {
child: Box<dyn Child + Send + Sync>,
}
struct SpawnedServerProcess {
child: std::process::Child,
}
impl Drop for SpawnedServerProcess {
fn drop(&mut self) {
let pid = self.child.id();
let _ = self.child.kill();
let _ = self.child.wait();
unregister_spawned_herdr_pid(Some(pid));
}
}
impl Drop for SpawnedHerdr {
fn drop(&mut self) {
let pid = self.child.process_id();
@@ -71,6 +84,91 @@ fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> Sp
spawn_herdr_with_path(config_home, runtime_dir, socket_path, None)
}
fn app_dir_name() -> &'static str {
if cfg!(debug_assertions) {
"herdr-dev"
} else {
"herdr"
}
}
fn named_session_socket(config_home: &Path, session: &str) -> PathBuf {
config_home
.join(app_dir_name())
.join("sessions")
.join(session)
.join("herdr.sock")
}
fn spawn_named_server(
config_home: &Path,
runtime_dir: &Path,
session: &str,
) -> SpawnedServerProcess {
fs::create_dir_all(config_home.join(app_dir_name())).unwrap();
fs::create_dir_all(runtime_dir).unwrap();
register_runtime_dir(runtime_dir);
fs::write(
config_home.join(app_dir_name()).join("config.toml"),
"onboarding = false\n",
)
.unwrap();
let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
command
.args(["--session", session, "server"])
.env("XDG_CONFIG_HOME", config_home)
.env("XDG_RUNTIME_DIR", runtime_dir)
.env_remove("HERDR_SOCKET_PATH")
.env_remove("HERDR_CLIENT_SOCKET_PATH")
.env_remove("HERDR_ENV")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let child = command.spawn().unwrap();
register_spawned_herdr_pid(Some(child.id()));
SpawnedServerProcess { child }
}
fn run_named_cli(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> std::process::Output {
run_named_cli_with_socket_override(config_home, runtime_dir, args, None)
}
fn run_named_cli_with_socket_override(
config_home: &Path,
runtime_dir: &Path,
args: &[&str],
socket_override: Option<&Path>,
) -> std::process::Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
command
.args(args)
.env("XDG_CONFIG_HOME", config_home)
.env("XDG_RUNTIME_DIR", runtime_dir)
.env_remove("HERDR_CLIENT_SOCKET_PATH")
.env_remove("HERDR_ENV");
if let Some(socket_override) = socket_override {
command.env("HERDR_SOCKET_PATH", socket_override);
} else {
command.env_remove("HERDR_SOCKET_PATH");
}
command.output().unwrap()
}
fn run_named_cli_json(config_home: &Path, runtime_dir: &Path, args: &[&str]) -> serde_json::Value {
let output = run_named_cli(config_home, runtime_dir, args);
assert!(
output.status.success(),
"command failed: herdr {}\nstatus: {:?}\nstderr: {}\nstdout: {}",
args.join(" "),
output.status.code(),
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
serde_json::from_slice(&output.stdout).unwrap()
}
fn spawn_herdr_with_path(
config_home: &Path,
runtime_dir: &Path,
@@ -472,6 +570,7 @@ fn help_commands_exit_successfully() {
&["tab", "-h"],
&["pane", "-h"],
&["wait", "-h"],
&["session", "-h"],
&["integration", "-h"],
];
@@ -511,6 +610,166 @@ fn removed_show_changelog_flag_fails_before_nested_guard() {
);
}
#[test]
fn named_sessions_use_separate_servers_and_workspace_state() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha");
let beta = spawn_named_server(&config_home, &runtime_dir, "beta");
wait_for_socket(
&named_session_socket(&config_home, "alpha"),
Duration::from_secs(5),
);
wait_for_socket(
&named_session_socket(&config_home, "beta"),
Duration::from_secs(5),
);
run_named_cli_json(
&config_home,
&runtime_dir,
&[
"--session",
"alpha",
"workspace",
"create",
"--label",
"alpha-ws",
"--no-focus",
],
);
run_named_cli_json(
&config_home,
&runtime_dir,
&[
"--session",
"beta",
"workspace",
"create",
"--label",
"beta-ws",
"--no-focus",
],
);
let alpha_list = run_named_cli_json(
&config_home,
&runtime_dir,
&["--session", "alpha", "workspace", "list"],
);
let beta_list = run_named_cli_json(
&config_home,
&runtime_dir,
&["--session", "beta", "workspace", "list"],
);
let alpha_labels: Vec<_> = alpha_list["result"]["workspaces"]
.as_array()
.unwrap()
.iter()
.map(|workspace| workspace["label"].as_str().unwrap())
.collect();
let beta_labels: Vec<_> = beta_list["result"]["workspaces"]
.as_array()
.unwrap()
.iter()
.map(|workspace| workspace["label"].as_str().unwrap())
.collect();
assert_eq!(alpha_labels, vec!["alpha-ws"]);
assert_eq!(beta_labels, vec!["beta-ws"]);
let beta_via_explicit_session = run_named_cli_with_socket_override(
&config_home,
&runtime_dir,
&["--session", "beta", "workspace", "list"],
Some(&named_session_socket(&config_home, "alpha")),
);
assert!(
beta_via_explicit_session.status.success(),
"stderr: {}",
String::from_utf8_lossy(&beta_via_explicit_session.stderr)
);
let beta_via_explicit_session: serde_json::Value =
serde_json::from_slice(&beta_via_explicit_session.stdout).unwrap();
let labels_via_explicit: Vec<_> = beta_via_explicit_session["result"]["workspaces"]
.as_array()
.unwrap()
.iter()
.map(|workspace| workspace["label"].as_str().unwrap())
.collect();
assert_eq!(labels_via_explicit, vec!["beta-ws"]);
let sessions = run_named_cli_json(&config_home, &runtime_dir, &["session", "list"]);
let sessions = sessions["sessions"].as_array().unwrap();
let default_session = sessions
.iter()
.find(|session| session["name"] == "default")
.unwrap();
let alpha_session = sessions
.iter()
.find(|session| session["name"] == "alpha")
.unwrap();
let beta_session = sessions
.iter()
.find(|session| session["name"] == "beta")
.unwrap();
assert_eq!(default_session["default"], true);
assert_eq!(default_session["running"], false);
assert_eq!(alpha_session["running"], true);
assert_eq!(beta_session["running"], true);
assert!(alpha_session["socket_path"]
.as_str()
.unwrap()
.ends_with("/sessions/alpha/herdr.sock"));
assert!(beta_session["session_dir"]
.as_str()
.unwrap()
.ends_with("/sessions/beta"));
let delete_running = run_named_cli(&config_home, &runtime_dir, &["session", "delete", "alpha"]);
assert_eq!(delete_running.status.code(), Some(1));
assert!(
String::from_utf8_lossy(&delete_running.stderr).contains("stop it before deleting"),
"stderr: {}",
String::from_utf8_lossy(&delete_running.stderr)
);
let delete_default = run_named_cli(
&config_home,
&runtime_dir,
&["session", "delete", "default"],
);
assert_eq!(delete_default.status.code(), Some(1));
assert!(
String::from_utf8_lossy(&delete_default.stderr).contains("default session"),
"stderr: {}",
String::from_utf8_lossy(&delete_default.stderr)
);
let stopped_alpha =
run_named_cli_json(&config_home, &runtime_dir, &["session", "stop", "alpha"]);
assert_eq!(stopped_alpha["stopped"], true);
assert_eq!(stopped_alpha["session"]["running"], false);
let deleted_alpha =
run_named_cli_json(&config_home, &runtime_dir, &["session", "delete", "alpha"]);
assert_eq!(deleted_alpha["deleted"], true);
assert!(!config_home
.join(app_dir_name())
.join("sessions")
.join("alpha")
.exists());
let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "beta"]);
drop(alpha);
drop(beta);
cleanup_test_base(&base);
}
#[test]
fn integration_commands_honor_socket_override_when_server_is_missing() {
let base = unique_test_dir();