aboutsummaryrefslogtreecommitdiff
path: root/tests/multi_client.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /tests/multi_client.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'tests/multi_client.rs')
-rw-r--r--tests/multi_client.rs1162
1 files changed, 182 insertions, 980 deletions
diff --git a/tests/multi_client.rs b/tests/multi_client.rs
index 20deedaf..83fb0b00 100644
--- a/tests/multi_client.rs
+++ b/tests/multi_client.rs
@@ -1,10 +1,9 @@
-//! Integration tests for multi-client server behavior.
+//! Gate B integration tests for the ClientShell protocol.
-mod support;
+pub mod support;
-use std::collections::VecDeque;
use std::fs;
-use std::io::{self, BufRead, BufReader, Read, Write};
+use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard, OnceLock};
@@ -12,20 +11,21 @@ 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, CURRENT_PROTOCOL,
+ cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir,
+ register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid,
+ wait_for_client_shell_bootstrap, wait_for_message_variant, wait_for_message_variants,
+ CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH,
};
fn unique_test_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
- .map(|d| d.as_nanos())
- .unwrap_or(0);
+ .unwrap()
+ .as_nanos();
PathBuf::from(format!(
- "/tmp/herdr-multi-client-test-{}-{nanos}",
+ "/tmp/herdr-multi-client-{}-{nanos}",
std::process::id()
))
}
@@ -39,45 +39,26 @@ 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 {
+ let done = unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) };
+ if done == pid as libc::pid_t || done == -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 deadline = Instant::now() + Duration::from_secs(5);
- while Instant::now() < deadline {
- if child.try_wait().ok().flatten().is_some() {
- return;
- }
- thread::sleep(Duration::from_millis(25));
- }
-}
-
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())
+ .unwrap_or_else(|p| p.into_inner())
}
fn wait_for_socket(path: &Path, timeout: Duration) {
@@ -94,24 +75,19 @@ fn wait_for_socket(path: &Path, timeout: Duration) {
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() {
+ if path.exists() {
return;
}
thread::sleep(Duration::from_millis(25));
}
- panic!("socket did not accept connections at {}", path.display());
+ panic!("socket 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();
-
+fn spawn_server(config: &Path, runtime: &Path, api: &Path) -> SpawnedHerdr {
+ fs::create_dir_all(config.join("herdr")).unwrap();
+ fs::create_dir_all(runtime).unwrap();
+ register_runtime_dir(runtime);
+ fs::write(config.join("herdr/config.toml"), "onboarding = false\n").unwrap();
let pair = native_pty_system()
.openpty(PtySize {
rows: 24,
@@ -120,32 +96,25 @@ fn spawn_server(config_home: &Path, runtime_dir: &Path, api_socket_path: &Path)
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("XDG_CONFIG_HOME", config);
+ cmd.env("XDG_RUNTIME_DIR", runtime);
+ cmd.env("HERDR_SOCKET_PATH", api);
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);
+fn spawn_client(config: &Path, runtime: &Path, api: &Path) -> SpawnedHerdr {
+ register_runtime_dir(runtime);
let pair = native_pty_system()
.openpty(PtySize {
rows: 24,
@@ -154,177 +123,86 @@ fn spawn_client_process(
pixel_height: 0,
})
.unwrap();
-
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
cmd.arg("client");
cmd.env("HERDR_DISABLE_SOUND", "1");
- cmd.env("XDG_CONFIG_HOME", config_home);
- cmd.env("XDG_RUNTIME_DIR", runtime_dir);
- cmd.env("HERDR_SOCKET_PATH", api_socket_path);
+ cmd.env("XDG_CONFIG_HOME", config);
+ cmd.env("XDG_RUNTIME_DIR", runtime);
+ cmd.env("HERDR_SOCKET_PATH", api);
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 log_tail(path: &Path, lines: usize) -> String {
- let Ok(text) = fs::read_to_string(path) else {
- return format!("could not read {}", path.display());
- };
- let mut tail = VecDeque::with_capacity(lines);
- for line in text.lines() {
- if tail.len() == lines {
- tail.pop_front();
- }
- tail.push_back(line.to_string());
- }
- tail.into_iter().collect::<Vec<_>>().join("\n")
-}
-
-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 cleanup(server: SpawnedHerdr, base: PathBuf) {
+ drop(server);
+ cleanup_test_base(&base);
}
-fn send_json_request(socket_path: &Path, request: &str) -> Value {
- let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
+fn api_request(socket: &Path, request: &str) -> Value {
+ let mut stream = UnixStream::connect(socket).unwrap();
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")
+ BufReader::new(stream).read_line(&mut response).unwrap();
+ serde_json::from_str(&response).unwrap()
}
-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}\"}}}}"
- ),
+fn create_pane(socket: &Path, label: &str) -> String {
+ let result = api_request(
+ socket,
+ &format!(r#"{{"id":"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
+ assert!(
+ result.get("error").is_none(),
+ "workspace.create failed: {result}"
+ );
+ result
.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)
+ .unwrap()
+ .as_str()
+ .unwrap()
+ .into()
}
-fn report_idle_agent(socket_path: &Path, pane_id: &str) {
- let response = send_json_request(
- socket_path,
+fn pane_input(socket: &Path, pane: &str, text: &str) {
+ let escaped = text.replace('"', "\\\"");
+ let result = api_request(
+ socket,
&format!(
- r#"{{"id":"report_agent","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"idle","source":"multi-client-test"}}}}"#
+ r#"{{"id":"input","method":"pane.send_input","params":{{"pane_id":"{pane}","text":"{escaped}","keys":["Enter"]}}}}"#
),
);
assert!(
- response.get("error").is_none(),
- "pane.report_agent should succeed: {response}"
+ result.get("error").is_none(),
+ "pane.send_input failed: {result}"
);
}
-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,
+fn pane_text(socket: &Path, pane: &str) -> String {
+ let result = api_request(
+ socket,
&format!(
- "{{\"id\":\"pane_read\",\"method\":\"pane.read\",\"params\":{{\"pane_id\":\"{pane_id}\",\"source\":\"recent\",\"lines\":{lines}}}}}"
+ r#"{{"id":"read","method":"pane.read","params":{{"pane_id":"{pane}","source":"recent","lines":200}}}}"#
),
);
-
- if response.get("error").is_some() {
- panic!("pane.read failed: {response}");
- }
-
- response
+ result
.pointer("/result/read/text")
.and_then(Value::as_str)
.unwrap_or_default()
- .to_string()
+ .into()
}
-fn pane_read_recent_contains(
- socket_path: &Path,
- pane_id: &str,
- needle: &str,
- timeout: Duration,
-) -> bool {
+fn pane_contains(socket: &Path, pane: &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) {
+ if pane_text(socket, pane).contains(needle) {
return true;
}
thread::sleep(Duration::from_millis(50));
@@ -332,844 +210,168 @@ fn pane_read_recent_contains(
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 shell(socket: &Path, cols: u16, rows: u16) -> UnixStream {
+ let mut stream = UnixStream::connect(socket).unwrap();
+ let (version, error) =
+ client_shell_handshake(&mut stream, CURRENT_PROTOCOL, cols, rows).unwrap();
+ assert_eq!(version, CURRENT_PROTOCOL);
+ assert!(error.is_none(), "ClientShell handshake failed: {error:?}");
+ wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)).unwrap();
+ stream
}
-fn try_read_pane_tty_size(
- socket_path: &Path,
- pane_id: &str,
- timeout: Duration,
-) -> Option<(u16, u16)> {
+fn tty_size(socket: &Path, pane: &str, timeout: Duration) -> (u16, u16) {
let marker = format!(
- "SIZE_MARKER_{}_{}",
- std::process::id(),
+ "SIZE_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
- .map(|d| d.as_nanos())
- .unwrap_or(0)
+ .unwrap()
+ .as_nanos()
);
-
- pane_send_input(socket_path, pane_id, &format!("echo {marker}; stty size"));
-
+ pane_input(socket, pane, &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 mut found = false;
+ for line in pane_text(socket, pane).lines() {
+ if line.contains(&marker) {
+ found = true;
+ continue;
}
- 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),
- &encode_varint_u32(8), // cell_width_px
- &encode_varint_u32(16), // cell_height_px
- &encode_varint_u32(0), // RenderEncoding::SemanticFrame
- &encode_varint_u32(0), // ClientKeybindings::Server
- &encode_varint_u32(0), // ClientLaunchMode::App
- ],
- );
- 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;
-
- let (_encoding, 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, CURRENT_PROTOCOL, 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 4
- let payload = encode_varint_u32(4);
- stream.write_all(&frame_message(&payload)).unwrap();
- stream.flush().unwrap();
-}
-
-#[allow(dead_code)]
-#[derive(Debug, Deserialize)]
-struct FrameWire {
- cells: Vec<CellWire>,
- width: u16,
- height: u16,
- cursor: Option<CursorWire>,
- hyperlinks: Vec<String>,
- graphics: Vec<u8>,
-}
-
-#[allow(dead_code)]
-#[derive(Debug, Deserialize)]
-struct CellWire {
- symbol: String,
- fg: u32,
- bg: u32,
- modifier: u16,
- skip: bool,
- hyperlink: Option<u32>,
-}
-
-#[derive(Debug, Deserialize)]
-struct CursorWire {
- x: u16,
- y: u16,
- visible: bool,
- shape: u8,
-}
-
-fn decode_frame_payload(payload: &[u8]) -> io::Result<FrameWire> {
- bincode::serde::decode_from_slice(payload, bincode::config::standard())
- .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_with_snapshots(
- stream: &mut UnixStream,
- timeout: Duration,
- predicate: impl Fn(&FrameWire) -> bool,
-) -> io::Result<(bool, Vec<String>)> {
- let deadline = Instant::now() + timeout;
- let mut snapshots = VecDeque::with_capacity(5);
- 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 snapshots.len() == 5 {
- snapshots.pop_front();
- }
- snapshots.push_back(frame_text(&frame));
- if predicate(&frame) {
- return Ok((true, snapshots.into_iter().collect()));
+ if found {
+ let mut words = line.split_whitespace();
+ if let (Some(r), Some(c)) = (words.next(), words.next()) {
+ if let (Ok(r), Ok(c)) = (r.parse(), c.parse()) {
+ return (r, c);
+ }
}
}
- Ok((_variant, _payload)) => {}
- Err(err) if is_timeout(&err) => {}
- Err(err) => return Err(err),
- }
- }
-
- Ok((false, snapshots.into_iter().collect()))
-}
-
-fn frame_text(frame: &FrameWire) -> String {
- if frame.cells.is_empty() {
- return String::new();
- }
-
- 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, frame.graphics.len());
- if let Some(cursor) = frame.cursor.as_ref() {
- let _ = (cursor.x, cursor.y, cursor.visible, cursor.shape);
+ thread::sleep(Duration::from_millis(50));
}
-
- full_text
-}
-
-fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool {
- frame_text(frame).contains(needle)
-}
-
-fn agent_panel_starts_with(frame: &FrameWire, agent_label: &str) -> bool {
- frame_text(frame)
- .lines()
- .skip_while(|line| !line.contains("agents"))
- .skip(1)
- .find(|line| line.contains("agent-"))
- .is_some_and(|line| line.contains(agent_label))
+ panic!("pane did not report tty size: {}", pane_text(socket, pane));
}
#[test]
-fn multi_client_allows_multiple_simultaneous_connections() {
+fn effective_size_uses_smallest_foreground_client_and_recovers_on_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 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"
- );
+ let config = base.join("config");
+ let runtime = base.join("runtime");
+ let api = runtime.join("herdr.sock");
+ let clients = runtime.join("herdr-client.sock");
+ let server = spawn_server(&config, &runtime, &api);
+ wait_for_socket(&api, Duration::from_secs(10));
+ wait_for_file(&clients, Duration::from_secs(10));
+ let pane = create_pane(&api, "effective-size");
+ let mut large = shell(&clients, 120, 40);
+ let mut small = shell(&clients, 80, 24);
+ let reduced = tty_size(&api, &pane, Duration::from_secs(5));
assert!(
- wait_for_frame(&mut client_b, Duration::from_secs(2)),
- "client B should receive frames"
+ reduced.0 <= 24 && reduced.1 <= 80,
+ "small ClientShell should determine effective size: {reduced:?}"
);
-
- 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 deadline = Instant::now() + Duration::from_secs(8);
- let mut last_seen_size = None;
- let mut size_with_small_client = None;
- while Instant::now() < deadline {
- if let Some(size) =
- try_read_pane_tty_size(&api_socket, &pane_id, Duration::from_millis(400))
- {
- last_seen_size = Some(size);
- if size.0 < large_only_size.0 && size.1 < large_only_size.1 {
- size_with_small_client = Some(size);
- break;
- }
- }
- thread::sleep(Duration::from_millis(60));
- }
-
- assert!(
- size_with_small_client.is_some(),
- "effective pane size should shrink when smaller client joins: before={large_only_size:?}, last_seen={last_seen_size:?}"
- );
-
- cleanup_spawned_herdr(server, base);
-}
-
-#[test]
-fn non_foreground_client_render_preserves_agent_panel_scroll() {
- 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 index in 1..=23 {
- let (_, pane_id) =
- create_workspace_and_root_pane(&api_socket, &format!("agent-{index:02}"));
- report_idle_agent(&api_socket, &pane_id);
- }
-
- let mut setup_client = connect_raw_client(&client_socket, 106, 40);
- assert!(wait_for_frame(&mut setup_client, Duration::from_secs(2)));
- drain_server_messages(&mut setup_client, Duration::from_millis(250));
-
- let wheel_down = b"\x1b[<65;10;30M";
- send_client_input(&mut setup_client, &wheel_down.repeat(20));
- let (reached_bottom, setup_frames) = wait_for_frame_matching_with_snapshots(
- &mut setup_client,
+ send_detach(&mut small).unwrap();
+ drop(small);
+ assert!(wait_for_message_variant(
+ &mut large,
Duration::from_secs(3),
- |frame| agent_panel_starts_with(frame, "agent-16"),
+ SERVER_MESSAGE_PANE_SURFACE
)
- .expect("setup frame decoding should succeed");
- assert!(
- reached_bottom,
- "40-row client should scroll the agent panel to its final page; frames:\n{}",
- setup_frames.join("\n--- frame ---\n")
- );
- send_client_detach(&mut setup_client);
- drop(setup_client);
-
- let mut tall_background = connect_raw_client(&client_socket, 106, 64);
- assert!(wait_for_frame(&mut tall_background, Duration::from_secs(2)));
- let mut probe = connect_raw_client(&client_socket, 106, 40);
- let (started_at_tall_limit, initial_frames) =
- wait_for_frame_matching_with_snapshots(&mut probe, Duration::from_secs(3), |frame| {
- agent_panel_starts_with(frame, "agent-10")
- })
- .expect("initial probe frame decoding should succeed");
+ .unwrap());
+ let restored = tty_size(&api, &pane, Duration::from_secs(5));
assert!(
- started_at_tall_limit,
- "tall client should normalize the shared scroll before the probe attaches; frames:\n{}",
- initial_frames.join("\n--- frame ---\n")
+ restored.0 > reduced.0 && restored.1 > reduced.1,
+ "size should recover: {reduced:?} -> {restored:?}"
);
- drain_server_messages(&mut probe, Duration::from_millis(250));
-
- send_client_input(&mut probe, wheel_down);
- let (scrolled, probe_frames) =
- wait_for_frame_matching_with_snapshots(&mut probe, Duration::from_secs(3), |frame| {
- agent_panel_starts_with(frame, "agent-11")
- })
- .expect("probe frame decoding should succeed");
- assert!(
- scrolled,
- "background client projection must not undo the foreground wheel event; frames:\n{}",
- probe_frames.join("\n--- frame ---\n")
- );
-
- cleanup_spawned_herdr(server, base);
+ cleanup(server, base);
}
#[test]
-fn multi_client_broadcasts_frame_updates_to_all_clients() {
+fn api_pane_output_is_fanned_out_as_pane_surface_updates() {
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 config = base.join("config");
+ let runtime = base.join("runtime");
+ let api = runtime.join("herdr.sock");
+ let clients = runtime.join("herdr-client.sock");
+ let server = spawn_server(&config, &runtime, &api);
+ wait_for_socket(&api, Duration::from_secs(10));
+ wait_for_file(&clients, Duration::from_secs(10));
+ let pane = create_pane(&api, "fanout");
+ let mut a = shell(&clients, 100, 30);
+ let mut b = shell(&clients, 100, 30);
+ drain_messages(&mut a);
+ drain_messages(&mut b);
let marker = format!(
- "MB{}",
+ "FANOUT_{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
- .map(|d| d.as_millis())
- .unwrap_or(0)
+ .unwrap()
+ .as_millis()
);
-
- send_client_input(&mut client_a, format!("echo {marker}\n").as_bytes());
- if !pane_read_recent_contains(&api_socket, &pane_id, &marker, Duration::from_secs(5)) {
- panic!(
- "pane output should include client A marker so broadcast reflects a real state change. pane output:\n{}\nserver log tail:\n{}",
- pane_read_recent(&api_socket, &pane_id, 200),
- log_tail(&server_log_path(&config_home), 80)
- );
- }
- let (received, client_b_frames) =
- wait_for_frame_matching_with_snapshots(&mut client_b, Duration::from_secs(10), |frame| {
- frame_contains_text(frame, &marker)
- })
- .expect("frame decoding should succeed");
-
- assert!(
- received,
- "client B should receive a broadcast frame containing client A marker. pane output:\n{}\nclient B frame snapshots:\n{}\nserver log tail:\n{}",
- pane_read_recent(&api_socket, &pane_id, 200),
- client_b_frames.join("\n--- frame ---\n"),
- log_tail(&server_log_path(&config_home), 80)
- );
-
- 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));
-
- drain_server_messages(&mut c100, Duration::from_millis(250));
-
- // Smallest client disconnects; effective size should increase to the next-smallest.
- send_client_detach(&mut c80);
- drop(c80);
-
- assert!(
- wait_for_frame(&mut c100, Duration::from_secs(2)),
- "next-smallest client should receive resized-up frame"
- );
-
- 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);
+ pane_input(&api, &pane, &format!("printf '{marker}\\n'"));
+ assert!(pane_contains(&api, &pane, &marker, Duration::from_secs(5)));
+ let surface_updates = [
+ SERVER_MESSAGE_PANE_SURFACE,
+ SERVER_MESSAGE_PANE_SURFACE_PATCH,
+ ];
+ assert!(wait_for_message_variants(&mut a, Duration::from_secs(5), &surface_updates).unwrap());
+ assert!(wait_for_message_variants(&mut b, Duration::from_secs(5), &surface_updates).unwrap());
+ cleanup(server, base);
}
#[test]
-fn multi_client_client_crash_sigkill_does_not_affect_server() {
+fn crashed_client_shell_does_not_affect_survivor() {
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);
+ let config = base.join("config");
+ let runtime = base.join("runtime");
+ let api = runtime.join("herdr.sock");
+ let clients = runtime.join("herdr-client.sock");
+ let server = spawn_server(&config, &runtime, &api);
+ wait_for_socket(&api, Duration::from_secs(10));
+ wait_for_file(&clients, Duration::from_secs(10));
+ let mut survivor = shell(&clients, 100, 30);
+ let crashed = spawn_client(&config, &runtime, &api);
+ // Give the supported client process time to complete its ClientShell hello;
+ // the point of this test is a connected peer dying, not a failed launch.
+ thread::sleep(Duration::from_secs(1));
+ let pid = crashed.child.process_id().unwrap();
+ unsafe {
+ libc::kill(pid as libc::pid_t, libc::SIGKILL);
+ }
+ drop(crashed);
+ let response = api_request(&api, r#"{"id":"ping","method":"ping","params":{}}"#);
+ assert!(response.to_string().contains("pong"));
+ pane_input(&api, &create_pane(&api, "survivor"), "printf 'survivor\\n'");
+ assert!(wait_for_message_variant(
+ &mut survivor,
+ Duration::from_secs(5),
+ SERVER_MESSAGE_PANE_SURFACE
+ )
+ .unwrap());
+ cleanup(server, base);
}
#[test]
-fn multi_client_rapid_connect_disconnect_stress_10_cycles() {
+fn rapid_client_shell_connect_disconnect_remains_healthy() {
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);
+ let config = base.join("config");
+ let runtime = base.join("runtime");
+ let api = runtime.join("herdr.sock");
+ let clients = runtime.join("herdr-client.sock");
+ let server = spawn_server(&config, &runtime, &api);
+ wait_for_socket(&api, Duration::from_secs(10));
+ wait_for_file(&clients, Duration::from_secs(10));
+ for i in 0..10 {
+ let mut client = shell(&clients, 80 + i, 24);
+ send_detach(&mut client).unwrap();
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);
+ let final_client = shell(&clients, 100, 30);
+ drop(final_client);
+ let response = api_request(&api, r#"{"id":"ping","method":"ping","params":{}}"#);
+ assert!(response.to_string().contains("pong"));
+ cleanup(server, base);
}