mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
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:
co-authored by
Ogulcan Celik
parent
22d7bef568
commit
5baf231e07
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user