//! Integration tests for headless server mode. #![cfg(unix)] pub 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, client_handshake, register_runtime_dir, register_spawned_herdr_pid, unregister_spawned_herdr_pid, CURRENT_PROTOCOL, }; 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: Option>, child: Box, } impl SpawnedHerdr { fn close_master(&mut self) { drop(self._master.take()); } } impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); support::stop_spawned_herdr(&mut *self.child); self.close_master(); 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> = 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() && UnixStream::connect(path).is_ok() { return; } thread::sleep(Duration::from_millis(25)); } panic!("socket did not accept connections 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")); support::isolate_herdr_test_process(&mut cmd); 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: Some(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() } // --------------------------------------------------------------------------- // 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)); // Stop the server. support::stop_spawned_herdr(&mut *spawned.child); spawned.close_master(); 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")); support::isolate_herdr_test_process(&mut cmd); 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 the current protocol version, 80 cols, 24 rows. let (version, error) = client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed"); assert_eq!( version, CURRENT_PROTOCOL, "server should report current protocol version" ); 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, CURRENT_PROTOCOL, "server should report its current protocol version" ); 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, CURRENT_PROTOCOL, 0, 0) .expect("handshake with 0x0 should succeed (server clamps)"); assert_eq!(version, CURRENT_PROTOCOL); 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, CURRENT_PROTOCOL, 80, 24) .expect("proper handshake should still work after no-hello client"); assert_eq!(version, CURRENT_PROTOCOL); 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); } fn server_log_path(config_home: &Path) -> PathBuf { let app_dir_name = if cfg!(debug_assertions) { "herdr-dev" } else { "herdr" }; config_home.join(app_dir_name).join("herdr-server.log") } fn wait_for_exit(child: &mut Box, 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 wait_for_log_line(path: &Path, needle: &str, timeout: Duration) -> String { let deadline = Instant::now() + timeout; while Instant::now() < deadline { let content = fs::read_to_string(path).unwrap_or_default(); if let Some(line) = content.lines().find(|line| line.contains(needle)) { return line.to_string(); } thread::sleep(Duration::from_millis(25)); } panic!( "log {} did not contain {needle:?}:\n{}", path.display(), fs::read_to_string(path).unwrap_or_default() ); } #[test] fn server_survives_hangup_and_logs_why_it_stops() { 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)); let pid = spawned.child.process_id().expect("server pid") as libc::pid_t; assert_eq!(unsafe { libc::kill(pid, libc::SIGHUP) }, 0); // Closing the terminal hangs up the server's whole session too. spawned.close_master(); assert!( !wait_for_exit(&mut spawned.child, Duration::from_millis(500)), "server must not stop on SIGHUP" ); assert!(ping_socket(&api_socket).contains("pong")); // API ping works before the main loop installs its signal handlers. wait_for_file(&client_socket, Duration::from_secs(10)); let mut client = UnixStream::connect(&client_socket).expect("connect to ready server"); let (version, error) = client_handshake(&mut client, CURRENT_PROTOCOL, 80, 24) .expect("server event loop must be ready before SIGTERM"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none()); drop(client); assert_eq!(unsafe { libc::kill(pid, libc::SIGTERM) }, 0); assert!( wait_for_exit(&mut spawned.child, Duration::from_secs(10)), "server must stop on SIGTERM" ); let line = wait_for_log_line( &server_log_path(&config_home), "server shutdown initiated", Duration::from_secs(5), ); assert!(line.contains("reason=SIGTERM"), "{line}"); cleanup_spawned_herdr(spawned, base); } #[test] fn server_stop_request_logs_its_caller() { 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)); let mut stream = UnixStream::connect(&api_socket).unwrap(); writeln!( stream, r#"{{"id":"stop","method":"server.stop","params":{{}}}}"# ) .unwrap(); let mut response = String::new(); BufReader::new(stream).read_line(&mut response).unwrap(); assert!(response.contains("\"ok\""), "{response}"); assert!(wait_for_exit(&mut spawned.child, Duration::from_secs(10))); let line = wait_for_log_line( &server_log_path(&config_home), "server shutdown initiated", Duration::from_secs(5), ); let expected = if cfg!(any(target_os = "linux", target_os = "macos")) { format!("server.stop request from pid {}", std::process::id()) } else { "server.stop request".to_string() }; assert!(line.contains(&expected), "{line}"); cleanup_spawned_herdr(spawned, base); }