mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
Enable termination-signal handling so SIGHUP and SIGTERM follow the graceful quit path and restore terminal state. Log handler registration failures and add PTY coverage for direct SIGHUP and server EOF restoration Co-authored-by: Matt Coles <macols@amazon.com>
1181 lines
41 KiB
Rust
1181 lines
41 KiB
Rust
//! Integration tests for thin client mode.
|
|
|
|
mod support;
|
|
|
|
use std::fs;
|
|
use std::io::{BufRead, BufReader, Read, Write};
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Mutex, MutexGuard, OnceLock};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
use support::{
|
|
cleanup_test_base, client_handshake, encode_varint_u32, frame_message, read_server_message,
|
|
register_runtime_dir, register_spawned_herdr_pid, unregister_spawned_herdr_pid,
|
|
wait_for_message_variant, wait_for_socket, wait_until, 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-client-test-{}-{nanos}",
|
|
std::process::id()
|
|
))
|
|
}
|
|
|
|
struct SpawnedHerdr {
|
|
_master: Option<Box<dyn MasterPty + Send>>,
|
|
child: Box<dyn Child + Send + Sync>,
|
|
}
|
|
|
|
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();
|
|
let _ = self.child.kill();
|
|
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<Mutex<()>> = OnceLock::new();
|
|
LOCK.get_or_init(|| Mutex::new(()))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
}
|
|
|
|
fn spawn_client_process(
|
|
config_home: &PathBuf,
|
|
runtime_dir: &PathBuf,
|
|
api_socket_path: &PathBuf,
|
|
) -> SpawnedHerdr {
|
|
register_runtime_dir(runtime_dir);
|
|
let pair = native_pty_system()
|
|
.openpty(PtySize {
|
|
rows: 24,
|
|
cols: 80,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.unwrap();
|
|
|
|
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
|
|
cmd.arg("client");
|
|
cmd.env("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_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 spawn_server(
|
|
config_home: &PathBuf,
|
|
runtime_dir: &PathBuf,
|
|
api_socket_path: &PathBuf,
|
|
_client_socket_path: &PathBuf,
|
|
) -> SpawnedHerdr {
|
|
fs::create_dir_all(config_home.join("herdr")).unwrap();
|
|
fs::create_dir_all(runtime_dir).unwrap();
|
|
register_runtime_dir(runtime_dir);
|
|
fs::write(
|
|
config_home.join("herdr/config.toml"),
|
|
"onboarding = false\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let pair = native_pty_system()
|
|
.openpty(PtySize {
|
|
rows: 24,
|
|
cols: 80,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.unwrap();
|
|
|
|
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
|
|
cmd.arg("server");
|
|
cmd.env("XDG_CONFIG_HOME", config_home);
|
|
cmd.env("XDG_RUNTIME_DIR", runtime_dir);
|
|
cmd.env("HERDR_SOCKET_PATH", api_socket_path);
|
|
cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
|
|
cmd.env("SHELL", "/bin/sh");
|
|
cmd.env_remove("HERDR_ENV");
|
|
|
|
let child = pair.slave.spawn_command(cmd).unwrap();
|
|
register_spawned_herdr_pid(child.process_id());
|
|
drop(pair.slave);
|
|
|
|
SpawnedHerdr {
|
|
_master: Some(pair.master),
|
|
child,
|
|
}
|
|
}
|
|
|
|
fn ping_socket(socket_path: &PathBuf) -> String {
|
|
let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
|
|
|
|
let request = r#"{"id":"1","method":"ping","params":{}}"#;
|
|
writeln!(stream, "{}", request).unwrap();
|
|
|
|
let mut reader = BufReader::new(stream);
|
|
let mut response = String::new();
|
|
reader.read_line(&mut response).unwrap();
|
|
response.trim().to_string()
|
|
}
|
|
|
|
fn send_json_request(socket_path: &PathBuf, request: &str) -> Value {
|
|
let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
|
|
writeln!(stream, "{}", request).unwrap();
|
|
|
|
let mut reader = BufReader::new(stream);
|
|
let mut response = String::new();
|
|
reader.read_line(&mut response).unwrap();
|
|
serde_json::from_str(&response).expect("response should be valid JSON")
|
|
}
|
|
|
|
fn first_pane_id_in_workspace(socket_path: &PathBuf, workspace_id: &str) -> String {
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while Instant::now() < deadline {
|
|
let request = format!(
|
|
r#"{{"id":"pane_list","method":"pane.list","params":{{"workspace_id":"{workspace_id}"}}}}"#
|
|
);
|
|
let panes = send_json_request(socket_path, &request);
|
|
if let Some(pane_id) = panes["result"]["panes"]
|
|
.as_array()
|
|
.and_then(|panes| panes.first())
|
|
.and_then(|pane| pane["pane_id"].as_str())
|
|
{
|
|
return pane_id.to_string();
|
|
}
|
|
thread::sleep(Duration::from_millis(25));
|
|
}
|
|
panic!("pane.list did not return a pane for workspace {workspace_id} before timeout");
|
|
}
|
|
|
|
fn app_dir_name() -> &'static str {
|
|
if cfg!(debug_assertions) {
|
|
"herdr-dev"
|
|
} else {
|
|
"herdr"
|
|
}
|
|
}
|
|
|
|
#[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]) -> std::io::Result<FrameWire> {
|
|
bincode::serde::decode_from_slice(payload, bincode::config::standard())
|
|
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string()))
|
|
.and_then(|(frame, consumed): (FrameWire, usize)| {
|
|
if consumed != payload.len() {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidData,
|
|
format!(
|
|
"frame payload had trailing bytes: consumed={}, len={}",
|
|
consumed,
|
|
payload.len()
|
|
),
|
|
));
|
|
}
|
|
Ok(frame)
|
|
})
|
|
}
|
|
|
|
fn read_next_frame_payload(stream: &mut UnixStream, timeout: Duration) -> Result<Vec<u8>, String> {
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_millis(200)))
|
|
.map_err(|e| e.to_string())?;
|
|
let deadline = Instant::now() + timeout;
|
|
while Instant::now() < deadline {
|
|
match read_server_message(stream) {
|
|
Ok((1, payload)) => return Ok(payload),
|
|
Ok(_) => continue,
|
|
Err(_) => continue,
|
|
}
|
|
}
|
|
Err("timed out waiting for Frame message".into())
|
|
}
|
|
|
|
fn frame_text(frame: &FrameWire) -> String {
|
|
if frame.cells.is_empty() {
|
|
return String::new();
|
|
}
|
|
|
|
let width = frame.width.max(1) as usize;
|
|
let mut text = String::new();
|
|
for row in frame.cells.chunks(width) {
|
|
for cell in row {
|
|
let _ = (cell.fg, cell.bg, cell.modifier, cell.skip);
|
|
text.push_str(&cell.symbol);
|
|
}
|
|
text.push('\n');
|
|
}
|
|
let _ = (frame.height, frame.graphics.len());
|
|
if let Some(cursor) = frame.cursor.as_ref() {
|
|
let _ = (cursor.x, cursor.y, cursor.visible, cursor.shape);
|
|
}
|
|
|
|
text
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn client_connects_and_receives_frame() {
|
|
// Client connects to server and handshake completes.
|
|
// Client receives Frame messages.
|
|
// Server sends rendered frames to connected clients.
|
|
let _lock = test_lock();
|
|
let base = unique_test_dir();
|
|
let config_home = base.join("config");
|
|
let runtime_dir = base.join("runtime");
|
|
let api_socket = runtime_dir.join("herdr.sock");
|
|
let client_socket = runtime_dir.join("herdr-client.sock");
|
|
|
|
let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
|
|
wait_for_socket(&api_socket, Duration::from_secs(10));
|
|
wait_for_socket(&client_socket, Duration::from_secs(10));
|
|
|
|
// Connect and handshake.
|
|
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
|
let (version, error) =
|
|
client_handshake(&mut stream, 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 error: {:?}",
|
|
error
|
|
);
|
|
|
|
read_next_frame_payload(&mut stream, Duration::from_secs(10))
|
|
.expect("should receive a frame from server");
|
|
|
|
cleanup_spawned_herdr(spawned, base);
|
|
}
|
|
|
|
#[test]
|
|
fn client_sees_headless_startup_config_diagnostic() {
|
|
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 app_dir = if cfg!(debug_assertions) {
|
|
"herdr-dev"
|
|
} else {
|
|
"herdr"
|
|
};
|
|
fs::create_dir_all(config_home.join(app_dir)).unwrap();
|
|
fs::write(
|
|
config_home.join(app_dir).join("config.toml"),
|
|
"[keys\nprefix = \"ctrl+a\"\n",
|
|
)
|
|
.unwrap();
|
|
fs::create_dir_all(&runtime_dir).unwrap();
|
|
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("server");
|
|
cmd.env("XDG_CONFIG_HOME", &config_home);
|
|
cmd.env("XDG_RUNTIME_DIR", &runtime_dir);
|
|
cmd.env("HERDR_SOCKET_PATH", &api_socket);
|
|
cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
|
|
cmd.env("SHELL", "/bin/sh");
|
|
cmd.env_remove("HERDR_ENV");
|
|
|
|
let child = pair.slave.spawn_command(cmd).unwrap();
|
|
register_spawned_herdr_pid(child.process_id());
|
|
drop(pair.slave);
|
|
|
|
let spawned = SpawnedHerdr {
|
|
_master: Some(pair.master),
|
|
child,
|
|
};
|
|
wait_for_socket(&api_socket, Duration::from_secs(10));
|
|
wait_for_socket(&client_socket, Duration::from_secs(10));
|
|
|
|
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
|
let (version, error) =
|
|
client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
|
|
assert_eq!(version, CURRENT_PROTOCOL);
|
|
assert!(error.is_none(), "{:?}", error);
|
|
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let mut found_diagnostic = false;
|
|
let mut last_frame_text = String::new();
|
|
while Instant::now() < deadline {
|
|
match read_server_message(&mut stream) {
|
|
Ok((1, payload)) => {
|
|
let frame = decode_frame_payload(&payload).expect("decode frame");
|
|
last_frame_text = frame_text(&frame);
|
|
if last_frame_text.contains("config.toml")
|
|
&& last_frame_text.contains("herdr config check")
|
|
{
|
|
found_diagnostic = true;
|
|
break;
|
|
}
|
|
}
|
|
Ok(_) => {}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
found_diagnostic,
|
|
"attached client should see startup config parse diagnostic; last frame:\n{last_frame_text}"
|
|
);
|
|
|
|
cleanup_spawned_herdr(spawned, base);
|
|
}
|
|
|
|
#[test]
|
|
fn server_unreachable_shows_clear_error() {
|
|
// when server is unreachable, the client exits quickly
|
|
// with an actionable connection-failed message.
|
|
let _lock = test_lock();
|
|
let base = unique_test_dir();
|
|
let config_home = base.join("config");
|
|
let runtime_dir = base.join("runtime");
|
|
let api_socket = runtime_dir.join("herdr.sock");
|
|
|
|
fs::create_dir_all(config_home.join("herdr")).unwrap();
|
|
fs::create_dir_all(&runtime_dir).unwrap();
|
|
register_runtime_dir(&runtime_dir);
|
|
fs::write(
|
|
config_home.join("herdr/config.toml"),
|
|
"onboarding = false\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let output = std::process::Command::new(env!("CARGO_BIN_EXE_herdr"))
|
|
.arg("client")
|
|
.env("HERDR_DISABLE_SOUND", "1")
|
|
.env("XDG_CONFIG_HOME", &config_home)
|
|
.env("XDG_RUNTIME_DIR", &runtime_dir)
|
|
.env("HERDR_SOCKET_PATH", &api_socket)
|
|
.env_remove("HERDR_CLIENT_SOCKET_PATH")
|
|
.env_remove("HERDR_ENV")
|
|
.output()
|
|
.expect("client command should run");
|
|
|
|
assert!(
|
|
!output.status.success(),
|
|
"client should fail when no server is running"
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("failed to connect to server"),
|
|
"stderr should mention connection failure: {stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("Is herdr server running?"),
|
|
"stderr should include actionable guidance: {stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("Socket path:"),
|
|
"stderr should include attempted socket path: {stderr}"
|
|
);
|
|
|
|
cleanup_test_base(&base);
|
|
}
|
|
|
|
#[test]
|
|
fn server_crash_after_attach_causes_lost_connection_error() {
|
|
// attach a real thin client connection, kill server unexpectedly,
|
|
// assert clean non-zero client exit plus lost-connection signal.
|
|
let _lock = test_lock();
|
|
let base = unique_test_dir();
|
|
let config_home = base.join("config");
|
|
let runtime_dir = base.join("runtime");
|
|
let api_socket = runtime_dir.join("herdr.sock");
|
|
let client_socket = runtime_dir.join("herdr-client.sock");
|
|
|
|
let mut spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
|
|
wait_for_socket(&api_socket, Duration::from_secs(10));
|
|
wait_for_socket(&client_socket, Duration::from_secs(10));
|
|
|
|
// Attach a real thin client (client subcommand) through PTY so handshake and
|
|
// terminal setup paths are exercised.
|
|
let mut thin_client = spawn_client_process(&config_home, &runtime_dir, &api_socket);
|
|
|
|
// Prove attached before kill by waiting for recognizable rendered app content.
|
|
let mut thin_reader = thin_client
|
|
._master
|
|
.as_ref()
|
|
.expect("thin client master")
|
|
.try_clone_reader()
|
|
.expect("clone client PTY reader");
|
|
let (attached_before_kill, attach_output) = {
|
|
let deadline = Instant::now() + Duration::from_secs(8);
|
|
let mut buf = [0u8; 4096];
|
|
let mut seen = false;
|
|
let mut output = String::new();
|
|
while Instant::now() < deadline {
|
|
match thin_reader.read(&mut buf) {
|
|
Ok(n) if n > 0 => {
|
|
let out = String::from_utf8_lossy(&buf[..n]);
|
|
output.push_str(&out);
|
|
if out.contains("\u{2500}")
|
|
|| out.contains("workspace")
|
|
|| out.contains("pane")
|
|
|| out.contains("terminal")
|
|
{
|
|
seen = true;
|
|
break;
|
|
}
|
|
if output.to_lowercase().contains("herdr:") {
|
|
break;
|
|
}
|
|
}
|
|
Ok(_) => thread::sleep(Duration::from_millis(30)),
|
|
Err(_) => thread::sleep(Duration::from_millis(30)),
|
|
}
|
|
}
|
|
(seen, output)
|
|
};
|
|
assert!(
|
|
attached_before_kill,
|
|
"thin client must complete attach and receive frame before server crash; output: {attach_output:?}"
|
|
);
|
|
|
|
// Kill server unexpectedly.
|
|
if let Some(pid) = spawned.child.process_id() {
|
|
unsafe {
|
|
libc::kill(pid as libc::pid_t, libc::SIGKILL);
|
|
}
|
|
}
|
|
spawned.close_master();
|
|
|
|
// Client should exit non-zero after connection loss.
|
|
let mut crash_output = String::new();
|
|
let exited = {
|
|
let deadline = Instant::now() + Duration::from_secs(12);
|
|
let mut exited = false;
|
|
while Instant::now() < deadline {
|
|
if thin_client.child.try_wait().ok().flatten().is_some() {
|
|
exited = true;
|
|
break;
|
|
}
|
|
// Keep draining client output so the process can progress to exit.
|
|
let mut buf = [0u8; 1024];
|
|
if let Ok(n) = thin_reader.read(&mut buf) {
|
|
if n > 0 {
|
|
crash_output.push_str(&String::from_utf8_lossy(&buf[..n]));
|
|
}
|
|
}
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
exited
|
|
};
|
|
assert!(exited, "thin client should exit after server SIGKILL");
|
|
|
|
let status = thin_client.child.wait().expect("wait thin client status");
|
|
assert!(
|
|
!status.success(),
|
|
"thin client should exit non-zero after lost server connection"
|
|
);
|
|
|
|
// Drain trailing output and require the explicit user-visible lost-connection message.
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let mut buf = [0u8; 2048];
|
|
while Instant::now() < deadline {
|
|
match thin_reader.read(&mut buf) {
|
|
Ok(n) if n > 0 => crash_output.push_str(&String::from_utf8_lossy(&buf[..n])),
|
|
Ok(_) => break,
|
|
Err(_) => break,
|
|
}
|
|
thread::sleep(Duration::from_millis(30));
|
|
}
|
|
|
|
let crash_output_lc = crash_output.to_lowercase();
|
|
assert!(
|
|
crash_output_lc.contains("lost connection to server"),
|
|
"thin client must emit explicit lost-connection message after server crash; output: {crash_output:?}"
|
|
);
|
|
|
|
// Ensure server is gone.
|
|
let _ = spawned.child.wait();
|
|
|
|
cleanup_test_base(&base);
|
|
}
|
|
|
|
/// Any of the mouse-disable modes emitted by `clear_host_mouse_reporting` on
|
|
/// terminal restore. Their presence in the client's PTY output proves the
|
|
/// restore path (`TerminalGuard::Drop` → `restore_terminal_state`) ran.
|
|
const MOUSE_TEARDOWN_MARKERS: [&str; 2] = ["\u{1b}[?1003l", "\u{1b}[?1000l"];
|
|
|
|
fn output_has_mouse_teardown(output: &str) -> bool {
|
|
MOUSE_TEARDOWN_MARKERS
|
|
.iter()
|
|
.all(|marker| output.contains(marker))
|
|
}
|
|
|
|
/// Shared buffer fed by a background PTY reader thread. Reading on a thread
|
|
/// keeps the blocking `Box<dyn Read>` (which has no timeout) off the test's
|
|
/// main thread, so a client that never exits fails the deadline instead of
|
|
/// hanging the whole test forever.
|
|
type SharedOutput = std::sync::Arc<Mutex<String>>;
|
|
|
|
fn spawn_pty_drain(mut reader: Box<dyn Read + Send>) -> SharedOutput {
|
|
let output: SharedOutput = std::sync::Arc::new(Mutex::new(String::new()));
|
|
let thread_output = output.clone();
|
|
thread::spawn(move || {
|
|
let mut buf = [0u8; 4096];
|
|
loop {
|
|
match reader.read(&mut buf) {
|
|
Ok(0) => break,
|
|
Ok(n) => thread_output
|
|
.lock()
|
|
.unwrap_or_else(|p| p.into_inner())
|
|
.push_str(&String::from_utf8_lossy(&buf[..n])),
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
});
|
|
output
|
|
}
|
|
|
|
fn read_output(output: &SharedOutput) -> String {
|
|
output.lock().unwrap_or_else(|p| p.into_inner()).clone()
|
|
}
|
|
|
|
/// Current captured byte length, used as a watermark so a test can search only
|
|
/// the output emitted *after* a trigger. The teardown markers also appear in
|
|
/// normal attach-phase output, so matching the whole buffer is meaningless.
|
|
fn output_len(output: &SharedOutput) -> usize {
|
|
output.lock().unwrap_or_else(|p| p.into_inner()).len()
|
|
}
|
|
|
|
/// Spawns a server + real thin client under a PTY and waits until the client
|
|
/// has attached and rendered a frame. Returns the pieces plus a shared buffer
|
|
/// that keeps accumulating PTY output (including teardown) on a background
|
|
/// thread.
|
|
fn attach_thin_client(
|
|
config_home: &PathBuf,
|
|
runtime_dir: &PathBuf,
|
|
api_socket: &PathBuf,
|
|
client_socket: &PathBuf,
|
|
) -> (SpawnedHerdr, SpawnedHerdr, SharedOutput) {
|
|
let spawned_server = spawn_server(config_home, runtime_dir, api_socket, client_socket);
|
|
wait_for_socket(api_socket, Duration::from_secs(10));
|
|
wait_for_socket(client_socket, Duration::from_secs(10));
|
|
|
|
let thin_client = spawn_client_process(config_home, runtime_dir, api_socket);
|
|
let reader = thin_client
|
|
._master
|
|
.as_ref()
|
|
.expect("thin client master")
|
|
.try_clone_reader()
|
|
.expect("clone client PTY reader");
|
|
let output = spawn_pty_drain(reader);
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(8);
|
|
let mut attached = false;
|
|
while Instant::now() < deadline {
|
|
let out = read_output(&output);
|
|
if out.contains('\u{2500}')
|
|
|| out.contains("workspace")
|
|
|| out.contains("pane")
|
|
|| out.contains("terminal")
|
|
{
|
|
attached = true;
|
|
break;
|
|
}
|
|
if out.to_lowercase().contains("herdr:") {
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(30));
|
|
}
|
|
assert!(
|
|
attached,
|
|
"thin client must attach and render a frame; output: {:?}",
|
|
read_output(&output)
|
|
);
|
|
|
|
(spawned_server, thin_client, output)
|
|
}
|
|
|
|
/// Polls until the client exits, then returns only the output captured after
|
|
/// the `since` byte watermark. Panics if the client does not exit within the
|
|
/// deadline.
|
|
fn drain_until_client_exits(
|
|
thin_client: &mut SpawnedHerdr,
|
|
output: &SharedOutput,
|
|
since: usize,
|
|
) -> String {
|
|
let deadline = Instant::now() + Duration::from_secs(12);
|
|
let mut exited = false;
|
|
while Instant::now() < deadline {
|
|
if thin_client.child.try_wait().ok().flatten().is_some() {
|
|
exited = true;
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(20));
|
|
}
|
|
// Give the reader thread a beat to flush trailing teardown bytes.
|
|
thread::sleep(Duration::from_millis(100));
|
|
let full = read_output(output);
|
|
assert!(exited, "thin client should exit; output: {full:?}");
|
|
full.get(since..).unwrap_or_default().to_string()
|
|
}
|
|
|
|
/// Attaches a thin client, runs `trigger` to force an exit, and asserts the
|
|
/// client emits the mouse teardown after that point. The teardown markers also
|
|
/// appear in normal attach output, so only bytes emitted after the trigger
|
|
/// (past the watermark) count.
|
|
fn assert_client_restores_terminal(trigger: impl FnOnce(&mut SpawnedHerdr, &mut SpawnedHerdr)) {
|
|
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_server, mut thin_client, pty_output) =
|
|
attach_thin_client(&config_home, &runtime_dir, &api_socket, &client_socket);
|
|
|
|
let since = output_len(&pty_output);
|
|
trigger(&mut spawned_server, &mut thin_client);
|
|
|
|
let output = drain_until_client_exits(&mut thin_client, &pty_output, since);
|
|
assert!(
|
|
output_has_mouse_teardown(&output),
|
|
"client must emit mouse teardown after trigger; output after trigger: {output:?}"
|
|
);
|
|
|
|
// SpawnedHerdr::Drop kills and reaps both processes with a bounded wait.
|
|
drop(spawned_server);
|
|
cleanup_spawned_herdr(thin_client, base);
|
|
}
|
|
|
|
/// The `--remote` ssh-death path: killing the bridge closes the socket, the
|
|
/// client sees EOF and unwinds normally, so the terminal is restored. This is
|
|
/// the path that does NOT deliver a signal to the client. Guards against a
|
|
/// regression that would leave mouse reporting on after an ssh disconnect.
|
|
#[test]
|
|
fn client_restores_terminal_on_server_eof() {
|
|
assert_client_restores_terminal(|server, _client| {
|
|
// Kill the server unexpectedly; the client socket closes and the
|
|
// client reader hits EOF, mirroring the ssh bridge dying under
|
|
// `herdr --remote`.
|
|
if let Some(pid) = server.child.process_id() {
|
|
unsafe {
|
|
libc::kill(pid as libc::pid_t, libc::SIGKILL);
|
|
}
|
|
}
|
|
server.close_master();
|
|
});
|
|
}
|
|
|
|
/// The path this PR actually fixes: a direct SIGHUP/SIGTERM to the client (e.g.
|
|
/// a terminal emulator SIGHUPing its foreground child on window close). With
|
|
/// `ctrlc`'s `termination` feature the handler sets `should_quit`, the loop
|
|
/// exits, and `TerminalGuard::Drop` restores the terminal. Without it the
|
|
/// process would die un-unwound and leak mouse reporting.
|
|
#[test]
|
|
fn client_restores_terminal_on_sighup() {
|
|
assert_client_restores_terminal(|_server, client| {
|
|
let pid = client.child.process_id().expect("thin client pid") as libc::pid_t;
|
|
unsafe {
|
|
libc::kill(pid, libc::SIGHUP);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn client_receives_frame_after_pane_output() {
|
|
// End-to-end test: server renders, client receives Frame.
|
|
// This test verifies the full flow:
|
|
// 1. Start server
|
|
// 2. Connect client, handshake
|
|
// 3. Send input to pane (echo command)
|
|
// 4. Wait for a new frame from the server
|
|
// 5. Verify the frame contains the pane output
|
|
let _lock = test_lock();
|
|
let base = unique_test_dir();
|
|
let config_home = base.join("config");
|
|
let runtime_dir = base.join("runtime");
|
|
let api_socket = runtime_dir.join("herdr.sock");
|
|
let client_socket = runtime_dir.join("herdr-client.sock");
|
|
|
|
let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
|
|
wait_for_socket(&api_socket, Duration::from_secs(10));
|
|
wait_for_socket(&client_socket, Duration::from_secs(10));
|
|
|
|
// Connect and handshake.
|
|
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
|
let (version, error) =
|
|
client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
|
|
assert_eq!(version, CURRENT_PROTOCOL);
|
|
assert!(error.is_none(), "{:?}", error);
|
|
|
|
read_next_frame_payload(&mut stream, Duration::from_secs(10))
|
|
.expect("should receive initial frame");
|
|
|
|
// Send input to trigger a state change and re-render.
|
|
let input_data = b"echo test-output\n".to_vec();
|
|
let input_payload = {
|
|
let mut buf = encode_varint_u32(1); // Input variant
|
|
buf.extend_from_slice(&encode_varint_u32(input_data.len() as u32));
|
|
buf.extend_from_slice(&input_data);
|
|
buf
|
|
};
|
|
let framed = frame_message(&input_payload);
|
|
stream.write_all(&framed).expect("send input");
|
|
stream.flush().expect("flush");
|
|
|
|
// Read subsequent frames — the server should have re-rendered after
|
|
// the input was processed.
|
|
let received_frame = wait_for_message_variant(&mut stream, Duration::from_secs(2), 1)
|
|
.expect("wait for post-output frame");
|
|
assert!(received_frame, "should receive a Frame after pane output");
|
|
|
|
cleanup_spawned_herdr(spawned, base);
|
|
}
|
|
|
|
#[test]
|
|
fn pane_spawn_cwd_fallback_in_server() {
|
|
// Pane spawn failure cwd fallback in server context.
|
|
// This test verifies that the server can start even with invalid
|
|
// session data pointing to non-existent directories.
|
|
let _lock = test_lock();
|
|
let base = unique_test_dir();
|
|
let config_home = base.join("config");
|
|
let runtime_dir = base.join("runtime");
|
|
let api_socket = runtime_dir.join("herdr.sock");
|
|
let client_socket = runtime_dir.join("herdr-client.sock");
|
|
let data_dir = config_home.join(app_dir_name());
|
|
let missing_cwd = base.join("missing-cwd-for-test");
|
|
let missing_cwd = missing_cwd.to_str().expect("test cwd should be UTF-8");
|
|
fs::create_dir_all(&data_dir).unwrap();
|
|
let session = serde_json::json!({
|
|
"version": 2,
|
|
"workspaces": [{
|
|
"custom_name": "missing-cwd",
|
|
"layout": { "Pane": 0 },
|
|
"panes": { "0": { "cwd": missing_cwd } },
|
|
"zoomed": false,
|
|
"focused": 0,
|
|
"root_pane": 0
|
|
}],
|
|
"active": 0,
|
|
"selected": 0
|
|
});
|
|
fs::write(
|
|
data_dir.join("session.json"),
|
|
serde_json::to_vec_pretty(&session).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
|
|
wait_for_socket(&api_socket, Duration::from_secs(10));
|
|
wait_for_socket(&client_socket, Duration::from_secs(10));
|
|
|
|
let workspaces = send_json_request(
|
|
&api_socket,
|
|
r#"{"id":"workspace_list","method":"workspace.list","params":{}}"#,
|
|
);
|
|
let restored_workspace = workspaces["result"]["workspaces"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|workspace| workspace["label"] == "missing-cwd")
|
|
.expect("server should restore workspace with missing pane cwd");
|
|
let workspace_id = restored_workspace["workspace_id"]
|
|
.as_str()
|
|
.expect("restored workspace should have public id");
|
|
let pane_id = first_pane_id_in_workspace(&api_socket, workspace_id);
|
|
let pane = send_json_request(
|
|
&api_socket,
|
|
&format!(r#"{{"id":"pane_get","method":"pane.get","params":{{"pane_id":"{pane_id}"}}}}"#),
|
|
);
|
|
assert_eq!(pane["result"]["pane"]["workspace_id"], workspace_id);
|
|
let cwd = pane["result"]["pane"]["cwd"]
|
|
.as_str()
|
|
.expect("restored pane should report fallback cwd");
|
|
assert_ne!(cwd, missing_cwd);
|
|
assert!(
|
|
std::path::Path::new(cwd).exists(),
|
|
"fallback cwd should exist: {cwd}"
|
|
);
|
|
|
|
cleanup_spawned_herdr(spawned, base);
|
|
}
|
|
|
|
#[test]
|
|
fn graceful_shutdown_sends_server_shutdown_to_client() {
|
|
// Issue 2 fix: SIGINT triggers initiate_shutdown → ServerShutdown
|
|
// broadcast to all clients before the server exits.
|
|
let _lock = test_lock();
|
|
let base = unique_test_dir();
|
|
let config_home = base.join("config");
|
|
let runtime_dir = base.join("runtime");
|
|
let api_socket = runtime_dir.join("herdr.sock");
|
|
let client_socket = runtime_dir.join("herdr-client.sock");
|
|
|
|
let mut spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
|
|
wait_for_socket(&api_socket, Duration::from_secs(10));
|
|
wait_for_socket(&client_socket, Duration::from_secs(10));
|
|
|
|
// Connect and handshake.
|
|
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
|
let (version, error) =
|
|
client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
|
|
assert_eq!(version, CURRENT_PROTOCOL);
|
|
assert!(error.is_none(), "{:?}", error);
|
|
|
|
// Drain initial frame(s).
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(2)))
|
|
.unwrap();
|
|
while read_server_message(&mut stream).is_ok() {}
|
|
|
|
// Send SIGINT to the server process to trigger graceful shutdown.
|
|
if let Some(pid) = spawned.child.process_id() {
|
|
unsafe {
|
|
libc::kill(pid as libc::pid_t, libc::SIGINT);
|
|
}
|
|
}
|
|
|
|
// The client should receive a ServerShutdown message (variant 4)
|
|
// before the connection is closed, not just an abrupt EOF.
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
let result = read_server_message(&mut stream);
|
|
match result {
|
|
Ok((variant, _payload)) => {
|
|
assert_eq!(
|
|
variant, 4,
|
|
"expected ServerShutdown (variant 4), got variant {variant}"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
panic!("expected ServerShutdown message before connection close, got error: {e}");
|
|
}
|
|
}
|
|
|
|
// Wait for the server to exit.
|
|
spawned.close_master();
|
|
let _ = spawned.child.wait();
|
|
|
|
drop(spawned);
|
|
cleanup_test_base(&base);
|
|
}
|
|
|
|
#[test]
|
|
fn client_receives_notify_on_agent_state_change() {
|
|
// Notification events (sound/toast) are forwarded as
|
|
// ServerMessage::Notify to connected clients when an agent state change
|
|
// is triggered via the API (pane.report_agent).
|
|
let _lock = test_lock();
|
|
let base = unique_test_dir();
|
|
let config_home = base.join("config");
|
|
let runtime_dir = base.join("runtime");
|
|
let api_socket = runtime_dir.join("herdr.sock");
|
|
let client_socket = runtime_dir.join("herdr-client.sock");
|
|
|
|
// Enable toast and sound in config so the server produces notifications.
|
|
fs::create_dir_all(config_home.join("herdr")).unwrap();
|
|
fs::write(
|
|
config_home.join("herdr/config.toml"),
|
|
"onboarding = false\n[ui.toast]\nenabled = true\n[ui.sound]\nenabled = true\n",
|
|
)
|
|
.unwrap();
|
|
fs::create_dir_all(&runtime_dir).unwrap();
|
|
register_runtime_dir(&runtime_dir);
|
|
|
|
// Spawn the server directly (not using spawn_server helper because it
|
|
// overwrites the config file with a minimal one).
|
|
let pair = native_pty_system()
|
|
.openpty(PtySize {
|
|
rows: 24,
|
|
cols: 80,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.unwrap();
|
|
|
|
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
|
|
cmd.arg("server");
|
|
cmd.env("XDG_CONFIG_HOME", &config_home);
|
|
cmd.env("XDG_RUNTIME_DIR", &runtime_dir);
|
|
cmd.env("HERDR_SOCKET_PATH", &api_socket);
|
|
cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
|
|
cmd.env("SHELL", "/bin/sh");
|
|
cmd.env_remove("HERDR_ENV");
|
|
|
|
let child = pair.slave.spawn_command(cmd).unwrap();
|
|
register_spawned_herdr_pid(child.process_id());
|
|
drop(pair.slave);
|
|
|
|
let spawned = SpawnedHerdr {
|
|
_master: Some(pair.master),
|
|
child,
|
|
};
|
|
wait_for_socket(&api_socket, Duration::from_secs(10));
|
|
wait_for_socket(&client_socket, Duration::from_secs(10));
|
|
|
|
// Connect as a client and perform handshake.
|
|
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
|
|
let (version, error) =
|
|
client_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24).expect("handshake should succeed");
|
|
assert_eq!(version, CURRENT_PROTOCOL);
|
|
assert!(error.is_none(), "{:?}", error);
|
|
|
|
// Drain initial frame(s).
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(2)))
|
|
.unwrap();
|
|
while read_server_message(&mut stream).is_ok() {}
|
|
|
|
// Create a workspace via the API.
|
|
let mut ws_stream = UnixStream::connect(&api_socket).expect("connect to API");
|
|
let request = r#"{"id":"1","method":"workspace.create","params":{}}"#;
|
|
writeln!(ws_stream, "{}", request).unwrap();
|
|
let mut reader = BufReader::new(ws_stream);
|
|
let mut ws_response = String::new();
|
|
reader.read_line(&mut ws_response).unwrap();
|
|
|
|
// Extract the workspace ID and pane ID from the response.
|
|
let ws_id = ws_response
|
|
.split('"')
|
|
.find(|s| s.starts_with("w_"))
|
|
.unwrap_or("w_1")
|
|
.to_string();
|
|
|
|
// Get pane list to find a pane ID.
|
|
let mut pane_stream = UnixStream::connect(&api_socket).expect("connect to API");
|
|
let pane_request =
|
|
format!(r#"{{"id":"2","method":"pane.list","params":{{"workspace_id":"{ws_id}"}}}}"#);
|
|
writeln!(pane_stream, "{}", pane_request).unwrap();
|
|
let mut pane_reader = BufReader::new(pane_stream);
|
|
let mut pane_response = String::new();
|
|
pane_reader.read_line(&mut pane_response).unwrap();
|
|
|
|
// Extract first pane ID (format: p_<ws>_<pane>).
|
|
let pane_id = pane_response
|
|
.split('"')
|
|
.find(|s| s.starts_with("p_"))
|
|
.unwrap_or("p_1_1")
|
|
.to_string();
|
|
|
|
// Report agent as Blocked via the API — this should trigger a
|
|
// ServerMessage::Notify with kind=Sound (Request sound).
|
|
let mut report_stream = UnixStream::connect(&api_socket).expect("connect to API");
|
|
let report_request = format!(
|
|
r#"{{"id":"3","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"blocked","source":"test"}}}}"#
|
|
);
|
|
writeln!(report_stream, "{}", report_request).unwrap();
|
|
let mut report_reader = BufReader::new(report_stream);
|
|
let mut report_response = String::new();
|
|
report_reader.read_line(&mut report_response).unwrap();
|
|
|
|
// Read messages from the client stream and look for Notify (variant 5).
|
|
// Notify = ServerMessage variant index 5.
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
let mut found_notify = false;
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while Instant::now() < deadline {
|
|
match read_server_message(&mut stream) {
|
|
Ok((variant, _payload)) => {
|
|
if variant == 5 {
|
|
// ServerMessage::Notify — found it!
|
|
found_notify = true;
|
|
break;
|
|
}
|
|
// Continue reading — Frame messages (variant 1) will come first.
|
|
}
|
|
Err(_) => {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
found_notify,
|
|
"client should receive a ServerMessage::Notify after pane.report_agent"
|
|
);
|
|
|
|
// Now report Idle from Working — this should trigger a Done sound
|
|
// if the pane is in a background workspace.
|
|
// First, create a second workspace to make the first one "background".
|
|
let mut ws2_stream = UnixStream::connect(&api_socket).expect("connect to API");
|
|
let ws2_request = r#"{"id":"4","method":"workspace.create","params":{}}"#;
|
|
writeln!(ws2_stream, "{}", ws2_request).unwrap();
|
|
let mut ws2_reader = BufReader::new(ws2_stream);
|
|
let mut ws2_response = String::new();
|
|
ws2_reader.read_line(&mut ws2_response).unwrap();
|
|
|
|
// Focus the new workspace (making the first one background).
|
|
let ws2_id = ws2_response
|
|
.split('"')
|
|
.find(|s| s.starts_with("w_"))
|
|
.unwrap_or("w_2")
|
|
.to_string();
|
|
let mut focus_stream = UnixStream::connect(&api_socket).expect("connect to API");
|
|
let focus_request = format!(
|
|
r#"{{"id":"5","method":"workspace.focus","params":{{"workspace_id":"{ws2_id}"}}}}"#
|
|
);
|
|
writeln!(focus_stream, "{}", focus_request).unwrap();
|
|
let mut focus_reader = BufReader::new(focus_stream);
|
|
let mut focus_response = String::new();
|
|
focus_reader.read_line(&mut focus_response).unwrap();
|
|
|
|
assert!(
|
|
wait_until(Duration::from_secs(2), Duration::from_millis(25), || {
|
|
ping_socket(&api_socket).contains("pong")
|
|
}),
|
|
"server should stay responsive after workspace focus"
|
|
);
|
|
|
|
// Report agent as Working first, then Idle — this transition in a
|
|
// background workspace should trigger a Done sound notification.
|
|
let mut work_stream = UnixStream::connect(&api_socket).expect("connect to API");
|
|
let work_request = format!(
|
|
r#"{{"id":"6","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"working","source":"test"}}}}"#
|
|
);
|
|
writeln!(work_stream, "{}", work_request).unwrap();
|
|
let mut work_reader = BufReader::new(work_stream);
|
|
let mut work_response = String::new();
|
|
work_reader.read_line(&mut work_response).unwrap();
|
|
|
|
assert!(
|
|
wait_until(Duration::from_secs(2), Duration::from_millis(25), || {
|
|
ping_socket(&api_socket).contains("pong")
|
|
}),
|
|
"server should stay responsive after working state report"
|
|
);
|
|
|
|
let mut idle_stream = UnixStream::connect(&api_socket).expect("connect to API");
|
|
let idle_request = format!(
|
|
r#"{{"id":"7","method":"pane.report_agent","params":{{"pane_id":"{pane_id}","agent":"pi","state":"idle","source":"test"}}}}"#
|
|
);
|
|
writeln!(idle_stream, "{}", idle_request).unwrap();
|
|
let mut idle_reader = BufReader::new(idle_stream);
|
|
let mut idle_response = String::new();
|
|
idle_reader.read_line(&mut idle_response).unwrap();
|
|
|
|
// Read messages and look for Done sound notify.
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
|
.unwrap();
|
|
let mut found_done_notify = false;
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while Instant::now() < deadline {
|
|
match read_server_message(&mut stream) {
|
|
Ok((variant, _payload)) => {
|
|
if variant == 5 {
|
|
// Found a Notify message — that's good enough.
|
|
// The test already verified the Blocked→Notify path above.
|
|
found_done_notify = true;
|
|
break;
|
|
}
|
|
// Continue reading — Frame messages will come first.
|
|
}
|
|
Err(e) => {
|
|
eprintln!("read error while looking for Done Notify: {e}");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
found_done_notify,
|
|
"client should receive a Sound Notify with 'agent done' when background pane transitions Working→Idle"
|
|
);
|
|
|
|
cleanup_spawned_herdr(spawned, base);
|
|
}
|