aboutsummaryrefslogtreecommitdiff
path: root/tests
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
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')
-rw-r--r--tests/api_ping.rs2
-rw-r--r--tests/auto_detect.rs118
-rw-r--r--tests/cli.rs2
-rw-r--r--tests/client_mode.rs527
-rw-r--r--tests/cross_area.rs306
-rw-r--r--tests/detach_reattach.rs210
-rw-r--r--tests/live_handoff.rs57
-rw-r--r--tests/multi_client.rs1162
-rw-r--r--tests/server_headless.rs205
-rw-r--r--tests/support/mod.rs152
10 files changed, 683 insertions, 2058 deletions
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index 0a52e6a7..b8823ac4 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -1,4 +1,4 @@
-mod support;
+pub mod support;
use std::fs;
use std::io::{Read, Write};
diff --git a/tests/auto_detect.rs b/tests/auto_detect.rs
index 94ab3f07..49473dbb 100644
--- a/tests/auto_detect.rs
+++ b/tests/auto_detect.rs
@@ -2,7 +2,7 @@
#![cfg(not(target_os = "macos"))]
-mod support;
+pub mod support;
use std::fs;
use std::io::{BufRead, BufReader, Write};
@@ -150,7 +150,7 @@ fn spawn_herdr_auto(
.unwrap();
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
- // No subcommand, no --no-session → auto-detect launch
+ // No subcommand → auto-detect launch.
cmd.env("XDG_CONFIG_HOME", config_home);
cmd.env("XDG_RUNTIME_DIR", runtime_dir);
cmd.env("HERDR_SOCKET_PATH", api_socket_path);
@@ -168,48 +168,6 @@ fn spawn_herdr_auto(
}
}
-/// Spawn `herdr --no-session` — the monolithic escape hatch.
-fn spawn_herdr_no_session(
- config_home: &Path,
- runtime_dir: &Path,
- api_socket_path: &Path,
-) -> SpawnedHerdr {
- fs::create_dir_all(config_home.join("herdr")).unwrap();
- fs::create_dir_all(runtime_dir).unwrap();
- register_runtime_dir(runtime_dir);
- fs::write(
- config_home.join("herdr/config.toml"),
- "onboarding = false\n",
- )
- .unwrap();
-
- let pair = native_pty_system()
- .openpty(PtySize {
- rows: 24,
- cols: 80,
- pixel_width: 0,
- pixel_height: 0,
- })
- .unwrap();
-
- let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
- cmd.arg("--no-session");
- cmd.env("XDG_CONFIG_HOME", config_home);
- cmd.env("XDG_RUNTIME_DIR", runtime_dir);
- cmd.env("HERDR_SOCKET_PATH", api_socket_path);
- cmd.env("SHELL", "/bin/sh");
- cmd.env_remove("HERDR_ENV");
-
- let child = pair.slave.spawn_command(cmd).unwrap();
- register_spawned_herdr_pid(child.process_id());
- drop(pair.slave);
-
- SpawnedHerdr {
- _master: pair.master,
- child,
- }
-}
-
fn ping_socket(socket_path: &Path) -> String {
let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket");
@@ -423,50 +381,6 @@ fn auto_detect_socket_path_consistency() {
cleanup_spawned_herdr(herdr, base);
}
-/// `herdr --no-session` bypasses server/client and runs
-/// monolithically. No server process is spawned. No client socket is created.
-#[test]
-fn no_session_flag_runs_monolithically() {
- let _lock = test_lock();
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let api_socket = runtime_dir.join("herdr.sock");
- let client_socket = runtime_dir.join("herdr-client.sock");
-
- // Run `herdr --no-session` — monolithic mode, no server/client.
- let herdr = spawn_herdr_no_session(&config_home, &runtime_dir, &api_socket);
-
- // Wait for the API socket (monolithic mode creates it).
- wait_for_socket(&api_socket, Duration::from_secs(10));
-
- // Verify the API socket exists and responds.
- let response = ping_socket(&api_socket);
- assert!(
- response.contains("pong"),
- "monolithic API should respond: {response}"
- );
-
- // Verify NO client socket was created — this is the key distinction
- // between monolithic mode and server/client mode.
- assert!(
- !client_socket.exists(),
- "no client socket should exist in monolithic mode"
- );
-
- // Verify the API socket is served by the monolithic process itself,
- // not by a separate server. We can check this by verifying the client
- // PID matches what would be serving the socket — in monolithic mode,
- // there is only one herdr process.
- let client_pid = herdr.child.process_id().expect("should have PID");
- assert!(
- process_exists(client_pid),
- "monolithic process should be running"
- );
-
- cleanup_spawned_herdr(herdr, base);
-}
-
/// CLI subcommands work through the server's JSON API socket.
#[test]
fn cli_subcommands_work_through_server() {
@@ -701,34 +615,6 @@ fn auto_detect_writes_client_and_server_logs_to_separate_files() {
}
#[test]
-fn no_session_writes_startup_logs_to_monolith_file() {
- 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 spawned = spawn_herdr_no_session(&config_home, &runtime_dir, &api_socket);
- wait_for_socket(&api_socket, Duration::from_secs(10));
-
- let app_dir_name = if cfg!(debug_assertions) {
- "herdr-dev"
- } else {
- "herdr"
- };
- let log_dir = config_home.join(app_dir_name);
- let monolith_log = log_dir.join("herdr.log");
-
- wait_for_log_contains(
- &monolith_log,
- "event=\"app.startup\" subsystem=\"app\"",
- Duration::from_secs(10),
- );
-
- cleanup_spawned_herdr(spawned, base);
-}
-
-#[test]
fn auto_detect_respects_nested_guard_before_auto_attach() {
let _lock = test_lock();
let base = unique_test_dir();
diff --git a/tests/cli.rs b/tests/cli.rs
index c7eb2834..ff238996 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -1,6 +1,6 @@
#![cfg(not(target_os = "macos"))]
-mod support;
+pub mod support;
#[path = "cli/mod.rs"]
mod cases;
diff --git a/tests/client_mode.rs b/tests/client_mode.rs
index 5112182d..c6c76c34 100644
--- a/tests/client_mode.rs
+++ b/tests/client_mode.rs
@@ -2,7 +2,7 @@
#![cfg(unix)]
-mod support;
+pub mod support;
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
@@ -13,12 +13,13 @@ 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,
+ cleanup_test_base, client_shell_handshake, read_server_message, register_runtime_dir,
+ register_spawned_herdr_pid, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap,
+ wait_for_message_variant, wait_for_message_variants, wait_for_socket, wait_until,
+ CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH,
+ SERVER_MESSAGE_SEMANTIC_NOTIFICATION, SERVER_MESSAGE_SERVER_SHUTDOWN,
};
fn unique_test_dir() -> PathBuf {
@@ -86,12 +87,30 @@ fn spawn_client_process(
spawn_client_process_with_args(config_home, runtime_dir, api_socket_path, &["client"])
}
+fn spawn_client_shell_process(
+ config_home: &PathBuf,
+ runtime_dir: &PathBuf,
+ api_socket_path: &PathBuf,
+) -> SpawnedHerdr {
+ spawn_client_process_with_args(config_home, runtime_dir, api_socket_path, &["client"])
+}
+
fn spawn_client_process_with_args(
config_home: &PathBuf,
runtime_dir: &PathBuf,
api_socket_path: &PathBuf,
args: &[&str],
) -> SpawnedHerdr {
+ spawn_client_process_with_args_and_env(config_home, runtime_dir, api_socket_path, args, &[])
+}
+
+fn spawn_client_process_with_args_and_env(
+ config_home: &PathBuf,
+ runtime_dir: &PathBuf,
+ api_socket_path: &PathBuf,
+ args: &[&str],
+ extra_env: &[(&str, &str)],
+) -> SpawnedHerdr {
register_runtime_dir(runtime_dir);
let pair = native_pty_system()
.openpty(PtySize {
@@ -111,47 +130,10 @@ fn spawn_client_process_with_args(
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,
+ for (key, value) in extra_env {
+ cmd.env(key, value);
}
-}
-fn spawn_no_session_process(config_home: &PathBuf, runtime_dir: &PathBuf) -> SpawnedHerdr {
- fs::create_dir_all(config_home.join(app_dir_name())).unwrap();
- fs::create_dir_all(runtime_dir).unwrap();
- register_runtime_dir(runtime_dir);
- fs::write(
- config_home.join(app_dir_name()).join("config.toml"),
- "onboarding = false\n[ui]\nwindow_title = \"monolithic\"\n",
- )
- .unwrap();
-
- let pair = native_pty_system()
- .openpty(PtySize {
- rows: 24,
- cols: 80,
- pixel_width: 0,
- pixel_height: 0,
- })
- .unwrap();
- let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
- cmd.arg("--no-session");
- cmd.env("XDG_CONFIG_HOME", config_home);
- cmd.env("XDG_RUNTIME_DIR", runtime_dir);
- cmd.env("SHELL", "/bin/sh");
- cmd.env_remove("HERDR_ENV");
- cmd.env_remove("HERDR_SOCKET_PATH");
- cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
- cmd.env_remove("HERDR_SESSION");
- cmd.env_remove("HERDR_WORKSPACE_ID");
- cmd.env_remove("HERDR_TAB_ID");
- cmd.env_remove("HERDR_PANE_ID");
let child = pair.slave.spawn_command(cmd).unwrap();
register_spawned_herdr_pid(child.process_id());
drop(pair.slave);
@@ -266,100 +248,12 @@ fn app_dir_name() -> &'static str {
}
}
-#[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.
+fn client_connects_and_receives_pane_surface() {
let _lock = test_lock();
let base = unique_test_dir();
let config_home = base.join("config");
@@ -371,22 +265,13 @@ fn client_connects_and_receives_frame() {
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");
+ let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23)
+ .expect("handshake should succeed");
+ assert_eq!(version, CURRENT_PROTOCOL);
+ assert!(error.is_none(), "{error:?}");
+ wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(10))
+ .expect("should receive the shell snapshot and pane surface");
cleanup_spawned_herdr(spawned, base);
}
@@ -560,41 +445,26 @@ fn client_sees_headless_startup_config_diagnostic() {
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,
- }
- }
-
+ let client = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket);
+ let output = spawn_pty_drain(
+ client
+ ._master
+ .as_ref()
+ .expect("client shell master")
+ .try_clone_reader()
+ .expect("clone client shell reader"),
+ );
assert!(
- found_diagnostic,
- "attached client should see startup config parse diagnostic; last frame:\n{last_frame_text}"
+ wait_until(Duration::from_secs(8), Duration::from_millis(20), || {
+ let output = read_output(&output);
+ output.contains("config.toml") && output.contains("herdr config check")
+ }),
+ "client shell should render startup config diagnostic; output: {:?}",
+ read_output(&output)
);
- cleanup_spawned_herdr(spawned, base);
+ drop(spawned);
+ cleanup_spawned_herdr(client, base);
}
#[test]
@@ -881,6 +751,120 @@ fn attach_thin_client_with_config(
(spawned_server, thin_client, output)
}
+#[test]
+fn client_shell_detaches_restores_and_freshly_reattaches_to_current_state() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let api_socket = runtime_dir.join("herdr.sock");
+ let client_socket = runtime_dir.join("herdr-client.sock");
+
+ let mut 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 created = send_json_request(
+ &api_socket,
+ &serde_json::json!({
+ "id": "client-shell-lifecycle-workspace",
+ "method": "workspace.create",
+ "params": {"cwd": base, "focus": true, "label": "shell-lifecycle"},
+ })
+ .to_string(),
+ );
+ assert_eq!(created["result"]["type"], "workspace_created", "{created}");
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .expect("root pane id")
+ .to_string();
+ send_pane_shell_command(&api_socket, &pane_id, "printf 'SHELL_LIFECYCLE_INITIAL\\n'");
+
+ let mut client_a = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket);
+ let output_a = spawn_pty_drain(
+ client_a
+ ._master
+ .as_ref()
+ .expect("first client shell PTY")
+ .try_clone_reader()
+ .expect("clone first client shell reader"),
+ );
+ assert!(
+ wait_until(Duration::from_secs(8), Duration::from_millis(20), || {
+ let output = read_output(&output_a);
+ output.contains("shell-lifecycle") && output.contains("SHELL_LIFECYCLE_INITIAL")
+ }),
+ "client shell should compose one coherent snapshot and pane surface; output: {:?}",
+ read_output(&output_a)
+ );
+
+ let detach_watermark = output_len(&output_a);
+ client_a
+ ._master
+ .as_ref()
+ .expect("first client shell PTY")
+ .take_writer()
+ .expect("first client shell writer")
+ .write_all(b"\x02q")
+ .expect("detach first client shell");
+ let detach_output = drain_until_client_exits(&mut client_a, &output_a, detach_watermark);
+ assert!(
+ output_has_mouse_teardown(&detach_output),
+ "client shell should restore the host terminal after detach; output: {detach_output:?}"
+ );
+ assert!(
+ ping_socket(&api_socket).contains("pong"),
+ "server should remain alive after client shell detach"
+ );
+ drop(client_a);
+
+ send_pane_shell_command(
+ &api_socket,
+ &pane_id,
+ "printf 'SHELL_LIFECYCLE_DETACHED\\n'",
+ );
+ let mut client_b = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket);
+ let output_b = spawn_pty_drain(
+ client_b
+ ._master
+ .as_ref()
+ .expect("reattached client shell PTY")
+ .try_clone_reader()
+ .expect("clone reattached client shell reader"),
+ );
+ assert!(
+ wait_until(Duration::from_secs(8), Duration::from_millis(20), || {
+ let output = read_output(&output_b);
+ output.contains("shell-lifecycle") && output.contains("SHELL_LIFECYCLE_DETACHED")
+ }),
+ "fresh client shell should receive current state and detached-period output; output: {:?}",
+ read_output(&output_b)
+ );
+
+ let disconnect_watermark = output_len(&output_b);
+ if let Some(pid) = server.child.process_id() {
+ unsafe {
+ libc::kill(pid as libc::pid_t, libc::SIGKILL);
+ }
+ }
+ server.close_master();
+ let disconnect_output =
+ drain_until_client_exits(&mut client_b, &output_b, disconnect_watermark);
+ assert!(
+ output_has_mouse_teardown(&disconnect_output),
+ "client shell should restore the host terminal after endpoint loss; output: {disconnect_output:?}"
+ );
+ assert!(
+ disconnect_output
+ .to_lowercase()
+ .contains("lost connection to server"),
+ "client shell should explain endpoint loss; output: {disconnect_output:?}"
+ );
+
+ drop(server);
+ cleanup_spawned_herdr(client_b, base);
+}
+
fn captured_window_titles(output: &SharedOutput) -> Vec<String> {
read_output(output)
.split("\x1b]0;")
@@ -943,26 +927,6 @@ fn send_pane_shell_command(socket_path: &PathBuf, pane_id: &str, command: &str)
}
#[test]
-fn configured_window_title_is_emitted_in_no_session_mode() {
- let _lock = test_lock();
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let no_session = spawn_no_session_process(&config_home, &runtime_dir);
- let reader = no_session
- ._master
- .as_ref()
- .expect("no-session PTY master")
- .try_clone_reader()
- .expect("clone no-session PTY reader");
- let output = spawn_pty_drain(reader);
-
- wait_for_window_title(&output, "monolithic");
-
- cleanup_spawned_herdr(no_session, base);
-}
-
-#[test]
fn configured_window_title_tracks_all_tokens_and_focused_osc_only() {
let _lock = test_lock();
let base = unique_test_dir();
@@ -1029,7 +993,7 @@ fn configured_window_title_tracks_all_tokens_and_focused_osc_only() {
"hostname token was empty: {renamed}"
);
- send_pane_shell_command(&api_socket, &pane_id, r"printf '\033]0;⠋ building\007'");
+ send_pane_shell_command(&api_socket, &pane_id, r"printf '\033]0;building\007'");
wait_for_window_title(&output, "|W=space-a|T=tab-a|P=pane-a|O=building");
let second_tab = send_json_request(
@@ -1308,14 +1272,7 @@ fn client_exits_cleanly_when_terminal_hangs_up() {
}
#[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
+fn client_receives_pane_surface_after_pane_output() {
let _lock = test_lock();
let base = unique_test_dir();
let config_home = base.join("config");
@@ -1327,33 +1284,55 @@ fn client_receives_frame_after_pane_output() {
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");
+ let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23)
+ .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");
+ assert!(error.is_none(), "{error:?}");
+ wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(10))
+ .expect("initial client shell bootstrap");
- // 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");
+ let created = send_json_request(
+ &api_socket,
+ &serde_json::json!({
+ "id": "create-output-workspace",
+ "method": "workspace.create",
+ "params": {"label": "output", "focus": true}
+ })
+ .to_string(),
+ );
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .expect("root pane id");
+ assert!(wait_for_message_variant(
+ &mut stream,
+ Duration::from_secs(5),
+ SERVER_MESSAGE_PANE_SURFACE,
+ )
+ .expect("wait for created workspace surface"));
+
+ let sent = send_json_request(
+ &api_socket,
+ &serde_json::json!({
+ "id": "send-output",
+ "method": "pane.send_text",
+ "params": {"pane_id": pane_id, "text": "printf 'test-output\\n'\\n"}
+ })
+ .to_string(),
+ );
+ assert!(sent.get("error").is_none(), "{sent}");
+ assert!(
+ wait_for_message_variants(
+ &mut stream,
+ Duration::from_secs(5),
+ &[
+ SERVER_MESSAGE_PANE_SURFACE,
+ SERVER_MESSAGE_PANE_SURFACE_PATCH,
+ ],
+ )
+ .expect("wait for post-output pane surface"),
+ "should receive a pane surface update after pane output"
+ );
cleanup_spawned_herdr(spawned, base);
}
@@ -1424,7 +1403,25 @@ fn pane_spawn_cwd_fallback_in_server() {
"fallback cwd should exist: {cwd}"
);
- cleanup_spawned_herdr(spawned, base);
+ let client_shell = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket);
+ let output = spawn_pty_drain(
+ client_shell
+ ._master
+ .as_ref()
+ .expect("restored client shell PTY")
+ .try_clone_reader()
+ .expect("clone restored client shell reader"),
+ );
+ assert!(
+ wait_until(Duration::from_secs(8), Duration::from_millis(20), || {
+ read_output(&output).contains("missing-cwd")
+ }),
+ "client shell should render the restored session; output: {:?}",
+ read_output(&output)
+ );
+
+ drop(spawned);
+ cleanup_spawned_herdr(client_shell, base);
}
#[test]
@@ -1442,18 +1439,13 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
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");
+ let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23)
+ .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() {}
+ assert!(error.is_none(), "{error:?}");
+ wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5))
+ .expect("client shell bootstrap");
// Send SIGINT to the server process to trigger graceful shutdown.
if let Some(pid) = spawned.child.process_id() {
@@ -1462,7 +1454,7 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
}
}
- // The client should receive a ServerShutdown message (variant 4)
+ // The client should receive a ServerShutdown message
// before the connection is closed, not just an abrupt EOF.
stream
.set_read_timeout(Some(Duration::from_secs(5)))
@@ -1471,8 +1463,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
match result {
Ok((variant, _payload)) => {
assert_eq!(
- variant, 4,
- "expected ServerShutdown (variant 4), got variant {variant}"
+ variant, SERVER_MESSAGE_SERVER_SHUTDOWN,
+ "expected ServerShutdown, got variant {variant}"
);
}
Err(e) => {
@@ -1501,9 +1493,9 @@ fn client_receives_notify_on_agent_state_change() {
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::create_dir_all(config_home.join(app_dir_name())).unwrap();
fs::write(
- config_home.join("herdr/config.toml"),
+ config_home.join(app_dir_name()).join("config.toml"),
"onboarding = false\n[ui.toast]\nenabled = true\n[ui.sound]\nenabled = true\n",
)
.unwrap();
@@ -1541,18 +1533,13 @@ fn client_receives_notify_on_agent_state_change() {
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");
+ let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 54, 23)
+ .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() {}
+ assert!(error.is_none(), "{error:?}");
+ wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5))
+ .expect("client shell bootstrap");
// Create a workspace via the API.
let mut ws_stream = UnixStream::connect(&api_socket).expect("connect to API");
@@ -1596,8 +1583,7 @@ fn client_receives_notify_on_agent_state_change() {
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.
+ // Read messages from the client stream and look for the semantic notification.
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
@@ -1606,12 +1592,11 @@ fn client_receives_notify_on_agent_state_change() {
while Instant::now() < deadline {
match read_server_message(&mut stream) {
Ok((variant, _payload)) => {
- if variant == 5 {
- // ServerMessage::Notify — found it!
+ if variant == SERVER_MESSAGE_SEMANTIC_NOTIFICATION {
found_notify = true;
break;
}
- // Continue reading — Frame messages (variant 1) will come first.
+ // Snapshot and pane-surface messages may arrive first.
}
Err(_) => {
break;
@@ -1621,7 +1606,7 @@ fn client_receives_notify_on_agent_state_change() {
assert!(
found_notify,
- "client should receive a ServerMessage::Notify after pane.report_agent"
+ "client should receive a semantic notification after pane.report_agent"
);
// Now report Idle from Working — this should trigger a Done sound
@@ -1683,7 +1668,7 @@ fn client_receives_notify_on_agent_state_change() {
let mut idle_response = String::new();
idle_reader.read_line(&mut idle_response).unwrap();
- // Read messages and look for Done sound notify.
+ // Read messages and look for the done semantic notification.
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
@@ -1692,16 +1677,14 @@ fn client_receives_notify_on_agent_state_change() {
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.
+ if variant == SERVER_MESSAGE_SEMANTIC_NOTIFICATION {
found_done_notify = true;
break;
}
- // Continue reading — Frame messages will come first.
+ // Snapshot and pane-surface messages may arrive first.
}
Err(e) => {
- eprintln!("read error while looking for Done Notify: {e}");
+ eprintln!("read error while looking for done notification: {e}");
break;
}
}
@@ -1709,7 +1692,7 @@ fn client_receives_notify_on_agent_state_change() {
assert!(
found_done_notify,
- "client should receive a Sound Notify with 'agent done' when background pane transitions Working→Idle"
+ "client should receive a semantic notification when a background pane transitions Working→Idle"
);
cleanup_spawned_herdr(spawned, base);
diff --git a/tests/cross_area.rs b/tests/cross_area.rs
index 6f1ab82b..9b856cb8 100644
--- a/tests/cross_area.rs
+++ b/tests/cross_area.rs
@@ -1,6 +1,6 @@
//! Cross-area integration tests for end-to-end persistence flows.
-mod support;
+pub mod support;
use std::fs;
use std::io::{self, BufRead, BufReader, Read, Write};
@@ -11,11 +11,11 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
-use serde::Deserialize;
use serde_json::{json, Value};
use support::{
- cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
- unregister_spawned_herdr_pid, CURRENT_PROTOCOL,
+ cleanup_test_base, client_shell_handshake, register_runtime_dir, register_spawned_herdr_pid,
+ unregister_spawned_herdr_pid, CURRENT_PROTOCOL, SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT,
+ SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH,
};
fn unique_test_dir() -> PathBuf {
@@ -196,7 +196,7 @@ fn workspace_create(socket_path: &Path, label: &str) -> Value {
socket_path,
"workspace_create",
"workspace.create",
- json!({ "label": label }),
+ json!({ "label": label, "focus": true }),
)
}
@@ -365,16 +365,6 @@ fn encode_varint_u32(v: u32) -> Vec<u8> {
}
}
-fn encode_varint_u16(v: u16) -> Vec<u8> {
- if v < 251 {
- vec![v as u8]
- } else {
- let mut buf = vec![251u8];
- buf.extend_from_slice(&v.to_le_bytes());
- buf
- }
-}
-
fn frame_message(payload: &[u8]) -> Vec<u8> {
let mut framed = (payload.len() as u32).to_le_bytes().to_vec();
framed.extend_from_slice(payload);
@@ -414,76 +404,6 @@ fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), Stri
}
}
-fn client_handshake(stream: &mut UnixStream, version: u32, cols: u16, rows: u16) {
- stream
- .set_read_timeout(Some(Duration::from_secs(5)))
- .expect("set read timeout");
-
- // ClientMessage::Hello = variant 0
- let mut payload = encode_varint_u32(0);
- payload.extend_from_slice(&encode_varint_u32(version));
- payload.extend_from_slice(&encode_varint_u16(cols));
- payload.extend_from_slice(&encode_varint_u16(rows));
- payload.extend_from_slice(&encode_varint_u32(8)); // cell_width_px
- payload.extend_from_slice(&encode_varint_u32(16)); // cell_height_px
- payload.extend_from_slice(&encode_varint_u32(0)); // RenderEncoding::SemanticFrame
- payload.extend_from_slice(&encode_varint_u32(0)); // ClientKeybindings::Server
- payload.extend_from_slice(&encode_varint_u32(0)); // ClientLaunchMode::App
-
- stream
- .write_all(&frame_message(&payload))
- .expect("write hello");
- stream.flush().expect("flush hello");
-
- let mut len_buf = [0u8; 4];
- stream
- .read_exact(&mut len_buf)
- .expect("read welcome length");
- let len = u32::from_le_bytes(len_buf) as usize;
- assert!(len > 0 && len <= 2 * 1024 * 1024, "unexpected welcome size");
-
- let mut welcome_payload = vec![0u8; len];
- stream
- .read_exact(&mut welcome_payload)
- .expect("read welcome payload");
-
- let mut offset = 0;
- let (variant, consumed) = decode_varint_u32(&welcome_payload, offset).expect("decode variant");
- offset += consumed;
- assert_eq!(variant, 0, "expected ServerMessage::Welcome variant");
-
- let (_server_version, consumed) =
- decode_varint_u32(&welcome_payload, offset).expect("decode version");
- offset += consumed;
-
- let (_encoding, consumed) =
- decode_varint_u32(&welcome_payload, offset).expect("decode render encoding");
- offset += consumed;
-
- let option_tag = *welcome_payload
- .get(offset)
- .expect("welcome payload should contain Option tag");
- if option_tag == 1 {
- let (str_len, consumed) =
- decode_varint_u32(&welcome_payload, offset + 1).expect("decode error length");
- let start = offset + 1 + consumed;
- let end = start + str_len as usize;
- let err = String::from_utf8(welcome_payload[start..end].to_vec()).expect("utf8 error");
- panic!("handshake rejected: {err}");
- }
-}
-
-fn send_client_input(stream: &mut UnixStream, data: &[u8]) {
- // ClientMessage::Input = variant 1
- let mut payload = encode_varint_u32(1);
- payload.extend_from_slice(&encode_varint_u32(data.len() as u32));
- payload.extend_from_slice(data);
- stream
- .write_all(&frame_message(&payload))
- .expect("write input");
- stream.flush().expect("flush input");
-}
-
fn send_client_detach(stream: &mut UnixStream) {
// ClientMessage::Detach = variant 4
let payload = encode_varint_u32(4);
@@ -500,112 +420,8 @@ fn is_timeout(err: &io::Error) -> bool {
)
}
-#[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 frame_contains_colored_symbol(frame: &FrameWire, symbol: &str, rgb: (u8, u8, u8)) -> bool {
- let (r, g, b) = rgb;
- let fg = 0x02_00_00_00 | (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b);
- frame
- .cells
- .iter()
- .any(|cell| cell.symbol == symbol && cell.fg == fg)
-}
-
-fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool {
- if frame.cells.is_empty() {
- return false;
- }
-
- let width = frame.width.max(1) as usize;
- let mut text = String::new();
- for row in frame.cells.chunks(width) {
- for cell in row {
- let _ = (cell.fg, cell.bg, cell.modifier, cell.skip);
- text.push_str(&cell.symbol);
- }
- text.push('\n');
- }
- let _ = (frame.height, frame.graphics.len());
- if let Some(cursor) = frame.cursor.as_ref() {
- let _ = (cursor.x, cursor.y, cursor.visible, cursor.shape);
- }
-
- text.contains(needle)
-}
-
fn read_server_variant(stream: &mut UnixStream, timeout: Duration) -> io::Result<u32> {
stream.set_read_timeout(Some(timeout))?;
-
- let mut len_buf = [0u8; 4];
- stream.read_exact(&mut len_buf)?;
- let len = u32::from_le_bytes(len_buf) as usize;
- if len == 0 {
- return Err(io::Error::new(
- io::ErrorKind::InvalidData,
- "zero-length payload",
- ));
- }
-
- let mut payload = vec![0u8; len];
- stream.read_exact(&mut payload)?;
-
- let (variant, _consumed) = decode_varint_u32(&payload, 0)
- .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
- Ok(variant)
-}
-
-fn read_server_message_payload(
- stream: &mut UnixStream,
- timeout: Duration,
-) -> io::Result<(u32, Vec<u8>)> {
- stream.set_read_timeout(Some(timeout))?;
-
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf)?;
let len = u32::from_le_bytes(len_buf) as usize;
@@ -615,49 +431,20 @@ fn read_server_message_payload(
"zero-length payload",
));
}
-
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload)?;
-
- let (variant, consumed) = decode_varint_u32(&payload, 0)
- .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
- Ok((variant, payload[consumed..].to_vec()))
-}
-
-fn wait_for_frame_matching(
- stream: &mut UnixStream,
- timeout: Duration,
- predicate: impl Fn(&FrameWire) -> bool,
-) -> io::Result<bool> {
- let deadline = Instant::now() + timeout;
- while Instant::now() < deadline {
- let slice = deadline
- .saturating_duration_since(Instant::now())
- .min(Duration::from_millis(80));
- match read_server_message_payload(stream, slice) {
- Ok((1, payload)) => {
- let frame = decode_frame_payload(&payload)?;
- if predicate(&frame) {
- return Ok(true);
- }
- }
- Ok((_variant, _payload)) => {}
- Err(err) if is_timeout(&err) => {}
- Err(err) => return Err(err),
- }
- }
-
- Ok(false)
+ decode_varint_u32(&payload, 0)
+ .map(|(variant, _)| variant)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
- let slice = deadline
- .saturating_duration_since(Instant::now())
- .min(Duration::from_millis(80));
+ let slice = deadline.saturating_duration_since(Instant::now());
match read_server_variant(stream, slice) {
- Ok(1) => return true, // ServerMessage::Frame
+ Ok(SERVER_MESSAGE_PANE_SURFACE | SERVER_MESSAGE_PANE_SURFACE_PATCH) => return true,
+ Ok(SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT) => {}
Ok(_) => {}
Err(err) if is_timeout(&err) => {}
Err(_) => return false,
@@ -669,7 +456,7 @@ fn wait_for_frame(stream: &mut UnixStream, timeout: Duration) -> bool {
fn drain_server_messages(stream: &mut UnixStream, max_drain: Duration) {
let deadline = Instant::now() + max_drain;
while Instant::now() < deadline {
- match read_server_variant(stream, Duration::from_millis(40)) {
+ match read_server_variant(stream, deadline.saturating_duration_since(Instant::now())) {
Ok(_) => {}
Err(err) if is_timeout(&err) => break,
Err(_) => break,
@@ -696,7 +483,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Local attach (client A).
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30);
+ client_shell_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30).expect("shell handshake");
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
// Use herdr: create a workspace and write output into its pane.
@@ -733,7 +520,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Reattach from another terminal/session (client B).
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24);
+ client_shell_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24).expect("shell handshake");
assert!(
wait_for_frame(&mut client_b, Duration::from_secs(5)),
"reattached client should receive frame"
@@ -789,7 +576,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30);
+ client_shell_handshake(&mut client_a, CURRENT_PROTOCOL, 100, 30).expect("shell handshake");
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
let created = workspace_create(&api_socket, "agent-persist");
@@ -840,18 +627,10 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
"agent status should remain working while detached"
);
- // Reattach and ensure client-side state reflects the persisted working status.
+ // Reattach and verify the persisted projection through the API.
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24);
- let saw_working_on_client =
- wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
- frame_contains_colored_symbol(frame, "●", (249, 226, 175))
- })
- .expect("frame decoding should succeed");
- assert!(
- saw_working_on_client,
- "reattached client frame should expose persisted agent working status"
- );
+ client_shell_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24).expect("shell handshake");
+ assert!(wait_for_frame(&mut client_b, Duration::from_secs(5)));
// Transition to blocked and verify API + client surfaces both observe it.
// The fake process remains visibly working, so blocked is the deterministic
@@ -862,15 +641,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
"pane agent status should transition to blocked"
);
- let saw_blocked_on_client =
- wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
- frame_contains_colored_symbol(frame, "●", (243, 139, 168))
- })
- .expect("frame decoding should succeed");
- assert!(
- saw_blocked_on_client,
- "reattached client frame should show blocked status after transition"
- );
+ // The API status above is the stable cross-area contract for this transition.
cleanup_spawned_herdr(server, base);
}
@@ -889,7 +660,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client = UnixStream::connect(&client_socket).expect("client should connect");
- client_handshake(&mut client, CURRENT_PROTOCOL, 100, 30);
+ client_shell_handshake(&mut client, CURRENT_PROTOCOL, 100, 30).expect("shell handshake");
assert!(wait_for_frame(&mut client, Duration::from_secs(2)));
drain_server_messages(&mut client, Duration::from_millis(300));
@@ -902,17 +673,8 @@ fn cross_area_client_and_api_workspace_views_are_consistent() {
.expect("workspace.create should return workspace_id")
.to_string();
- // The attached client must receive a frame that includes the new workspace
- // label, proving client-side state reflects the API surface.
- let saw_workspace_on_client =
- wait_for_frame_matching(&mut client, Duration::from_secs(3), |frame| {
- frame_contains_text(frame, "api-visible-workspace")
- })
- .expect("frame decoding should succeed");
- assert!(
- saw_workspace_on_client,
- "client-side frame should include the newly created workspace label"
- );
+ // ClientShell state is asserted through the authoritative API projection below;
+ // do not decode client-composed UI to duplicate that assertion.
let deadline = Instant::now() + Duration::from_secs(5);
let mut count_reached = false;
@@ -952,9 +714,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, CURRENT_PROTOCOL, 110, 30);
+ client_shell_handshake(&mut client_a, CURRENT_PROTOCOL, 110, 30).expect("shell handshake");
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, CURRENT_PROTOCOL, 100, 30);
+ client_shell_handshake(&mut client_b, CURRENT_PROTOCOL, 100, 30).expect("shell handshake");
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
assert!(wait_for_frame(&mut client_b, Duration::from_secs(2)));
@@ -968,7 +730,7 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() {
.to_string();
// Input from client A should update shared state visible to client B.
- send_client_input(&mut client_a, b"echo SHARED_VIEW\n");
+ pane_send_text(&api_socket, &pane_id, "echo SHARED_VIEW\n");
assert!(
wait_for_frame(&mut client_b, Duration::from_secs(2)),
"client B should receive update from client A"
@@ -982,19 +744,24 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() {
// Detach client A; client B should keep working.
send_client_detach(&mut client_a);
- drop(client_a);
-
- send_client_input(&mut client_b, b"echo AFTER_A_DETACH\n");
assert!(
- wait_for_frame(&mut client_b, Duration::from_secs(2)),
- "remaining client should still receive frames after other client detaches"
+ support::wait_for_disconnect(&mut client_a, Duration::from_secs(2))
+ .expect("wait for client A detach"),
+ "client A should disconnect before the remaining-client assertion"
);
+ drop(client_a);
+
+ pane_send_text(&api_socket, &pane_id, "echo AFTER_A_DETACH\n");
assert!(pane_read_recent_contains(
&api_socket,
&pane_id,
"AFTER_A_DETACH",
Duration::from_secs(5)
));
+ assert!(
+ wait_for_frame(&mut client_b, Duration::from_secs(2)),
+ "remaining client should still receive frames after other client detaches"
+ );
let ping = ping_socket(&api_socket);
assert!(
@@ -1123,7 +890,8 @@ fn cross_area_server_kill_then_restart_and_reconnect() {
let mut reconnect_client =
UnixStream::connect(&client_socket).expect("new client should connect after restart");
- client_handshake(&mut reconnect_client, CURRENT_PROTOCOL, 80, 24);
+ client_shell_handshake(&mut reconnect_client, CURRENT_PROTOCOL, 80, 24)
+ .expect("shell handshake");
assert!(
wait_for_frame(&mut reconnect_client, Duration::from_secs(5)),
"new client should receive frame after restart"
diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs
index 4461a17b..f570e979 100644
--- a/tests/detach_reattach.rs
+++ b/tests/detach_reattach.rs
@@ -1,7 +1,7 @@
//! Integration tests for detach/reattach flow.
//!
-mod support;
+pub mod support;
use std::fs;
use std::io::{BufRead, BufReader, Write};
@@ -14,9 +14,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use serde_json::Value;
use support::{
- cleanup_test_base, client_handshake, drain_messages, read_server_message, register_runtime_dir,
- register_spawned_herdr_pid, send_detach, send_input, unregister_spawned_herdr_pid,
- wait_for_disconnect, wait_for_message_variant, wait_for_socket, wait_until, 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_disconnect,
+ wait_for_socket, wait_until, CURRENT_PROTOCOL,
};
const CUSTOM_HEADLESS_SIZE_CONFIG: &str = r#"onboarding = false
@@ -280,72 +280,6 @@ fn first_pane_id(response: &Value) -> String {
// ---------------------------------------------------------------------------
#[test]
-fn navigate_q_detaches_client_and_server_persists() {
- // In persistence mode, navigate-mode q detaches the client and the server persists.
- // Flow:
- // 1. Start server
- // 2. Connect client, handshake
- // 3. Send prefix key (Ctrl+B) then 'q'
- // 4. Verify server is still alive via API ping
- // 5. Verify the client connection is closed
- let _lock = test_lock();
- let base = unique_test_dir();
- let config_home = base.join("config");
- let runtime_dir = base.join("runtime");
- let api_socket = runtime_dir.join("herdr.sock");
- let client_socket = runtime_dir.join("herdr-client.sock");
-
- let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket);
- wait_for_socket(&api_socket, Duration::from_secs(10));
- wait_for_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 frames.
- drain_messages(&mut stream);
-
- // Send prefix key (Ctrl+B = 0x02) then 'q' (quit/detach in persistence mode).
- send_input(&mut stream, &[0x02]).expect("send prefix");
-
- // Drain any frames generated by entering navigate mode.
- drain_messages(&mut stream);
-
- send_input(&mut stream, b"q").expect("send detach key");
-
- assert!(
- wait_until(Duration::from_secs(2), Duration::from_millis(25), || {
- ping_socket(&api_socket).contains("pong")
- }),
- "server should still respond to ping after client detach"
- );
-
- // Verify server is still alive and responsive.
- let response = ping_socket(&api_socket);
- assert!(
- response.contains("pong"),
- "server should still respond to ping after client detach: {response}"
- );
-
- // The client should receive a ServerShutdown with reason "detached"
- // shortly after the quit/detach key. There may be some frames in
- // between from the mode change, so we read multiple messages.
- let got_shutdown = wait_for_message_variant(&mut stream, Duration::from_secs(2), 2)
- .expect("wait for shutdown message")
- || wait_for_disconnect(&mut stream, Duration::from_secs(1)).expect("wait for disconnect");
- assert!(
- got_shutdown,
- "client should receive ServerShutdown after quit/detach key"
- );
-
- cleanup_spawned_herdr(spawned, base);
-}
-
-#[test]
fn explicit_detach_message_causes_clean_disconnect() {
// Client sends ClientMessage::Detach
// directly (not via keybind), server handles it gracefully.
@@ -363,8 +297,8 @@ fn explicit_detach_message_causes_clean_disconnect() {
// Connect and 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");
+ let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24)
+ .expect("handshake should succeed");
assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
@@ -422,7 +356,7 @@ fn reattach_after_detach_shows_current_state() {
// --- Client A ---
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
- let (version, error) = client_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24)
+ let (version, error) = client_shell_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24)
.expect("handshake should succeed");
assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
@@ -461,7 +395,7 @@ fn reattach_after_detach_shows_current_state() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
- let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24)
+ let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24)
.expect("handshake should succeed");
assert_eq!(version, CURRENT_PROTOCOL);
assert!(
@@ -470,31 +404,10 @@ fn reattach_after_detach_shows_current_state() {
error
);
- // Client B should receive a frame with the current state,
+ // Client B receives the client-owned shell projection.
// including the workspace created while client A was attached.
- stream_b.set_nonblocking(false).unwrap();
- stream_b
- .set_read_timeout(Some(Duration::from_secs(5)))
- .unwrap();
-
- let mut received_frame = false;
- let deadline = Instant::now() + Duration::from_secs(5);
- while Instant::now() < deadline {
- match read_server_message(&mut stream_b) {
- Ok((variant, _payload)) => {
- if variant == 1 {
- // ServerMessage::Frame
- received_frame = true;
- break;
- }
- }
- Err(_) => break,
- }
- }
- assert!(
- received_frame,
- "reattached client should receive a Frame with current state"
- );
+ support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5))
+ .expect("client shell bootstrap");
// Verify the workspace still exists via API.
let mut list_stream = UnixStream::connect(&api_socket).expect("connect to API");
@@ -541,19 +454,29 @@ fn processes_survive_during_and_after_detach() {
// Connect and 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");
+ let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24)
+ .expect("handshake should succeed");
assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
drain_messages(&mut stream);
- // Send input to the pane — the fresh server should have at least one
- // pane with a shell running.
- send_input(&mut stream, b"echo SURVIVED_DETACH\n").expect("send echo command");
+ // Drive the pane through the JSON API; the shell transport carries only
+ // client-composed UI state, not legacy raw shell input.
+ let created = workspace_create(&api_socket, "process-survival");
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .expect("root pane id")
+ .to_string();
+ pane_send_text(&api_socket, &pane_id, "echo SURVIVED_DETACH\n");
+ assert!(wait_until(
+ Duration::from_secs(5),
+ Duration::from_millis(50),
+ || pane_read_recent_text(&api_socket, &pane_id).contains("SURVIVED_DETACH")
+ ));
- // Drain any frames generated by the input.
+ // Drain any shell projection updates.
drain_messages(&mut stream);
// Detach the client via explicit Detach message.
@@ -580,32 +503,14 @@ fn processes_survive_during_and_after_detach() {
// Reattach — verify we can connect and receive a frame.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
- let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24)
+ let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24)
.expect("reattach handshake should succeed");
assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Verify the reattached client receives a frame.
- stream_b
- .set_read_timeout(Some(Duration::from_secs(5)))
- .unwrap();
- let mut received_frame = false;
- let deadline = Instant::now() + Duration::from_secs(5);
- while Instant::now() < deadline {
- match read_server_message(&mut stream_b) {
- Ok((variant, _)) => {
- if variant == 1 {
- received_frame = true;
- break;
- }
- }
- Err(_) => break,
- }
- }
- assert!(
- received_frame,
- "reattached client should receive a Frame showing current state"
- );
+ support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5))
+ .expect("client shell bootstrap");
cleanup_spawned_herdr(spawned, base);
}
@@ -629,8 +534,8 @@ fn server_persists_after_client_connection_drop() {
// Connect and 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");
+ let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24)
+ .expect("handshake should succeed");
assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
@@ -656,7 +561,7 @@ fn server_persists_after_client_connection_drop() {
// Reattach — verify we can connect and handshake again.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
- let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24)
+ let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24)
.expect("reattach handshake should succeed");
assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "reattach should succeed: {:?}", error);
@@ -723,11 +628,12 @@ fn pane_created_after_detach_uses_configured_headless_size() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut stream = UnixStream::connect(&client_socket).expect("client should connect");
- let (version, error) =
- client_handshake(&mut stream, CURRENT_PROTOCOL, 160, 50).expect("handshake should succeed");
+ let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 160, 50)
+ .expect("handshake should succeed");
assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{error:?}");
- drain_messages(&mut stream);
+ support::wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5))
+ .expect("client shell bootstrap");
let first = workspace_create(&api_socket, "attached-size");
let first_pane_id = first["result"]["root_pane"]["pane_id"]
@@ -787,8 +693,8 @@ fn detached_output_preserves_last_attached_pty_size() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut stream = UnixStream::connect(&client_socket).expect("client should connect");
- let (version, error) =
- client_handshake(&mut stream, CURRENT_PROTOCOL, 120, 40).expect("handshake should succeed");
+ let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 120, 40)
+ .expect("handshake should succeed");
assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
drain_messages(&mut stream);
@@ -856,7 +762,7 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// Connect and handshake client A.
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
- let (version, error) = client_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24)
+ let (version, error) = client_shell_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24)
.expect("handshake should succeed");
assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
@@ -914,44 +820,14 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
- let (version, error) = client_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24)
+ let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24)
.expect("reattach handshake should succeed");
assert_eq!(version, CURRENT_PROTOCOL);
assert!(error.is_none(), "{:?}", error);
// Client B should receive a frame with the current state.
- stream_b
- .set_read_timeout(Some(Duration::from_secs(5)))
- .unwrap();
- let mut received_frame = false;
- let deadline = Instant::now() + Duration::from_secs(5);
- while Instant::now() < deadline {
- match read_server_message(&mut stream_b) {
- Ok((variant, _)) => {
- if variant == 1 {
- received_frame = true;
- break;
- }
- }
- Err(_) => break,
- }
- }
- assert!(
- received_frame,
- "reattached client should receive a Frame showing current state"
- );
-
- // Verify the pane content via API includes the output sent while detached.
- let read_response = pane_read_recent(&api_socket, &pane_id);
-
- // The pane output should contain the text sent while detached.
- assert!(
- read_response["result"]["read"]["text"]
- .as_str()
- .unwrap_or_default()
- .contains("DURING_DETACH"),
- "pane should contain output produced while detached: {read_response}"
- );
+ support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5))
+ .expect("client shell bootstrap");
cleanup_spawned_herdr(spawned, base);
}
diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs
index 832079e8..d58fb301 100644
--- a/tests/live_handoff.rs
+++ b/tests/live_handoff.rs
@@ -1,4 +1,4 @@
-mod support;
+pub mod support;
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
@@ -12,8 +12,10 @@ use std::time::{Duration, Instant};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use support::{
- cleanup_test_base, client_handshake, register_runtime_dir, register_spawned_herdr_pid,
- send_input, unregister_spawned_herdr_pid, wait_for_disconnect, wait_for_socket,
+ cleanup_test_base, client_shell_handshake, register_runtime_dir, register_spawned_herdr_pid,
+ send_client_shell_shift_enter, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap,
+ wait_for_message_variant, wait_for_socket, SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT,
+ SERVER_MESSAGE_SERVER_SHUTDOWN,
};
struct SpawnedHerdr {
@@ -868,9 +870,19 @@ fn live_handoff_preserves_pane_process_io() {
.as_u64()
.unwrap() as u32;
let mut client_stream = UnixStream::connect(&client_socket).unwrap();
- let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap();
+ let (server_protocol, error) =
+ client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap();
assert_eq!(server_protocol, protocol);
- assert!(error.is_none(), "client handshake failed: {error:?}");
+ assert!(error.is_none(), "client shell handshake failed: {error:?}");
+ assert!(
+ wait_for_message_variant(
+ &mut client_stream,
+ Duration::from_secs(5),
+ SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT,
+ )
+ .unwrap(),
+ "client shell should receive a complete snapshot before handoff"
+ );
assert_ok(request(
&api_socket,
@@ -888,8 +900,13 @@ fn live_handoff_preserves_pane_process_io() {
));
drop(spawned);
assert!(
- wait_for_disconnect(&mut client_stream, Duration::from_secs(5)).unwrap(),
- "connected clients should disconnect during live handoff"
+ wait_for_message_variant(
+ &mut client_stream,
+ Duration::from_secs(5),
+ SERVER_MESSAGE_SERVER_SHUTDOWN,
+ )
+ .unwrap(),
+ "connected client shell should receive live-handoff shutdown"
);
thread::sleep(Duration::from_millis(300));
wait_for_api(&api_socket, Duration::from_secs(10));
@@ -935,6 +952,14 @@ fn live_handoff_preserves_pane_process_io() {
);
wait_for_output(&api_socket, &second_pane_id, "second:after-handoff-sec");
+ let mut reattached_shell = UnixStream::connect(&client_socket).unwrap();
+ let (server_protocol, error) =
+ client_shell_handshake(&mut reattached_shell, protocol, 54, 23).unwrap();
+ assert_eq!(server_protocol, protocol);
+ assert!(error.is_none(), "reattached client shell failed: {error:?}");
+ wait_for_client_shell_bootstrap(&mut reattached_shell, Duration::from_secs(5))
+ .expect("fresh client shell should receive restored snapshot before pane content");
+
let _ = request(
&api_socket,
serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}),
@@ -1020,10 +1045,13 @@ pathlib.Path({received:?}).write_text(data.hex())
wait_for_socket(&client_socket, Duration::from_secs(5));
let mut client_stream = UnixStream::connect(&client_socket).unwrap();
- let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap();
+ let (server_protocol, error) =
+ client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap();
assert_eq!(server_protocol, protocol);
- assert!(error.is_none(), "client handshake failed: {error:?}");
- send_input(&mut client_stream, b"\x1b[13;2u").unwrap();
+ assert!(error.is_none(), "client shell handshake failed: {error:?}");
+ wait_for_client_shell_bootstrap(&mut client_stream, Duration::from_secs(5))
+ .expect("client shell should receive restored state before sending input");
+ send_client_shell_shift_enter(&mut client_stream, &pane_id).unwrap();
wait_for_file_contains(&received_marker, "1b5b31333b3275", Duration::from_secs(5));
@@ -1111,10 +1139,13 @@ pathlib.Path({received:?}).write_text(data.hex())
wait_for_socket(&client_socket, Duration::from_secs(5));
let mut client_stream = UnixStream::connect(&client_socket).unwrap();
- let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap();
+ let (server_protocol, error) =
+ client_shell_handshake(&mut client_stream, protocol, 54, 23).unwrap();
assert_eq!(server_protocol, protocol);
- assert!(error.is_none(), "client handshake failed: {error:?}");
- send_input(&mut client_stream, b"\x1b[13;2u").unwrap();
+ assert!(error.is_none(), "client shell handshake failed: {error:?}");
+ wait_for_client_shell_bootstrap(&mut client_stream, Duration::from_secs(5))
+ .expect("client shell should receive restored state before sending input");
+ send_client_shell_shift_enter(&mut client_stream, &pane_id).unwrap();
wait_for_file_contains(
&received_marker,
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);
}
diff --git a/tests/server_headless.rs b/tests/server_headless.rs
index 5abb5ace..f6eb0771 100644
--- a/tests/server_headless.rs
+++ b/tests/server_headless.rs
@@ -1,6 +1,6 @@
//! Integration tests for headless server mode.
-mod support;
+pub mod support;
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
@@ -13,7 +13,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use support::{
- cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid,
+ cleanup_test_base, client_handshake, register_runtime_dir, register_spawned_herdr_pid,
unregister_spawned_herdr_pid, CURRENT_PROTOCOL,
};
@@ -151,207 +151,6 @@ fn ping_socket(socket_path: &Path) -> String {
response.trim().to_string()
}
-/// Sends a Hello message over the client socket and reads the Welcome response.
-/// Uses bincode v2 wire format: [u32LE length][bincode payload]
-/// bincode v2 standard config uses VarintEncoding:
-/// - Integers < 251 are encoded as a single byte
-/// - Enum variant index is encoded as u32 varint
-/// - Option discriminant is always a single byte (0=None, 1=Some)
-/// - String: length (varint) + UTF-8 bytes
-fn client_handshake(
- stream: &mut UnixStream,
- version: u32,
- cols: u16,
- rows: u16,
-) -> Result<(u32, Option<String>), String> {
- stream
- .set_read_timeout(Some(Duration::from_secs(5)))
- .map_err(|e| e.to_string())?;
-
- // Encode Hello message using bincode v2 varint format.
- // ClientMessage::Hello is variant 0.
- let hello_payload = encode_varint_enum(
- 0,
- &[
- &encode_varint_u32(version),
- &encode_varint_u16(cols),
- &encode_varint_u16(rows),
- &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
- ],
- );
- let framed = frame_message(&hello_payload);
- stream.write_all(&framed).map_err(|e| e.to_string())?;
- stream.flush().map_err(|e| e.to_string())?;
-
- // Read the framed response.
- let mut len_buf = [0u8; 4];
- stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?;
- let len = u32::from_le_bytes(len_buf) as usize;
-
- if len > 2 * 1024 * 1024 {
- return Err(format!("oversized response: {len}"));
- }
-
- let mut payload = vec![0u8; len];
- stream.read_exact(&mut payload).map_err(|e| e.to_string())?;
-
- // Decode Welcome: ServerMessage variant 0 = Welcome { version: u32, error: Option<String> }
- decode_welcome(&payload)
-}
-
-/// Encode a varint u32 value according to bincode v2 VarintEncoding.
-fn encode_varint_u32(v: u32) -> Vec<u8> {
- if v < 251 {
- vec![v as u8]
- } else if v < 65536 {
- let mut buf = vec![251u8];
- buf.extend_from_slice(&(v as u16).to_le_bytes());
- buf
- } else {
- let mut buf = vec![252u8];
- buf.extend_from_slice(&v.to_le_bytes());
- buf
- }
-}
-
-/// Encode a varint u16 value.
-fn encode_varint_u16(v: u16) -> Vec<u8> {
- if v < 251 {
- vec![v as u8]
- } else {
- let mut buf = vec![251u8];
- buf.extend_from_slice(&v.to_le_bytes());
- buf
- }
-}
-
-/// Encode an enum variant with its fields.
-fn encode_varint_enum(variant_idx: u32, fields: &[&[u8]]) -> Vec<u8> {
- let mut buf = encode_varint_u32(variant_idx);
- for field in fields {
- buf.extend_from_slice(field);
- }
- buf
-}
-
-/// Frame a message with u32LE length prefix.
-fn frame_message(payload: &[u8]) -> Vec<u8> {
- let len = payload.len() as u32;
- let mut framed = len.to_le_bytes().to_vec();
- framed.extend_from_slice(payload);
- framed
-}
-
-/// Decode a varint u32 from a byte slice at the given offset.
-/// Returns (value, bytes_consumed).
-fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> {
- if offset >= payload.len() {
- return Err("payload too short for varint".into());
- }
- let first_byte = payload[offset];
- match first_byte {
- 0..=250 => Ok((first_byte as u32, 1)),
- 251 => {
- if offset + 3 > payload.len() {
- return Err("payload too short for u16 varint".into());
- }
- let v = u16::from_le_bytes(
- payload[offset + 1..offset + 3]
- .try_into()
- .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
- );
- Ok((v as u32, 3))
- }
- 252 => {
- if offset + 5 > payload.len() {
- return Err("payload too short for u32 varint".into());
- }
- let v = u32::from_le_bytes(
- payload[offset + 1..offset + 5]
- .try_into()
- .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
- );
- Ok((v, 5))
- }
- _ => Err(format!("unsupported varint tag: {first_byte}")),
- }
-}
-
-/// Decode a varint u16 from a byte slice at the given offset.
-#[allow(dead_code)]
-fn decode_varint_u16(payload: &[u8], offset: usize) -> Result<(u16, usize), String> {
- if offset >= payload.len() {
- return Err("payload too short for varint".into());
- }
- let first_byte = payload[offset];
- match first_byte {
- 0..=250 => Ok((first_byte as u16, 1)),
- 251 => {
- if offset + 3 > payload.len() {
- return Err("payload too short for u16 varint".into());
- }
- let v = u16::from_le_bytes(
- payload[offset + 1..offset + 3]
- .try_into()
- .map_err(|e: std::array::TryFromSliceError| e.to_string())?,
- );
- Ok((v, 3))
- }
- _ => Err(format!("unsupported varint tag for u16: {first_byte}")),
- }
-}
-
-/// Decode a ServerMessage::Welcome from bincode v2 payload.
-fn decode_welcome(payload: &[u8]) -> Result<(u32, Option<String>), String> {
- let mut offset = 0;
-
- // Variant index (should be 0 for Welcome)
- let (variant, consumed) = decode_varint_u32(payload, offset)?;
- offset += consumed;
- if variant != 0 {
- return Err(format!(
- "expected Welcome (variant 0), got variant {variant}"
- ));
- }
-
- // version: u32
- let (version, consumed) = decode_varint_u32(payload, offset)?;
- offset += consumed;
-
- // encoding: RenderEncoding
- let (_encoding, consumed) = decode_varint_u32(payload, offset)?;
- offset += consumed;
-
- // error: Option<String> — discriminant is always 1 byte
- if offset >= payload.len() {
- return Err("payload too short for Option tag".into());
- }
- let option_tag = payload[offset];
- offset += 1;
-
- let error = if option_tag == 1 {
- // Some(String) — length as varint + UTF-8 bytes
- let (str_len, consumed) = decode_varint_u32(payload, offset)?;
- offset += consumed;
- let str_len = str_len as usize;
-
- if offset + str_len > payload.len() {
- return Err("payload too short for string content".into());
- }
- let s = String::from_utf8(payload[offset..offset + str_len].to_vec())
- .map_err(|e| e.to_string())?;
- Some(s)
- } else {
- None
- };
-
- Ok((version, error))
-}
-
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index f295dbbe..1da9985b 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -1,5 +1,3 @@
-#![allow(dead_code)]
-
use std::collections::HashSet;
use std::fs;
use std::io::{Read, Write};
@@ -16,6 +14,13 @@ static CLEANUP_GUARD: OnceLock<CleanupGuard> = OnceLock::new();
const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1);
const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid";
pub const CURRENT_PROTOCOL: u32 = 21;
+pub const SERVER_MESSAGE_SERVER_SHUTDOWN: u32 = 3;
+pub const SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT: u32 = 12;
+pub const SERVER_MESSAGE_PANE_SURFACE: u32 = 13;
+pub const SERVER_MESSAGE_SEMANTIC_NOTIFICATION: u32 = 14;
+pub const SERVER_MESSAGE_PANE_SURFACE_PATCH: u32 = 19;
+const CLIENT_MESSAGE_CLIENT_SHELL_HELLO: u32 = 11;
+const CLIENT_MESSAGE_CLIENT_SHELL_PANE_INPUT: u32 = 13;
pub fn register_spawned_herdr_pid(pid: Option<u32>) {
let Some(pid) = pid else {
@@ -108,7 +113,7 @@ pub fn wait_for_file(path: &Path, timeout: Duration) {
panic!("file did not appear at {}", path.display());
}
-pub fn encode_varint_u32(v: u32) -> Vec<u8> {
+fn encode_varint_u32(v: u32) -> Vec<u8> {
if v < 251 {
vec![v as u8]
} else if v < 65536 {
@@ -122,7 +127,7 @@ pub fn encode_varint_u32(v: u32) -> Vec<u8> {
}
}
-pub fn encode_varint_u16(v: u16) -> Vec<u8> {
+fn encode_varint_u16(v: u16) -> Vec<u8> {
if v < 251 {
vec![v as u8]
} else {
@@ -132,14 +137,14 @@ pub fn encode_varint_u16(v: u16) -> Vec<u8> {
}
}
-pub fn frame_message(payload: &[u8]) -> Vec<u8> {
+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
}
-pub fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> {
+fn decode_varint_u32(payload: &[u8], offset: usize) -> Result<(u32, usize), String> {
if offset >= payload.len() {
return Err("payload too short for varint".into());
}
@@ -220,16 +225,35 @@ fn decode_welcome(payload: &[u8]) -> Result<(u32, Option<String>), String> {
Ok((version, error))
}
-pub fn client_handshake(
+fn finish_handshake(
stream: &mut UnixStream,
- version: u32,
- cols: u16,
- rows: u16,
+ hello_payload: &[u8],
) -> Result<(u32, Option<String>), String> {
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.map_err(|e| e.to_string())?;
+ stream
+ .write_all(&frame_message(hello_payload))
+ .map_err(|e| e.to_string())?;
+ stream.flush().map_err(|e| e.to_string())?;
+
+ let mut len_buf = [0u8; 4];
+ stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?;
+ let len = u32::from_le_bytes(len_buf) as usize;
+ if len > 2 * 1024 * 1024 {
+ return Err(format!("oversized response: {len}"));
+ }
+ let mut payload = vec![0u8; len];
+ stream.read_exact(&mut payload).map_err(|e| e.to_string())?;
+ decode_welcome(&payload)
+}
+pub fn client_handshake(
+ stream: &mut UnixStream,
+ version: u32,
+ cols: u16,
+ rows: u16,
+) -> Result<(u32, Option<String>), String> {
let hello_payload = encode_varint_enum(
0,
&[
@@ -238,25 +262,33 @@ pub fn client_handshake(
&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
+ &[0], // pixel_mouse = false
],
);
- let framed = frame_message(&hello_payload);
- stream.write_all(&framed).map_err(|e| e.to_string())?;
- stream.flush().map_err(|e| e.to_string())?;
-
- let mut len_buf = [0u8; 4];
- stream.read_exact(&mut len_buf).map_err(|e| e.to_string())?;
- let len = u32::from_le_bytes(len_buf) as usize;
- if len > 2 * 1024 * 1024 {
- return Err(format!("oversized response: {len}"));
- }
+ finish_handshake(stream, &hello_payload)
+}
- let mut payload = vec![0u8; len];
- stream.read_exact(&mut payload).map_err(|e| e.to_string())?;
- decode_welcome(&payload)
+pub fn client_shell_handshake(
+ stream: &mut UnixStream,
+ version: u32,
+ surface_cols: u16,
+ surface_rows: u16,
+) -> Result<(u32, Option<String>), String> {
+ let hello_payload = encode_varint_enum(
+ CLIENT_MESSAGE_CLIENT_SHELL_HELLO,
+ &[
+ &encode_varint_u32(version),
+ &encode_varint_u32(8),
+ &encode_varint_u32(16),
+ &encode_varint_u16(surface_cols),
+ &encode_varint_u16(surface_rows),
+ &[0], // pixel mouse disabled
+ &[0], // direct graphics disabled
+ &[0], // client-owned keybindings
+ &[0], // mouse capture disabled
+ ],
+ );
+ finish_handshake(stream, &hello_payload)
}
pub fn read_server_message(stream: &mut UnixStream) -> Result<(u32, Vec<u8>), String> {
@@ -281,16 +313,27 @@ pub fn read_server_message(stream: &mut UnixStream) -> Result<(u32, Vec<u8>), St
Ok((variant, payload[consumed..].to_vec()))
}
-pub fn send_input(stream: &mut UnixStream, data: &[u8]) -> Result<(), String> {
- let mut buf = encode_varint_u32(1);
- buf.extend_from_slice(&encode_varint_u32(data.len() as u32));
- buf.extend_from_slice(data);
- let framed = frame_message(&buf);
+pub fn send_client_shell_shift_enter(stream: &mut UnixStream, pane_id: &str) -> Result<(), String> {
+ let mut payload = encode_varint_u32(CLIENT_MESSAGE_CLIENT_SHELL_PANE_INPUT);
+ payload.extend_from_slice(&encode_varint_u32(pane_id.len() as u32));
+ payload.extend_from_slice(pane_id.as_bytes());
+ payload.extend_from_slice(&encode_varint_u32(1)); // one pane input event
+ payload.extend_from_slice(&encode_varint_u32(0)); // Key
+ payload.extend_from_slice(&encode_varint_u32(1)); // Enter
+ payload.push(1); // Shift
+ payload.extend_from_slice(&encode_varint_u32(0)); // Press
+ payload.extend_from_slice(&encode_varint_u16(1));
+ payload.push(0); // no shifted codepoint
+ payload.push(0); // no generated text
+ payload.push(0); // does not track release
+ payload.push(0); // no physical key id
+
stream
- .write_all(&framed)
- .map_err(|e| format!("write input: {e}"))?;
- stream.flush().map_err(|e| format!("flush input: {e}"))?;
- Ok(())
+ .write_all(&frame_message(&payload))
+ .map_err(|e| format!("write client shell key: {e}"))?;
+ stream
+ .flush()
+ .map_err(|e| format!("flush client shell key: {e}"))
}
pub fn send_detach(stream: &mut UnixStream) -> Result<(), String> {
@@ -330,13 +373,21 @@ pub fn wait_for_message_variant(
timeout: Duration,
variant: u32,
) -> Result<bool, String> {
+ wait_for_message_variants(stream, timeout, &[variant])
+}
+
+pub fn wait_for_message_variants(
+ stream: &mut UnixStream,
+ timeout: Duration,
+ variants: &[u32],
+) -> Result<bool, 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((got, _)) if got == variant => return Ok(true),
+ Ok((got, _)) if variants.contains(&got) => return Ok(true),
Ok(_) => continue,
Err(_) => continue,
}
@@ -344,6 +395,35 @@ pub fn wait_for_message_variant(
Ok(false)
}
+pub fn wait_for_client_shell_bootstrap(
+ stream: &mut UnixStream,
+ timeout: Duration,
+) -> Result<(), String> {
+ stream
+ .set_read_timeout(Some(Duration::from_millis(200)))
+ .map_err(|e| e.to_string())?;
+ let deadline = Instant::now() + timeout;
+ let mut saw_snapshot = false;
+ while Instant::now() < deadline {
+ match read_server_message(stream) {
+ Ok((SERVER_MESSAGE_CLIENT_SHELL_SNAPSHOT, _)) => saw_snapshot = true,
+ Ok((SERVER_MESSAGE_PANE_SURFACE, _)) if saw_snapshot => return Ok(()),
+ Ok((SERVER_MESSAGE_PANE_SURFACE, _)) => {
+ return Err("client shell pane surface arrived before its snapshot".into());
+ }
+ Ok(_) | Err(_) => {}
+ }
+ }
+ Err(format!(
+ "timed out waiting for client shell {}",
+ if saw_snapshot {
+ "pane surface"
+ } else {
+ "snapshot"
+ }
+ ))
+}
+
pub fn wait_for_disconnect(stream: &mut UnixStream, timeout: Duration) -> Result<bool, String> {
stream.set_nonblocking(true).map_err(|e| e.to_string())?;
let deadline = Instant::now() + timeout;