diff options
Diffstat (limited to 'tests/multi_client.rs')
| -rw-r--r-- | tests/multi_client.rs | 1019 |
1 files changed, 1019 insertions, 0 deletions
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); +} |
