aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-21 16:30:04 +0300
committerGitHub <noreply@github.com>2026-04-21 16:30:04 +0300
commit3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch)
treec457a7cf0af271dea1a412474fad5a33c1b60be8 /tests
parent2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff)
feat: add persistent server-client sessions
Make herdr persistent by default. Launching herdr now starts or reattaches to a background session server. Clients can detach and reattach while panes and agent processes keep running. Session mode now supports multi-client attach, auto-detect startup, and a thin-client/headless-server split. This also refactors the large app, ui, input, pane, config, workspace, and persistence modules into smaller focused submodules, while preserving behavior and colocating tests with the code they exercise. Upgrade notes: - persistence mode is now the default - in-app quit detaches the current client instead of stopping the server - use `herdr server stop` to stop the background session - use `--no-session` for the old single-process behavior - default socket paths now live under the config directory
Diffstat (limited to 'tests')
-rw-r--r--tests/api_ping.rs44
-rw-r--r--tests/auto_detect.rs706
-rw-r--r--tests/cli_wrapper.rs346
-rw-r--r--tests/client_mode.rs1221
-rw-r--r--tests/cross_area.rs1103
-rw-r--r--tests/detach_reattach.rs955
-rw-r--r--tests/multi_client.rs1019
-rw-r--r--tests/server_headless.rs713
-rw-r--r--tests/support/mod.rs418
9 files changed, 6462 insertions, 63 deletions
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index acd7f1b8..b8de04f3 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -1,3 +1,5 @@
+mod support;
+
use std::fs;
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
@@ -7,6 +9,10 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
+use support::{
+ cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
+ unregister_spawned_herdr_pid,
+};
fn unique_test_dir() -> PathBuf {
let nanos = SystemTime::now()
@@ -21,24 +27,31 @@ struct SpawnedHerdr {
child: Box<dyn Child + Send + Sync>,
}
-fn cleanup_spawned_herdr(mut spawned: SpawnedHerdr, base: PathBuf) {
- let pid = spawned.child.process_id();
- let _ = spawned.child.kill();
-
- if let Some(pid) = pid {
- let deadline = Instant::now() + Duration::from_secs(2);
- while Instant::now() < deadline {
- let mut status = 0;
- let result = unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
- if result == pid as libc::pid_t || result == -1 {
- break;
+impl Drop for SpawnedHerdr {
+ fn drop(&mut self) {
+ let pid = self.child.process_id();
+ let _ = self.child.kill();
+
+ if let Some(pid) = pid {
+ let deadline = Instant::now() + Duration::from_secs(2);
+ while Instant::now() < deadline {
+ let mut status = 0;
+ let result =
+ unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
+ if result == pid as libc::pid_t || result == -1 {
+ break;
+ }
+ thread::sleep(Duration::from_millis(20));
}
- thread::sleep(Duration::from_millis(20));
+
+ unregister_spawned_herdr_pid(Some(pid));
}
}
+}
+fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) {
drop(spawned);
- let _ = fs::remove_dir_all(base);
+ cleanup_test_base(&base);
}
fn test_lock() -> MutexGuard<'static, ()> {
@@ -71,6 +84,7 @@ fn spawn_herdr_with_path(
) -> SpawnedHerdr {
fs::create_dir_all(config_home.join("herdr")).unwrap();
fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
fs::write(
config_home.join("herdr/config.toml"),
"onboarding = false\n",
@@ -87,10 +101,11 @@ fn spawn_herdr_with_path(
.unwrap();
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
- cmd.arg("--no-session");
+ cmd.arg("server");
cmd.env("XDG_CONFIG_HOME", config_home);
cmd.env("XDG_RUNTIME_DIR", runtime_dir);
cmd.env("HERDR_SOCKET_PATH", socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
cmd.env("SHELL", "/bin/sh");
cmd.env_remove("HERDR_ENV");
if let Some(path) = path_override {
@@ -98,6 +113,7 @@ fn spawn_herdr_with_path(
}
let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
SpawnedHerdr {
_master: pair.master,
diff --git a/tests/auto_detect.rs b/tests/auto_detect.rs
new file mode 100644
index 00000000..4c92d62c
--- /dev/null
+++ b/tests/auto_detect.rs
@@ -0,0 +1,706 @@
+//! Integration tests for auto-detect launch behavior.
+
+#![cfg(not(target_os = "macos"))]
+
+mod support;
+
+use std::fs;
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::UnixStream;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::{Mutex, MutexGuard, OnceLock};
+use std::thread;
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
+use serde_json::Value;
+use support::{
+ cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
+ unregister_spawned_herdr_pid,
+};
+
+fn unique_test_dir() -> PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ PathBuf::from(format!(
+ "/tmp/herdr-autodetect-test-{}-{nanos}",
+ std::process::id()
+ ))
+}
+
+struct SpawnedHerdr {
+ _master: Box<dyn MasterPty + Send>,
+ child: Box<dyn Child + Send + Sync>,
+}
+
+impl Drop for SpawnedHerdr {
+ fn drop(&mut self) {
+ let pid = self.child.process_id();
+ let _ = self.child.kill();
+
+ if let Some(pid) = pid {
+ let deadline = Instant::now() + Duration::from_secs(2);
+ while Instant::now() < deadline {
+ let mut status = 0;
+ let result =
+ unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
+ if result == pid as libc::pid_t || result == -1 {
+ break;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+
+ unregister_spawned_herdr_pid(Some(pid));
+ }
+ }
+}
+
+fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) {
+ drop(spawned);
+ cleanup_test_base(&base);
+}
+
+fn wait_for_socket(path: &Path, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() && UnixStream::connect(path).is_ok() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("socket did not appear at {}", path.display());
+}
+
+fn test_lock() -> MutexGuard<'static, ()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn spawn_server(
+ config_home: &Path,
+ runtime_dir: &Path,
+ api_socket_path: &Path,
+ _client_socket_path: &Path,
+) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("server");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+/// Spawn `herdr` (no subcommand) — the auto-detect launch path.
+fn spawn_herdr_auto(
+ config_home: &Path,
+ runtime_dir: &Path,
+ api_socket_path: &Path,
+ _client_socket_path: &Path,
+) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ // No subcommand, no --no-session → auto-detect launch
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+/// Spawn `herdr --no-session` — the monolithic escape hatch.
+fn spawn_herdr_no_session(
+ config_home: &Path,
+ runtime_dir: &Path,
+ api_socket_path: &Path,
+) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("--no-session");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+fn ping_socket(socket_path: &Path) -> String {
+ let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
+
+ let request = r#"{"id":"1","method":"ping","params":{}}"#;
+ writeln!(stream, "{}", request).unwrap();
+
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+ response.trim().to_string()
+}
+
+fn run_cli(socket_path: &Path, args: &[&str]) -> std::process::Output {
+ let mut command = Command::new(env!("CARGO_BIN_EXE_herdr"));
+ command.args(args);
+ command.env("HERDR_SOCKET_PATH", socket_path);
+ command.output().unwrap()
+}
+
+fn process_exists(pid: u32) -> bool {
+ let result = unsafe { libc::kill(pid as i32, 0) };
+ if result == 0 {
+ true
+ } else {
+ std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
+ }
+}
+
+fn read_json_line(stream: UnixStream) -> Value {
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+ serde_json::from_str(&response).unwrap()
+}
+
+fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if !process_exists(pid) {
+ return true;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+ false
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+/// Running `herdr` with no server present starts a server
+/// and attaches as client.
+#[test]
+fn auto_detect_no_server_spawns_server_and_attaches() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Ensure no server is running initially.
+ assert!(
+ !api_socket.exists(),
+ "api socket should not exist initially"
+ );
+ assert!(
+ !client_socket.exists(),
+ "client socket should not exist initially"
+ );
+
+ // Run `herdr` (no subcommand) — should auto-detect, spawn server, attach as client.
+ let herdr = spawn_herdr_auto(&config_home, &runtime_dir, &api_socket, &client_socket);
+
+ // Wait for both sockets to appear (server was spawned).
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ // Verify the API socket responds to ping (server is running).
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API socket should respond to ping: {response}"
+ );
+
+ // Verify the client socket accepts connections (server is listening on it).
+ let _stream = UnixStream::connect(&client_socket)
+ .expect("should connect to client socket (server is listening)");
+
+ // Verify the client process is running.
+ let client_pid = herdr.child.process_id().expect("client should have PID");
+ assert!(
+ process_exists(client_pid),
+ "client process should be running"
+ );
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+/// Running `herdr` with a server already running attaches
+/// as client directly (no second server).
+#[test]
+fn auto_detect_server_running_attaches_directly() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Start a server explicitly.
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ let server_pid = server.child.process_id().expect("server should have PID");
+
+ // Verify server is running.
+ assert!(process_exists(server_pid), "server should be running");
+
+ // Run `herdr` (no subcommand) — should detect the running server and attach.
+ let client = spawn_herdr_auto(&config_home, &runtime_dir, &api_socket, &client_socket);
+
+ // Wait a moment for the client to attach.
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify the client is running.
+ let client_pid = client.child.process_id().expect("client should have PID");
+ assert!(
+ process_exists(client_pid),
+ "client process should be running"
+ );
+
+ // Verify the server is still the same one (no second server spawned).
+ assert!(
+ process_exists(server_pid),
+ "original server should still be running"
+ );
+
+ // Verify API still responds.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should still respond to ping: {response}"
+ );
+
+ cleanup_spawned_herdr(client, PathBuf::from("/nonexistent"));
+ cleanup_spawned_herdr(server, base);
+}
+
+/// Socket path resolution is consistent between server and client.
+/// Both derive the client socket from the `HERDR_SOCKET_PATH` override,
+/// so overriding the API socket keeps both endpoints aligned.
+#[test]
+fn auto_detect_socket_path_consistency() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Run `herdr` with custom socket paths.
+ let herdr = spawn_herdr_auto(&config_home, &runtime_dir, &api_socket, &client_socket);
+
+ // Wait for both sockets to appear at the custom paths.
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ // Verify sockets exist at the specified paths.
+ assert!(
+ api_socket.exists(),
+ "API socket should exist at custom path"
+ );
+ assert!(
+ client_socket.exists(),
+ "client socket should exist at custom path"
+ );
+
+ // Verify API responds (server is using the custom API socket path).
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should respond at custom path: {response}"
+ );
+
+ // Verify client socket accepts connections (server is using the custom
+ // client socket path).
+ let _stream = UnixStream::connect(&client_socket)
+ .expect("should connect to client socket at custom path");
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+/// `herdr --no-session` bypasses server/client and runs
+/// monolithically. No server process is spawned. No client socket is created.
+#[test]
+fn no_session_flag_runs_monolithically() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Run `herdr --no-session` — monolithic mode, no server/client.
+ let herdr = spawn_herdr_no_session(&config_home, &runtime_dir, &api_socket);
+
+ // Wait for the API socket (monolithic mode creates it).
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+
+ // Verify the API socket exists and responds.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "monolithic API should respond: {response}"
+ );
+
+ // Verify NO client socket was created — this is the key distinction
+ // between monolithic mode and server/client mode.
+ assert!(
+ !client_socket.exists(),
+ "no client socket should exist in monolithic mode"
+ );
+
+ // Verify the API socket is served by the monolithic process itself,
+ // not by a separate server. We can check this by verifying the client
+ // PID matches what would be serving the socket — in monolithic mode,
+ // there is only one herdr process.
+ let client_pid = herdr.child.process_id().expect("should have PID");
+ assert!(
+ process_exists(client_pid),
+ "monolithic process should be running"
+ );
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+/// CLI subcommands work through the server's JSON API socket.
+#[test]
+fn cli_subcommands_work_through_server() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Start a server.
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ // Test `herdr workspace list` through the server's API socket.
+ let output = run_cli(&api_socket, &["workspace", "list"]);
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ output.status.success(),
+ "workspace list should succeed: stderr={}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ // The response should be valid JSON with a "result" field.
+ assert!(
+ stdout.contains("result"),
+ "workspace list output should contain 'result': {stdout}"
+ );
+
+ // Test `herdr pane list` through the server's API socket.
+ let output = run_cli(&api_socket, &["pane", "list"]);
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ output.status.success(),
+ "pane list should succeed: stderr={}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ assert!(
+ stdout.contains("result"),
+ "pane list output should contain 'result': {stdout}"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+/// Verify that the server spawned by auto-detect
+/// persists after the client exits, and a new `herdr` can reattach.
+#[test]
+fn auto_detect_server_persists_and_reattaches() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Run `herdr` — auto-detect spawns server + attaches client.
+ let mut client1 = spawn_herdr_auto(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ // Verify API responds.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should respond before client exit: {response}"
+ );
+
+ // Kill the first client.
+ let client1_pid = client1.child.process_id().expect("client1 should have PID");
+ let _ = client1.child.kill();
+ let _ = wait_for_pid_exit(client1_pid, Duration::from_secs(2));
+ drop(client1);
+
+ // Wait a moment for the server to process the disconnect.
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify server is still running after client exit — the API should
+ // still respond because the server is a separate daemon process.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should still respond after client exit (server persists): {response}"
+ );
+
+ // The client socket should still exist (server is still listening).
+ assert!(
+ client_socket.exists(),
+ "client socket should still exist after client exit"
+ );
+
+ // Run `herdr` again — should detect the running server and reattach.
+ let client2 = spawn_herdr_auto(&config_home, &runtime_dir, &api_socket, &client_socket);
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify the new client is running.
+ let client2_pid = client2.child.process_id().expect("client2 should have PID");
+ assert!(
+ process_exists(client2_pid),
+ "second client should be running"
+ );
+
+ // Verify API still responds (same server).
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should still respond after reattach: {response}"
+ );
+
+ cleanup_spawned_herdr(client2, PathBuf::from("/nonexistent"));
+ cleanup_test_base(&base);
+}
+
+/// Verify that the default API and client
+/// sockets live in the app config directory when no env override is set.
+#[test]
+fn auto_detect_default_socket_path_from_config_dir() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+
+ // Don't set HERDR_SOCKET_PATH or HERDR_CLIENT_SOCKET_PATH.
+ // The default paths should come from the app config directory, not XDG_RUNTIME_DIR.
+ let app_dir_name = if cfg!(debug_assertions) {
+ "herdr-dev"
+ } else {
+ "herdr"
+ };
+ let api_socket = config_home.join(app_dir_name).join("herdr.sock");
+ let client_socket = config_home.join(app_dir_name).join("herdr-client.sock");
+
+ // Spawn server with XDG_RUNTIME_DIR set to a different directory to prove it is ignored.
+ 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 pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("server");
+ cmd.env("XDG_CONFIG_HOME", &config_home);
+ cmd.env("XDG_RUNTIME_DIR", &runtime_dir);
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+ // Explicitly remove socket overrides to test default path resolution.
+ cmd.env_remove("HERDR_SOCKET_PATH");
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+ let server = SpawnedHerdr {
+ _master: pair.master,
+ child,
+ };
+
+ // Wait for sockets to appear at the default config-dir paths.
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ // Verify both sockets exist.
+ assert!(api_socket.exists(), "API socket should exist in config dir");
+ assert!(
+ client_socket.exists(),
+ "client socket should exist in config dir"
+ );
+
+ // Verify API responds.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should respond at config-dir path: {response}"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+#[test]
+fn auto_detect_respects_nested_guard_before_auto_attach() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ let baseline = read_json_line({
+ let mut stream = UnixStream::connect(&api_socket).unwrap();
+ writeln!(
+ stream,
+ r#"{{"id":"ws_before","method":"workspace.list","params":{{}}}}"#
+ )
+ .unwrap();
+ stream
+ });
+ let baseline_count = baseline["result"]["workspaces"]
+ .as_array()
+ .map(|workspaces| workspaces.len())
+ .unwrap_or(0);
+
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .env("XDG_CONFIG_HOME", &config_home)
+ .env("XDG_RUNTIME_DIR", &runtime_dir)
+ .env("HERDR_SOCKET_PATH", &api_socket)
+ .env_remove("HERDR_CLIENT_SOCKET_PATH")
+ .env("HERDR_ENV", "1")
+ .output()
+ .unwrap();
+
+ assert!(
+ !output.status.success(),
+ "nested launch should fail before auto-attach"
+ );
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("nested herdr is disabled by default"),
+ "stderr should mention nested-launch guard: {stderr}"
+ );
+
+ let after = read_json_line({
+ let mut stream = UnixStream::connect(&api_socket).unwrap();
+ writeln!(
+ stream,
+ r#"{{"id":"ws_after","method":"workspace.list","params":{{}}}}"#
+ )
+ .unwrap();
+ stream
+ });
+ let after_count = after["result"]["workspaces"]
+ .as_array()
+ .map(|workspaces| workspaces.len())
+ .unwrap_or(0);
+ assert_eq!(
+ after_count, baseline_count,
+ "nested launch should not auto-attach or mutate server state"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 8c8574d3..92684bff 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -1,5 +1,7 @@
#![cfg(not(target_os = "macos"))]
+mod support;
+
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
@@ -9,6 +11,10 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
+use support::{
+ cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
+ unregister_spawned_herdr_pid,
+};
fn unique_test_dir() -> PathBuf {
let nanos = SystemTime::now()
@@ -23,24 +29,31 @@ struct SpawnedHerdr {
child: Box<dyn Child + Send + Sync>,
}
-fn cleanup_spawned_herdr(mut spawned: SpawnedHerdr, base: PathBuf) {
- let pid = spawned.child.process_id();
- let _ = spawned.child.kill();
-
- if let Some(pid) = pid {
- let deadline = Instant::now() + Duration::from_secs(2);
- while Instant::now() < deadline {
- let mut status = 0;
- let result = unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
- if result == pid as libc::pid_t || result == -1 {
- break;
+impl Drop for SpawnedHerdr {
+ fn drop(&mut self) {
+ let pid = self.child.process_id();
+ let _ = self.child.kill();
+
+ if let Some(pid) = pid {
+ let deadline = Instant::now() + Duration::from_secs(2);
+ while Instant::now() < deadline {
+ let mut status = 0;
+ let result =
+ unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
+ if result == pid as libc::pid_t || result == -1 {
+ break;
+ }
+ thread::sleep(Duration::from_millis(20));
}
- thread::sleep(Duration::from_millis(20));
+
+ unregister_spawned_herdr_pid(Some(pid));
}
}
+}
+fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) {
drop(spawned);
- let _ = fs::remove_dir_all(base);
+ cleanup_test_base(&base);
}
fn wait_for_socket(path: &Path, timeout: Duration) {
@@ -66,6 +79,7 @@ fn spawn_herdr_with_path(
) -> SpawnedHerdr {
fs::create_dir_all(config_home.join("herdr")).unwrap();
fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
fs::write(
config_home.join("herdr/config.toml"),
"onboarding = false\n",
@@ -82,10 +96,11 @@ fn spawn_herdr_with_path(
.unwrap();
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
- cmd.arg("--no-session");
+ cmd.arg("server");
cmd.env("XDG_CONFIG_HOME", config_home);
cmd.env("XDG_RUNTIME_DIR", runtime_dir);
cmd.env("HERDR_SOCKET_PATH", socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
cmd.env("SHELL", "/bin/sh");
cmd.env_remove("HERDR_ENV");
if let Some(path) = path_override {
@@ -93,6 +108,7 @@ fn spawn_herdr_with_path(
}
let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
SpawnedHerdr {
_master: pair.master,
child,
@@ -106,6 +122,28 @@ fn run_cli(socket_path: &Path, args: &[&str]) -> std::process::Output {
command.output().unwrap()
}
+fn run_cli_json(socket_path: &Path, args: &[&str]) -> serde_json::Value {
+ let output = run_cli(socket_path, 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_or_else(|err| {
+ panic!(
+ "failed to parse JSON response for `herdr {}`: {}\nstdout: {}\nstderr: {}",
+ args.join(" "),
+ err,
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ )
+ })
+}
+
fn process_exists(pid: u32) -> bool {
let result = unsafe { libc::kill(pid as i32, 0) };
if result == 0 {
@@ -126,6 +164,118 @@ fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool {
!process_exists(pid)
}
+fn wait_for_pid_file(pid_file: &Path, timeout: Duration) -> Result<u32, String> {
+ const STABLE_PID_CONTENT_WINDOW: Duration = Duration::from_millis(250);
+
+ let deadline = Instant::now() + timeout;
+ let mut last_contents = String::new();
+ let mut stable_candidate: Option<(String, u32, Instant)> = None;
+
+ while Instant::now() < deadline {
+ if let Ok(contents) = fs::read_to_string(pid_file) {
+ let trimmed = contents.trim().to_string();
+ last_contents = contents;
+
+ if let Ok(pid) = trimmed.parse::<u32>() {
+ match &stable_candidate {
+ Some((candidate_text, candidate_pid, stable_since))
+ if candidate_text == &trimmed && *candidate_pid == pid =>
+ {
+ if stable_since.elapsed() >= STABLE_PID_CONTENT_WINDOW {
+ return Ok(pid);
+ }
+ }
+ _ => {
+ stable_candidate = Some((trimmed, pid, Instant::now()));
+ }
+ }
+ } else {
+ stable_candidate = None;
+ }
+ }
+
+ thread::sleep(Duration::from_millis(25));
+ }
+
+ Err(format!(
+ "pid file {} did not contain stable parseable pid before timeout; last contents={:?}",
+ pid_file.display(),
+ last_contents
+ ))
+}
+
+#[test]
+fn wait_for_pid_file_retries_until_pid_is_written() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let pid_file = base.join("delayed.pid");
+ fs::write(&pid_file, "").unwrap();
+
+ let writer = thread::spawn({
+ let pid_file = pid_file.clone();
+ move || {
+ thread::sleep(Duration::from_millis(100));
+ fs::write(pid_file, "424242\n").unwrap();
+ }
+ });
+
+ let pid = wait_for_pid_file(&pid_file, Duration::from_secs(2)).unwrap();
+ assert_eq!(pid, 424242);
+
+ writer.join().unwrap();
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn wait_for_pid_file_errors_when_file_never_contains_pid() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let pid_file = base.join("empty.pid");
+ fs::write(&pid_file, "").unwrap();
+
+ let err = wait_for_pid_file(&pid_file, Duration::from_millis(150)).unwrap_err();
+ assert!(
+ err.contains("did not contain stable parseable pid"),
+ "unexpected error: {err}"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn wait_for_pid_file_rejects_partial_write_race_until_stable_contents() {
+ let base = unique_test_dir();
+ fs::create_dir_all(&base).unwrap();
+ let pid_file = base.join("partial-race.pid");
+ fs::write(&pid_file, "").unwrap();
+
+ let writer = thread::spawn({
+ let pid_file = pid_file.clone();
+ move || {
+ thread::sleep(Duration::from_millis(40));
+ fs::write(&pid_file, "12").unwrap();
+ thread::sleep(Duration::from_millis(40));
+ fs::write(&pid_file, "123").unwrap();
+ thread::sleep(Duration::from_millis(40));
+ fs::write(&pid_file, "1234").unwrap();
+ thread::sleep(Duration::from_millis(200));
+ fs::write(&pid_file, "424242\n").unwrap();
+ }
+ });
+
+ let start = Instant::now();
+ let pid = wait_for_pid_file(&pid_file, Duration::from_secs(2)).unwrap();
+ assert_eq!(pid, 424242);
+ assert!(
+ start.elapsed() >= Duration::from_millis(300),
+ "helper should wait for stable complete contents, elapsed={:?}",
+ start.elapsed()
+ );
+
+ writer.join().unwrap();
+ cleanup_test_base(&base);
+}
+
fn send_request(socket_path: &Path, json: &str) -> serde_json::Value {
let mut stream = UnixStream::connect(socket_path).unwrap();
stream.write_all(json.as_bytes()).unwrap();
@@ -205,7 +355,106 @@ fn pane_run_sends_one_send_input_request_with_enter_key() {
second_line
);
- fs::remove_dir_all(base).unwrap();
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn integration_commands_honor_socket_override_when_server_is_missing() {
+ let base = unique_test_dir();
+ let home_dir = base.join("home");
+ let extensions_dir = home_dir.join(".pi/agent/extensions");
+ fs::create_dir_all(&extensions_dir).unwrap();
+
+ let runtime_dir = base.join("runtime");
+ fs::create_dir_all(&runtime_dir).unwrap();
+ register_runtime_dir(&runtime_dir);
+ let missing_socket = runtime_dir.join("missing.sock");
+
+ let expected_extension = extensions_dir.join("herdr-agent-state.ts");
+ assert!(
+ !expected_extension.exists(),
+ "test setup should start without extension file"
+ );
+
+ let workspace_list = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["workspace", "list"])
+ .env("HERDR_SOCKET_PATH", &missing_socket)
+ .env("HOME", &home_dir)
+ .output()
+ .unwrap();
+ assert_eq!(workspace_list.status.code(), Some(1));
+
+ let integration_install = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["integration", "install", "pi"])
+ .env("HERDR_SOCKET_PATH", &missing_socket)
+ .env("HOME", &home_dir)
+ .output()
+ .unwrap();
+ assert_eq!(integration_install.status.code(), Some(1));
+ assert!(
+ !expected_extension.exists(),
+ "integration install should not run local install logic when socket is missing"
+ );
+
+ let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["integration", "uninstall", "pi"])
+ .env("HERDR_SOCKET_PATH", &missing_socket)
+ .env("HOME", &home_dir)
+ .output()
+ .unwrap();
+ assert_eq!(integration_uninstall.status.code(), Some(1));
+ assert!(
+ !expected_extension.exists(),
+ "integration uninstall should also be socket-backed when socket is missing"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn server_stop_command_shuts_down_running_server() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+ wait_for_socket(&client_socket, Duration::from_secs(5));
+
+ let stopped = run_cli(&socket_path, &["server", "stop"]);
+ assert!(
+ stopped.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&stopped.stderr)
+ );
+ assert!(
+ stopped.stdout.is_empty(),
+ "server stop should not print stdout: {}",
+ String::from_utf8_lossy(&stopped.stdout)
+ );
+
+ let pid = herdr.child.process_id();
+ let exit_status = herdr.child.wait().unwrap();
+ unregister_spawned_herdr_pid(pid);
+ assert!(exit_status.success(), "server stop should exit cleanly");
+
+ let deadline = Instant::now() + Duration::from_secs(3);
+ while Instant::now() < deadline && (socket_path.exists() || client_socket.exists()) {
+ thread::sleep(Duration::from_millis(25));
+ }
+
+ assert!(
+ !socket_path.exists() || UnixStream::connect(&socket_path).is_err(),
+ "api socket should be removed or stale after server stop"
+ );
+ assert!(
+ !client_socket.exists() || UnixStream::connect(&client_socket).is_err(),
+ "client socket should be removed or stale after server stop"
+ );
+
+ cleanup_spawned_herdr(herdr, base);
}
#[test]
@@ -636,11 +885,9 @@ fn closing_pane_terminates_processes_inside_it() {
}
assert!(pid_file.exists(), "pid file was not created");
- let pid: u32 = fs::read_to_string(&pid_file)
- .unwrap()
- .trim()
- .parse()
- .unwrap();
+ let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| {
+ panic!("failed to read pane child pid: {err}");
+ });
assert!(process_exists(pid), "child process was not running");
let closed = run_cli(&socket_path, &["pane", "close", pane_id]);
@@ -691,11 +938,9 @@ fn closing_workspace_terminates_processes_inside_it() {
}
assert!(pid_file.exists(), "pid file was not created");
- let pid: u32 = fs::read_to_string(&pid_file)
- .unwrap()
- .trim()
- .parse()
- .unwrap();
+ let pid = wait_for_pid_file(&pid_file, Duration::from_secs(3)).unwrap_or_else(|err| {
+ panic!("failed to read pane child pid: {err}");
+ });
assert!(process_exists(pid), "child process was not running");
let closed = run_cli(&socket_path, &["workspace", "close", "1"]);
@@ -722,42 +967,37 @@ fn workspace_ids_are_stable_and_pane_numbers_stay_compact() {
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
- let ws1 = run_cli(
+ let ws1_json = run_cli_json(
&socket_path,
&["workspace", "create", "--cwd", base.to_str().unwrap()],
);
- assert!(ws1.status.success());
- let ws1_json: serde_json::Value = serde_json::from_slice(&ws1.stdout).unwrap();
let ws1_id = ws1_json["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
- let split_12 = run_cli(
+ let split_12_json = run_cli_json(
&socket_path,
&["pane", "split", "1-1", "--direction", "right", "--no-focus"],
);
- let split_12_json: serde_json::Value = serde_json::from_slice(&split_12.stdout).unwrap();
assert_eq!(
split_12_json["result"]["pane"]["pane_id"],
format!("{ws1_id}-2")
);
- let split_13 = run_cli(
+ let split_13_json = run_cli_json(
&socket_path,
&["pane", "split", "1-1", "--direction", "down", "--no-focus"],
);
- let split_13_json: serde_json::Value = serde_json::from_slice(&split_13.stdout).unwrap();
assert_eq!(
split_13_json["result"]["pane"]["pane_id"],
format!("{ws1_id}-3")
);
- let ws2 = run_cli(
+ let ws2_json = run_cli_json(
&socket_path,
&["workspace", "create", "--cwd", "/tmp", "--no-focus"],
);
- let ws2_json: serde_json::Value = serde_json::from_slice(&ws2.stdout).unwrap();
let ws2_id = ws2_json["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
@@ -765,22 +1005,25 @@ fn workspace_ids_are_stable_and_pane_numbers_stay_compact() {
assert_ne!(ws2_id, ws1_id);
let ws2_focus = run_cli(&socket_path, &["workspace", "focus", &ws2_id]);
- assert!(ws2_focus.status.success());
- let ws2_split = run_cli(
+ assert!(
+ ws2_focus.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&ws2_focus.stderr)
+ );
+
+ let ws2_split_json = run_cli_json(
&socket_path,
&["pane", "split", "2-1", "--direction", "right", "--no-focus"],
);
- let ws2_split_json: serde_json::Value = serde_json::from_slice(&ws2_split.stdout).unwrap();
assert_eq!(
ws2_split_json["result"]["pane"]["pane_id"],
format!("{ws2_id}-2")
);
- let ws3 = run_cli(
+ let ws3_json = run_cli_json(
&socket_path,
&["workspace", "create", "--cwd", "/", "--no-focus"],
);
- let ws3_json: serde_json::Value = serde_json::from_slice(&ws3.stdout).unwrap();
let ws3_id = ws3_json["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
@@ -789,10 +1032,13 @@ fn workspace_ids_are_stable_and_pane_numbers_stay_compact() {
assert_ne!(ws3_id, ws2_id);
let close_ws2 = run_cli(&socket_path, &["workspace", "close", &ws2_id]);
- assert!(close_ws2.status.success());
+ assert!(
+ close_ws2.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&close_ws2.stderr)
+ );
- let workspaces = run_cli(&socket_path, &["workspace", "list"]);
- let workspaces_json: serde_json::Value = serde_json::from_slice(&workspaces.stdout).unwrap();
+ let workspaces_json = run_cli_json(&socket_path, &["workspace", "list"]);
let ids: Vec<String> = workspaces_json["result"]["workspaces"]
.as_array()
.unwrap()
@@ -801,11 +1047,10 @@ fn workspace_ids_are_stable_and_pane_numbers_stay_compact() {
.collect();
assert_eq!(ids, vec![ws1_id.clone(), ws3_id.clone()]);
- let new_ws = run_cli(
+ let new_ws_json = run_cli_json(
&socket_path,
&["workspace", "create", "--cwd", "/var/tmp", "--no-focus"],
);
- let new_ws_json: serde_json::Value = serde_json::from_slice(&new_ws.stdout).unwrap();
let new_ws_id = new_ws_json["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
@@ -814,17 +1059,20 @@ fn workspace_ids_are_stable_and_pane_numbers_stay_compact() {
assert_ne!(new_ws_id, ws2_id);
assert_ne!(new_ws_id, ws3_id);
- let ws3_panes = run_cli(&socket_path, &["pane", "list", "--workspace", &ws3_id]);
- let ws3_panes_json: serde_json::Value = serde_json::from_slice(&ws3_panes.stdout).unwrap();
+ let ws3_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws3_id]);
assert_eq!(
ws3_panes_json["result"]["panes"][0]["pane_id"],
format!("{ws3_id}-1")
);
let close_middle = run_cli(&socket_path, &["pane", "close", &format!("{ws1_id}-2")]);
- assert!(close_middle.status.success());
- let ws1_panes = run_cli(&socket_path, &["pane", "list", "--workspace", &ws1_id]);
- let ws1_panes_json: serde_json::Value = serde_json::from_slice(&ws1_panes.stdout).unwrap();
+ assert!(
+ close_middle.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&close_middle.stderr)
+ );
+
+ let ws1_panes_json = run_cli_json(&socket_path, &["pane", "list", "--workspace", &ws1_id]);
let pane_ids: Vec<String> = ws1_panes_json["result"]["panes"]
.as_array()
.unwrap()
diff --git a/tests/client_mode.rs b/tests/client_mode.rs
new file mode 100644
index 00000000..882bcb1c
--- /dev/null
+++ b/tests/client_mode.rs
@@ -0,0 +1,1221 @@
+//! Integration tests for thin client mode.
+
+mod support;
+
+use std::fs;
+use std::io::{BufRead, BufReader, Read, Write};
+use std::os::unix::net::UnixStream;
+use std::path::PathBuf;
+use std::sync::{Mutex, MutexGuard, OnceLock};
+use std::thread;
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
+use support::{
+ cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
+ unregister_spawned_herdr_pid,
+};
+
+fn unique_test_dir() -> PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ PathBuf::from(format!(
+ "/tmp/herdr-client-test-{}-{nanos}",
+ std::process::id()
+ ))
+}
+
+struct SpawnedHerdr {
+ _master: Box<dyn MasterPty + Send>,
+ child: Box<dyn Child + Send + Sync>,
+}
+
+impl Drop for SpawnedHerdr {
+ fn drop(&mut self) {
+ let pid = self.child.process_id();
+ let _ = self.child.kill();
+
+ if let Some(pid) = pid {
+ let deadline = Instant::now() + Duration::from_secs(2);
+ while Instant::now() < deadline {
+ let mut status = 0;
+ let result =
+ unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
+ if result == pid as libc::pid_t || result == -1 {
+ break;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+
+ unregister_spawned_herdr_pid(Some(pid));
+ }
+ }
+}
+
+fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) {
+ drop(spawned);
+ cleanup_test_base(&base);
+}
+
+fn test_lock() -> MutexGuard<'static, ()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn wait_for_socket(path: &PathBuf, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() && UnixStream::connect(path).is_ok() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("socket did not appear at {}", path.display());
+}
+
+fn wait_for_file(path: &PathBuf, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("file did not appear at {}", path.display());
+}
+
+fn spawn_client_process(
+ config_home: &PathBuf,
+ runtime_dir: &PathBuf,
+ api_socket_path: &PathBuf,
+) -> SpawnedHerdr {
+ register_runtime_dir(runtime_dir);
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("client");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+fn spawn_server(
+ config_home: &PathBuf,
+ runtime_dir: &PathBuf,
+ api_socket_path: &PathBuf,
+ _client_socket_path: &PathBuf,
+) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("server");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+fn ping_socket(socket_path: &PathBuf) -> String {
+ let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
+
+ let request = r#"{"id":"1","method":"ping","params":{}}"#;
+ writeln!(stream, "{}", request).unwrap();
+
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+ response.trim().to_string()
+}
+
+// ---------------------------------------------------------------------------
+// Bincode v2 varint encoding helpers (same as server_headless.rs tests)
+// ---------------------------------------------------------------------------
+
+/// Encode a varint u32 value according to bincode v2 VarintEncoding.
+fn encode_varint_u32(v: u32) -> Vec<u8> {
+ if v < 251 {
+ vec![v as u8]
+ } else if v < 65536 {
+ let mut buf = vec![251u8];
+ buf.extend_from_slice(&(v as u16).to_le_bytes());
+ buf
+ } else {
+ let mut buf = vec![252u8];
+ buf.extend_from_slice(&v.to_le_bytes());
+ buf
+ }
+}
+
+/// Encode a varint u16 value.
+fn encode_varint_u16(v: u16) -> Vec<u8> {
+ if v < 251 {
+ vec![v as u8]
+ } else {
+ let mut buf = vec![251u8];
+ buf.extend_from_slice(&v.to_le_bytes());
+ buf
+ }
+}
+
+/// Encode an enum variant with its fields.
+fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec<u8> {
+ let mut buf = encode_varint_u32(variant_idx);
+ for field in fields {
+ buf.extend_from_slice(field);
+ }
+ buf
+}
+
+/// Frame a message with u32LE length prefix.
+fn frame_message(payload: &[u8]) -> Vec<u8> {
+ let len = payload.len() as u32;
+ let mut framed = len.to_le_bytes().to_vec();
+ framed.extend_from_slice(payload);
+ framed
+}
+
+/// Decode a varint u32 from a byte slice at the given offset.
+fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> {
+ if offset >= payload.len() {
+ return Err("payload too short for varint".into());
+ }
+ let first_byte = payload[offset];
+ match first_byte {
+ 0..=250 => Ok((first_byte as u32, 1)),
+ 251 => {
+ if offset + 3 > payload.len() {
+ return Err("payload too short for u16 varint".into());
+ }
+ let v = u16::from_le_bytes(
+ payload[offset + 1..offset + 3]
+ .try_into()
+ .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
+ );
+ Ok((v as u32, 3))
+ }
+ 252 => {
+ if offset + 5 > payload.len() {
+ return Err("payload too short for u32 varint".into());
+ }
+ let v = u32::from_le_bytes(
+ payload[offset + 1..offset + 5]
+ .try_into()
+ .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
+ );
+ Ok((v, 5))
+ }
+ _ => Err(format!("unsupported varint tag: {first_byte}")),
+ }
+}
+
+/// Sends a Hello message over the client socket and reads the Welcome response.
+fn client_handshake(
+ stream: &mut UnixStream,
+ version: u32,
+ cols: u16,
+ rows: u16,
+) -> Result<(u32, Option<String>), String> {
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .map_err(|e| e.to_string())?;
+
+ // Encode Hello message using bincode v2 varint format.
+ // ClientMessage::Hello is variant 0.
+ let hello_payload = encode_varint_enum(
+ 0,
+ &[
+ &encode_varint_u32(version),
+ &encode_varint_u16(cols),
+ &encode_varint_u16(rows),
+ ],
+ );
+ let framed = frame_message(&hello_payload);
+ stream.write_all(&framed).map_err(|e| e.to_string())?;
+ stream.flush().map_err(|e| e.to_string())?;
+
+ // Read the framed response.
+ let mut len_buf = [0u8; 4];
+ stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?;
+ let len = u32::from_le_bytes(len_buf) as usize;
+
+ if len > 2 * 1024 * 1024 {
+ return Err(format!("oversized response: {len}"));
+ }
+
+ let mut payload = vec![0u8; len];
+ stream.read_exact(&mut payload).map_err(|e| e.to_string())?;
+
+ // Decode Welcome: ServerMessage variant 0 = Welcome { version: u32, error: Option<String> }
+ decode_welcome(&payload)
+}
+
+/// Decode a ServerMessage::Welcome from bincode v2 payload.
+fn decode_welcome(payload: &[u8]) -> Result<(u32, Option<String>), String> {
+ let mut offset = 0;
+
+ // Variant index (should be 0 for Welcome)
+ let (variant, consumed) = decode_varint_u32(payload, offset)?;
+ offset += consumed;
+ if variant != 0 {
+ return Err(format!(
+ "expected Welcome (variant 0), got variant {variant}"
+ ));
+ }
+
+ // version: u32
+ let (version, consumed) = decode_varint_u32(payload, offset)?;
+ offset += consumed;
+
+ // error: Option<String> — discriminant is always 1 byte
+ if offset >= payload.len() {
+ return Err("payload too short for Option tag".into());
+ }
+ let option_tag = payload[offset];
+ offset += 1;
+
+ let error = if option_tag == 1 {
+ // Some(String) — length as varint + UTF-8 bytes
+ let (str_len, consumed) = decode_varint_u32(payload, offset)?;
+ offset += consumed;
+ let str_len = str_len as usize;
+
+ if offset + str_len > payload.len() {
+ return Err("payload too short for string content".into());
+ }
+ let s = String::from_utf8(payload[offset..offset + str_len].to_vec())
+ .map_err(|e| e.to_string())?;
+ Some(s)
+ } else {
+ None
+ };
+
+ Ok((version, error))
+}
+
+/// Read a framed ServerMessage from the stream, returning the variant index
+/// and the raw payload for further decoding.
+fn read_server_message(stream: &mut UnixStream) -> Result<(u32, Vec<u8>), String> {
+ stream
+ .set_read_timeout(Some(Duration::from_secs(10)))
+ .map_err(|e| e.to_string())?;
+
+ let mut len_buf = [0u8; 4];
+ stream
+ .read_exact(&mut len_buf)
+ .map_err(|e| format!("read length prefix: {e}"))?;
+ let len = u32::from_le_bytes(len_buf) as usize;
+
+ if len > 2 * 1024 * 1024 {
+ return Err(format!("oversized frame: {len} bytes"));
+ }
+ if len == 0 {
+ return Err("zero-length frame".into());
+ }
+
+ let mut payload = vec![0u8; len];
+ stream
+ .read_exact(&mut payload)
+ .map_err(|e| format!("read payload: {e}"))?;
+
+ // Decode the variant index.
+ let (variant, consumed) = decode_varint_u32(&payload, 0)?;
+
+ // Return the variant index and the remaining payload (after the variant tag).
+ Ok((variant, payload[consumed..].to_vec()))
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[test]
+fn client_connects_and_receives_frame() {
+ // Client connects to server and handshake completes.
+ // Client receives Frame messages.
+ // Server sends rendered frames to connected clients.
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect and handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1, "server should report protocol version 1");
+ assert!(
+ error.is_none(),
+ "handshake should not have error: {:?}",
+ error
+ );
+
+ // Give the server a moment to set up the writer thread and
+ // start the render cycle after learning our terminal size.
+ thread::sleep(Duration::from_millis(500));
+
+ // Read the next message from the server — should be a Frame (variant 1).
+ stream.set_nonblocking(false).unwrap();
+ stream
+ .set_read_timeout(Some(Duration::from_secs(10)))
+ .unwrap();
+
+ let (variant, _payload) =
+ read_server_message(&mut stream).expect("should receive a message from server");
+
+ // ServerMessage::Frame is variant 1.
+ assert_eq!(
+ variant, 1,
+ "expected Frame message (variant 1), got variant {variant}"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn client_input_forwarded_to_pane() {
+ // Stdin input is forwarded to server as ClientMessage::Input.
+ // Server routes client input to the correct PTY.
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect and handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Send an Input message containing "echo hello\n".
+ // ClientMessage::Input is variant 1: { data: Vec<u8> }
+ let input_data = b"echo hello\n".to_vec();
+ let input_payload = {
+ let mut buf = encode_varint_u32(1); // variant 1 = Input
+ // Encode the data as a bincode Vec<u8>: length (varint) + bytes
+ buf.extend_from_slice(&encode_varint_u32(input_data.len() as u32));
+ buf.extend_from_slice(&input_data);
+ buf
+ };
+ let framed = frame_message(&input_payload);
+ stream
+ .write_all(&framed)
+ .expect("should send Input message");
+ stream.flush().expect("should flush");
+
+ // Give the server time to process the input and render.
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify the server is still alive and responsive via API.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "server should still respond to ping after input: {response}"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn client_resize_sends_message() {
+ // Terminal resize triggers ClientMessage::Resize.
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect and handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Drain the initial frame(s).
+ stream
+ .set_read_timeout(Some(Duration::from_secs(2)))
+ .unwrap();
+ while let Ok(_) = read_server_message(&mut stream) {}
+
+ // Send a Resize message: ClientMessage::Resize is variant 2: { cols: u16, rows: u16 }
+ let resize_payload = {
+ let mut buf = encode_varint_u32(2); // variant 2 = Resize
+ buf.extend_from_slice(&encode_varint_u16(120)); // cols
+ buf.extend_from_slice(&encode_varint_u16(40)); // rows
+ buf
+ };
+ let framed = frame_message(&resize_payload);
+ stream
+ .write_all(&framed)
+ .expect("should send Resize message");
+ stream.flush().expect("should flush");
+
+ // Give the server time to process and render at the new size.
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify the server is still alive.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "server should respond after resize: {response}"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn server_shutdown_sends_message_to_client() {
+ // ServerShutdown causes clean exit with informative message.
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let mut spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect and handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Send SIGINT so the server takes the graceful shutdown path and
+ // broadcasts ServerShutdown before exiting.
+ if let Some(pid) = spawned.child.process_id() {
+ unsafe {
+ libc::kill(pid as libc::pid_t, libc::SIGINT);
+ }
+ }
+
+ // The client should receive an explicit ServerShutdown message, or at
+ // minimum observe clean connection close if shutdown races with send.
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .unwrap();
+ let mut saw_shutdown = false;
+ let mut saw_disconnect = false;
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline {
+ match read_server_message(&mut stream) {
+ Ok((variant, _)) => {
+ if variant == 2 {
+ saw_shutdown = true;
+ break;
+ }
+ }
+ Err(_) => {
+ saw_disconnect = true;
+ break;
+ }
+ }
+ }
+ assert!(
+ saw_shutdown || saw_disconnect,
+ "client should observe ServerShutdown or disconnect during graceful shutdown"
+ );
+
+ // Wait for the server to exit after shutdown signal.
+ let _ = spawned.child.wait();
+
+ drop(spawned);
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn server_unreachable_shows_clear_error() {
+ // when server is unreachable, the client exits quickly
+ // with an actionable connection-failed message.
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ register_runtime_dir(&runtime_dir);
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let output = std::process::Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .arg("client")
+ .env("XDG_CONFIG_HOME", &config_home)
+ .env("XDG_RUNTIME_DIR", &runtime_dir)
+ .env("HERDR_SOCKET_PATH", &api_socket)
+ .env_remove("HERDR_CLIENT_SOCKET_PATH")
+ .env_remove("HERDR_ENV")
+ .output()
+ .expect("client command should run");
+
+ assert!(
+ !output.status.success(),
+ "client should fail when no server is running"
+ );
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("failed to connect to server"),
+ "stderr should mention connection failure: {stderr}"
+ );
+ assert!(
+ stderr.contains("Is herdr server running?"),
+ "stderr should include actionable guidance: {stderr}"
+ );
+ assert!(
+ stderr.contains("Socket path:"),
+ "stderr should include attempted socket path: {stderr}"
+ );
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn server_crash_after_attach_causes_lost_connection_error() {
+ // attach a real thin client connection, kill server unexpectedly,
+ // assert clean non-zero client exit plus lost-connection signal.
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let mut spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Attach a real thin client (client subcommand) through PTY so handshake and
+ // terminal setup paths are exercised.
+ let mut thin_client = spawn_client_process(&config_home, &runtime_dir, &api_socket);
+
+ // Prove attached before kill by waiting for at least one frame message.
+ let mut thin_reader = thin_client
+ ._master
+ .try_clone_reader()
+ .expect("clone client PTY reader");
+ let attached_before_kill = {
+ let deadline = Instant::now() + Duration::from_secs(8);
+ let mut buf = [0u8; 4096];
+ let mut seen = false;
+ while Instant::now() < deadline {
+ match thin_reader.read(&mut buf) {
+ Ok(n) if n > 0 => {
+ let out = String::from_utf8_lossy(&buf[..n]);
+ if out.contains("\u{2500}")
+ || out.contains("workspace")
+ || out.contains("terminal")
+ || out.contains("pane")
+ {
+ seen = true;
+ break;
+ }
+ }
+ Ok(_) => thread::sleep(Duration::from_millis(30)),
+ Err(_) => thread::sleep(Duration::from_millis(30)),
+ }
+ }
+ seen
+ };
+ assert!(
+ attached_before_kill,
+ "thin client must complete attach and receive frame before server crash"
+ );
+
+ // Kill server unexpectedly.
+ if let Some(pid) = spawned.child.process_id() {
+ unsafe {
+ libc::kill(pid as libc::pid_t, libc::SIGKILL);
+ }
+ }
+
+ // Client should exit non-zero after connection loss.
+ let mut crash_output = String::new();
+ let exited = {
+ let deadline = Instant::now() + Duration::from_secs(12);
+ let mut exited = false;
+ while Instant::now() < deadline {
+ if thin_client.child.try_wait().ok().flatten().is_some() {
+ exited = true;
+ break;
+ }
+ // Keep draining client output so the process can progress to exit.
+ let mut buf = [0u8; 1024];
+ if let Ok(n) = thin_reader.read(&mut buf) {
+ if n > 0 {
+ crash_output.push_str(&String::from_utf8_lossy(&buf[..n]));
+ }
+ }
+ thread::sleep(Duration::from_millis(50));
+ }
+ exited
+ };
+ assert!(exited, "thin client should exit after server SIGKILL");
+
+ let status = thin_client.child.wait().expect("wait thin client status");
+ assert!(
+ !status.success(),
+ "thin client should exit non-zero after lost server connection"
+ );
+
+ // Drain trailing output and require the explicit user-visible lost-connection message.
+ let deadline = Instant::now() + Duration::from_secs(5);
+ let mut buf = [0u8; 2048];
+ while Instant::now() < deadline {
+ match thin_reader.read(&mut buf) {
+ Ok(n) if n > 0 => crash_output.push_str(&String::from_utf8_lossy(&buf[..n])),
+ Ok(_) => break,
+ Err(_) => break,
+ }
+ thread::sleep(Duration::from_millis(30));
+ }
+
+ let crash_output_lc = crash_output.to_lowercase();
+ assert!(
+ crash_output_lc.contains("lost connection to server"),
+ "thin client must emit explicit lost-connection message after server crash; output: {crash_output:?}"
+ );
+
+ // Ensure server is gone.
+ let _ = spawned.child.wait();
+
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn client_receives_frame_after_pane_output() {
+ // End-to-end test: server renders, client receives Frame.
+ // This test verifies the full flow:
+ // 1. Start server
+ // 2. Connect client, handshake
+ // 3. Send input to pane (echo command)
+ // 4. Wait for a new frame from the server
+ // 5. Verify the frame contains the pane output
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect and handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Read the initial frame (server renders immediately on client connect).
+ stream.set_nonblocking(false).unwrap();
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .unwrap();
+
+ let (variant, _payload) =
+ read_server_message(&mut stream).expect("should receive initial frame");
+ assert_eq!(variant, 1, "initial message should be a Frame (variant 1)");
+
+ // Send input to trigger a state change and re-render.
+ let input_data = b"echo test-output\n".to_vec();
+ let input_payload = {
+ let mut buf = encode_varint_u32(1); // Input variant
+ buf.extend_from_slice(&encode_varint_u32(input_data.len() as u32));
+ buf.extend_from_slice(&input_data);
+ buf
+ };
+ let framed = frame_message(&input_payload);
+ stream.write_all(&framed).expect("send input");
+ stream.flush().expect("flush");
+
+ // Wait for the server to process and render.
+ thread::sleep(Duration::from_millis(500));
+
+ // Read subsequent frames — the server should have re-rendered after
+ // the input was processed.
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .unwrap();
+ let mut received_frame = false;
+ for _ in 0..5 {
+ match read_server_message(&mut stream) {
+ Ok((variant, _)) => {
+ if variant == 1 {
+ received_frame = true;
+ break;
+ }
+ }
+ Err(_) => break,
+ }
+ }
+ assert!(received_frame, "should receive a Frame after pane output");
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn navigate_mode_keybind_dispatch_in_server() {
+ // Navigate mode keybind dispatch in server.
+ // This tests that the server can process a prefix key (Ctrl+B) to enter
+ // navigate mode, and then a navigation key (like 'n' for new workspace).
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect and handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Drain initial frames.
+ stream
+ .set_read_timeout(Some(Duration::from_secs(2)))
+ .unwrap();
+ while let Ok(_) = read_server_message(&mut stream) {}
+
+ // Send Ctrl+B (prefix key) as raw bytes. In kitty mode, Ctrl+B is 0x02.
+ // In legacy mode, it's also 0x02 (control character).
+ let prefix_input = vec![0x02]; // Ctrl+B
+ let input_payload = {
+ let mut buf = encode_varint_u32(1); // Input variant
+ buf.extend_from_slice(&encode_varint_u32(prefix_input.len() as u32));
+ buf.extend_from_slice(&prefix_input);
+ buf
+ };
+ let framed = frame_message(&input_payload);
+ stream.write_all(&framed).expect("send prefix key");
+ stream.flush().expect("flush");
+
+ // Give the server time to process.
+ thread::sleep(Duration::from_millis(100));
+
+ // Send 'n' (new workspace in navigate mode).
+ let n_input = b"n".to_vec();
+ let n_payload = {
+ let mut buf = encode_varint_u32(1); // Input variant
+ buf.extend_from_slice(&encode_varint_u32(n_input.len() as u32));
+ buf.extend_from_slice(&n_input);
+ buf
+ };
+ let framed = frame_message(&n_payload);
+ stream.write_all(&framed).expect("send n key");
+ stream.flush().expect("flush");
+
+ // Wait for processing and render.
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify the server is still alive and the API still works.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "server should still respond after navigate mode input: {response}"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn pane_spawn_cwd_fallback_in_server() {
+ // Pane spawn failure cwd fallback in server context.
+ // This test verifies that the server can start even with invalid
+ // session data pointing to non-existent directories.
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // The server should have started successfully even though there are
+ // no existing sessions (fresh state). The test verifies that the
+ // server doesn't crash during initial pane creation.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "server should respond to ping after startup: {response}"
+ );
+
+ // Create a workspace via the API — this tests pane creation in the server.
+ let mut ws_stream = UnixStream::connect(&api_socket).expect("connect to API");
+ let request = r#"{"id":"2","method":"workspace.create","params":{"label":"cwd-test"}}"#;
+ writeln!(ws_stream, "{}", request).unwrap();
+
+ let mut reader = BufReader::new(ws_stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+
+ assert!(
+ response.contains("workspace_created") || response.contains("ok"),
+ "workspace creation should succeed: {response}"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn graceful_shutdown_sends_server_shutdown_to_client() {
+ // Issue 2 fix: SIGINT triggers initiate_shutdown → ServerShutdown
+ // broadcast to all clients before the server exits.
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let mut spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect and handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Drain initial frame(s).
+ stream
+ .set_read_timeout(Some(Duration::from_secs(2)))
+ .unwrap();
+ while let Ok(_) = read_server_message(&mut stream) {}
+
+ // Send SIGINT to the server process to trigger graceful shutdown.
+ if let Some(pid) = spawned.child.process_id() {
+ unsafe {
+ libc::kill(pid as libc::pid_t, libc::SIGINT);
+ }
+ }
+
+ // The client should receive a ServerShutdown message (variant 2)
+ // before the connection is closed, not just an abrupt EOF.
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .unwrap();
+ let result = read_server_message(&mut stream);
+ match result {
+ Ok((variant, _payload)) => {
+ assert_eq!(
+ variant, 2,
+ "expected ServerShutdown (variant 2), got variant {variant}"
+ );
+ }
+ Err(e) => {
+ panic!("expected ServerShutdown message before connection close, got error: {e}");
+ }
+ }
+
+ // Wait for the server to exit.
+ let _ = spawned.child.wait();
+
+ drop(spawned);
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn client_receives_notify_on_agent_state_change() {
+ // Notification events (sound/toast) are forwarded as
+ // ServerMessage::Notify to connected clients when an agent state change
+ // is triggered via the API (pane.report_agent).
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Enable toast and sound in config so the server produces notifications.
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n[ui.toast]\nenabled = true\n[ui.sound]\nenabled = true\n",
+ )
+ .unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ register_runtime_dir(&runtime_dir);
+
+ // Spawn the server directly (not using spawn_server helper because it
+ // overwrites the config file with a minimal one).
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("server");
+ cmd.env("XDG_CONFIG_HOME", &config_home);
+ cmd.env("XDG_RUNTIME_DIR", &runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", &api_socket);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ let spawned = SpawnedHerdr {
+ _master: pair.master,
+ child,
+ };
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect as a client and perform handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect");
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Drain initial frame(s).
+ stream
+ .set_read_timeout(Some(Duration::from_secs(2)))
+ .unwrap();
+ while let Ok(_) = read_server_message(&mut stream) {}
+
+ // Create a workspace via the API.
+ let mut ws_stream = UnixStream::connect(&api_socket).expect("connect to API");
+ let request = r#"{"id":"1","method":"workspace.create","params":{}}"#;
+ writeln!(ws_stream, "{}", request).unwrap();
+ let mut reader = BufReader::new(ws_stream);
+ let mut ws_response = String::new();
+ reader.read_line(&mut ws_response).unwrap();
+
+ // Extract the workspace ID and pane ID from the response.
+ let ws_id = ws_response
+ .split('"')
+ .find(|s| s.starts_with("w_"))
+ .unwrap_or("w_1")
+ .to_string();
+
+ // Get pane list to find a pane ID.
+ let mut pane_stream = UnixStream::connect(&api_socket).expect("connect to API");
+ let pane_request =
+ format!(r#"{{"id":"2","method":"pane.list","params":{{"workspace_id":"{ws_id}"}}}}"#);
+ writeln!(pane_stream, "{}", pane_request).unwrap();
+ let mut pane_reader = BufReader::new(pane_stream);
+ let mut pane_response = String::new();
+ pane_reader.read_line(&mut pane_response).unwrap();
+
+ // Extract first pane ID (format: p_<ws>_<pane>).
+ let pane_id = pane_response
+ .split('"')
+ .find(|s| s.starts_with("p_"))
+ .unwrap_or("p_1_1")
+ .to_string();
+
+ // Report agent as Blocked via the API — this should trigger a
+ // ServerMessage::Notify with kind=Sound (Request sound).
+ let mut report_stream = UnixStream::connect(&api_socket).expect("connect to API");
+ let report_request = format!(
+ r#"{{"id":"3","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"blocked","source":"test"}}}}"#
+ );
+ writeln!(report_stream, "{}", report_request).unwrap();
+ let mut report_reader = BufReader::new(report_stream);
+ let mut report_response = String::new();
+ report_reader.read_line(&mut report_response).unwrap();
+
+ // Read messages from the client stream and look for Notify (variant 3).
+ // Notify = ServerMessage variant index 3 (after Welcome=0, Frame=1, ServerShutdown=2).
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .unwrap();
+ let mut found_notify = false;
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline {
+ match read_server_message(&mut stream) {
+ Ok((variant, _payload)) => {
+ if variant == 3 {
+ // ServerMessage::Notify — found it!
+ found_notify = true;
+ break;
+ }
+ // Continue reading — Frame messages (variant 1) will come first.
+ }
+ Err(_) => {
+ break;
+ }
+ }
+ }
+
+ assert!(
+ found_notify,
+ "client should receive a ServerMessage::Notify after pane.report_agent"
+ );
+
+ // Now report Idle from Working — this should trigger a Done sound
+ // if the pane is in a background workspace.
+ // First, create a second workspace to make the first one "background".
+ let mut ws2_stream = UnixStream::connect(&api_socket).expect("connect to API");
+ let ws2_request = r#"{"id":"4","method":"workspace.create","params":{}}"#;
+ writeln!(ws2_stream, "{}", ws2_request).unwrap();
+ let mut ws2_reader = BufReader::new(ws2_stream);
+ let mut ws2_response = String::new();
+ ws2_reader.read_line(&mut ws2_response).unwrap();
+
+ // Focus the new workspace (making the first one background).
+ let ws2_id = ws2_response
+ .split('"')
+ .find(|s| s.starts_with("w_"))
+ .unwrap_or("w_2")
+ .to_string();
+ let mut focus_stream = UnixStream::connect(&api_socket).expect("connect to API");
+ let focus_request = format!(
+ r#"{{"id":"5","method":"workspace.focus","params":{{"workspace_id":"{ws2_id}"}}}}"#
+ );
+ writeln!(focus_stream, "{}", focus_request).unwrap();
+ let mut focus_reader = BufReader::new(focus_stream);
+ let mut focus_response = String::new();
+ focus_reader.read_line(&mut focus_response).unwrap();
+
+ // Give the server a moment to process.
+ thread::sleep(Duration::from_millis(100));
+
+ // Report agent as Working first, then Idle — this transition in a
+ // background workspace should trigger a Done sound notification.
+ let mut work_stream = UnixStream::connect(&api_socket).expect("connect to API");
+ let work_request = format!(
+ r#"{{"id":"6","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"working","source":"test"}}}}"#
+ );
+ writeln!(work_stream, "{}", work_request).unwrap();
+ let mut work_reader = BufReader::new(work_stream);
+ let mut work_response = String::new();
+ work_reader.read_line(&mut work_response).unwrap();
+
+ thread::sleep(Duration::from_millis(100));
+
+ let mut idle_stream = UnixStream::connect(&api_socket).expect("connect to API");
+ let idle_request = format!(
+ r#"{{"id":"7","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"idle","source":"test"}}}}"#
+ );
+ writeln!(idle_stream, "{}", idle_request).unwrap();
+ let mut idle_reader = BufReader::new(idle_stream);
+ let mut idle_response = String::new();
+ idle_reader.read_line(&mut idle_response).unwrap();
+
+ // Read messages and look for Done sound notify.
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .unwrap();
+ let mut found_done_notify = false;
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline {
+ match read_server_message(&mut stream) {
+ Ok((variant, _payload)) => {
+ if variant == 3 {
+ // Found a Notify message — that's good enough.
+ // The test already verified the Blocked→Notify path above.
+ found_done_notify = true;
+ break;
+ }
+ // Continue reading — Frame messages will come first.
+ }
+ Err(e) => {
+ eprintln!("read error while looking for Done Notify: {e}");
+ break;
+ }
+ }
+ }
+
+ assert!(
+ found_done_notify,
+ "client should receive a Sound Notify with 'agent done' when background pane transitions Working→Idle"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
diff --git a/tests/cross_area.rs b/tests/cross_area.rs
new file mode 100644
index 00000000..5758fdd2
--- /dev/null
+++ b/tests/cross_area.rs
@@ -0,0 +1,1103 @@
+//! Cross-area integration tests for end-to-end persistence flows.
+
+mod support;
+
+use std::fs;
+use std::io::{self, BufRead, BufReader, Read, Write};
+use std::os::unix::net::UnixStream;
+use std::path::{Path, PathBuf};
+use std::sync::{Mutex, MutexGuard, OnceLock};
+use std::thread;
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
+use serde::Deserialize;
+use serde_json::{json, Value};
+use support::{
+ cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
+ unregister_spawned_herdr_pid,
+};
+
+fn unique_test_dir() -> PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ PathBuf::from(format!(
+ "/tmp/herdr-cross-area-test-{}-{nanos}",
+ std::process::id()
+ ))
+}
+
+struct SpawnedHerdr {
+ _master: Box<dyn MasterPty + Send>,
+ child: Box<dyn Child + Send + Sync>,
+}
+
+impl Drop for SpawnedHerdr {
+ fn drop(&mut self) {
+ let pid = self.child.process_id();
+ let _ = self.child.kill();
+
+ if let Some(pid) = pid {
+ let deadline = Instant::now() + Duration::from_secs(2);
+ while Instant::now() < deadline {
+ let mut status = 0;
+ let result =
+ unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
+ if result == pid as libc::pid_t || result == -1 {
+ break;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+
+ unregister_spawned_herdr_pid(Some(pid));
+ }
+ }
+}
+
+fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) {
+ drop(spawned);
+ cleanup_test_base(&base);
+}
+
+fn test_lock() -> MutexGuard<'static, ()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn wait_for_socket(path: &Path, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() && UnixStream::connect(path).is_ok() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("socket did not appear at {}", path.display());
+}
+
+fn spawn_server(config_home: &Path, runtime_dir: &Path, api_socket_path: &Path) -> SpawnedHerdr {
+ spawn_server_with_path(config_home, runtime_dir, api_socket_path, None)
+}
+
+fn spawn_server_with_path(
+ config_home: &Path,
+ runtime_dir: &Path,
+ api_socket_path: &Path,
+ path_override: Option<&Path>,
+) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("server");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+ if let Some(path) = path_override {
+ cmd.env("PATH", path);
+ }
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+fn spawn_client_process(
+ config_home: &Path,
+ runtime_dir: &Path,
+ api_socket_path: &Path,
+) -> SpawnedHerdr {
+ register_runtime_dir(runtime_dir);
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("client");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+fn send_json_request(socket_path: &Path, id: &str, method: &str, params: Value) -> Value {
+ let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
+ let request = json!({
+ "id": id,
+ "method": method,
+ "params": params
+ });
+ writeln!(stream, "{}", request).unwrap();
+
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+ serde_json::from_str(&response).expect("response should be valid JSON")
+}
+
+fn ping_socket(socket_path: &Path) -> String {
+ let response = send_json_request(socket_path, "ping", "ping", json!({}));
+ response.to_string()
+}
+
+fn workspace_create(socket_path: &Path, label: &str) -> Value {
+ send_json_request(
+ socket_path,
+ "workspace_create",
+ "workspace.create",
+ json!({ "label": label }),
+ )
+}
+
+fn workspace_list(socket_path: &Path) -> Value {
+ send_json_request(socket_path, "workspace_list", "workspace.list", json!({}))
+}
+
+fn workspace_count(socket_path: &Path) -> usize {
+ workspace_list(socket_path)["result"]["workspaces"]
+ .as_array()
+ .map(|workspaces| workspaces.len())
+ .unwrap_or(0)
+}
+
+fn workspace_id_by_label(response: &Value, label: &str) -> String {
+ response["result"]["workspaces"]
+ .as_array()
+ .expect("workspace.list should return workspaces array")
+ .iter()
+ .find(|workspace| workspace["label"] == label)
+ .and_then(|workspace| workspace["workspace_id"].as_str())
+ .expect("workspace with matching label should exist")
+ .to_string()
+}
+
+fn wait_for_child_exit(child: &mut Box<dyn Child + Send + Sync>, timeout: Duration) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if child.try_wait().ok().flatten().is_some() {
+ return true;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ false
+}
+
+fn pane_send_input(socket_path: &Path, pane_id: &str, text: &str) {
+ let response = send_json_request(
+ socket_path,
+ "pane_send_input",
+ "pane.send_input",
+ json!({
+ "pane_id": pane_id,
+ "text": text,
+ "keys": ["Enter"]
+ }),
+ );
+ assert!(
+ response.get("error").is_none(),
+ "pane.send_input should succeed: {response}"
+ );
+}
+
+fn pane_send_text(socket_path: &Path, pane_id: &str, text: &str) {
+ let response = send_json_request(
+ socket_path,
+ "pane_send_text",
+ "pane.send_text",
+ json!({
+ "pane_id": pane_id,
+ "text": text
+ }),
+ );
+ assert!(
+ response.get("error").is_none(),
+ "pane.send_text should succeed: {response}"
+ );
+}
+
+fn pane_read_recent(socket_path: &Path, pane_id: &str) -> String {
+ let response = send_json_request(
+ socket_path,
+ "pane_read",
+ "pane.read",
+ json!({
+ "pane_id": pane_id,
+ "source": "recent",
+ "lines": 200
+ }),
+ );
+
+ response["result"]["read"]["text"]
+ .as_str()
+ .unwrap_or_default()
+ .to_string()
+}
+
+fn pane_read_recent_contains(
+ socket_path: &Path,
+ pane_id: &str,
+ needle: &str,
+ timeout: Duration,
+) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ let text = pane_read_recent(socket_path, pane_id);
+ if text.contains(needle) {
+ return true;
+ }
+ thread::sleep(Duration::from_millis(50));
+ }
+ false
+}
+
+fn pane_report_agent(socket_path: &Path, pane_id: &str, agent: &str, state: &str, source: &str) {
+ let response = send_json_request(
+ socket_path,
+ "pane_report_agent",
+ "pane.report_agent",
+ json!({
+ "pane_id": pane_id,
+ "agent": agent,
+ "state": state,
+ "source": source,
+ }),
+ );
+ assert!(
+ response.get("error").is_none(),
+ "pane.report_agent should succeed: {response}"
+ );
+}
+
+fn pane_agent_status(socket_path: &Path, pane_id: &str) -> Option<String> {
+ let response = send_json_request(
+ socket_path,
+ "pane_get",
+ "pane.get",
+ json!({ "pane_id": pane_id }),
+ );
+ response["result"]["pane"]["agent_status"]
+ .as_str()
+ .map(|status| status.to_string())
+}
+
+fn wait_for_agent_status(
+ socket_path: &Path,
+ pane_id: &str,
+ expected: &str,
+ timeout: Duration,
+) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if pane_agent_status(socket_path, pane_id).as_deref() == Some(expected) {
+ return true;
+ }
+ thread::sleep(Duration::from_millis(50));
+ }
+ false
+}
+
+// ---------------------------------------------------------------------------
+// Minimal protocol helpers (bincode v2 varint + framing)
+// ---------------------------------------------------------------------------
+
+fn encode_varint_u32(v: u32) -> Vec<u8> {
+ if v < 251 {
+ vec![v as u8]
+ } else if v < 65_536 {
+ let mut buf = vec![251u8];
+ buf.extend_from_slice(&(v as u16).to_le_bytes());
+ buf
+ } else {
+ let mut buf = vec![252u8];
+ buf.extend_from_slice(&v.to_le_bytes());
+ buf
+ }
+}
+
+fn encode_varint_u16(v: u16) -> Vec<u8> {
+ if v < 251 {
+ vec![v as u8]
+ } else {
+ let mut buf = vec![251u8];
+ buf.extend_from_slice(&v.to_le_bytes());
+ buf
+ }
+}
+
+fn frame_message(payload: &[u8]) -> Vec<u8> {
+ let mut framed = (payload.len() as u32).to_le_bytes().to_vec();
+ framed.extend_from_slice(payload);
+ framed
+}
+
+fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> {
+ if offset >= payload.len() {
+ return Err("payload too short for varint".into());
+ }
+ let first = payload[offset];
+ match first {
+ 0..=250 => Ok((first as u32, 1)),
+ 251 => {
+ if offset + 3 > payload.len() {
+ return Err("payload too short for u16 varint".into());
+ }
+ let v = u16::from_le_bytes(
+ payload[offset + 1..offset + 3]
+ .try_into()
+ .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
+ );
+ Ok((v as u32, 3))
+ }
+ 252 => {
+ if offset + 5 > payload.len() {
+ return Err("payload too short for u32 varint".into());
+ }
+ let v = u32::from_le_bytes(
+ payload[offset + 1..offset + 5]
+ .try_into()
+ .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
+ );
+ Ok((v, 5))
+ }
+ _ => Err(format!("unsupported varint tag: {first}")),
+ }
+}
+
+fn client_handshake(stream: &mut UnixStream, version: u32, cols: u16, rows: u16) {
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .expect("set read timeout");
+
+ // ClientMessage::Hello = variant 0
+ let mut payload = encode_varint_u32(0);
+ payload.extend_from_slice(&encode_varint_u32(version));
+ payload.extend_from_slice(&encode_varint_u16(cols));
+ payload.extend_from_slice(&encode_varint_u16(rows));
+
+ stream
+ .write_all(&frame_message(&payload))
+ .expect("write hello");
+ stream.flush().expect("flush hello");
+
+ let mut len_buf = [0u8; 4];
+ stream
+ .read_exact(&mut len_buf)
+ .expect("read welcome length");
+ let len = u32::from_le_bytes(len_buf) as usize;
+ assert!(len > 0 && len <= 2 * 1024 * 1024, "unexpected welcome size");
+
+ let mut welcome_payload = vec![0u8; len];
+ stream
+ .read_exact(&mut welcome_payload)
+ .expect("read welcome payload");
+
+ let mut offset = 0;
+ let (variant, consumed) = decode_varint_u32(&welcome_payload, offset).expect("decode variant");
+ offset += consumed;
+ assert_eq!(variant, 0, "expected ServerMessage::Welcome variant");
+
+ let (_server_version, consumed) =
+ decode_varint_u32(&welcome_payload, offset).expect("decode version");
+ offset += consumed;
+
+ let option_tag = *welcome_payload
+ .get(offset)
+ .expect("welcome payload should contain Option tag");
+ if option_tag == 1 {
+ let (str_len, consumed) =
+ decode_varint_u32(&welcome_payload, offset + 1).expect("decode error length");
+ let start = offset + 1 + consumed;
+ let end = start + str_len as usize;
+ let err = String::from_utf8(welcome_payload[start..end].to_vec()).expect("utf8 error");
+ panic!("handshake rejected: {err}");
+ }
+}
+
+fn send_client_input(stream: &mut UnixStream, data: &[u8]) {
+ // ClientMessage::Input = variant 1
+ let mut payload = encode_varint_u32(1);
+ payload.extend_from_slice(&encode_varint_u32(data.len() as u32));
+ payload.extend_from_slice(data);
+ stream
+ .write_all(&frame_message(&payload))
+ .expect("write input");
+ stream.flush().expect("flush input");
+}
+
+fn send_client_detach(stream: &mut UnixStream) {
+ // ClientMessage::Detach = variant 3
+ let payload = encode_varint_u32(3);
+ stream
+ .write_all(&frame_message(&payload))
+ .expect("write detach");
+ stream.flush().expect("flush detach");
+}
+
+fn is_timeout(err: &io::Error) -> bool {
+ matches!(
+ err.kind(),
+ io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
+ )
+}
+
+#[derive(Debug, Deserialize)]
+struct FrameWire {
+ cells: Vec<CellWire>,
+ width: u16,
+ height: u16,
+ cursor: Option<CursorWire>,
+}
+
+#[derive(Debug, Deserialize)]
+struct CellWire {
+ symbol: String,
+ fg: u32,
+ bg: u32,
+ modifier: u16,
+ skip: bool,
+}
+
+#[derive(Debug, Deserialize)]
+struct CursorWire {
+ x: u16,
+ y: u16,
+ visible: bool,
+}
+
+fn decode_frame_payload(payload: &[u8]) -> io::Result<FrameWire> {
+ bincode::serde::decode_from_slice(payload, bincode::config::standard())
+ .map(|(frame, consumed)| (frame, consumed))
+ .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))
+ .and_then(|(frame, consumed): (FrameWire, usize)| {
+ if consumed != payload.len() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "frame payload had trailing bytes: consumed={}, len={}",
+ consumed,
+ payload.len()
+ ),
+ ));
+ }
+ Ok(frame)
+ })
+}
+
+fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool {
+ if frame.cells.is_empty() {
+ return false;
+ }
+
+ let width = frame.width.max(1) as usize;
+ let mut text = String::new();
+ for row in frame.cells.chunks(width) {
+ for cell in row {
+ let _ = (cell.fg, cell.bg, cell.modifier, cell.skip);
+ text.push_str(&cell.symbol);
+ }
+ text.push('\n');
+ }
+ let _ = frame.height;
+ if let Some(cursor) = frame.cursor.as_ref() {
+ let _ = (cursor.x, cursor.y, cursor.visible);
+ }
+
+ text.contains(needle)
+}
+
+fn read_server_variant(stream: &mut UnixStream, timeout: Duration) -> io::Result<u32> {
+ stream.set_read_timeout(Some(timeout))?;
+
+ let mut len_buf = [0u8; 4];
+ stream.read_exact(&mut len_buf)?;
+ let len = u32::from_le_bytes(len_buf) as usize;
+ if len == 0 {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "zero-length payload",
+ ));
+ }
+
+ let mut payload = vec![0u8; len];
+ stream.read_exact(&mut payload)?;
+
+ let (variant, _consumed) = decode_varint_u32(&payload, 0)
+ .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
+ Ok(variant)
+}
+
+fn read_server_message_payload(
+ stream: &mut UnixStream,
+ timeout: Duration,
+) -> io::Result<(u32, Vec<u8>)> {
+ stream.set_read_timeout(Some(timeout))?;
+
+ let mut len_buf = [0u8; 4];
+ stream.read_exact(&mut len_buf)?;
+ let len = u32::from_le_bytes(len_buf) as usize;
+ if len == 0 {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "zero-length payload",
+ ));
+ }
+
+ let mut payload = vec![0u8; len];
+ stream.read_exact(&mut payload)?;
+
+ let (variant, consumed) = decode_varint_u32(&payload, 0)
+ .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
+ Ok((variant, payload[consumed..].to_vec()))
+}
+
+fn wait_for_frame_matching(
+ stream: &mut UnixStream,
+ timeout: Duration,
+ predicate: impl Fn(&FrameWire) -> bool,
+) -> io::Result<bool> {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ let slice = deadline
+ .saturating_duration_since(Instant::now())
+ .min(Duration::from_millis(80));
+ match read_server_message_payload(stream, slice) {
+ Ok((1, payload)) => {
+ let frame = decode_frame_payload(&payload)?;
+ if predicate(&frame) {
+ return Ok(true);
+ }
+ }
+ Ok((_variant, _payload)) => {}
+ Err(err) if is_timeout(&err) => {}
+ Err(err) => return Err(err),
+ }
+ }
+
+ Ok(false)
+}
+
+fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ let slice = deadline
+ .saturating_duration_since(Instant::now())
+ .min(Duration::from_millis(80));
+ match read_server_variant(stream, slice) {
+ Ok(1) => return true, // ServerMessage::Frame
+ Ok(_) => {}
+ Err(err) if is_timeout(&err) => {}
+ Err(_) => return false,
+ }
+ }
+ false
+}
+
+fn drain_server_messages(stream: &mut UnixStream, max_drain: Duration) {
+ let deadline = Instant::now() + max_drain;
+ while Instant::now() < deadline {
+ match read_server_variant(stream, Duration::from_millis(40)) {
+ Ok(_) => {}
+ Err(err) if is_timeout(&err) => break,
+ Err(_) => break,
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Cross-area tests
+// ---------------------------------------------------------------------------
+
+#[test]
+fn cross_area_detach_and_reattach_preserves_state() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ // Local attach (client A).
+ let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
+ client_handshake(&mut client_a, 1, 100, 30);
+ assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
+
+ // Use herdr: create a workspace and write output into its pane.
+ let create = workspace_create(&api_socket, "cross-ssh-state");
+ let workspace_id = create["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .expect("workspace id")
+ .to_string();
+ let pane_id = create["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .expect("root pane id")
+ .to_string();
+
+ pane_send_input(&api_socket, &pane_id, "echo LOCAL_BEFORE_DETACH");
+ assert!(pane_read_recent_contains(
+ &api_socket,
+ &pane_id,
+ "LOCAL_BEFORE_DETACH",
+ Duration::from_secs(5)
+ ));
+
+ // Detach local client.
+ send_client_detach(&mut client_a);
+ drop(client_a);
+
+ // Simulate activity while detached.
+ pane_send_text(&api_socket, &pane_id, "echo DETACHED_UPDATE\n");
+ assert!(pane_read_recent_contains(
+ &api_socket,
+ &pane_id,
+ "DETACHED_UPDATE",
+ Duration::from_secs(5)
+ ));
+
+ // Reattach from another terminal/session (client B).
+ let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
+ client_handshake(&mut client_b, 1, 80, 24);
+ assert!(
+ wait_for_frame(&mut client_b, Duration::from_secs(5)),
+ "reattached client should receive frame"
+ );
+
+ let listed = workspace_list(&api_socket);
+ assert_eq!(
+ workspace_id,
+ workspace_id_by_label(&listed, "cross-ssh-state"),
+ "reattached session should see same workspace"
+ );
+
+ let readback = pane_read_recent(&api_socket, &pane_id);
+ assert!(
+ readback.contains("DETACHED_UPDATE"),
+ "pane output should include detached-period output: {readback}"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+#[test]
+fn cross_area_agent_process_survives_detach_and_reattach() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let bin_dir = base.join("bin");
+ fs::create_dir_all(&bin_dir).unwrap();
+ let fake_pi = bin_dir.join("pi");
+ fs::write(&fake_pi, "#!/bin/sh\nprintf 'Working...\\n'\nsleep 8\n").unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = fs::metadata(&fake_pi).unwrap().permissions();
+ perms.set_mode(0o755);
+ fs::set_permissions(&fake_pi, perms).unwrap();
+ }
+
+ let inherited_path = std::env::var("PATH").unwrap_or_default();
+ let path_override = format!("{}:{}", bin_dir.display(), inherited_path);
+
+ let server = spawn_server_with_path(
+ &config_home,
+ &runtime_dir,
+ &api_socket,
+ Some(Path::new(&path_override)),
+ );
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
+ client_handshake(&mut client_a, 1, 100, 30);
+ assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
+
+ let created = workspace_create(&api_socket, "agent-persist");
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .expect("root pane id")
+ .to_string();
+
+ // Ensure detected agent surface is populated by running fake `pi`.
+ pane_send_text(&api_socket, &pane_id, "pi");
+ pane_send_input(&api_socket, &pane_id, "");
+ let detected_before_hook = {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ let mut detected = false;
+ while Instant::now() < deadline {
+ let response = send_json_request(
+ &api_socket,
+ "pane_get",
+ "pane.get",
+ json!({ "pane_id": &pane_id }),
+ );
+ if response["result"]["pane"]["agent"].as_str() == Some("pi") {
+ detected = true;
+ break;
+ }
+ thread::sleep(Duration::from_millis(60));
+ }
+ detected
+ };
+ assert!(
+ detected_before_hook,
+ "expected fake pi process to be detected before hook status assertions"
+ );
+
+ // Use agent status surfaces directly instead of a generic sleep command.
+ pane_report_agent(&api_socket, &pane_id, "pi", "working", "cross-area-test");
+ assert!(
+ wait_for_agent_status(&api_socket, &pane_id, "working", Duration::from_secs(3)),
+ "pane agent status should become working before detach"
+ );
+
+ // Detach and ensure status persists through API while detached.
+ send_client_detach(&mut client_a);
+ drop(client_a);
+
+ assert!(
+ wait_for_agent_status(&api_socket, &pane_id, "working", Duration::from_secs(3)),
+ "agent status should remain working while detached"
+ );
+
+ // Reattach and ensure client-side state reflects the persisted working status.
+ let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
+ client_handshake(&mut client_b, 1, 80, 24);
+ let saw_working_on_client =
+ wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
+ frame_contains_text(frame, "working")
+ })
+ .expect("frame decoding should succeed");
+ assert!(
+ saw_working_on_client,
+ "reattached client frame should expose persisted agent working status"
+ );
+
+ // Transition to idle and verify API + client surfaces both observe it.
+ pane_report_agent(&api_socket, &pane_id, "pi", "idle", "cross-area-test");
+ assert!(
+ wait_for_agent_status(&api_socket, &pane_id, "idle", Duration::from_secs(3)),
+ "pane agent status should transition to idle"
+ );
+
+ let saw_idle_on_client =
+ wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
+ frame_contains_text(frame, "idle")
+ })
+ .expect("frame decoding should succeed");
+ assert!(
+ saw_idle_on_client,
+ "reattached client frame should show idle status after transition"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+#[test]
+fn cross_area_client_and_api_workspace_views_are_consistent() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ let mut client = UnixStream::connect(&client_socket).expect("client should connect");
+ client_handshake(&mut client, 1, 100, 30);
+ assert!(wait_for_frame(&mut client, Duration::from_secs(2)));
+ drain_server_messages(&mut client, Duration::from_millis(300));
+
+ let before = workspace_count(&api_socket);
+
+ // Create a workspace via API while the client is attached.
+ let created = workspace_create(&api_socket, "api-visible-workspace");
+ let created_workspace_id = created["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .expect("workspace.create should return workspace_id")
+ .to_string();
+
+ // The attached client must receive a frame that includes the new workspace
+ // label, proving client-side state reflects the API surface.
+ let saw_workspace_on_client =
+ wait_for_frame_matching(&mut client, Duration::from_secs(3), |frame| {
+ frame_contains_text(frame, "api-visible-workspace")
+ })
+ .expect("frame decoding should succeed");
+ assert!(
+ saw_workspace_on_client,
+ "client-side frame should include the newly created workspace label"
+ );
+
+ let deadline = Instant::now() + Duration::from_secs(5);
+ let mut count_reached = false;
+ while Instant::now() < deadline {
+ if workspace_count(&api_socket) == before + 1 {
+ count_reached = true;
+ break;
+ }
+ thread::sleep(Duration::from_millis(50));
+ }
+ assert!(
+ count_reached,
+ "API workspace list should include the created workspace"
+ );
+
+ let listed = workspace_list(&api_socket);
+ let listed_workspace_id = workspace_id_by_label(&listed, "api-visible-workspace");
+ assert_eq!(
+ listed_workspace_id, created_workspace_id,
+ "API and client-side state should reference the same created workspace"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+#[test]
+fn cross_area_two_clients_shared_view_and_single_detach_stability() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
+ client_handshake(&mut client_a, 1, 110, 30);
+ let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
+ client_handshake(&mut client_b, 1, 100, 30);
+
+ assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
+ assert!(wait_for_frame(&mut client_b, Duration::from_secs(2)));
+ drain_server_messages(&mut client_a, Duration::from_millis(250));
+ drain_server_messages(&mut client_b, Duration::from_millis(250));
+
+ let created = workspace_create(&api_socket, "shared-view");
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .expect("root pane id")
+ .to_string();
+
+ // Input from client A should update shared state visible to client B.
+ send_client_input(&mut client_a, b"echo SHARED_VIEW\n");
+ assert!(
+ wait_for_frame(&mut client_b, Duration::from_secs(2)),
+ "client B should receive update from client A"
+ );
+ assert!(pane_read_recent_contains(
+ &api_socket,
+ &pane_id,
+ "SHARED_VIEW",
+ Duration::from_secs(5)
+ ));
+
+ // Detach client A; client B should keep working.
+ send_client_detach(&mut client_a);
+ drop(client_a);
+
+ send_client_input(&mut client_b, b"echo AFTER_A_DETACH\n");
+ assert!(
+ wait_for_frame(&mut client_b, Duration::from_secs(2)),
+ "remaining client should still receive frames after other client detaches"
+ );
+ assert!(pane_read_recent_contains(
+ &api_socket,
+ &pane_id,
+ "AFTER_A_DETACH",
+ Duration::from_secs(5)
+ ));
+
+ let ping = ping_socket(&api_socket);
+ assert!(
+ ping.contains("pong"),
+ "server and remaining client flow should stay healthy: {ping}"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+#[test]
+fn cross_area_server_kill_then_restart_and_reconnect() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let mut server = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ // Attach a real thin client process and prove it reached attached state
+ // by observing an incoming frame on its PTY stream.
+ let mut thin_client = spawn_client_process(&config_home, &runtime_dir, &api_socket);
+ let mut thin_reader = thin_client
+ ._master
+ .try_clone_reader()
+ .expect("clone thin client reader");
+
+ let attached_before_kill = {
+ let deadline = Instant::now() + Duration::from_secs(8);
+ let mut observed = false;
+ let mut buf = [0u8; 4096];
+ while Instant::now() < deadline {
+ match thin_reader.read(&mut buf) {
+ Ok(n) if n > 0 => {
+ let out = String::from_utf8_lossy(&buf[..n]);
+ if out.contains("\u{2500}")
+ || out.contains("workspace")
+ || out.contains("pane")
+ || out.contains("terminal")
+ {
+ observed = true;
+ break;
+ }
+ }
+ Ok(_) => thread::sleep(Duration::from_millis(30)),
+ Err(_) => thread::sleep(Duration::from_millis(30)),
+ }
+ }
+ observed
+ };
+ assert!(
+ attached_before_kill,
+ "thin client should complete attach before server SIGKILL"
+ );
+
+ // Kill server abruptly and verify thin client exits with lost-connection messaging.
+ let server_pid = server.child.process_id().expect("server pid should exist");
+ unsafe {
+ libc::kill(server_pid as libc::pid_t, libc::SIGKILL);
+ }
+ assert!(
+ wait_for_child_exit(&mut server.child, Duration::from_secs(5)),
+ "server should exit after SIGKILL"
+ );
+ drop(server);
+
+ let mut crash_output = String::new();
+ let thin_exited = {
+ let deadline = Instant::now() + Duration::from_secs(12);
+ let mut exited = false;
+ let mut buf = [0u8; 1024];
+ while Instant::now() < deadline {
+ if thin_client.child.try_wait().ok().flatten().is_some() {
+ exited = true;
+ break;
+ }
+ if let Ok(n) = thin_reader.read(&mut buf) {
+ if n > 0 {
+ crash_output.push_str(&String::from_utf8_lossy(&buf[..n]));
+ }
+ }
+ thread::sleep(Duration::from_millis(50));
+ }
+ exited
+ };
+ assert!(thin_exited, "thin client should exit after server SIGKILL");
+
+ let thin_status = thin_client
+ .child
+ .wait()
+ .expect("wait for thin client exit status");
+ assert!(
+ !thin_status.success(),
+ "thin client should exit non-zero after unexpected server crash"
+ );
+
+ // Drain trailing output and require the explicit user-visible lost-connection message.
+ let deadline = Instant::now() + Duration::from_secs(5);
+ let mut buf = [0u8; 2048];
+ while Instant::now() < deadline {
+ match thin_reader.read(&mut buf) {
+ Ok(n) if n > 0 => crash_output.push_str(&String::from_utf8_lossy(&buf[..n])),
+ Ok(_) => break,
+ Err(_) => break,
+ }
+ thread::sleep(Duration::from_millis(30));
+ }
+
+ let crash_output_lc = crash_output.to_lowercase();
+ assert!(
+ crash_output_lc.contains("lost connection to server"),
+ "thin client output must include explicit lost-connection message after server kill; output: {crash_output:?}"
+ );
+
+ // Restart server and verify new client can connect (stale socket cleaned).
+ let server2 = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_socket(&client_socket, Duration::from_secs(10));
+
+ let mut reconnect_client =
+ UnixStream::connect(&client_socket).expect("new client should connect after restart");
+ client_handshake(&mut reconnect_client, 1, 80, 24);
+ assert!(
+ wait_for_frame(&mut reconnect_client, Duration::from_secs(5)),
+ "new client should receive frame after restart"
+ );
+
+ let ping = ping_socket(&api_socket);
+ assert!(
+ ping.contains("pong"),
+ "restarted server should respond over API: {ping}"
+ );
+
+ cleanup_spawned_herdr(server2, base);
+}
diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs
new file mode 100644
index 00000000..a6bed866
--- /dev/null
+++ b/tests/detach_reattach.rs
@@ -0,0 +1,955 @@
+//! Integration tests for detach/reattach flow.
+//!
+
+mod support;
+
+use std::fs;
+use std::io::{BufRead, BufReader, Read, Write};
+use std::os::unix::net::UnixStream;
+use std::path::PathBuf;
+use std::sync::{Mutex, MutexGuard, OnceLock};
+use std::thread;
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
+use serde_json::Value;
+use support::{
+ cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
+ unregister_spawned_herdr_pid,
+};
+
+fn unique_test_dir() -> PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ PathBuf::from(format!(
+ "/tmp/herdr-detach-test-{}-{nanos}",
+ std::process::id()
+ ))
+}
+
+struct SpawnedHerdr {
+ _master: Box<dyn MasterPty + Send>,
+ child: Box<dyn Child + Send + Sync>,
+}
+
+impl Drop for SpawnedHerdr {
+ fn drop(&mut self) {
+ let pid = self.child.process_id();
+ let _ = self.child.kill();
+
+ if let Some(pid) = pid {
+ let deadline = Instant::now() + Duration::from_secs(2);
+ while Instant::now() < deadline {
+ let mut status = 0;
+ let result =
+ unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
+ if result == pid as libc::pid_t || result == -1 {
+ break;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+
+ unregister_spawned_herdr_pid(Some(pid));
+ }
+ }
+}
+
+fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) {
+ drop(spawned);
+ cleanup_test_base(&base);
+}
+
+fn test_lock() -> MutexGuard<'static, ()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn wait_for_socket(path: &PathBuf, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() && UnixStream::connect(path).is_ok() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("socket did not appear at {}", path.display());
+}
+
+fn wait_for_file(path: &PathBuf, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("file did not appear at {}", path.display());
+}
+
+fn spawn_server(
+ config_home: &PathBuf,
+ runtime_dir: &PathBuf,
+ api_socket_path: &PathBuf,
+ _client_socket_path: &PathBuf,
+) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("server");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+fn ping_socket(socket_path: &PathBuf) -> String {
+ let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
+
+ let request = r#"{"id":"1","method":"ping","params":{}}"#;
+ writeln!(stream, "{}", request).unwrap();
+
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+ response.trim().to_string()
+}
+
+fn send_json_request(socket_path: &PathBuf, request: &str) -> Value {
+ let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
+ writeln!(stream, "{}", request).unwrap();
+
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+ serde_json::from_str(&response).expect("response should be valid JSON")
+}
+
+fn workspace_create(socket_path: &PathBuf, label: &str) -> Value {
+ send_json_request(
+ socket_path,
+ &format!(
+ r#"{{"id":"workspace_create","method":"workspace.create","params":{{"label":"{label}"}}}}"#
+ ),
+ )
+}
+
+fn workspace_list(socket_path: &PathBuf) -> Value {
+ send_json_request(
+ socket_path,
+ r#"{"id":"workspace_list","method":"workspace.list","params":{}}"#,
+ )
+}
+
+fn pane_list(socket_path: &PathBuf, workspace_id: &str) -> Value {
+ send_json_request(
+ socket_path,
+ &format!(
+ r#"{{"id":"pane_list","method":"pane.list","params":{{"workspace_id":"{workspace_id}"}}}}"#
+ ),
+ )
+}
+
+fn pane_read_recent(socket_path: &PathBuf, pane_id: &str) -> Value {
+ send_json_request(
+ socket_path,
+ &format!(
+ r#"{{"id":"pane_read","method":"pane.read","params":{{"pane_id":"{pane_id}","source":"recent"}}}}"#
+ ),
+ )
+}
+
+fn workspace_id_by_label(response: &Value, label: &str) -> String {
+ response["result"]["workspaces"]
+ .as_array()
+ .expect("workspace.list should return an array")
+ .iter()
+ .find(|workspace| workspace["label"] == label)
+ .and_then(|workspace| workspace["workspace_id"].as_str())
+ .expect("workspace with expected label should exist")
+ .to_string()
+}
+
+fn first_pane_id(response: &Value) -> String {
+ response["result"]["panes"]
+ .as_array()
+ .expect("pane.list should return an array")
+ .first()
+ .and_then(|pane| pane["pane_id"].as_str())
+ .expect("pane.list should contain at least one pane")
+ .to_string()
+}
+
+// ---------------------------------------------------------------------------
+// Bincode v2 varint encoding helpers
+// ---------------------------------------------------------------------------
+
+/// Encode a varint u32 value according to bincode v2 VarintEncoding.
+fn encode_varint_u32(v: u32) -> Vec<u8> {
+ if v < 251 {
+ vec![v as u8]
+ } else if v < 65536 {
+ let mut buf = vec![251u8];
+ buf.extend_from_slice(&(v as u16).to_le_bytes());
+ buf
+ } else {
+ let mut buf = vec![252u8];
+ buf.extend_from_slice(&v.to_le_bytes());
+ buf
+ }
+}
+
+/// Encode a varint u16 value.
+fn encode_varint_u16(v: u16) -> Vec<u8> {
+ if v < 251 {
+ vec![v as u8]
+ } else {
+ let mut buf = vec![251u8];
+ buf.extend_from_slice(&v.to_le_bytes());
+ buf
+ }
+}
+
+/// Encode an enum variant with its fields.
+fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec<u8> {
+ let mut buf = encode_varint_u32(variant_idx);
+ for field in fields {
+ buf.extend_from_slice(field);
+ }
+ buf
+}
+
+/// Frame a message with u32LE length prefix.
+fn frame_message(payload: &[u8]) -> Vec<u8> {
+ let len = payload.len() as u32;
+ let mut framed = len.to_le_bytes().to_vec();
+ framed.extend_from_slice(payload);
+ framed
+}
+
+/// Decode a varint u32 from a byte slice at the given offset.
+fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> {
+ if offset >= payload.len() {
+ return Err("payload too short for varint".into());
+ }
+ let first_byte = payload[offset];
+ match first_byte {
+ 0..=250 => Ok((first_byte as u32, 1)),
+ 251 => {
+ if offset + 3 > payload.len() {
+ return Err("payload too short for u16 varint".into());
+ }
+ let v = u16::from_le_bytes(
+ payload[offset + 1..offset + 3]
+ .try_into()
+ .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
+ );
+ Ok((v as u32, 3))
+ }
+ 252 => {
+ if offset + 5 > payload.len() {
+ return Err("payload too short for u32 varint".into());
+ }
+ let v = u32::from_le_bytes(
+ payload[offset + 1..offset + 5]
+ .try_into()
+ .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
+ );
+ Ok((v, 5))
+ }
+ _ => Err(format!("unsupported varint tag: {first_byte}")),
+ }
+}
+
+/// Sends a Hello message over the client socket and reads the Welcome response.
+fn client_handshake(
+ stream: &mut UnixStream,
+ version: u32,
+ cols: u16,
+ rows: u16,
+) -> Result<(u32, Option<String>), String> {
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .map_err(|e| e.to_string())?;
+
+ // Encode Hello message: ClientMessage variant 0.
+ let hello_payload = encode_varint_enum(
+ 0,
+ &[
+ &encode_varint_u32(version),
+ &encode_varint_u16(cols),
+ &encode_varint_u16(rows),
+ ],
+ );
+ let framed = frame_message(&hello_payload);
+ stream.write_all(&framed).map_err(|e| e.to_string())?;
+ stream.flush().map_err(|e| e.to_string())?;
+
+ // Read the framed response.
+ let mut len_buf = [0u8; 4];
+ stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?;
+ let len = u32::from_le_bytes(len_buf) as usize;
+
+ if len > 2 * 1024 * 1024 {
+ return Err(format!("oversized response: {len}"));
+ }
+
+ let mut payload = vec![0u8; len];
+ stream.read_exact(&mut payload).map_err(|e| e.to_string())?;
+
+ // Decode Welcome: ServerMessage variant 0 = Welcome { version: u32, error: Option<String> }
+ let mut offset = 0;
+
+ let (variant, consumed) = decode_varint_u32(&payload, offset)?;
+ offset += consumed;
+ if variant != 0 {
+ return Err(format!(
+ "expected Welcome (variant 0), got variant {variant}"
+ ));
+ }
+
+ let (version, consumed) = decode_varint_u32(&payload, offset)?;
+ offset += consumed;
+
+ if offset >= payload.len() {
+ return Err("payload too short for Option tag".into());
+ }
+ let option_tag = payload[offset];
+ offset += 1;
+
+ let error = if option_tag == 1 {
+ let (str_len, consumed) = decode_varint_u32(&payload, offset)?;
+ offset += consumed;
+ let str_len = str_len as usize;
+
+ if offset + str_len > payload.len() {
+ return Err("payload too short for string content".into());
+ }
+ let s = String::from_utf8(payload[offset..offset + str_len].to_vec())
+ .map_err(|e| e.to_string())?;
+ Some(s)
+ } else {
+ None
+ };
+
+ Ok((version, error))
+}
+
+/// Read a framed ServerMessage from the stream, returning the variant index
+/// and the raw payload for further decoding.
+fn read_server_message(stream: &mut UnixStream) -> Result<(u32, Vec<u8>), String> {
+ stream
+ .set_read_timeout(Some(Duration::from_secs(10)))
+ .map_err(|e| e.to_string())?;
+
+ let mut len_buf = [0u8; 4];
+ stream
+ .read_exact(&mut len_buf)
+ .map_err(|e| format!("read length prefix: {e}"))?;
+ let len = u32::from_le_bytes(len_buf) as usize;
+
+ if len > 2 * 1024 * 1024 {
+ return Err(format!("oversized frame: {len} bytes"));
+ }
+ if len == 0 {
+ return Err("zero-length frame".into());
+ }
+
+ let mut payload = vec![0u8; len];
+ stream
+ .read_exact(&mut payload)
+ .map_err(|e| format!("read payload: {e}"))?;
+
+ let (variant, consumed) = decode_varint_u32(&payload, 0)?;
+
+ Ok((variant, payload[consumed..].to_vec()))
+}
+
+/// Sends a ClientMessage::Input with the given raw bytes.
+fn send_input(stream: &mut UnixStream, data: &[u8]) -> Result<(), String> {
+ let input_payload = {
+ let mut buf = encode_varint_u32(1); // variant 1 = Input
+ buf.extend_from_slice(&encode_varint_u32(data.len() as u32));
+ buf.extend_from_slice(data);
+ buf
+ };
+ let framed = frame_message(&input_payload);
+ stream
+ .write_all(&framed)
+ .map_err(|e| format!("write input: {e}"))?;
+ stream.flush().map_err(|e| format!("flush input: {e}"))?;
+ Ok(())
+}
+
+/// Sends a ClientMessage::Detach.
+fn send_detach(stream: &mut UnixStream) -> Result<(), String> {
+ // ClientMessage::Detach is variant 3 with no fields.
+ let detach_payload = encode_varint_u32(3);
+ let framed = frame_message(&detach_payload);
+ stream
+ .write_all(&framed)
+ .map_err(|e| format!("write detach: {e}"))?;
+ stream.flush().map_err(|e| format!("flush detach: {e}"))?;
+ Ok(())
+}
+
+/// Drains all pending messages from the server stream (non-blocking).
+fn drain_messages(stream: &mut UnixStream) {
+ stream
+ .set_read_timeout(Some(Duration::from_millis(200)))
+ .unwrap();
+ while let Ok(_) = read_server_message(stream) {}
+ stream.set_read_timeout(None).unwrap();
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[test]
+fn navigate_q_detaches_client_and_server_persists() {
+ // In persistence mode, navigate-mode q detaches the client and the server persists.
+ // Flow:
+ // 1. Start server
+ // 2. Connect client, handshake
+ // 3. Send prefix key (Ctrl+B) then 'q'
+ // 4. Verify server is still alive via API ping
+ // 5. Verify the client connection is closed
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect and handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Drain initial frames.
+ drain_messages(&mut stream);
+
+ // Send prefix key (Ctrl+B = 0x02) then 'q' (quit/detach in persistence mode).
+ send_input(&mut stream, &[0x02]).expect("send prefix");
+ thread::sleep(Duration::from_millis(300));
+
+ // Drain any frames generated by entering navigate mode.
+ drain_messages(&mut stream);
+
+ send_input(&mut stream, b"q").expect("send detach key");
+
+ // Give the server time to process the detach.
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify server is still alive and responsive.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "server should still respond to ping after client detach: {response}"
+ );
+
+ // The client should receive a ServerShutdown with reason "detached"
+ // shortly after the quit/detach key. There may be some frames in
+ // between from the mode change, so we read multiple messages.
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .unwrap();
+ let mut got_shutdown = false;
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline {
+ match read_server_message(&mut stream) {
+ Ok((variant, _payload)) => {
+ if variant == 2 {
+ // ServerMessage::ServerShutdown — detach acknowledged.
+ got_shutdown = true;
+ break;
+ }
+ // Variant 1 = Frame — may arrive before the shutdown.
+ // Keep reading.
+ }
+ Err(_) => {
+ // Connection closed — also acceptable.
+ got_shutdown = true; // consider this a successful detach
+ break;
+ }
+ }
+ }
+ assert!(
+ got_shutdown,
+ "client should receive ServerShutdown after quit/detach key"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn explicit_detach_message_causes_clean_disconnect() {
+ // Client sends ClientMessage::Detach
+ // directly (not via keybind), server handles it gracefully.
+ // This is the flow when the client process is exiting cleanly (Ctrl+C).
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect and handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect");
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Drain initial frames.
+ drain_messages(&mut stream);
+
+ // Send ClientMessage::Detach directly.
+ send_detach(&mut stream).expect("send detach message");
+
+ // Give the server time to process.
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify server is still alive.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "server should persist after client Detach message: {response}"
+ );
+
+ // The client connection should eventually be closed.
+ // After sending Detach, the server removes the client.
+ // We may still receive a few queued frames before the connection closes.
+ stream
+ .set_read_timeout(Some(Duration::from_secs(3)))
+ .unwrap();
+
+ // Drain remaining messages until EOF or error.
+ let mut got_eof = false;
+ let deadline = Instant::now() + Duration::from_secs(3);
+ while Instant::now() < deadline {
+ match read_server_message(&mut stream) {
+ Ok((variant, _)) => {
+ // May receive frames or other messages before disconnect.
+ let _ = variant;
+ }
+ Err(_) => {
+ got_eof = true;
+ break;
+ }
+ }
+ }
+ assert!(
+ got_eof,
+ "client connection should be closed after explicit Detach message"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn reattach_after_detach_shows_current_state() {
+ // Flow:
+ // 1. Start server
+ // 2. Connect client A, create a workspace via API
+ // 3. Client A detaches
+ // 4. Connect client B (reattach), verify it receives a frame
+ // 5. Verify client B can see the workspace created by client A
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // --- Client A ---
+ let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
+ let (version, error) =
+ client_handshake(&mut stream_a, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Drain initial frames.
+ drain_messages(&mut stream_a);
+
+ // Create a workspace via API while client A is attached.
+ let mut ws_stream = UnixStream::connect(&api_socket).expect("connect to API");
+ let request = r#"{"id":"1","method":"workspace.create","params":{"label":"reattach-test"}}"#;
+ writeln!(ws_stream, "{}", request).unwrap();
+ let mut reader = BufReader::new(ws_stream);
+ let mut ws_response = String::new();
+ reader.read_line(&mut ws_response).unwrap();
+ assert!(
+ ws_response.contains("workspace_created") || ws_response.contains("ok"),
+ "workspace creation should succeed: {ws_response}"
+ );
+
+ // Give the server time to process and render.
+ thread::sleep(Duration::from_millis(300));
+
+ // Client A detaches (send ClientMessage::Detach).
+ send_detach(&mut stream_a).expect("send detach");
+
+ // Give the server time to process the detach.
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify server is still alive.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "server should persist after detach: {response}"
+ );
+
+ // --- Client B (reattach) ---
+ let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
+ let (version, error) =
+ client_handshake(&mut stream_b, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(
+ error.is_none(),
+ "reattach handshake should succeed: {:?}",
+ error
+ );
+
+ // Client B should receive a frame with the current state,
+ // including the workspace created while client A was attached.
+ stream_b.set_nonblocking(false).unwrap();
+ stream_b
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .unwrap();
+
+ let mut received_frame = false;
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline {
+ match read_server_message(&mut stream_b) {
+ Ok((variant, _payload)) => {
+ if variant == 1 {
+ // ServerMessage::Frame
+ received_frame = true;
+ break;
+ }
+ }
+ Err(_) => break,
+ }
+ }
+ assert!(
+ received_frame,
+ "reattached client should receive a Frame with current state"
+ );
+
+ // Verify the workspace still exists via API.
+ let mut list_stream = UnixStream::connect(&api_socket).expect("connect to API");
+ let list_request = r#"{"id":"2","method":"workspace.list","params":{}}"#;
+ writeln!(list_stream, "{}", list_request).unwrap();
+ let mut list_reader = BufReader::new(list_stream);
+ let mut list_response = String::new();
+ list_reader.read_line(&mut list_response).unwrap();
+ assert!(
+ list_response.contains("reattach-test"),
+ "workspace should still exist after detach/reattach: {list_response}"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn processes_survive_during_and_after_detach() {
+ // PTY processes continue running during and after detach.
+ //
+ // Simplified flow:
+ // 1. Start server
+ // 2. Connect client, send "echo SURVIVED" to the pane
+ // 3. Detach client
+ // 4. Verify server is still alive and API works
+ // 5. Reattach and verify we can receive a frame
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Verify server starts with a workspace (session restore or fresh state).
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "server should respond to ping: {response}"
+ );
+
+ // Connect and handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect");
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Drain initial frames.
+ drain_messages(&mut stream);
+
+ // Send input to the pane — the fresh server should have at least one
+ // pane with a shell running.
+ send_input(&mut stream, b"echo SURVIVED_DETACH\n").expect("send echo command");
+
+ // Wait for the shell to process the command.
+ thread::sleep(Duration::from_millis(500));
+
+ // Drain any frames generated by the input.
+ drain_messages(&mut stream);
+
+ // Detach the client via explicit Detach message.
+ send_detach(&mut stream).expect("send detach");
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify server is still alive after detach.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "server should persist after detach: {response}"
+ );
+
+ // Wait a moment while detached.
+ thread::sleep(Duration::from_secs(1));
+
+ // Reattach — verify we can connect and receive a frame.
+ let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
+ let (version, error) =
+ client_handshake(&mut stream_b, 1, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Verify the reattached client receives a frame.
+ stream_b
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .unwrap();
+ let mut received_frame = false;
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline {
+ match read_server_message(&mut stream_b) {
+ Ok((variant, _)) => {
+ if variant == 1 {
+ received_frame = true;
+ break;
+ }
+ }
+ Err(_) => break,
+ }
+ }
+ assert!(
+ received_frame,
+ "reattached client should receive a Frame showing current state"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn server_persists_after_client_connection_drop() {
+ // Server continues running after a client
+ // disconnects (not just detach — also connection drop).
+ // Verify that after a client connection is abruptly closed (not via Detach),
+ // the server continues running and can accept new connections.
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect and handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect");
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Drain initial frames.
+ drain_messages(&mut stream);
+
+ // Drop the connection abruptly (simulating client crash).
+ drop(stream);
+
+ // Give the server time to detect the disconnection.
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify server is still alive.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "server should persist after client connection drop: {response}"
+ );
+
+ // Reattach — verify we can connect and handshake again.
+ let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
+ let (version, error) =
+ client_handshake(&mut stream_b, 1, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "reattach should succeed: {:?}", error);
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn output_accumulated_while_detached_visible_on_reattach() {
+ // Output produced while detached is visible in the
+ // reattached client's scrollback.
+ //
+ // Simplified flow:
+ // 1. Start server, attach client A
+ // 2. Detach client A (without sending any special input)
+ // 3. Use API to send text to a pane while detached
+ // 4. Reattach as client B
+ // 5. Verify client B receives a frame
+ // 6. Verify the pane content via API includes the text sent while detached
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect and handshake client A.
+ let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
+ let (version, error) =
+ client_handshake(&mut stream_a, 1, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Detach client A immediately.
+ send_detach(&mut stream_a).expect("send detach");
+ thread::sleep(Duration::from_millis(500));
+
+ // Verify server alive.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "server should persist: {response}"
+ );
+
+ // Use API to send text to a pane while no client is attached.
+ // First create a workspace and find its pane.
+ let ws_create_response = workspace_create(&api_socket, "scrollback-test");
+ assert_eq!(ws_create_response["result"]["type"], "workspace_created");
+
+ thread::sleep(Duration::from_millis(300));
+
+ // Find the workspace and pane IDs.
+ let ws_response = workspace_list(&api_socket);
+ let ws_id = workspace_id_by_label(&ws_response, "scrollback-test");
+
+ // Get pane list for this workspace.
+ let pane_response = pane_list(&api_socket, &ws_id);
+ let pane_id = first_pane_id(&pane_response);
+
+ // Send text to the pane via API while detached.
+ let mut send_stream = UnixStream::connect(&api_socket).expect("connect to API");
+ let send_request = format!(
+ r#"{{"id":"4","method":"pane.send_text","params":{{"pane_id":"{pane_id}","text":"echo DURING_DETACH\n"}}}}"#
+ );
+ writeln!(send_stream, "{}", send_request).unwrap();
+ let mut send_reader = BufReader::new(send_stream);
+ let mut send_response = String::new();
+ send_reader.read_line(&mut send_response).unwrap();
+
+ // Wait for the echo command to execute.
+ thread::sleep(Duration::from_millis(500));
+
+ // --- Client B (reattach) ---
+ let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
+ let (version, error) =
+ client_handshake(&mut stream_b, 1, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 1);
+ assert!(error.is_none(), "{:?}", error);
+
+ // Client B should receive a frame with the current state.
+ stream_b
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .unwrap();
+ let mut received_frame = false;
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while Instant::now() < deadline {
+ match read_server_message(&mut stream_b) {
+ Ok((variant, _)) => {
+ if variant == 1 {
+ received_frame = true;
+ break;
+ }
+ }
+ Err(_) => break,
+ }
+ }
+ assert!(
+ received_frame,
+ "reattached client should receive a Frame showing current state"
+ );
+
+ // Verify the pane content via API includes the output sent while detached.
+ let read_response = pane_read_recent(&api_socket, &pane_id);
+
+ // The pane output should contain the text sent while detached.
+ assert!(
+ read_response["result"]["read"]["text"]
+ .as_str()
+ .unwrap_or_default()
+ .contains("DURING_DETACH"),
+ "pane should contain output produced while detached: {read_response}"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
diff --git a/tests/multi_client.rs b/tests/multi_client.rs
new file mode 100644
index 00000000..dee24f08
--- /dev/null
+++ b/tests/multi_client.rs
@@ -0,0 +1,1019 @@
+//! Integration tests for multi-client server behavior.
+
+mod support;
+
+use std::fs;
+use std::io::{self, BufRead, BufReader, Read, Write};
+use std::os::unix::net::UnixStream;
+use std::path::{Path, PathBuf};
+use std::sync::{Mutex, MutexGuard, OnceLock};
+use std::thread;
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
+use serde::Deserialize;
+use serde_json::Value;
+use support::{
+ cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
+ unregister_spawned_herdr_pid,
+};
+
+fn unique_test_dir() -> PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ PathBuf::from(format!(
+ "/tmp/herdr-multi-client-test-{}-{nanos}",
+ std::process::id()
+ ))
+}
+
+struct SpawnedHerdr {
+ _master: Box<dyn MasterPty + Send>,
+ child: Box<dyn Child + Send + Sync>,
+}
+
+impl Drop for SpawnedHerdr {
+ fn drop(&mut self) {
+ let pid = self.child.process_id();
+ let _ = self.child.kill();
+
+ if let Some(pid) = pid {
+ let deadline = Instant::now() + Duration::from_secs(2);
+ while Instant::now() < deadline {
+ let mut status = 0;
+ let result =
+ unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
+ if result == pid as libc::pid_t || result == -1 {
+ break;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+
+ unregister_spawned_herdr_pid(Some(pid));
+ }
+ }
+}
+
+fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) {
+ drop(spawned);
+ cleanup_test_base(&base);
+}
+
+fn wait_for_child_exit(child: &mut Box<dyn Child + Send + Sync>) {
+ let _ = child.kill();
+ let _ = child.wait();
+}
+
+fn test_lock() -> MutexGuard<'static, ()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn wait_for_socket(path: &Path, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() && UnixStream::connect(path).is_ok() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("socket did not appear at {}", path.display());
+}
+
+fn wait_for_file(path: &Path, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("file did not appear at {}", path.display());
+}
+
+fn spawn_server(config_home: &Path, runtime_dir: &Path, api_socket_path: &Path) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("server");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+fn spawn_client_process(
+ config_home: &Path,
+ runtime_dir: &Path,
+ api_socket_path: &Path,
+) -> SpawnedHerdr {
+ register_runtime_dir(runtime_dir);
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("client");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+fn server_log_path(config_home: &Path) -> PathBuf {
+ let app_dir = if cfg!(debug_assertions) {
+ "herdr-dev"
+ } else {
+ "herdr"
+ };
+ config_home.join(app_dir).join("herdr-server.log")
+}
+
+fn count_log_occurrences(path: &Path, needle: &str) -> usize {
+ fs::read_to_string(path)
+ .ok()
+ .map(|text| text.lines().filter(|line| line.contains(needle)).count())
+ .unwrap_or(0)
+}
+
+fn wait_for_log_occurrence_count(
+ path: &Path,
+ needle: &str,
+ min_count: usize,
+ timeout: Duration,
+) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if count_log_occurrences(path, needle) >= min_count {
+ return true;
+ }
+ thread::sleep(Duration::from_millis(40));
+ }
+ false
+}
+
+fn ping_socket(socket_path: &Path) -> String {
+ let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
+ writeln!(
+ stream,
+ "{{\"id\":\"ping\",\"method\":\"ping\",\"params\":{{}}}}"
+ )
+ .unwrap();
+
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+ response.trim().to_string()
+}
+
+fn send_json_request(socket_path: &Path, request: &str) -> Value {
+ let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
+ writeln!(stream, "{request}").unwrap();
+
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+
+ serde_json::from_str(&response).expect("response should be valid JSON")
+}
+
+fn create_workspace_and_root_pane(socket_path: &Path, label: &str) -> (String, String) {
+ let response = send_json_request(
+ socket_path,
+ &format!(
+ "{{\"id\":\"ws_create\",\"method\":\"workspace.create\",\"params\":{{\"label\":\"{label}\"}}}}"
+ ),
+ );
+
+ if response.get("error").is_some() {
+ panic!("workspace.create failed: {response}");
+ }
+
+ let workspace_id = response
+ .pointer("/result/workspace/workspace_id")
+ .and_then(Value::as_str)
+ .expect("workspace.create should return workspace id")
+ .to_string();
+
+ let pane_id = response
+ .pointer("/result/root_pane/pane_id")
+ .and_then(Value::as_str)
+ .expect("workspace.create should return root pane id")
+ .to_string();
+
+ (workspace_id, pane_id)
+}
+
+fn pane_send_input(socket_path: &Path, pane_id: &str, text: &str) {
+ let request = format!(
+ "{{\"id\":\"send_input\",\"method\":\"pane.send_input\",\"params\":{{\"pane_id\":\"{pane_id}\",\"text\":\"{}\",\"keys\":[\"Enter\"]}}}}",
+ text.replace('"', "\\\"")
+ );
+ let response = send_json_request(socket_path, &request);
+ if response.get("error").is_some() {
+ panic!("pane.send_input failed: {response}");
+ }
+}
+
+fn pane_read_recent(socket_path: &Path, pane_id: &str, lines: usize) -> String {
+ let response = send_json_request(
+ socket_path,
+ &format!(
+ "{{\"id\":\"pane_read\",\"method\":\"pane.read\",\"params\":{{\"pane_id\":\"{pane_id}\",\"source\":\"recent\",\"lines\":{lines}}}}}"
+ ),
+ );
+
+ if response.get("error").is_some() {
+ panic!("pane.read failed: {response}");
+ }
+
+ response
+ .pointer("/result/read/text")
+ .and_then(Value::as_str)
+ .unwrap_or_default()
+ .to_string()
+}
+
+fn pane_read_recent_contains(
+ socket_path: &Path,
+ pane_id: &str,
+ needle: &str,
+ timeout: Duration,
+) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if pane_read_recent(socket_path, pane_id, 200).contains(needle) {
+ return true;
+ }
+ thread::sleep(Duration::from_millis(50));
+ }
+ false
+}
+
+fn parse_size_after_marker(text: &str, marker: &str) -> Option<(u16, u16)> {
+ let mut seen_marker = false;
+ for line in text.lines() {
+ if !seen_marker {
+ if line.contains(marker) {
+ seen_marker = true;
+ }
+ continue;
+ }
+
+ let mut parts = line.split_whitespace();
+ let Some(rows_raw) = parts.next() else {
+ continue;
+ };
+ let Some(cols_raw) = parts.next() else {
+ continue;
+ };
+
+ let Ok(rows) = rows_raw.parse::<u16>() else {
+ continue;
+ };
+ let Ok(cols) = cols_raw.parse::<u16>() else {
+ continue;
+ };
+
+ return Some((rows, cols));
+ }
+
+ None
+}
+
+fn try_read_pane_tty_size(
+ socket_path: &Path,
+ pane_id: &str,
+ timeout: Duration,
+) -> Option<(u16, u16)> {
+ let marker = format!(
+ "SIZE_MARKER_{}_{}",
+ std::process::id(),
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ );
+
+ pane_send_input(socket_path, pane_id, &format!("echo {marker}; stty size"));
+
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ let text = pane_read_recent(socket_path, pane_id, 200);
+ if let Some(size) = parse_size_after_marker(&text, &marker) {
+ return Some(size);
+ }
+ thread::sleep(Duration::from_millis(50));
+ }
+
+ None
+}
+
+fn read_pane_tty_size(socket_path: &Path, pane_id: &str, timeout: Duration) -> (u16, u16) {
+ if let Some(size) = try_read_pane_tty_size(socket_path, pane_id, timeout) {
+ return size;
+ }
+
+ let snapshot = pane_read_recent(socket_path, pane_id, 200);
+ panic!(
+ "did not observe tty size after marker. pane output:\n{}",
+ snapshot
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Minimal bincode v2 varint helpers for protocol tests
+// ---------------------------------------------------------------------------
+
+fn encode_varint_u32(v: u32) -> Vec<u8> {
+ if v < 251 {
+ vec![v as u8]
+ } else if v < 65536 {
+ let mut buf = vec![251u8];
+ buf.extend_from_slice(&(v as u16).to_le_bytes());
+ buf
+ } else {
+ let mut buf = vec![252u8];
+ buf.extend_from_slice(&v.to_le_bytes());
+ buf
+ }
+}
+
+fn encode_varint_u16(v: u16) -> Vec<u8> {
+ if v < 251 {
+ vec![v as u8]
+ } else {
+ let mut buf = vec![251u8];
+ buf.extend_from_slice(&v.to_le_bytes());
+ buf
+ }
+}
+
+fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec<u8> {
+ let mut buf = encode_varint_u32(variant_idx);
+ for field in fields {
+ buf.extend_from_slice(field);
+ }
+ buf
+}
+
+fn frame_message(payload: &[u8]) -> Vec<u8> {
+ let len = payload.len() as u32;
+ let mut framed = len.to_le_bytes().to_vec();
+ framed.extend_from_slice(payload);
+ framed
+}
+
+fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> {
+ if offset >= payload.len() {
+ return Err("payload too short for varint".into());
+ }
+ let first_byte = payload[offset];
+ match first_byte {
+ 0..=250 => Ok((first_byte as u32, 1)),
+ 251 => {
+ if offset + 3 > payload.len() {
+ return Err("payload too short for u16 varint".into());
+ }
+ let v = u16::from_le_bytes(
+ payload[offset + 1..offset + 3]
+ .try_into()
+ .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
+ );
+ Ok((v as u32, 3))
+ }
+ 252 => {
+ if offset + 5 > payload.len() {
+ return Err("payload too short for u32 varint".into());
+ }
+ let v = u32::from_le_bytes(
+ payload[offset + 1..offset + 5]
+ .try_into()
+ .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
+ );
+ Ok((v, 5))
+ }
+ _ => Err(format!("unsupported varint tag: {first_byte}")),
+ }
+}
+
+fn is_timeout(err: &io::Error) -> bool {
+ matches!(
+ err.kind(),
+ io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
+ )
+}
+
+fn read_server_variant(stream: &mut UnixStream, timeout: Duration) -> io::Result<u32> {
+ stream.set_read_timeout(Some(timeout))?;
+
+ let mut len_buf = [0u8; 4];
+ stream.read_exact(&mut len_buf)?;
+ let len = u32::from_le_bytes(len_buf) as usize;
+ if len == 0 {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "zero-length payload",
+ ));
+ }
+
+ let mut payload = vec![0u8; len];
+ stream.read_exact(&mut payload)?;
+
+ let (variant, _consumed) = decode_varint_u32(&payload, 0)
+ .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
+ Ok(variant)
+}
+
+fn client_handshake(
+ stream: &mut UnixStream,
+ version: u32,
+ cols: u16,
+ rows: u16,
+) -> Result<(), String> {
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .map_err(|e| e.to_string())?;
+
+ // ClientMessage::Hello = variant 0
+ let hello_payload = encode_varint_enum(
+ 0,
+ &[
+ &encode_varint_u32(version),
+ &encode_varint_u16(cols),
+ &encode_varint_u16(rows),
+ ],
+ );
+ stream
+ .write_all(&frame_message(&hello_payload))
+ .map_err(|e| e.to_string())?;
+ stream.flush().map_err(|e| e.to_string())?;
+
+ // Read ServerMessage::Welcome = variant 0
+ let mut len_buf = [0u8; 4];
+ stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?;
+ let len = u32::from_le_bytes(len_buf) as usize;
+ let mut payload = vec![0u8; len];
+ stream.read_exact(&mut payload).map_err(|e| e.to_string())?;
+
+ let mut offset = 0;
+ let (variant, consumed) = decode_varint_u32(&payload, offset)?;
+ offset += consumed;
+ if variant != 0 {
+ return Err(format!("expected Welcome variant 0, got {variant}"));
+ }
+
+ let (_server_version, consumed) = decode_varint_u32(&payload, offset)?;
+ offset += consumed;
+
+ if offset >= payload.len() {
+ return Err("payload too short for Welcome.error option tag".into());
+ }
+ let option_tag = payload[offset];
+ offset += 1;
+
+ if option_tag == 1 {
+ let (str_len, consumed) = decode_varint_u32(&payload, offset)?;
+ offset += consumed;
+ let str_len = str_len as usize;
+ if offset + str_len > payload.len() {
+ return Err("payload too short for welcome error string".into());
+ }
+ let err = String::from_utf8(payload[offset..offset + str_len].to_vec())
+ .map_err(|e| e.to_string())?;
+ return Err(format!("handshake rejected: {err}"));
+ }
+
+ Ok(())
+}
+
+fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream {
+ let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket");
+ client_handshake(&mut stream, 1, cols, rows).expect("handshake should succeed");
+ stream
+}
+
+fn send_client_input(stream: &mut UnixStream, data: &[u8]) {
+ // ClientMessage::Input = variant 1
+ let payload = {
+ let mut buf = encode_varint_u32(1);
+ buf.extend_from_slice(&encode_varint_u32(data.len() as u32));
+ buf.extend_from_slice(data);
+ buf
+ };
+ stream.write_all(&frame_message(&payload)).unwrap();
+ stream.flush().unwrap();
+}
+
+fn send_client_detach(stream: &mut UnixStream) {
+ // ClientMessage::Detach = variant 3
+ let payload = encode_varint_u32(3);
+ stream.write_all(&frame_message(&payload)).unwrap();
+ stream.flush().unwrap();
+}
+
+#[derive(Debug, Deserialize)]
+struct FrameWire {
+ cells: Vec<CellWire>,
+ width: u16,
+ height: u16,
+ cursor: Option<CursorWire>,
+}
+
+#[derive(Debug, Deserialize)]
+struct CellWire {
+ symbol: String,
+ fg: u32,
+ bg: u32,
+ modifier: u16,
+ skip: bool,
+}
+
+#[derive(Debug, Deserialize)]
+struct CursorWire {
+ x: u16,
+ y: u16,
+ visible: bool,
+}
+
+fn decode_frame_payload(payload: &[u8]) -> io::Result<FrameWire> {
+ bincode::serde::decode_from_slice(payload, bincode::config::standard())
+ .map(|(frame, consumed)| (frame, consumed))
+ .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))
+ .and_then(|(frame, consumed): (FrameWire, usize)| {
+ if consumed != payload.len() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "frame payload had trailing bytes: consumed={}, len={}",
+ consumed,
+ payload.len()
+ ),
+ ));
+ }
+ Ok(frame)
+ })
+}
+
+fn read_server_message_payload(
+ stream: &mut UnixStream,
+ timeout: Duration,
+) -> io::Result<(u32, Vec<u8>)> {
+ stream.set_read_timeout(Some(timeout))?;
+
+ let mut len_buf = [0u8; 4];
+ stream.read_exact(&mut len_buf)?;
+ let len = u32::from_le_bytes(len_buf) as usize;
+ if len == 0 {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "zero-length payload",
+ ));
+ }
+
+ let mut payload = vec![0u8; len];
+ stream.read_exact(&mut payload)?;
+
+ let (variant, consumed) = decode_varint_u32(&payload, 0)
+ .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
+
+ Ok((variant, payload[consumed..].to_vec()))
+}
+
+fn drain_server_messages(stream: &mut UnixStream, max_drain: Duration) {
+ let deadline = Instant::now() + max_drain;
+ while Instant::now() < deadline {
+ match read_server_variant(stream, Duration::from_millis(50)) {
+ Ok(_) => {}
+ Err(err) if is_timeout(&err) => break,
+ Err(_) => break,
+ }
+ }
+}
+
+fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ let remaining = deadline.saturating_duration_since(Instant::now());
+ let slice = remaining.min(Duration::from_millis(75));
+ match read_server_variant(stream, slice) {
+ Ok(1) => return true, // ServerMessage::Frame
+ Ok(_) => {}
+ Err(err) if is_timeout(&err) => {}
+ Err(_) => return false,
+ }
+ }
+ false
+}
+
+fn wait_for_frame_matching(
+ stream: &mut UnixStream,
+ timeout: Duration,
+ predicate: impl Fn(&FrameWire) -> bool,
+) -> io::Result<bool> {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ let slice = deadline
+ .saturating_duration_since(Instant::now())
+ .min(Duration::from_millis(80));
+ match read_server_message_payload(stream, slice) {
+ Ok((1, frame_payload)) => {
+ let frame = decode_frame_payload(&frame_payload)?;
+ if predicate(&frame) {
+ return Ok(true);
+ }
+ }
+ Ok((_variant, _payload)) => {}
+ Err(err) if is_timeout(&err) => {}
+ Err(err) => return Err(err),
+ }
+ }
+
+ Ok(false)
+}
+
+fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool {
+ if frame.cells.is_empty() {
+ return false;
+ }
+
+ let row_width = frame.width.max(1) as usize;
+ let mut full_text = String::new();
+
+ for row in frame.cells.chunks(row_width) {
+ for cell in row {
+ let _ = (cell.fg, cell.bg, cell.modifier, cell.skip);
+ full_text.push_str(&cell.symbol);
+ }
+ full_text.push('\n');
+ }
+
+ let _ = frame.height;
+ if let Some(cursor) = frame.cursor.as_ref() {
+ let _ = (cursor.x, cursor.y, cursor.visible);
+ }
+
+ full_text.contains(needle)
+}
+
+#[test]
+fn multi_client_allows_multiple_simultaneous_connections() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ let mut client_a = connect_raw_client(&client_socket, 120, 40);
+ let mut client_b = connect_raw_client(&client_socket, 100, 30);
+
+ assert!(
+ wait_for_frame(&mut client_a, Duration::from_secs(2)),
+ "client A should receive frames"
+ );
+ assert!(
+ wait_for_frame(&mut client_b, Duration::from_secs(2)),
+ "client B should receive frames"
+ );
+
+ let ping = ping_socket(&api_socket);
+ assert!(
+ ping.contains("pong"),
+ "server should remain responsive: {ping}"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+#[test]
+fn multi_client_effective_size_shrinks_when_smaller_client_joins() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ let (_workspace_id, pane_id) = create_workspace_and_root_pane(&api_socket, "size-shrink");
+
+ let mut large = connect_raw_client(&client_socket, 120, 40);
+ assert!(wait_for_frame(&mut large, Duration::from_secs(2)));
+ let large_only_size = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5));
+
+ let mut small = connect_raw_client(&client_socket, 80, 24);
+ assert!(wait_for_frame(&mut small, Duration::from_secs(2)));
+ let with_small_size = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5));
+
+ assert!(
+ with_small_size.0 < large_only_size.0 && with_small_size.1 < large_only_size.1,
+ "effective pane size should shrink when smaller client joins: before={:?}, after={:?}",
+ large_only_size,
+ with_small_size
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+#[test]
+fn multi_client_broadcasts_frame_updates_to_all_clients_within_500ms() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ let mut client_a = connect_raw_client(&client_socket, 100, 30);
+ let mut client_b = connect_raw_client(&client_socket, 100, 30);
+
+ // Ensure we have an active pane that can reflect input changes.
+ let (_workspace_id, pane_id) =
+ create_workspace_and_root_pane(&api_socket, "broadcast-client-a-to-b");
+
+ // Drain initial frames so we measure the frame caused by new input.
+ drain_server_messages(&mut client_a, Duration::from_millis(300));
+ drain_server_messages(&mut client_b, Duration::from_millis(300));
+
+ let marker = format!(
+ "MB{}",
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis())
+ .unwrap_or(0)
+ );
+
+ let start = Instant::now();
+ send_client_input(&mut client_a, marker.as_bytes());
+ let received = wait_for_frame_matching(&mut client_b, Duration::from_millis(500), |frame| {
+ frame_contains_text(frame, &marker)
+ })
+ .expect("frame decoding should succeed");
+
+ assert!(
+ received,
+ "client B should receive a broadcast frame containing client A marker within 500ms"
+ );
+ assert!(
+ start.elapsed() <= Duration::from_millis(500),
+ "broadcast frame containing marker should arrive within 500ms, got {:?}",
+ start.elapsed()
+ );
+ assert!(
+ pane_read_recent_contains(&api_socket, &pane_id, &marker, Duration::from_secs(5)),
+ "pane output should include client A marker so broadcast reflects a real state change"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+#[test]
+fn multi_client_disconnect_recalculates_to_next_smallest() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ let (_workspace_id, pane_id) =
+ create_workspace_and_root_pane(&api_socket, "size-next-smallest");
+
+ let mut c120 = connect_raw_client(&client_socket, 120, 40);
+ let mut c100 = connect_raw_client(&client_socket, 100, 30);
+ let mut c80 = connect_raw_client(&client_socket, 80, 24);
+
+ assert!(wait_for_frame(&mut c120, Duration::from_secs(2)));
+ assert!(wait_for_frame(&mut c100, Duration::from_secs(2)));
+ assert!(wait_for_frame(&mut c80, Duration::from_secs(2)));
+
+ let size_with_three = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5));
+
+ // Smallest client disconnects; effective size should increase to the next-smallest.
+ send_client_detach(&mut c80);
+ drop(c80);
+
+ let deadline = Instant::now() + Duration::from_secs(8);
+ let mut size_after_smallest_disconnect = None;
+ while Instant::now() < deadline {
+ let maybe_size = try_read_pane_tty_size(&api_socket, &pane_id, Duration::from_millis(400));
+ if let Some(size) = maybe_size {
+ if size.0 > size_with_three.0 && size.1 > size_with_three.1 {
+ size_after_smallest_disconnect = Some(size);
+ break;
+ }
+ }
+ thread::sleep(Duration::from_millis(60));
+ }
+
+ assert!(
+ size_after_smallest_disconnect.is_some(),
+ "effective pane size should increase after smallest disconnects: before={:?}, last_seen={:?}",
+ size_with_three,
+ try_read_pane_tty_size(&api_socket, &pane_id, Duration::from_millis(300))
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+#[test]
+fn multi_client_smallest_leaving_resizes_up_for_remaining_clients() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ let (_workspace_id, pane_id) = create_workspace_and_root_pane(&api_socket, "size-resize-up");
+
+ let mut large = connect_raw_client(&client_socket, 120, 40);
+ let mut small = connect_raw_client(&client_socket, 80, 24);
+
+ assert!(wait_for_frame(&mut large, Duration::from_secs(2)));
+ assert!(wait_for_frame(&mut small, Duration::from_secs(2)));
+
+ let size_with_small_client = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5));
+
+ drain_server_messages(&mut large, Duration::from_millis(250));
+
+ send_client_detach(&mut small);
+ drop(small);
+
+ // Remaining client should receive a new (larger) frame.
+ assert!(
+ wait_for_frame(&mut large, Duration::from_secs(2)),
+ "remaining client should receive resized-up frame"
+ );
+
+ let size_after_small_leaves = read_pane_tty_size(&api_socket, &pane_id, Duration::from_secs(5));
+
+ assert!(
+ size_after_small_leaves.0 > size_with_small_client.0
+ && size_after_small_leaves.1 > size_with_small_client.1,
+ "remaining clients should get larger effective pane size after smallest leaves: before={:?}, after={:?}",
+ size_with_small_client,
+ size_after_small_leaves
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+#[test]
+fn multi_client_client_crash_sigkill_does_not_affect_server() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ let mut survivor = connect_raw_client(&client_socket, 100, 30);
+ assert!(wait_for_frame(&mut survivor, Duration::from_secs(2)));
+
+ let log_path = server_log_path(&config_home);
+ let connected_before = count_log_occurrences(&log_path, "client connected");
+
+ let crashing_client = spawn_client_process(&config_home, &runtime_dir, &api_socket);
+
+ let attached_before_kill = wait_for_log_occurrence_count(
+ &log_path,
+ "client connected",
+ connected_before + 1,
+ Duration::from_secs(8),
+ );
+ assert!(
+ attached_before_kill,
+ "thin client must complete handshake/attachment before SIGKILL"
+ );
+
+ if let Some(pid) = crashing_client.child.process_id() {
+ unsafe {
+ libc::kill(pid as libc::pid_t, libc::SIGKILL);
+ }
+ }
+ let mut crashing_client = crashing_client;
+ wait_for_child_exit(&mut crashing_client.child);
+
+ let ping = ping_socket(&api_socket);
+ assert!(
+ ping.contains("pong"),
+ "server should stay healthy after SIGKILLed client: {ping}"
+ );
+
+ drain_server_messages(&mut survivor, Duration::from_millis(250));
+ send_client_input(&mut survivor, b"echo survivor-still-works\n");
+ assert!(
+ wait_for_frame(&mut survivor, Duration::from_secs(2)),
+ "remaining client should continue receiving frames"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
+
+#[test]
+fn multi_client_rapid_connect_disconnect_stress_10_cycles() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let server = spawn_server(&config_home, &runtime_dir, &api_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ for i in 0..10u16 {
+ let mut client = connect_raw_client(&client_socket, 80 + i, 24 + (i % 4));
+ let _ = wait_for_frame(&mut client, Duration::from_millis(500));
+ send_client_detach(&mut client);
+ drop(client);
+ thread::sleep(Duration::from_millis(40));
+ }
+
+ let ping = ping_socket(&api_socket);
+ assert!(
+ ping.contains("pong"),
+ "server should remain healthy after rapid connect/disconnect: {ping}"
+ );
+
+ let mut final_client = connect_raw_client(&client_socket, 100, 30);
+ assert!(
+ wait_for_frame(&mut final_client, Duration::from_secs(2)),
+ "new client should still connect and receive frames after stress"
+ );
+
+ cleanup_spawned_herdr(server, base);
+}
diff --git a/tests/server_headless.rs b/tests/server_headless.rs
new file mode 100644
index 00000000..98ca0b08
--- /dev/null
+++ b/tests/server_headless.rs
@@ -0,0 +1,713 @@
+//! Integration tests for headless server mode.
+
+mod support;
+
+use std::fs;
+use std::io::{BufRead, BufReader, Read, Write};
+use std::os::unix::fs::FileTypeExt;
+use std::os::unix::net::UnixStream;
+use std::path::{Path, PathBuf};
+use std::sync::{Mutex, MutexGuard, OnceLock};
+use std::thread;
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
+use support::{
+ cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
+ unregister_spawned_herdr_pid,
+};
+
+fn unique_test_dir() -> PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ PathBuf::from(format!(
+ "/tmp/herdr-server-test-{}-{nanos}",
+ std::process::id()
+ ))
+}
+
+struct SpawnedHerdr {
+ _master: Box<dyn MasterPty + Send>,
+ child: Box<dyn Child + Send + Sync>,
+}
+
+impl Drop for SpawnedHerdr {
+ fn drop(&mut self) {
+ let pid = self.child.process_id();
+ let _ = self.child.kill();
+
+ if let Some(pid) = pid {
+ let deadline = Instant::now() + Duration::from_secs(2);
+ while Instant::now() < deadline {
+ let mut status = 0;
+ let result =
+ unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
+ if result == pid as libc::pid_t || result == -1 {
+ break;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+
+ unregister_spawned_herdr_pid(Some(pid));
+ }
+ }
+}
+
+fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) {
+ drop(spawned);
+ cleanup_test_base(&base);
+}
+
+fn test_lock() -> MutexGuard<'static, ()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn wait_for_socket(path: &Path, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() && UnixStream::connect(path).is_ok() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("socket did not appear at {}", path.display());
+}
+
+fn wait_for_file(path: &Path, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("file did not appear at {}", path.display());
+}
+
+fn spawn_server(
+ config_home: &Path,
+ runtime_dir: &Path,
+ api_socket_path: &Path,
+ _client_socket_path: &Path,
+) -> SpawnedHerdr {
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(runtime_dir).unwrap();
+ register_runtime_dir(runtime_dir);
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("server");
+ cmd.env("XDG_CONFIG_HOME", config_home);
+ cmd.env("XDG_RUNTIME_DIR", runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child.process_id());
+ drop(pair.slave);
+
+ SpawnedHerdr {
+ _master: pair.master,
+ child,
+ }
+}
+
+fn ping_socket(socket_path: &Path) -> String {
+ let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
+
+ let request = r#"{"id":"1","method":"ping","params":{}}"#;
+ writeln!(stream, "{}", request).unwrap();
+
+ let mut reader = BufReader::new(stream);
+ let mut response = String::new();
+ reader.read_line(&mut response).unwrap();
+ response.trim().to_string()
+}
+
+/// Sends a Hello message over the client socket and reads the Welcome response.
+/// Uses bincode v2 wire format: [u32LE length][bincode payload]
+/// bincode v2 standard config uses VarintEncoding:
+/// - Integers < 251 are encoded as a single byte
+/// - Enum variant index is encoded as u32 varint
+/// - Option discriminant is always a single byte (0=None, 1=Some)
+/// - String: length (varint) + UTF-8 bytes
+fn client_handshake(
+ stream: &mut UnixStream,
+ version: u32,
+ cols: u16,
+ rows: u16,
+) -> Result<(u32, Option<String>), String> {
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .map_err(|e| e.to_string())?;
+
+ // Encode Hello message using bincode v2 varint format.
+ // ClientMessage::Hello is variant 0.
+ let hello_payload = encode_varint_enum(
+ 0,
+ &[
+ &encode_varint_u32(version),
+ &encode_varint_u16(cols),
+ &encode_varint_u16(rows),
+ ],
+ );
+ let framed = frame_message(&hello_payload);
+ stream.write_all(&framed).map_err(|e| e.to_string())?;
+ stream.flush().map_err(|e| e.to_string())?;
+
+ // Read the framed response.
+ let mut len_buf = [0u8; 4];
+ stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?;
+ let len = u32::from_le_bytes(len_buf) as usize;
+
+ if len > 2 * 1024 * 1024 {
+ return Err(format!("oversized response: {len}"));
+ }
+
+ let mut payload = vec![0u8; len];
+ stream.read_exact(&mut payload).map_err(|e| e.to_string())?;
+
+ // Decode Welcome: ServerMessage variant 0 = Welcome { version: u32, error: Option<String> }
+ decode_welcome(&payload)
+}
+
+/// Encode a varint u32 value according to bincode v2 VarintEncoding.
+fn encode_varint_u32(v: u32) -> Vec<u8> {
+ if v < 251 {
+ vec![v as u8]
+ } else if v < 65536 {
+ let mut buf = vec![251u8];
+ buf.extend_from_slice(&(v as u16).to_le_bytes());
+ buf
+ } else {
+ let mut buf = vec![252u8];
+ buf.extend_from_slice(&v.to_le_bytes());
+ buf
+ }
+}
+
+/// Encode a varint u16 value.
+fn encode_varint_u16(v: u16) -> Vec<u8> {
+ if v < 251 {
+ vec![v as u8]
+ } else {
+ let mut buf = vec![251u8];
+ buf.extend_from_slice(&v.to_le_bytes());
+ buf
+ }
+}
+
+/// Encode an enum variant with its fields.
+fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec<u8> {
+ let mut buf = encode_varint_u32(variant_idx);
+ for field in fields {
+ buf.extend_from_slice(field);
+ }
+ buf
+}
+
+/// Frame a message with u32LE length prefix.
+fn frame_message(payload: &[u8]) -> Vec<u8> {
+ let len = payload.len() as u32;
+ let mut framed = len.to_le_bytes().to_vec();
+ framed.extend_from_slice(payload);
+ framed
+}
+
+/// Decode a varint u32 from a byte slice at the given offset.
+/// Returns (value, bytes_consumed).
+fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> {
+ if offset >= payload.len() {
+ return Err("payload too short for varint".into());
+ }
+ let first_byte = payload[offset];
+ match first_byte {
+ 0..=250 => Ok((first_byte as u32, 1)),
+ 251 => {
+ if offset + 3 > payload.len() {
+ return Err("payload too short for u16 varint".into());
+ }
+ let v = u16::from_le_bytes(
+ payload[offset + 1..offset + 3]
+ .try_into()
+ .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
+ );
+ Ok((v as u32, 3))
+ }
+ 252 => {
+ if offset + 5 > payload.len() {
+ return Err("payload too short for u32 varint".into());
+ }
+ let v = u32::from_le_bytes(
+ payload[offset + 1..offset + 5]
+ .try_into()
+ .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
+ );
+ Ok((v, 5))
+ }
+ _ => Err(format!("unsupported varint tag: {first_byte}")),
+ }
+}
+
+/// Decode a varint u16 from a byte slice at the given offset.
+#[allow(dead_code)]
+fn decode_varint_u16(payload: &[u8], offset: usize) -> Result<(u16, usize), String> {
+ if offset >= payload.len() {
+ return Err("payload too short for varint".into());
+ }
+ let first_byte = payload[offset];
+ match first_byte {
+ 0..=250 => Ok((first_byte as u16, 1)),
+ 251 => {
+ if offset + 3 > payload.len() {
+ return Err("payload too short for u16 varint".into());
+ }
+ let v = u16::from_le_bytes(
+ payload[offset + 1..offset + 3]
+ .try_into()
+ .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
+ );
+ Ok((v, 3))
+ }
+ _ => Err(format!("unsupported varint tag for u16: {first_byte}")),
+ }
+}
+
+/// Decode a ServerMessage::Welcome from bincode v2 payload.
+fn decode_welcome(payload: &[u8]) -> Result<(u32, Option<String>), String> {
+ let mut offset = 0;
+
+ // Variant index (should be 0 for Welcome)
+ let (variant, consumed) = decode_varint_u32(payload, offset)?;
+ offset += consumed;
+ if variant != 0 {
+ return Err(format!(
+ "expected Welcome (variant 0), got variant {variant}"
+ ));
+ }
+
+ // version: u32
+ let (version, consumed) = decode_varint_u32(payload, offset)?;
+ offset += consumed;
+
+ // error: Option<String> — discriminant is always 1 byte
+ if offset >= payload.len() {
+ return Err("payload too short for Option tag".into());
+ }
+ let option_tag = payload[offset];
+ offset += 1;
+
+ let error = if option_tag == 1 {
+ // Some(String) — length as varint + UTF-8 bytes
+ let (str_len, consumed) = decode_varint_u32(payload, offset)?;
+ offset += consumed;
+ let str_len = str_len as usize;
+
+ if offset + str_len > payload.len() {
+ return Err("payload too short for string content".into());
+ }
+ let s = String::from_utf8(payload[offset..offset + str_len].to_vec())
+ .map_err(|e| e.to_string())?;
+ Some(s)
+ } else {
+ None
+ };
+
+ Ok((version, error))
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[test]
+fn server_creates_both_sockets() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+
+ // Wait for both sockets to appear.
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Verify the client socket is a socket file.
+ let metadata = fs::metadata(&client_socket).unwrap();
+ let file_type = metadata.file_type();
+ assert!(
+ file_type.is_socket(),
+ "client socket should be a socket file"
+ );
+
+ // Verify the API socket works.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "ping should return pong: {response}"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn server_starts_without_terminal() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+
+ // Wait for the API socket to appear — proves the server started.
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+
+ // The server process should be running.
+ if let Some(pid) = spawned.child.process_id() {
+ let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
+ assert_eq!(result, 0, "server process should be running");
+ }
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn server_api_responds_to_ping() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+
+ // Ping the API socket.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should respond to ping: {response}"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn server_removes_client_socket_on_exit() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let mut spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Kill the server.
+ let _ = spawned.child.kill();
+ let _ = spawned.child.wait();
+
+ // Give it a moment to clean up.
+ thread::sleep(Duration::from_millis(300));
+
+ // The client socket should be removed (best-effort by Drop).
+ // If it still exists, it should be stale (not connectable).
+ if client_socket.exists() {
+ assert!(
+ UnixStream::connect(&client_socket).is_err(),
+ "stale client socket should not accept connections"
+ );
+ }
+
+ drop(spawned);
+ cleanup_test_base(&base);
+}
+
+#[test]
+fn server_cleans_up_stale_client_socket() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Create a stale client socket file (simulating a crashed server).
+ fs::create_dir_all(&runtime_dir).unwrap();
+ register_runtime_dir(&runtime_dir);
+ {
+ let _listener = std::os::unix::net::UnixListener::bind(&client_socket).unwrap();
+ // Drop the listener so the socket becomes stale.
+ }
+
+ // Now start the server — it should clean up the stale socket.
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+
+ // The API should work.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should respond to ping: {response}"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn server_persists_after_client_disconnect() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect to the client socket and then immediately disconnect.
+ {
+ let _stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+ // Immediately drop the connection.
+ }
+
+ // Give the server a moment to process the disconnect.
+ thread::sleep(Duration::from_millis(200));
+
+ // The server should still be running and the API should still respond.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "API should still respond after client disconnect: {response}"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn duplicate_server_start_fails_gracefully() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ // Start the first server.
+ let spawned1 = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+
+ // Try to start a second server — it should fail.
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("server");
+ cmd.env("XDG_CONFIG_HOME", &config_home);
+ cmd.env("XDG_RUNTIME_DIR", &runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", &api_socket);
+ cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
+ cmd.env("SHELL", "/bin/sh");
+ cmd.env_remove("HERDR_ENV");
+
+ let mut child2 = pair.slave.spawn_command(cmd).unwrap();
+ register_spawned_herdr_pid(child2.process_id());
+ drop(pair.slave);
+
+ // Wait for the second server to exit.
+ let exit_status = child2.wait().unwrap();
+ unregister_spawned_herdr_pid(child2.process_id());
+
+ // The second server should exit with a non-zero code.
+ assert!(!exit_status.success(), "duplicate server start should fail");
+
+ cleanup_spawned_herdr(spawned1, base);
+}
+
+#[test]
+fn client_handshake_succeeds() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect to the client socket and perform a handshake.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+
+ // Send Hello with version 1, 80 cols, 24 rows.
+ let (version, error) =
+ client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+
+ assert_eq!(version, 1, "server should report protocol version 1");
+ assert!(
+ error.is_none(),
+ "handshake should not have an error: {:?}",
+ error
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn client_handshake_rejects_incompatible_version() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect to the client socket and send Hello with version 0 (pre-persistence).
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+
+ let (version, error) = client_handshake(&mut stream, 0, 80, 24)
+ .expect("should read Welcome response even on rejection");
+
+ assert_eq!(version, 1, "server should report its version 1");
+ assert!(
+ error.is_some(),
+ "version 0 should be rejected with an error"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn client_handshake_clamps_small_terminal_size() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Send Hello with 0x0 terminal size — should be clamped.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+
+ let (version, error) = client_handshake(&mut stream, 1, 0, 0)
+ .expect("handshake with 0x0 should succeed (server clamps)");
+
+ assert_eq!(version, 1);
+ assert!(
+ error.is_none(),
+ "0x0 size should be accepted (clamped): {:?}",
+ error
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
+
+#[test]
+fn no_hello_client_closed_within_five_seconds() {
+ // Client connection that sends no Hello is closed within 5 seconds.
+ // The server sets a handshake timeout of 4 seconds to guarantee the connection
+ // is closed within the 5-second deadline even with OS overhead.
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
+ wait_for_socket(&api_socket, Duration::from_secs(10));
+ wait_for_file(&client_socket, Duration::from_secs(10));
+
+ // Connect but don't send Hello — just a raw connection.
+ let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
+
+ // Set a read timeout longer than the handshake timeout so we can detect
+ // when the server closes the connection.
+ stream
+ .set_read_timeout(Some(Duration::from_secs(6)))
+ .unwrap();
+
+ let start = Instant::now();
+
+ // Try to read from the stream. The server should close the connection
+ // within 5 seconds, causing our read to return with an error (EOF or
+ // connection reset).
+ let mut buf = [0u8; 1024];
+ let result = stream.read(&mut buf);
+ let elapsed = start.elapsed();
+
+ // The read should fail (connection closed by server).
+ assert!(
+ result.is_err() || result.unwrap() == 0,
+ "server should close the connection when no Hello is sent"
+ );
+
+ // The connection should be closed within 5 seconds.
+ assert!(
+ elapsed < Duration::from_secs(5),
+ "connection should be closed within 5 seconds, took {:?}",
+ elapsed
+ );
+
+ // Verify the server is still healthy — a proper client can still connect.
+ let mut good_stream =
+ UnixStream::connect(&client_socket).expect("should connect after no-hello client");
+ let (version, error) = client_handshake(&mut good_stream, 1, 80, 24)
+ .expect("proper handshake should still work after no-hello client");
+ assert_eq!(version, 1);
+ assert!(error.is_none());
+
+ // API should still work.
+ let response = ping_socket(&api_socket);
+ assert!(
+ response.contains("pong"),
+ "server should still respond to ping: {response}"
+ );
+
+ cleanup_spawned_herdr(spawned, base);
+}
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
new file mode 100644
index 00000000..567db154
--- /dev/null
+++ b/tests/support/mod.rs
@@ -0,0 +1,418 @@
+use std::collections::HashSet;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::sync::{Mutex, Once, OnceLock};
+use std::thread;
+use std::time::{Duration, Instant};
+
+static PID_REGISTRY: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
+static RUNTIME_DIR_REGISTRY: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
+static INIT: Once = Once::new();
+static CLEANUP_GUARD: OnceLock<CleanupGuard> = OnceLock::new();
+const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1);
+const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid";
+
+pub fn register_spawned_herdr_pid(pid: Option<u32>) {
+ let Some(pid) = pid else {
+ return;
+ };
+
+ ensure_cleanup_hooks();
+ let mut registry = pid_registry_lock();
+ registry.insert(pid);
+}
+
+pub fn unregister_spawned_herdr_pid(pid: Option<u32>) {
+ let Some(pid) = pid else {
+ return;
+ };
+
+ if let Some(registry) = PID_REGISTRY.get() {
+ let mut guard = registry
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ guard.remove(&pid);
+ }
+}
+
+pub fn register_runtime_dir(path: &Path) {
+ ensure_cleanup_hooks();
+
+ let _ = fs::create_dir_all(path);
+ let _ = fs::write(
+ path.join(RUNTIME_OWNER_MARKER),
+ std::process::id().to_string(),
+ );
+
+ let mut runtime_dirs = runtime_dir_registry_lock();
+ runtime_dirs.insert(path.to_path_buf());
+}
+
+pub fn unregister_runtime_dir(path: &Path) {
+ if let Some(registry) = RUNTIME_DIR_REGISTRY.get() {
+ let mut guard = registry
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ guard.remove(path);
+ }
+}
+
+pub fn cleanup_test_base(base: &Path) {
+ let runtime_dir = base.join("runtime");
+ let runtime_dirs = HashSet::from([runtime_dir.clone()]);
+
+ terminate_servers_for_runtime_dirs(&runtime_dirs);
+ unregister_runtime_dir(&runtime_dir);
+ let _ = fs::remove_dir_all(base);
+}
+
+pub fn cleanup_registered_herdr_pids() {
+ let pids: Vec<u32> = {
+ let mut registry = pid_registry_lock();
+ registry.drain().collect()
+ };
+
+ for pid in pids {
+ terminate_pid(pid);
+ }
+
+ let runtime_dirs: HashSet<PathBuf> = {
+ let mut runtime_dirs = runtime_dir_registry_lock();
+ runtime_dirs.drain().collect()
+ };
+
+ terminate_servers_for_runtime_dirs(&runtime_dirs);
+ let _ = cleanup_servers_with_missing_runtime_dir();
+}
+
+fn ensure_cleanup_hooks() {
+ INIT.call_once(|| {
+ let _ = cleanup_servers_with_missing_runtime_dir();
+ start_global_watchdog();
+
+ let _ = CLEANUP_GUARD.set(CleanupGuard);
+
+ let previous_hook = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |panic_info| {
+ cleanup_registered_herdr_pids();
+ previous_hook(panic_info);
+ }));
+
+ let _ = ctrlc::set_handler(|| {
+ cleanup_registered_herdr_pids();
+ std::process::exit(130);
+ });
+
+ unsafe {
+ libc::atexit(run_atexit_cleanup);
+ }
+ });
+}
+
+fn pid_registry_lock() -> std::sync::MutexGuard<'static, HashSet<u32>> {
+ PID_REGISTRY
+ .get_or_init(|| Mutex::new(HashSet::new()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn runtime_dir_registry_lock() -> std::sync::MutexGuard<'static, HashSet<PathBuf>> {
+ RUNTIME_DIR_REGISTRY
+ .get_or_init(|| Mutex::new(HashSet::new()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn registered_runtime_dirs_snapshot() -> HashSet<PathBuf> {
+ if let Some(runtime_dirs) = RUNTIME_DIR_REGISTRY.get() {
+ runtime_dirs
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .clone()
+ } else {
+ HashSet::new()
+ }
+}
+
+fn should_terminate_runtime_dir(
+ runtime_dir: &Path,
+ registered_runtime_dirs: &HashSet<PathBuf>,
+) -> bool {
+ if !registered_runtime_dirs.contains(runtime_dir) {
+ return false;
+ }
+
+ if !runtime_dir.exists() {
+ return true;
+ }
+
+ !runtime_dir_owner_alive(runtime_dir)
+}
+
+fn start_global_watchdog() {
+ thread::spawn(|| loop {
+ thread::sleep(WATCHDOG_SCAN_INTERVAL);
+
+ if let Err(err) = cleanup_servers_with_missing_runtime_dir() {
+ eprintln!("herdr test cleanup watchdog error: {err}");
+ }
+ });
+}
+
+fn cleanup_servers_with_missing_runtime_dir() -> std::io::Result<()> {
+ let registered_runtime_dirs = registered_runtime_dirs_snapshot();
+ if registered_runtime_dirs.is_empty() {
+ return Ok(());
+ }
+
+ for pid in iter_worktree_server_pids()? {
+ let Some(runtime_dir) = process_runtime_dir(pid)? else {
+ continue;
+ };
+
+ if should_terminate_runtime_dir(&runtime_dir, &registered_runtime_dirs) {
+ terminate_pid(pid);
+ }
+ }
+
+ Ok(())
+}
+
+fn terminate_servers_for_runtime_dirs(runtime_dirs: &HashSet<PathBuf>) {
+ if runtime_dirs.is_empty() {
+ return;
+ }
+
+ let Ok(pids) = iter_worktree_server_pids() else {
+ return;
+ };
+
+ for pid in pids {
+ let Ok(runtime_dir) = process_runtime_dir(pid) else {
+ continue;
+ };
+
+ let Some(runtime_dir) = runtime_dir else {
+ continue;
+ };
+
+ if runtime_dirs.contains(&runtime_dir) {
+ terminate_pid(pid);
+ }
+ }
+}
+
+fn iter_worktree_server_pids() -> std::io::Result<Vec<u32>> {
+ let own_pid = std::process::id();
+ let mut pids = Vec::new();
+
+ for entry in fs::read_dir("/proc")? {
+ let entry = entry?;
+ let file_name = entry.file_name();
+ let Some(pid) = file_name.to_str().and_then(|name| name.parse::<u32>().ok()) else {
+ continue;
+ };
+
+ if pid == own_pid {
+ continue;
+ }
+
+ if is_worktree_herdr_server_process(pid) {
+ pids.push(pid);
+ }
+ }
+
+ Ok(pids)
+}
+
+fn is_worktree_herdr_server_process(pid: u32) -> bool {
+ let Some(exe_path) = proc_link_target(pid, "exe") else {
+ return false;
+ };
+
+ if !is_worktree_herdr_binary(&exe_path) {
+ return false;
+ }
+
+ let Ok(cmdline) = read_cmdline(pid) else {
+ return false;
+ };
+
+ cmdline.iter().any(|arg| arg == "server")
+}
+
+fn proc_link_target(pid: u32, link: &str) -> Option<PathBuf> {
+ fs::read_link(format!("/proc/{pid}/{link}")).ok()
+}
+
+fn read_cmdline(pid: u32) -> std::io::Result<Vec<String>> {
+ let cmdline = fs::read(format!("/proc/{pid}/cmdline"))?;
+ Ok(cmdline
+ .split(|byte| *byte == 0)
+ .filter(|chunk| !chunk.is_empty())
+ .map(|chunk| String::from_utf8_lossy(chunk).to_string())
+ .collect())
+}
+
+fn process_runtime_dir(pid: u32) -> std::io::Result<Option<PathBuf>> {
+ let environ = fs::read(format!("/proc/{pid}/environ"))?;
+
+ let mut socket_path: Option<PathBuf> = None;
+
+ for entry in environ.split(|byte| *byte == 0) {
+ if entry.is_empty() {
+ continue;
+ }
+
+ let kv = String::from_utf8_lossy(entry);
+ if let Some(value) = kv.strip_prefix("XDG_RUNTIME_DIR=") {
+ return Ok(Some(PathBuf::from(value)));
+ }
+
+ if let Some(value) = kv.strip_prefix("HERDR_SOCKET_PATH=") {
+ socket_path = Some(PathBuf::from(value));
+ }
+ }
+
+ Ok(socket_path.and_then(|path| path.parent().map(Path::to_path_buf)))
+}
+
+fn runtime_dir_owner_alive(runtime_dir: &Path) -> bool {
+ let marker = runtime_dir.join(RUNTIME_OWNER_MARKER);
+ let Ok(contents) = fs::read_to_string(marker) else {
+ return false;
+ };
+
+ let Ok(owner_pid) = contents.trim().parse::<libc::pid_t>() else {
+ return false;
+ };
+
+ process_exists(owner_pid)
+}
+
+fn is_worktree_herdr_binary(path: &Path) -> bool {
+ path.ends_with("target/debug/herdr")
+ && path
+ .components()
+ .any(|component| component.as_os_str() == "herdr-worktrees")
+}
+
+extern "C" fn run_atexit_cleanup() {
+ cleanup_registered_herdr_pids();
+}
+
+struct CleanupGuard;
+
+impl Drop for CleanupGuard {
+ fn drop(&mut self) {
+ cleanup_registered_herdr_pids();
+ }
+}
+
+fn terminate_pid(pid: u32) {
+ let pid_t = pid as libc::pid_t;
+
+ if process_exists(pid_t) {
+ unsafe {
+ libc::kill(pid_t, libc::SIGTERM);
+ }
+ }
+
+ if wait_for_pid_exit(pid_t, Duration::from_millis(400)) {
+ return;
+ }
+
+ if process_exists(pid_t) {
+ unsafe {
+ libc::kill(pid_t, libc::SIGKILL);
+ }
+ }
+
+ let _ = wait_for_pid_exit(pid_t, Duration::from_secs(2));
+}
+
+fn wait_for_pid_exit(pid: libc::pid_t, timeout: Duration) -> bool {
+ let deadline = Instant::now() + timeout;
+
+ while Instant::now() < deadline {
+ if !process_exists(pid) {
+ return true;
+ }
+
+ let mut status = 0;
+ let result = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
+ if result == pid {
+ return true;
+ }
+
+ if result == -1 {
+ match std::io::Error::last_os_error().raw_os_error() {
+ Some(libc::ECHILD) => {
+ // Not our child (or already reaped elsewhere). Poll /proc existence
+ // until the process is truly gone.
+ if !process_exists(pid) {
+ return true;
+ }
+ }
+ Some(libc::ESRCH) => return true,
+ _ => {
+ if !process_exists(pid) {
+ return true;
+ }
+ }
+ }
+ }
+
+ thread::sleep(Duration::from_millis(20));
+ }
+
+ !process_exists(pid)
+}
+
+fn process_exists(pid: libc::pid_t) -> bool {
+ let result = unsafe { libc::kill(pid, 0) };
+ if result == 0 {
+ true
+ } else {
+ std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn unique_missing_runtime_dir(label: &str) -> PathBuf {
+ let unique = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos();
+ std::env::temp_dir().join(format!(
+ "herdr-watchdog-scoping-{label}-{}-{unique}",
+ std::process::id()
+ ))
+ }
+
+ #[test]
+ fn watchdog_scoping_does_not_terminate_missing_unregistered_runtime_dir() {
+ let runtime_dir = unique_missing_runtime_dir("unregistered");
+ let registered_runtime_dirs = HashSet::new();
+
+ assert!(
+ !should_terminate_runtime_dir(&runtime_dir, &registered_runtime_dirs),
+ "missing runtime dirs must not be killable until they are proven session-owned"
+ );
+ }
+
+ #[test]
+ fn watchdog_scoping_terminates_missing_registered_runtime_dir() {
+ let runtime_dir = unique_missing_runtime_dir("registered");
+ let mut registered_runtime_dirs = HashSet::new();
+ registered_runtime_dirs.insert(runtime_dir.clone());
+
+ assert!(
+ should_terminate_runtime_dir(&runtime_dir, &registered_runtime_dirs),
+ "missing runtime dirs that are session-owned should be considered killable"
+ );
+ }
+}