aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-08 22:19:34 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-08 22:20:04 +0300
commit793f127e54dd5f2cfb78922470b7efcb74a1fa5e (patch)
treedf9a925c7d4dc4be64ffd77cf5dfe3e5abb273a0
parentbb0dad86f81768c8b7d5894acf7cd53bd05d302b (diff)
feat(remote): add network-efficient thin client transport
Add SSH stdio remote attach, negotiate terminal-ANSI render encoding, move blit encoding server-side for remote clients, and split control/render writer delivery for safer backpressure. Fixes #76
-rw-r--r--src/api/schema.rs2
-rw-r--r--src/client/blit.rs66
-rw-r--r--src/client/mod.rs89
-rw-r--r--src/main.rs35
-rw-r--r--src/remote.rs363
-rw-r--r--src/server/headless.rs515
-rw-r--r--src/server/protocol.rs64
-rw-r--r--tests/api_ping.rs2
-rw-r--r--tests/cli_wrapper.rs16
-rw-r--r--tests/client_mode.rs52
-rw-r--r--tests/cross_area.rs21
-rw-r--r--tests/detach_reattach.rs44
-rw-r--r--tests/multi_client.rs6
-rw-r--r--tests/server_headless.rs21
-rw-r--r--tests/support/mod.rs4
15 files changed, 1127 insertions, 173 deletions
diff --git a/src/api/schema.rs b/src/api/schema.rs
index c9467d5a..fc3cc68f 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -941,7 +941,7 @@ mod tests {
id: "req_1".into(),
result: ResponseResult::Pong {
version: "0.1.2".into(),
- protocol: 3,
+ protocol: 4,
},
};
diff --git a/src/client/blit.rs b/src/client/blit.rs
index 3d562563..a206f97e 100644
--- a/src/client/blit.rs
+++ b/src/client/blit.rs
@@ -24,12 +24,62 @@
//! and minimize cursor movement.
use std::cmp;
-use std::io::{self, Write};
+use std::io::Write;
use unicode_width::UnicodeWidthStr;
use crate::server::protocol::{CellData, FrameData};
+/// Bytes produced by a [`BlitEncoder`] for one terminal frame.
+pub(crate) struct EncodedBlit {
+ /// Terminal escape bytes ready to write to the host terminal.
+ pub(crate) bytes: Vec<u8>,
+ /// Whether this frame was encoded as a full redraw.
+ pub(crate) full: bool,
+ next_last_visible_cursor: Option<(u16, u16)>,
+}
+
+/// Stateful encoder that diffs semantic frames into terminal ANSI bytes.
+#[derive(Default)]
+pub(crate) struct BlitEncoder {
+ last_frame: Option<FrameData>,
+ last_visible_cursor: Option<(u16, u16)>,
+}
+
+impl BlitEncoder {
+ pub(crate) fn new() -> Self {
+ Self::default()
+ }
+
+ pub(crate) fn encode(&self, frame: &FrameData, force_full: bool) -> EncodedBlit {
+ let prev = if force_full {
+ None
+ } else {
+ self.last_frame.as_ref()
+ };
+ let full = force_full
+ || prev.is_none()
+ || prev.is_some_and(|p| p.width != frame.width || p.height != frame.height);
+ let mut bytes = Vec::new();
+ let mut next_last_visible_cursor = self.last_visible_cursor;
+ blit_frame_to_with_cursor_memory(&mut bytes, frame, prev, &mut next_last_visible_cursor);
+ EncodedBlit {
+ bytes,
+ full,
+ next_last_visible_cursor,
+ }
+ }
+
+ pub(crate) fn commit(&mut self, frame: FrameData, encoded: EncodedBlit) {
+ self.last_visible_cursor = encoded.next_last_visible_cursor;
+ self.last_frame = Some(frame);
+ }
+
+ pub(crate) fn is_current(&self, frame: &FrameData) -> bool {
+ self.last_frame.as_ref() == Some(frame)
+ }
+}
+
// ---------------------------------------------------------------------------
// Color → escape sequence
// ---------------------------------------------------------------------------
@@ -190,20 +240,6 @@ fn cells_equal(a: &CellData, b: &CellData) -> bool {
// Blitting
// ---------------------------------------------------------------------------
-/// Blits a frame to the terminal, diffing against the previous frame.
-///
-/// On the first frame (no previous), does a full redraw.
-/// On subsequent frames, only writes cells that changed.
-/// After writing cells, positions the cursor.
-pub fn blit_frame_with_cursor_memory(
- frame: &FrameData,
- prev: Option<&FrameData>,
- last_visible_cursor: &mut Option<(u16, u16)>,
-) {
- let mut stdout = io::stdout();
- blit_frame_to_with_cursor_memory(&mut stdout, frame, prev, last_visible_cursor);
-}
-
/// Blits a frame to a writer, diffing against the previous frame.
#[cfg(test)]
fn blit_frame_to(writer: impl Write, frame: &FrameData, prev: Option<&FrameData>) {
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 5b6c1bf1..851066eb 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -12,7 +12,7 @@
//! - Forwards OSC 52 clipboard writes from server to its own stdout
//! - Displays sound/toast notifications forwarded from server
-mod blit;
+pub(crate) mod blit;
mod input;
use std::io::{self, Write as _};
@@ -31,7 +31,8 @@ use tracing::{debug, info, warn};
use crate::server::headless::client_socket_path;
use crate::server::protocol::{
- self, ClientMessage, FrameData, NotifyKind, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION,
+ self, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_FRAME_SIZE,
+ PROTOCOL_VERSION,
};
// ---------------------------------------------------------------------------
@@ -40,11 +41,8 @@ use crate::server::protocol::{
/// State tracking for the thin client.
struct ClientState {
- /// The last frame we rendered, used for diff-based blitting.
- last_frame: Option<FrameData>,
- /// Last visible cursor exported by the focused pane, used as the hidden
- /// cursor parking position when the pane temporarily hides its cursor.
- last_visible_cursor: Option<(u16, u16)>,
+ /// Stateful semantic-frame encoder used when the server sends FrameData.
+ blit_encoder: blit::BlitEncoder,
/// The terminal size we reported to the server in our last Hello/Resize.
reported_size: (u16, u16),
/// Client-local sound playback config, refreshed on server request.
@@ -198,16 +196,33 @@ impl Drop for TerminalGuard {
// Handshake
// ---------------------------------------------------------------------------
+fn requested_render_encoding() -> RenderEncoding {
+ match std::env::var("HERDR_RENDER_ENCODING").ok().as_deref() {
+ Some("terminal-ansi" | "terminal_ansi" | "ansi") => RenderEncoding::TerminalAnsi,
+ _ => RenderEncoding::SemanticFrame,
+ }
+}
+
/// Performs the client→server handshake.
///
/// Sends Hello with the terminal size and protocol version, reads the Welcome
/// response. Returns Ok(()) on success, or an error if the server rejects us.
-fn do_handshake(stream: &mut UnixStream, cols: u16, rows: u16) -> Result<(), ClientError> {
+fn do_handshake(
+ stream: &mut UnixStream,
+ cols: u16,
+ rows: u16,
+ requested_encoding: RenderEncoding,
+) -> Result<RenderEncoding, ClientError> {
+ stream
+ .set_nonblocking(false)
+ .map_err(ClientError::ConnectionFailed)?;
+
// Send Hello.
let hello = ClientMessage::Hello {
version: PROTOCOL_VERSION,
cols,
rows,
+ requested_encoding,
};
protocol::write_message(stream, &hello).map_err(|e| {
ClientError::ConnectionFailed(io::Error::new(io::ErrorKind::Other, e.to_string()))
@@ -223,12 +238,16 @@ fn do_handshake(stream: &mut UnixStream, cols: u16, rows: u16) -> Result<(), Cli
.map_err(ClientError::ConnectionFailed)?;
match welcome {
- ServerMessage::Welcome { version, error } => {
+ ServerMessage::Welcome {
+ version,
+ encoding,
+ error,
+ } => {
if let Some(error) = error {
return Err(ClientError::HandshakeRejected { version, error });
}
- info!(version, "handshake succeeded");
- Ok(())
+ info!(version, ?encoding, "handshake succeeded");
+ Ok(encoding)
}
_ => Err(ClientError::Protocol(protocol::FramingError::Io(
io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"),
@@ -282,11 +301,16 @@ pub fn run_client() -> io::Result<()> {
// Get the terminal size before handshake (before raw mode).
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
+ let requested_encoding = requested_render_encoding();
+
// Perform handshake while the stream is still in blocking mode.
- if let Err(err) = do_handshake(&mut stream, cols, rows) {
- eprintln!("herdr: {err}");
- std::process::exit(1);
- }
+ let negotiated_encoding = match do_handshake(&mut stream, cols, rows, requested_encoding) {
+ Ok(encoding) => encoding,
+ Err(err) => {
+ eprintln!("herdr: {err}");
+ std::process::exit(1);
+ }
+ };
// Now set up the terminal (raw mode, mouse, keyboard enhancements).
// This must happen AFTER the handshake succeeds, so we don't leave
@@ -318,8 +342,17 @@ pub fn run_client() -> io::Result<()> {
quit_flag.store(true, Ordering::Release);
});
- let result =
- rt.block_on(async { run_client_loop(stream, cols, rows, should_quit, sound_config).await });
+ let result = rt.block_on(async {
+ run_client_loop(
+ stream,
+ cols,
+ rows,
+ should_quit,
+ sound_config,
+ negotiated_encoding,
+ )
+ .await
+ });
// Restore the terminal before printing any final status message.
drop(_guard);
@@ -359,13 +392,14 @@ async fn run_client_loop(
rows: u16,
should_quit: Arc<AtomicBool>,
sound_config: crate::config::SoundConfig,
+ negotiated_encoding: RenderEncoding,
) -> Result<(), ClientError> {
let mut state = ClientState {
- last_frame: None,
- last_visible_cursor: None,
+ blit_encoder: blit::BlitEncoder::new(),
reported_size: (cols, rows),
sound_config,
};
+ debug!(?negotiated_encoding, "client render encoding active");
// Channel for events from the stdin, resize, and server reader threads.
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256);
@@ -428,13 +462,16 @@ async fn run_client_loop(
}
ClientLoopEvent::ServerMessage(msg) => match msg {
ServerMessage::Frame(frame_data) => {
- blit::blit_frame_with_cursor_memory(
- &frame_data,
- state.last_frame.as_ref(),
- &mut state.last_visible_cursor,
- );
- state.last_frame = Some(frame_data);
- let _ = io::stdout().flush();
+ let encoded = state.blit_encoder.encode(&frame_data, false);
+ let mut stdout = io::stdout();
+ let _ = stdout.write_all(&encoded.bytes);
+ let _ = stdout.flush();
+ state.blit_encoder.commit(frame_data, encoded);
+ }
+ ServerMessage::Terminal(frame) => {
+ let mut stdout = io::stdout();
+ let _ = stdout.write_all(&frame.bytes);
+ let _ = stdout.flush();
}
ServerMessage::ServerShutdown { reason } => {
return Err(ClientError::ServerShutdown { reason });
diff --git a/src/main.rs b/src/main.rs
index 45ac3db1..ee2c30e4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -36,6 +36,7 @@ mod persist;
mod platform;
mod raw_input;
mod release_notes;
+mod remote;
mod selection;
mod server;
mod session;
@@ -173,12 +174,38 @@ fn main() -> io::Result<()> {
std::process::exit(2);
}
};
+ let (args, remote_launch) = match remote::extract_remote_args(&args) {
+ Ok(parsed) => parsed,
+ Err(err) => {
+ eprintln!("error: {err}");
+ eprintln!("run 'herdr --help' for usage");
+ std::process::exit(2);
+ }
+ };
+
+ if remote_launch.is_some()
+ && args.get(1).is_some()
+ && !args.iter().any(|a| {
+ matches!(
+ a.as_str(),
+ "--help" | "-h" | "--version" | "-V" | "--default-config"
+ )
+ })
+ {
+ eprintln!("error: --remote can only be used with the default launch command");
+ eprintln!("run 'herdr --help' for usage");
+ std::process::exit(2);
+ }
if let cli::CommandOutcome::Handled(code) = cli::maybe_run(&args)? {
std::process::exit(code);
}
// Subcommands and flags (no TUI, no logging needed)
+ if args.get(1).map(|s| s.as_str()) == Some("remote-client-bridge") {
+ return remote::run_remote_client_bridge();
+ }
+
if args.get(1).map(|s| s.as_str()) == Some("server") {
return server::headless::run_server();
}
@@ -203,6 +230,7 @@ fn main() -> io::Result<()> {
println!();
println!("Usage: herdr [options]");
println!(" herdr --session <name> [options]");
+ println!(" herdr --remote <ssh-target> [--session <name>]");
println!(" herdr session attach <name>");
println!(" herdr update");
println!(" herdr server stop");
@@ -270,6 +298,7 @@ fn main() -> io::Result<()> {
println!("Options:");
println!(" --no-session Run monolithically (no server/client, escape hatch)");
println!(" --session <name> Use or create a named persistent session");
+ println!(" --remote <target> Attach through SSH to a remote Herdr server");
println!(" --default-config Print default configuration and exit");
println!(" --version, -V Print version and exit");
println!(" --help, -h Show this help");
@@ -295,6 +324,7 @@ fn main() -> io::Result<()> {
let known_flags = [
"--no-session",
"--session",
+ "--remote",
"--version",
"-V",
"--default-config",
@@ -311,6 +341,7 @@ fn main() -> io::Result<()> {
&& ![
"server",
"client",
+ "remote-client-bridge",
"update",
"status",
"workspace",
@@ -327,6 +358,10 @@ fn main() -> io::Result<()> {
}
}
+ if let Some(remote_launch) = remote_launch {
+ return remote::run_remote(remote_launch);
+ }
+
let loaded_config = config::Config::load();
if should_block_nested(&loaded_config.config) {
eprintln!("\x1b[1merror:\x1b[0m nested herdr is disabled by default.");
diff --git a/src/remote.rs b/src/remote.rs
new file mode 100644
index 00000000..a1344031
--- /dev/null
+++ b/src/remote.rs
@@ -0,0 +1,363 @@
+//! Remote thin-client launcher over SSH command stdio.
+
+use std::io;
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+use std::sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc,
+};
+use std::thread::{self, JoinHandle};
+use std::time::Duration;
+
+const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50);
+const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct RemoteLaunch {
+ pub(crate) target: String,
+}
+
+pub(crate) fn extract_remote_args(
+ args: &[String],
+) -> Result<(Vec<String>, Option<RemoteLaunch>), String> {
+ let mut cleaned = Vec::with_capacity(args.len());
+ if let Some(program) = args.first() {
+ cleaned.push(program.clone());
+ }
+
+ let mut remote = None;
+ let mut index = 1;
+ while index < args.len() {
+ let arg = &args[index];
+ if arg == "--remote" {
+ if remote.is_some() {
+ return Err("--remote can only be specified once".to_string());
+ }
+ let Some(value) = args.get(index + 1) else {
+ return Err("missing value for --remote".to_string());
+ };
+ remote = Some(RemoteLaunch {
+ target: validate_remote_target(value)?.to_owned(),
+ });
+ index += 2;
+ continue;
+ }
+ if let Some(value) = arg.strip_prefix("--remote=") {
+ if remote.is_some() {
+ return Err("--remote can only be specified once".to_string());
+ }
+ remote = Some(RemoteLaunch {
+ target: validate_remote_target(value)?.to_owned(),
+ });
+ index += 1;
+ continue;
+ }
+
+ cleaned.push(arg.clone());
+ index += 1;
+ }
+
+ Ok((cleaned, remote))
+}
+
+fn validate_remote_target(target: &str) -> Result<&str, String> {
+ if target.is_empty() {
+ return Err("missing value for --remote".to_string());
+ }
+ if target.starts_with('-') {
+ return Err("--remote target must not start with '-'".to_string());
+ }
+ Ok(target)
+}
+
+pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
+ let session_name = crate::session::active_name()
+ .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string());
+ let local_socket = local_forward_socket_path(&remote.target, &session_name);
+
+ let _bridge = SshStdioBridge::start(remote.target, local_socket.clone(), session_name)?;
+
+ run_client_process(&local_socket)
+}
+
+pub(crate) fn run_remote_client_bridge() -> io::Result<()> {
+ let socket_path = crate::server::headless::client_socket_path();
+ let stream = UnixStream::connect(&socket_path).map_err(|err| {
+ io::Error::new(
+ err.kind(),
+ format!(
+ "failed to connect to remote Herdr client socket {}: {err}",
+ socket_path.display()
+ ),
+ )
+ })?;
+
+ let mut stdout = io::stdout().lock();
+ let mut socket_to_stdout = stream.try_clone()?;
+ let mut stdin_to_socket = stream;
+
+ let _upload = thread::spawn(move || {
+ let mut stdin = io::stdin();
+ let _ = copy_flush(&mut stdin, &mut stdin_to_socket);
+ let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write);
+ });
+
+ copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ())
+}
+
+struct SshStdioBridge {
+ local_socket: PathBuf,
+ should_stop: Arc<AtomicBool>,
+ thread: Option<JoinHandle<()>>,
+}
+
+impl SshStdioBridge {
+ fn start(target: String, local_socket: PathBuf, session_name: String) -> io::Result<Self> {
+ let _ = std::fs::remove_file(&local_socket);
+ let listener = UnixListener::bind(&local_socket)?;
+ crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)?;
+ listener.set_nonblocking(true)?;
+
+ let should_stop = Arc::new(AtomicBool::new(false));
+ let thread_stop = Arc::clone(&should_stop);
+ let thread = thread::spawn(move || {
+ while !thread_stop.load(Ordering::Acquire) {
+ match listener.accept() {
+ Ok((stream, _addr)) => {
+ if let Err(err) = stream.set_nonblocking(false) {
+ eprintln!(
+ "herdr: remote bridge failed to prepare client socket: {err}"
+ );
+ continue;
+ }
+ if let Err(err) = bridge_connection(stream, &target, &session_name) {
+ eprintln!("herdr: remote bridge failed: {err}");
+ }
+ }
+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
+ thread::sleep(BRIDGE_ACCEPT_POLL);
+ }
+ Err(err) => {
+ eprintln!("herdr: remote bridge listener failed: {err}");
+ break;
+ }
+ }
+ }
+ });
+
+ Ok(Self {
+ local_socket,
+ should_stop,
+ thread: Some(thread),
+ })
+ }
+}
+
+impl Drop for SshStdioBridge {
+ fn drop(&mut self) {
+ self.should_stop.store(true, Ordering::Release);
+ let _ = std::fs::remove_file(&self.local_socket);
+ if let Some(thread) = self.thread.take() {
+ let _ = thread.join();
+ }
+ }
+}
+
+fn bridge_connection(stream: UnixStream, target: &str, session_name: &str) -> io::Result<()> {
+ let mut command = Command::new("ssh");
+ command.arg("-T").arg(target).arg("herdr");
+ if session_name != crate::session::DEFAULT_SESSION_NAME {
+ command.arg("--session").arg(session_name);
+ }
+ command
+ .arg("remote-client-bridge")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::inherit());
+
+ let mut child = command
+ .spawn()
+ .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?;
+ let mut child_stdin = child
+ .stdin
+ .take()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdin missing"))?;
+ let mut child_stdout = child
+ .stdout
+ .take()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdout missing"))?;
+ let mut stream_to_child = stream.try_clone()?;
+ let mut child_to_stream = stream;
+
+ let upload = thread::spawn(move || {
+ let _ = copy_flush(&mut stream_to_child, &mut child_stdin);
+ });
+ let download = thread::spawn(move || {
+ let _ = copy_flush(&mut child_stdout, &mut child_to_stream);
+ let _ = child_to_stream.shutdown(std::net::Shutdown::Write);
+ });
+
+ let status = child.wait()?;
+ let _ = upload.join();
+ let _ = download.join();
+
+ if status.success() {
+ Ok(())
+ } else {
+ Err(io::Error::new(
+ io::ErrorKind::ConnectionAborted,
+ format!("ssh bridge exited with {status}"),
+ ))
+ }
+}
+
+fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> {
+ let mut buffer = [0_u8; 16 * 1024];
+ let mut total = 0;
+
+ loop {
+ let bytes_read = match reader.read(&mut buffer) {
+ Ok(0) => return Ok(total),
+ Ok(bytes_read) => bytes_read,
+ Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
+ Err(err) => return Err(err),
+ };
+
+ writer.write_all(&buffer[..bytes_read])?;
+ writer.flush()?;
+ total += bytes_read as u64;
+ }
+}
+
+fn run_client_process(local_socket: &Path) -> io::Result<()> {
+ let exe = std::env::current_exe()?;
+ let status = Command::new(exe)
+ .arg("client")
+ .env(
+ crate::server::headless::CLIENT_SOCKET_PATH_ENV_VAR,
+ local_socket,
+ )
+ .env("HERDR_RENDER_ENCODING", "terminal-ansi")
+ .env_remove(crate::api::SOCKET_PATH_ENV_VAR)
+ .stdin(Stdio::inherit())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .status()?;
+
+ if status.success() {
+ Ok(())
+ } else {
+ Err(io::Error::new(
+ io::ErrorKind::Interrupted,
+ format!("remote client exited with {status}"),
+ ))
+ }
+}
+
+fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf {
+ std::env::temp_dir().join(format!(
+ "herdr-remote-{}-{}-{}.sock",
+ std::process::id(),
+ sanitize_path_component(target),
+ sanitize_path_component(session_name)
+ ))
+}
+
+fn sanitize_path_component(input: &str) -> String {
+ let sanitized: String = input
+ .chars()
+ .map(|ch| {
+ if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
+ ch
+ } else {
+ '-'
+ }
+ })
+ .collect();
+
+ sanitized.trim_matches('-').chars().take(32).collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bridge_socket_is_user_only() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let socket = std::env::temp_dir().join(format!(
+ "herdr-bridge-permissions-test-{}.sock",
+ std::process::id()
+ ));
+ let bridge =
+ SshStdioBridge::start("example".to_string(), socket.clone(), "default".to_string())
+ .expect("start bridge listener");
+
+ let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777;
+ assert_eq!(mode, BRIDGE_SOCKET_PERMISSION_MODE);
+
+ drop(bridge);
+ let _ = std::fs::remove_file(socket);
+ }
+
+ #[test]
+ fn extract_remote_args_removes_space_form() {
+ let args = vec![
+ "herdr".into(),
+ "--remote".into(),
+ "dev".into(),
+ "--help".into(),
+ ];
+ let (cleaned, remote) = extract_remote_args(&args).unwrap();
+ assert_eq!(cleaned, vec!["herdr", "--help"]);
+ assert_eq!(remote.unwrap().target, "dev");
+ }
+
+ #[test]
+ fn extract_remote_args_removes_equals_form() {
+ let args = vec!["herdr".into(), "--remote=user@host".into()];
+ let (cleaned, remote) = extract_remote_args(&args).unwrap();
+ assert_eq!(cleaned, vec!["herdr"]);
+ assert_eq!(remote.unwrap().target, "user@host");
+ }
+
+ #[test]
+ fn extract_remote_args_requires_value() {
+ let args = vec!["herdr".into(), "--remote".into()];
+ let err = extract_remote_args(&args).unwrap_err();
+ assert_eq!(err, "missing value for --remote");
+ }
+
+ #[test]
+ fn extract_remote_args_rejects_empty_value() {
+ let args = vec!["herdr".into(), "--remote=".into()];
+ let err = extract_remote_args(&args).unwrap_err();
+ assert_eq!(err, "missing value for --remote");
+ }
+
+ #[test]
+ fn extract_remote_args_rejects_duplicate_values() {
+ let args = vec![
+ "herdr".into(),
+ "--remote=dev".into(),
+ "--remote=prod".into(),
+ ];
+ let err = extract_remote_args(&args).unwrap_err();
+ assert_eq!(err, "--remote can only be specified once");
+ }
+
+ #[test]
+ fn extract_remote_args_rejects_option_like_target() {
+ let args = vec!["herdr".into(), "--remote".into(), "-oProxyCommand=x".into()];
+ let err = extract_remote_args(&args).unwrap_err();
+ assert_eq!(err, "--remote target must not start with '-'");
+ }
+
+ #[test]
+ fn sanitize_path_component_removes_shell_sensitive_chars() {
+ assert_eq!(sanitize_path_component("user@host:22"), "user-host-22");
+ }
+}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 5fc7a544..463d37a4 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -34,12 +34,14 @@ use crate::api;
use crate::app;
use crate::app::state::AppState;
use crate::app::Mode;
+use crate::client::blit::BlitEncoder;
use crate::config;
use crate::detect::AgentState;
use crate::events::AppEvent;
use crate::layout::PaneId;
use crate::server::protocol::{
- self, ClientMessage, CursorState, FrameData, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION,
+ self, ClientMessage, CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame,
+ MAX_FRAME_SIZE, PROTOCOL_VERSION,
};
// ---------------------------------------------------------------------------
@@ -76,7 +78,7 @@ const MIN_CLIENT_ROWS: u16 = 1;
/// Contractual override behavior for auto-detect uses `HERDR_SOCKET_PATH`.
/// This variable is kept as a fallback for callers that explicitly need a
/// client-only override when `HERDR_SOCKET_PATH` is not set.
-const CLIENT_SOCKET_PATH_ENV_VAR: &str = "HERDR_CLIENT_SOCKET_PATH";
+pub const CLIENT_SOCKET_PATH_ENV_VAR: &str = "HERDR_CLIENT_SOCKET_PATH";
/// Socket permission mode (owner read/write only).
const SOCKET_PERMISSION_MODE: u32 = 0o600;
@@ -213,6 +215,15 @@ pub fn clamp_terminal_size(cols: u16, rows: u16) -> (u16, u16) {
// Connected client state
// ---------------------------------------------------------------------------
+/// Channels owned by the server side of a client writer thread.
+#[derive(Clone, Debug)]
+pub(crate) struct ClientWriter {
+ /// Reliable control messages such as shutdown, notifications, and clipboard writes.
+ control: std::sync::mpsc::Sender<Vec<u8>>,
+ /// Droppable render messages. Capacity is one so slow clients cannot build lag.
+ render: std::sync::mpsc::SyncSender<Vec<u8>>,
+}
+
/// A connected client tracked by the server.
struct ClientConnection {
/// The client's terminal size (after clamping).
@@ -223,10 +234,18 @@ struct ClientConnection {
outer_terminal_focus: Option<bool>,
/// Monotonic activity stamp used to choose the fallback foreground client.
last_activity: u64,
- /// Last frame sent to this client. Used to skip identical frame sends.
+ /// Render encoding negotiated for this client.
+ render_encoding: RenderEncoding,
+ /// Last semantic frame sent to this client. Used to skip identical frame sends.
last_frame: Option<FrameData>,
- /// Channel for sending framed ServerMessage data to the client writer thread.
- writer: Option<std::sync::mpsc::Sender<Vec<u8>>>,
+ /// Terminal-ANSI encoder and baseline for this client.
+ blit_encoder: BlitEncoder,
+ /// Monotonic render sequence for this client.
+ frame_seq: u64,
+ /// Whether a render was skipped because the render channel was full.
+ render_pending: bool,
+ /// Channels for sending framed ServerMessage data to the client writer thread.
+ writer: Option<ClientWriter>,
}
// ---------------------------------------------------------------------------
@@ -662,6 +681,7 @@ impl HeadlessServer {
// even if the next rendered buffer compares equal to its cached frame.
for client in self.clients.values_mut() {
client.last_frame = None;
+ client.blit_encoder = BlitEncoder::new();
}
}
@@ -1165,7 +1185,7 @@ impl HeadlessServer {
let mut broken_clients: Vec<u64> = Vec::new();
for (&client_id, client) in &mut self.clients {
if let Some(writer) = &client.writer {
- if writer.send(serialized.clone()).is_err() {
+ if writer.control.send(serialized.clone()).is_err() {
debug!(client_id, "client writer channel closed during broadcast");
broken_clients.push(client_id);
}
@@ -1202,7 +1222,7 @@ impl HeadlessServer {
if let Some(client) = self.clients.get(&client_id) {
if let Some(writer) = &client.writer {
- if writer.send(serialized).is_err() {
+ if writer.control.send(serialized).is_err() {
debug!(
client_id,
"client writer channel closed during targeted send"
@@ -1228,8 +1248,9 @@ impl HeadlessServer {
cols,
rows,
writer,
+ render_encoding,
} => {
- info!(client_id, cols, rows, "client connected");
+ info!(client_id, cols, rows, ?render_encoding, "client connected");
let last_activity = self.allocate_activity_stamp();
self.clients.insert(
client_id,
@@ -1238,7 +1259,11 @@ impl HeadlessServer {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: None,
last_activity,
+ render_encoding,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: Some(writer),
},
);
@@ -1250,11 +1275,13 @@ impl HeadlessServer {
ServerEvent::ClientInput { client_id, data } => {
debug!(client_id, len = data.len(), "client input received");
if let Some(client) = self.clients.get_mut(&client_id) {
- // Ensure the next render after client input is delivered even if the
- // current frame buffer still compares equal. Input can change cursor or
- // PTY state asynchronously, and thin clients should not stall waiting for
- // a post-input frame behind identical-frame dedupe.
- client.last_frame = None;
+ // Ensure semantic clients receive one post-input frame even if the
+ // semantic buffer compares equal. Terminal-ANSI clients must keep their
+ // server-side blit baseline; resetting it here forces a full redraw on
+ // every keypress and makes remote sessions feel extremely slow.
+ if client.render_encoding == RenderEncoding::SemanticFrame {
+ client.last_frame = None;
+ }
}
let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
self.update_client_outer_focus_from_events(client_id, &events);
@@ -1334,6 +1361,17 @@ impl HeadlessServer {
}
true
}
+ ServerEvent::ClientWriterDrained { client_id } => {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+ if client.render_pending {
+ client.render_pending = false;
+ true
+ } else {
+ false
+ }
+ }
ServerEvent::QuitSignal => {
// The quit check at the top of the loop handles this.
// No render needed — the next iteration will initiate shutdown.
@@ -1607,11 +1645,33 @@ impl HeadlessServer {
continue;
};
- if client.last_frame.as_ref() == Some(&frame) {
- continue;
- }
+ let mut terminal_encoded = None;
+ let message = match client.render_encoding {
+ RenderEncoding::SemanticFrame => {
+ if client.last_frame.as_ref() == Some(&frame) {
+ client.render_pending = false;
+ continue;
+ }
+ ServerMessage::Frame(frame.clone())
+ }
+ RenderEncoding::TerminalAnsi => {
+ if client.blit_encoder.is_current(&frame) {
+ client.render_pending = false;
+ continue;
+ }
+ let encoded = client.blit_encoder.encode(&frame, false);
+ let message = ServerMessage::Terminal(TerminalFrame {
+ seq: client.frame_seq + 1,
+ width: frame.width,
+ height: frame.height,
+ full: encoded.full,
+ bytes: encoded.bytes.clone(),
+ });
+ terminal_encoded = Some(encoded);
+ message
+ }
+ };
- let message = ServerMessage::Frame(frame.clone());
let serialized = match Self::frame_server_message(&message) {
Ok(framed) => framed,
Err(protocol::FramingError::Oversized { claimed, max }) => {
@@ -1619,7 +1679,9 @@ impl HeadlessServer {
client_id,
claimed, max, "skipping oversized frame for client"
);
- client.last_frame = Some(frame);
+ if client.render_encoding == RenderEncoding::SemanticFrame {
+ client.last_frame = Some(frame);
+ }
continue;
}
Err(err) => {
@@ -1629,13 +1691,34 @@ impl HeadlessServer {
}
};
- if writer.send(serialized).is_err() {
- debug!(client_id, "client writer channel closed, marking as broken");
- broken_clients.push(client_id);
- continue;
+ match writer.render.try_send(serialized) {
+ Ok(()) => {
+ client.render_pending = false;
+ match message {
+ ServerMessage::Frame(_) => {
+ client.last_frame = Some(frame);
+ client.frame_seq += 1;
+ }
+ ServerMessage::Terminal(_) => {
+ if let Some(encoded) = terminal_encoded {
+ client.blit_encoder.commit(frame, encoded);
+ client.frame_seq += 1;
+ }
+ }
+ _ => {}
+ }
+ }
+ Err(std::sync::mpsc::TrySendError::Full(_)) => {
+ client.render_pending = true;
+ debug!(client_id, "render queue full, deferring latest frame");
+ continue;
+ }
+ Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
+ debug!(client_id, "client writer channel closed, marking as broken");
+ broken_clients.push(client_id);
+ continue;
+ }
}
-
- client.last_frame = Some(frame);
}
if !broken_clients.is_empty() {
@@ -1814,7 +1897,8 @@ pub enum ServerEvent {
client_id: u64,
cols: u16,
rows: u16,
- writer: std::sync::mpsc::Sender<Vec<u8>>,
+ render_encoding: RenderEncoding,
+ writer: ClientWriter,
},
/// A client sent an input message.
ClientInput { client_id: u64, data: Vec<u8> },
@@ -1828,6 +1912,8 @@ pub enum ServerEvent {
ClientDetach { client_id: u64 },
/// A client connection was lost.
ClientDisconnected { client_id: u64 },
+ /// A client writer drained its render slot and can accept another render.
+ ClientWriterDrained { client_id: u64 },
/// Ctrl+C or external shutdown signal received.
QuitSignal,
}
@@ -1866,11 +1952,12 @@ fn handle_client_handshake(
}
};
- let (client_cols, client_rows) = match hello {
+ let (client_cols, client_rows, render_encoding) = match hello {
ClientMessage::Hello {
version,
cols,
rows,
+ requested_encoding,
} => {
// Version check.
match protocol::check_client_version(version) {
@@ -1879,6 +1966,7 @@ fn handle_client_handshake(
// Send rejection Welcome.
let welcome = ServerMessage::Welcome {
version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
error: Some(reason),
};
let _ = protocol::write_message(&mut stream, &welcome);
@@ -1888,13 +1976,14 @@ fn handle_client_handshake(
// Clamp size.
let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
- (clamped_cols, clamped_rows)
+ (clamped_cols, clamped_rows, requested_encoding)
}
_ => {
// First message must be Hello.
debug!(client_id, "first message was not Hello, closing");
let welcome = ServerMessage::Welcome {
version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
error: Some("expected Hello as first message".to_owned()),
};
let _ = protocol::write_message(&mut stream, &welcome);
@@ -1905,6 +1994,7 @@ fn handle_client_handshake(
// Send Welcome.
let welcome = ServerMessage::Welcome {
version: PROTOCOL_VERSION,
+ encoding: render_encoding,
error: None,
};
protocol::write_message(&mut stream, &welcome)
@@ -1913,53 +2003,123 @@ fn handle_client_handshake(
// Clear read timeout for normal operation.
stream.set_read_timeout(None)?;
- // Create a channel for writing frames back to the client.
- let (frame_tx, frame_rx) = std::sync::mpsc::channel::<Vec<u8>>();
+ // Create separate channels for reliable control messages and droppable renders.
+ let (control_tx, control_rx) = std::sync::mpsc::channel::<Vec<u8>>();
+ let (render_tx, render_rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(1);
+ let writer = ClientWriter {
+ control: control_tx,
+ render: render_tx,
+ };
// Notify the main loop about the new client.
let _ = server_event_tx.blocking_send(ServerEvent::ClientConnected {
client_id,
cols: client_cols,
rows: client_rows,
- writer: frame_tx,
+ render_encoding,
+ writer,
});
- // Spawn a writer thread that forwards frames from the channel to the stream.
+ // Spawn a writer thread that forwards messages from the channels to the stream.
let write_stream = stream.try_clone()?;
let write_quit = should_quit.clone();
+ let writer_event_tx = server_event_tx.clone();
std::thread::spawn(move || {
- client_writer_loop(write_stream, frame_rx, &write_quit);
+ client_writer_loop(
+ write_stream,
+ client_id,
+ control_rx,
+ render_rx,
+ writer_event_tx,
+ &write_quit,
+ );
});
// Enter read loop — read client messages and forward to main loop.
client_read_loop(stream, client_id, server_event_tx, should_quit)
}
-/// The client writer loop — sends frames from the channel to the client stream.
+/// The client writer loop — prioritizes control messages over render frames.
fn client_writer_loop(
mut stream: UnixStream,
- frame_rx: std::sync::mpsc::Receiver<Vec<u8>>,
+ client_id: u64,
+ control_rx: std::sync::mpsc::Receiver<Vec<u8>>,
+ render_rx: std::sync::mpsc::Receiver<Vec<u8>>,
+ server_event_tx: mpsc::Sender<ServerEvent>,
should_quit: &Arc<AtomicBool>,
) {
+ let mut control_closed = false;
+ let mut render_closed = false;
+
while !should_quit.load(Ordering::Acquire) {
- match frame_rx.recv_timeout(Duration::from_millis(100)) {
+ match control_rx.try_recv() {
+ Ok(data) => {
+ if !write_framed_bytes(&mut stream, &data) {
+ break;
+ }
+ continue;
+ }
+ Err(std::sync::mpsc::TryRecvError::Empty) => {}
+ Err(std::sync::mpsc::TryRecvError::Disconnected) => control_closed = true,
+ }
+
+ match render_rx.try_recv() {
Ok(data) => {
- if let Err(err) = stream.write_all(&data) {
- debug!(err = %err, "client write failed, closing writer");
+ let _ =
+ server_event_tx.blocking_send(ServerEvent::ClientWriterDrained { client_id });
+ if !write_framed_bytes(&mut stream, &data) {
break;
}
- if let Err(err) = stream.flush() {
- debug!(err = %err, "client flush failed, closing writer");
+ continue;
+ }
+ Err(std::sync::mpsc::TryRecvError::Empty) => {}
+ Err(std::sync::mpsc::TryRecvError::Disconnected) => render_closed = true,
+ }
+
+ if control_closed && render_closed {
+ break;
+ }
+
+ if control_closed {
+ match render_rx.recv_timeout(Duration::from_millis(5)) {
+ Ok(data) => {
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientWriterDrained { client_id });
+ if !write_framed_bytes(&mut stream, &data) {
+ break;
+ }
+ }
+ Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
+ Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => render_closed = true,
+ }
+ continue;
+ }
+
+ match control_rx.recv_timeout(Duration::from_millis(5)) {
+ Ok(data) => {
+ if !write_framed_bytes(&mut stream, &data) {
break;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
- Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
+ Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => control_closed = true,
}
}
debug!("client writer thread exiting");
}
+fn write_framed_bytes(stream: &mut UnixStream, data: &[u8]) -> bool {
+ if let Err(err) = stream.write_all(data) {
+ debug!(err = %err, "client write failed, closing writer");
+ return false;
+ }
+ if let Err(err) = stream.flush() {
+ debug!(err = %err, "client flush failed, closing writer");
+ return false;
+ }
+ true
+}
+
/// The client read loop — reads messages from the client and forwards to the server event channel.
fn client_read_loop(
mut stream: UnixStream,
@@ -2193,6 +2353,23 @@ mod tests {
}
}
+ fn test_client_writer() -> (
+ ClientWriter,
+ std::sync::mpsc::Receiver<Vec<u8>>,
+ std::sync::mpsc::Receiver<Vec<u8>>,
+ ) {
+ let (control_tx, control_rx) = std::sync::mpsc::channel();
+ let (render_tx, render_rx) = std::sync::mpsc::sync_channel(1);
+ (
+ ClientWriter {
+ control: control_tx,
+ render: render_tx,
+ },
+ control_rx,
+ render_rx,
+ )
+ }
+
#[test]
fn clamp_terminal_size_zero_zero() {
assert_eq!(
@@ -2426,7 +2603,11 @@ mod tests {
},
outer_terminal_focus: None,
last_activity: 1,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: None,
},
);
@@ -2448,7 +2629,11 @@ mod tests {
},
outer_terminal_focus: None,
last_activity: 2,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: None,
},
);
@@ -2489,7 +2674,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: None,
last_activity: 1,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: None,
},
);
@@ -2500,7 +2689,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: Some(true),
last_activity: 2,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: None,
},
);
@@ -2529,7 +2722,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: None,
last_activity: 1,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: None,
},
);
@@ -2540,7 +2737,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: Some(true),
last_activity: 2,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: None,
},
);
@@ -2569,7 +2770,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: Some(true),
last_activity: 1,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: None,
},
);
@@ -2594,8 +2799,8 @@ mod tests {
server.app.state.selected = 0;
server.app.state.mode = crate::app::Mode::Terminal;
- let (desktop_tx, desktop_rx) = std::sync::mpsc::channel();
- let (phone_tx, phone_rx) = std::sync::mpsc::channel();
+ let (desktop_tx, _desktop_control_rx, desktop_rx) = test_client_writer();
+ let (phone_tx, _phone_control_rx, phone_rx) = test_client_writer();
server.clients.insert(
1,
@@ -2604,7 +2809,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: None,
last_activity: 1,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: Some(desktop_tx),
},
);
@@ -2615,7 +2824,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: None,
last_activity: 2,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: Some(phone_tx),
},
);
@@ -2633,6 +2846,168 @@ mod tests {
}
#[test]
+ fn render_and_stream_sends_terminal_frame_for_terminal_ansi_client() {
+ let mut server = test_headless_server();
+ let (client_tx, _client_control_rx, client_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
+ last_activity: 1,
+ render_encoding: RenderEncoding::TerminalAnsi,
+ last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
+ writer: Some(client_tx),
+ },
+ );
+ server.foreground_client_id = Some(1);
+
+ server.render_and_stream();
+
+ match read_server_message(
+ client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("terminal frame"),
+ ) {
+ ServerMessage::Terminal(frame) => {
+ assert_eq!(frame.seq, 1);
+ assert_eq!((frame.width, frame.height), (80, 24));
+ assert!(frame.full);
+ assert!(!frame.bytes.is_empty());
+ }
+ other => panic!("expected terminal frame, got {other:?}"),
+ }
+ assert_eq!(server.clients.get(&1).unwrap().frame_seq, 1);
+ }
+
+ #[test]
+ fn terminal_ansi_input_does_not_reset_blit_baseline() {
+ let mut server = test_headless_server();
+ let (client_tx, _client_control_rx, client_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
+ last_activity: 1,
+ render_encoding: RenderEncoding::TerminalAnsi,
+ last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
+ writer: Some(client_tx),
+ },
+ );
+ server.foreground_client_id = Some(1);
+
+ server.render_and_stream();
+ let _ = client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("initial terminal frame");
+ assert_eq!(server.clients.get(&1).unwrap().frame_seq, 1);
+
+ assert!(!server.handle_server_event(ServerEvent::ClientInput {
+ client_id: 1,
+ data: Vec::new(),
+ }));
+ server.render_and_stream();
+
+ assert_eq!(server.clients.get(&1).unwrap().frame_seq, 1);
+ assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
+ }
+
+ #[test]
+ fn full_render_queue_does_not_advance_terminal_ansi_baseline() {
+ let mut server = test_headless_server();
+ let (client_tx, _client_control_rx, client_rx) = test_client_writer();
+ let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
+ .expect("serialize dummy message");
+ client_tx
+ .render
+ .send(queued)
+ .expect("pre-fill render queue");
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
+ last_activity: 1,
+ render_encoding: RenderEncoding::TerminalAnsi,
+ last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
+ writer: Some(client_tx),
+ },
+ );
+ server.foreground_client_id = Some(1);
+
+ server.render_and_stream();
+
+ assert_eq!(server.clients.get(&1).unwrap().frame_seq, 0);
+ assert!(matches!(
+ read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()),
+ ServerMessage::ReloadSoundConfig
+ ));
+ assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
+ }
+
+ #[test]
+ fn writer_drained_retries_pending_terminal_ansi_render() {
+ let mut server = test_headless_server();
+ let (client_tx, _client_control_rx, client_rx) = test_client_writer();
+ let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
+ .expect("serialize dummy message");
+ client_tx
+ .render
+ .send(queued)
+ .expect("pre-fill render queue");
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
+ last_activity: 1,
+ render_encoding: RenderEncoding::TerminalAnsi,
+ last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
+ writer: Some(client_tx),
+ },
+ );
+ server.foreground_client_id = Some(1);
+
+ server.render_and_stream();
+ assert!(server.clients.get(&1).unwrap().render_pending);
+ assert!(matches!(
+ read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()),
+ ServerMessage::ReloadSoundConfig
+ ));
+
+ assert!(server.handle_server_event(ServerEvent::ClientWriterDrained { client_id: 1 }));
+ server.render_and_stream();
+
+ match read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()) {
+ ServerMessage::Terminal(frame) => assert_eq!(frame.seq, 1),
+ other => panic!("expected terminal frame, got {other:?}"),
+ }
+ assert_eq!(server.clients.get(&1).unwrap().frame_seq, 1);
+ assert!(!server.clients.get(&1).unwrap().render_pending);
+ }
+
+ #[test]
fn render_and_stream_skips_identical_frame_sends() {
let mut server = test_headless_server();
server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")];
@@ -2640,7 +3015,7 @@ mod tests {
server.app.state.selected = 0;
server.app.state.mode = crate::app::Mode::Terminal;
- let (client_tx, client_rx) = std::sync::mpsc::channel();
+ let (client_tx, _client_control_rx, client_rx) = test_client_writer();
server.clients.insert(
1,
@@ -2649,7 +3024,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: None,
last_activity: 1,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: Some(client_tx),
},
);
@@ -2671,7 +3050,7 @@ mod tests {
#[test]
fn client_sound_reload_request_refreshes_attached_clients() {
let mut server = test_headless_server();
- let (client_tx, client_rx) = std::sync::mpsc::channel();
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
server.clients.insert(
1,
@@ -2680,7 +3059,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: None,
last_activity: 1,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: Some(client_tx),
},
);
@@ -2689,7 +3072,7 @@ mod tests {
server.drain_client_sound_config_reload_request();
match read_server_message(
- client_rx
+ client_control_rx
.recv_timeout(Duration::from_millis(100))
.expect("client sound reload message"),
) {
@@ -2702,8 +3085,8 @@ mod tests {
#[test]
fn clipboard_write_targets_foreground_client_only() {
let mut server = test_headless_server();
- let (background_tx, background_rx) = std::sync::mpsc::channel();
- let (foreground_tx, foreground_rx) = std::sync::mpsc::channel();
+ let (background_tx, background_control_rx, _background_rx) = test_client_writer();
+ let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer();
server.clients.insert(
1,
@@ -2712,7 +3095,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: None,
last_activity: 1,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: Some(background_tx),
},
);
@@ -2723,7 +3110,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: None,
last_activity: 2,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: Some(foreground_tx),
},
);
@@ -2736,7 +3127,7 @@ mod tests {
assert!(!changed);
match read_server_message(
- foreground_rx
+ foreground_control_rx
.recv_timeout(Duration::from_millis(100))
.expect("foreground clipboard message"),
) {
@@ -2744,7 +3135,7 @@ mod tests {
other => panic!("expected clipboard message, got {other:?}"),
}
assert!(
- background_rx
+ background_control_rx
.recv_timeout(Duration::from_millis(50))
.is_err(),
"background client should not receive clipboard writes"
@@ -2754,8 +3145,8 @@ mod tests {
#[test]
fn client_local_notifications_target_foreground_client_only() {
let mut server = test_headless_server();
- let (background_tx, background_rx) = std::sync::mpsc::channel();
- let (foreground_tx, foreground_rx) = std::sync::mpsc::channel();
+ let (background_tx, background_control_rx, _background_rx) = test_client_writer();
+ let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer();
server.clients.insert(
1,
@@ -2764,7 +3155,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: None,
last_activity: 1,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: Some(background_tx),
},
);
@@ -2775,7 +3170,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: None,
last_activity: 2,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: Some(foreground_tx),
},
);
@@ -2788,7 +3187,7 @@ mod tests {
}));
match read_server_message(
- foreground_rx
+ foreground_control_rx
.recv_timeout(Duration::from_millis(100))
.expect("foreground toast message"),
) {
@@ -2799,7 +3198,7 @@ mod tests {
other => panic!("expected toast notify, got {other:?}"),
}
assert!(
- background_rx
+ background_control_rx
.recv_timeout(Duration::from_millis(50))
.is_err(),
"background client should not receive client-local notifications"
@@ -2809,7 +3208,7 @@ mod tests {
#[test]
fn herdr_toast_delivery_keeps_toast_in_frame_without_client_notify() {
let mut server = test_headless_server();
- let (client_tx, client_rx) = std::sync::mpsc::channel();
+ let (client_tx, client_control_rx, _client_rx) = test_client_writer();
server.clients.insert(
1,
@@ -2818,7 +3217,11 @@ mod tests {
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
outer_terminal_focus: None,
last_activity: 1,
+ render_encoding: RenderEncoding::SemanticFrame,
last_frame: None,
+ blit_encoder: BlitEncoder::new(),
+ frame_seq: 0,
+ render_pending: false,
writer: Some(client_tx),
},
);
@@ -2831,7 +3234,9 @@ mod tests {
assert!(changed);
assert!(server.app.state.toast.is_some());
assert!(
- client_rx.recv_timeout(Duration::from_millis(50)).is_err(),
+ client_control_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
"herdr delivery should render in-frame instead of forwarding a terminal notification"
);
}
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
index 74e2d7f3..15aa3784 100644
--- a/src/server/protocol.rs
+++ b/src/server/protocol.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 3;
+pub const PROTOCOL_VERSION: u32 = 4;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
@@ -26,6 +26,15 @@ const LENGTH_PREFIX_BYTES: usize = 4;
// Client → Server messages
// ---------------------------------------------------------------------------
+/// Render payload encoding negotiated during client handshake.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum RenderEncoding {
+ /// Send full semantic FrameData values. This is the local/default mode.
+ SemanticFrame,
+ /// Send already-diffed terminal ANSI byte streams.
+ TerminalAnsi,
+}
+
/// Messages sent from the client to the server over the client protocol socket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientMessage {
@@ -37,6 +46,8 @@ pub enum ClientMessage {
cols: u16,
/// Terminal height in rows.
rows: u16,
+ /// Render encoding requested by the client.
+ requested_encoding: RenderEncoding,
},
/// Raw input bytes read from the client's stdin.
@@ -200,6 +211,21 @@ impl FrameData {
}
}
+/// Terminal ANSI bytes encoded by the server for network-efficient clients.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TerminalFrame {
+ /// Monotonic per-client frame sequence.
+ pub seq: u64,
+ /// Frame width in columns.
+ pub width: u16,
+ /// Frame height in rows.
+ pub height: u16,
+ /// Whether bytes contain a full redraw rather than an incremental diff.
+ pub full: bool,
+ /// Terminal escape bytes ready to write directly to stdout.
+ pub bytes: Vec<u8>,
+}
+
/// Notification kind forwarded from server to client.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum NotifyKind {
@@ -216,14 +242,19 @@ pub enum ServerMessage {
Welcome {
/// Protocol version the server speaks.
version: u32,
+ /// Render encoding selected by the server for this connection.
+ encoding: RenderEncoding,
/// If present, the handshake failed and this describes why.
/// The client should exit with a clear error message.
error: Option<String>,
},
- /// A rendered frame to be displayed by the client.
+ /// A rendered frame to be displayed by a semantic-frame client.
Frame(FrameData),
+ /// Terminal bytes to write directly for a terminal-ANSI client.
+ Terminal(TerminalFrame),
+
/// Server is shutting down. Clients should exit gracefully.
ServerShutdown {
/// Optional reason for the shutdown.
@@ -464,10 +495,9 @@ fn read_exact_or_eof<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<(), Fram
/// Result of checking a client's protocol version against the server's.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionCheck {
- /// Versions are compatible. The server should reply `Welcome { version, error: None }`.
+ /// Versions are compatible. The server should reply with a successful Welcome.
Compatible,
- /// Versions are incompatible. The server should reply
- /// `Welcome { version, error: Some(reason) }` and close the connection.
+ /// Versions are incompatible. The server should reply with a Welcome error and close.
Incompatible(String),
}
@@ -516,6 +546,7 @@ mod tests {
version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
+ requested_encoding: RenderEncoding::SemanticFrame,
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
let (decoded, _): (ClientMessage, _) =
@@ -558,6 +589,7 @@ mod tests {
fn server_welcome_roundtrip() {
let msg = ServerMessage::Welcome {
version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
error: None,
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
@@ -570,6 +602,7 @@ mod tests {
fn server_welcome_with_error_roundtrip() {
let msg = ServerMessage::Welcome {
version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
error: Some("incompatible version".to_owned()),
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
@@ -690,6 +723,21 @@ mod tests {
}
#[test]
+ fn server_terminal_frame_roundtrip() {
+ let msg = ServerMessage::Terminal(TerminalFrame {
+ seq: 7,
+ width: 120,
+ height: 40,
+ full: false,
+ bytes: b"\x1b[1;1Hhello".to_vec(),
+ });
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
fn server_reload_sound_config_roundtrip() {
let msg = ServerMessage::ReloadSoundConfig;
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
@@ -706,6 +754,7 @@ mod tests {
version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
+ requested_encoding: RenderEncoding::SemanticFrame,
};
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
@@ -773,6 +822,7 @@ mod tests {
version: PROTOCOL_VERSION,
cols: (80 + (i % 40) as u16),
rows: (24 + (i % 20) as u16),
+ requested_encoding: RenderEncoding::SemanticFrame,
},
1 => ClientMessage::Input {
data: vec![(i % 256) as u8; (i as usize % 50) + 1],
@@ -929,10 +979,12 @@ mod tests {
let response = match check {
VersionCheck::Compatible => ServerMessage::Welcome {
version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
error: None,
},
VersionCheck::Incompatible(reason) => ServerMessage::Welcome {
version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
error: Some(reason),
},
};
@@ -1194,6 +1246,7 @@ mod tests {
version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
+ requested_encoding: RenderEncoding::SemanticFrame,
};
let mut buf = Vec::new();
write_message(&mut buf, &msg).unwrap();
@@ -1224,6 +1277,7 @@ mod tests {
version: PROTOCOL_VERSION,
cols: 200,
rows: 60,
+ requested_encoding: RenderEncoding::SemanticFrame,
},
ClientMessage::Input {
data: b"hello world".to_vec(),
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index b0411559..bcb97ab3 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -223,7 +223,7 @@ fn ping_over_socket_returns_version() {
assert_eq!(value["id"], "req_1");
assert_eq!(value["result"]["type"], "pong");
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
- assert_eq!(value["result"]["protocol"], 3);
+ assert_eq!(value["result"]["protocol"], 4);
cleanup_spawned_herdr(child, base);
}
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index a68fcb6c..de0d141f 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -871,7 +871,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {full_stdout}"
);
assert!(
- full_stdout.contains(" protocol: 3"),
+ full_stdout.contains(" protocol: 4"),
"stdout: {full_stdout}"
);
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
@@ -904,7 +904,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {server_stdout}"
);
assert!(
- server_stdout.contains("protocol: 3"),
+ server_stdout.contains("protocol: 4"),
"stdout: {server_stdout}"
);
@@ -916,7 +916,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {client_stdout}"
);
assert!(
- client_stdout.contains("protocol: 3"),
+ client_stdout.contains("protocol: 4"),
"stdout: {client_stdout}"
);
assert!(
@@ -1666,11 +1666,17 @@ fn pane_shell_gets_herdr_socket_and_pane_env() {
assert!(ran.status.success());
let deadline = Instant::now() + Duration::from_secs(3);
- while Instant::now() < deadline && !env_capture.exists() {
+ let mut text = String::new();
+ while Instant::now() < deadline {
+ if env_capture.exists() {
+ text = fs::read_to_string(&env_capture).unwrap();
+ if text.contains(&socket_path.display().to_string()) && text.contains("p_") {
+ break;
+ }
+ }
thread::sleep(Duration::from_millis(25));
}
assert!(env_capture.exists(), "env capture file was not created");
- let text = fs::read_to_string(&env_capture).unwrap();
assert!(
text.contains(&socket_path.display().to_string()),
"env file was: {text:?}"
diff --git a/tests/client_mode.rs b/tests/client_mode.rs
index f35ee301..7b6639f9 100644
--- a/tests/client_mode.rs
+++ b/tests/client_mode.rs
@@ -250,8 +250,8 @@ fn client_connects_and_receives_frame() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3, "server should report protocol version 3");
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4, "server should report protocol version 4");
assert!(
error.is_none(),
"handshake should not have error: {:?}",
@@ -330,8 +330,8 @@ fn client_sees_headless_startup_config_diagnostic() {
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
stream
@@ -379,8 +379,8 @@ fn client_input_forwarded_to_pane() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Send an Input message containing "echo hello\n".
@@ -433,8 +433,8 @@ fn client_resize_sends_message() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Drain the initial frame(s).
@@ -490,8 +490,8 @@ fn server_shutdown_sends_message_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Send SIGINT so the server takes the graceful shutdown path and
@@ -513,7 +513,7 @@ fn server_shutdown_sends_message_to_client() {
while Instant::now() < deadline {
match read_server_message(&mut stream) {
Ok((variant, _)) => {
- if variant == 2 {
+ if variant == 3 {
saw_shutdown = true;
break;
}
@@ -717,8 +717,8 @@ fn client_receives_frame_after_pane_output() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Read the initial frame (server renders immediately on client connect).
@@ -771,8 +771,8 @@ fn navigate_mode_keybind_dispatch_in_server() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -889,8 +889,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
@@ -906,7 +906,7 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
}
}
- // The client should receive a ServerShutdown message (variant 2)
+ // The client should receive a ServerShutdown message (variant 3)
// before the connection is closed, not just an abrupt EOF.
stream
.set_read_timeout(Some(Duration::from_secs(5)))
@@ -915,8 +915,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
match result {
Ok((variant, _payload)) => {
assert_eq!(
- variant, 2,
- "expected ServerShutdown (variant 2), got variant {variant}"
+ variant, 3,
+ "expected ServerShutdown (variant 3), got variant {variant}"
);
}
Err(e) => {
@@ -987,8 +987,8 @@ fn client_receives_notify_on_agent_state_change() {
// Connect as a client and perform handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
@@ -1039,8 +1039,8 @@ 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 3).
- // Notify = ServerMessage variant index 3 (after Welcome=0, Frame=1, ServerShutdown=2).
+ // Read messages from the client stream and look for Notify (variant 4).
+ // Notify = ServerMessage variant index 4 (after Welcome=0, Frame=1, Terminal=2, ServerShutdown=3).
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
@@ -1049,7 +1049,7 @@ fn client_receives_notify_on_agent_state_change() {
while Instant::now() < deadline {
match read_server_message(&mut stream) {
Ok((variant, _payload)) => {
- if variant == 3 {
+ if variant == 4 {
// ServerMessage::Notify — found it!
found_notify = true;
break;
@@ -1135,7 +1135,7 @@ fn client_receives_notify_on_agent_state_change() {
while Instant::now() < deadline {
match read_server_message(&mut stream) {
Ok((variant, _payload)) => {
- if variant == 3 {
+ if variant == 4 {
// Found a Notify message — that's good enough.
// The test already verified the Blocked→Notify path above.
found_done_notify = true;
diff --git a/tests/cross_area.rs b/tests/cross_area.rs
index 0740a7dd..db7f5b1f 100644
--- a/tests/cross_area.rs
+++ b/tests/cross_area.rs
@@ -416,6 +416,7 @@ fn client_handshake(stream: &mut UnixStream, version: u32, cols: u16, rows: u16)
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(0)); // RenderEncoding::SemanticFrame
stream
.write_all(&frame_message(&payload))
@@ -443,6 +444,10 @@ fn client_handshake(stream: &mut UnixStream, version: u32, cols: u16, rows: u16)
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");
@@ -669,7 +674,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, 3, 100, 30);
+ client_handshake(&mut client_a, 4, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
// Use herdr: create a workspace and write output into its pane.
@@ -706,7 +711,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, 3, 80, 24);
+ client_handshake(&mut client_b, 4, 80, 24);
assert!(
wait_for_frame(&mut client_b, Duration::from_secs(5)),
"reattached client should receive frame"
@@ -762,7 +767,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, 3, 100, 30);
+ client_handshake(&mut client_a, 4, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
let created = workspace_create(&api_socket, "agent-persist");
@@ -815,7 +820,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
// Reattach and ensure client-side state reflects the persisted working status.
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 3, 80, 24);
+ client_handshake(&mut client_b, 4, 80, 24);
let saw_working_on_client =
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
frame_contains_text(frame, "working")
@@ -860,7 +865,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, 3, 100, 30);
+ client_handshake(&mut client, 4, 100, 30);
assert!(wait_for_frame(&mut client, Duration::from_secs(2)));
drain_server_messages(&mut client, Duration::from_millis(300));
@@ -923,9 +928,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, 3, 110, 30);
+ client_handshake(&mut client_a, 4, 110, 30);
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 3, 100, 30);
+ client_handshake(&mut client_b, 4, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
assert!(wait_for_frame(&mut client_b, Duration::from_secs(2)));
@@ -1091,7 +1096,7 @@ 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, 3, 80, 24);
+ client_handshake(&mut reconnect_client, 4, 80, 24);
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 259cf350..519c2ca2 100644
--- a/tests/detach_reattach.rs
+++ b/tests/detach_reattach.rs
@@ -276,8 +276,8 @@ fn navigate_q_detaches_client_and_server_persists() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -338,8 +338,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, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -397,8 +397,8 @@ 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, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream_a, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -436,8 +436,8 @@ 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, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream_b, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(
error.is_none(),
"reattach handshake should succeed: {:?}",
@@ -516,8 +516,8 @@ 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, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -555,8 +555,8 @@ 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, 3, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream_b, 4, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Verify the reattached client receives a frame.
@@ -604,8 +604,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, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -631,8 +631,8 @@ 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, 3, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream_b, 4, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "reattach should succeed: {:?}", error);
cleanup_spawned_herdr(spawned, base);
@@ -653,8 +653,8 @@ fn detached_output_preserves_last_attached_pty_size() {
let mut stream = UnixStream::connect(&client_socket).expect("client should connect");
let (version, error) =
- client_handshake(&mut stream, 3, 120, 40).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream, 4, 120, 40).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
drain_messages(&mut stream);
@@ -722,8 +722,8 @@ 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, 3, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream_a, 4, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Detach client A immediately.
@@ -780,8 +780,8 @@ 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, 3, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 3);
+ client_handshake(&mut stream_b, 4, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 4);
assert!(error.is_none(), "{:?}", error);
// Client B should receive a frame with the current state.
diff --git a/tests/multi_client.rs b/tests/multi_client.rs
index f65538b4..3294c98b 100644
--- a/tests/multi_client.rs
+++ b/tests/multi_client.rs
@@ -495,6 +495,7 @@ fn client_handshake(
&encode_varint_u32(version),
&encode_varint_u16(cols),
&encode_varint_u16(rows),
+ &encode_varint_u32(0), // RenderEncoding::SemanticFrame
],
);
stream
@@ -519,6 +520,9 @@ fn client_handshake(
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());
}
@@ -542,7 +546,7 @@ fn client_handshake(
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, 3, cols, rows).expect("handshake should succeed");
+ client_handshake(&mut stream, 4, cols, rows).expect("handshake should succeed");
stream
}
diff --git a/tests/server_headless.rs b/tests/server_headless.rs
index 687b886b..33a3fa7f 100644
--- a/tests/server_headless.rs
+++ b/tests/server_headless.rs
@@ -169,6 +169,7 @@ fn client_handshake(
&encode_varint_u32(version),
&encode_varint_u16(cols),
&encode_varint_u16(rows),
+ &encode_varint_u32(0), // RenderEncoding::SemanticFrame
],
);
let framed = frame_message(&hello_payload);
@@ -310,6 +311,10 @@ fn decode_welcome(payload: &[u8]) -> Result<(u32, Option<String>), String> {
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());
@@ -576,11 +581,11 @@ fn client_handshake_succeeds() {
// Connect to the client socket and perform a handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- // Send Hello with version 2, 80 cols, 24 rows.
+ // Send Hello with version 4, 80 cols, 24 rows.
let (version, error) =
- client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
+ client_handshake(&mut stream, 4, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 3, "server should report protocol version 3");
+ assert_eq!(version, 4, "server should report protocol version 4");
assert!(
error.is_none(),
"handshake should not have an error: {:?}",
@@ -609,7 +614,7 @@ fn client_handshake_rejects_incompatible_version() {
let (version, error) = client_handshake(&mut stream, 0, 80, 24)
.expect("should read Welcome response even on rejection");
- assert_eq!(version, 3, "server should report its version 3");
+ assert_eq!(version, 4, "server should report its version 4");
assert!(
error.is_some(),
"version 0 should be rejected with an error"
@@ -634,10 +639,10 @@ fn client_handshake_clamps_small_terminal_size() {
// Send Hello with 0x0 terminal size — should be clamped.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- let (version, error) = client_handshake(&mut stream, 3, 0, 0)
+ let (version, error) = client_handshake(&mut stream, 4, 0, 0)
.expect("handshake with 0x0 should succeed (server clamps)");
- assert_eq!(version, 3);
+ assert_eq!(version, 4);
assert!(
error.is_none(),
"0x0 size should be accepted (clamped): {:?}",
@@ -697,9 +702,9 @@ fn no_hello_client_closed_within_five_seconds() {
// Verify the server is still healthy — a proper client can still connect.
let mut good_stream =
UnixStream::connect(&client_socket).expect("should connect after no-hello client");
- let (version, error) = client_handshake(&mut good_stream, 3, 80, 24)
+ let (version, error) = client_handshake(&mut good_stream, 4, 80, 24)
.expect("proper handshake should still work after no-hello client");
- assert_eq!(version, 3);
+ assert_eq!(version, 4);
assert!(error.is_none());
// API should still work.
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index 44c25188..d26a2043 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -177,6 +177,9 @@ fn decode_welcome(payload: &[u8]) -> Result<(u32, Option<String>), String> {
let (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 Option tag".into());
}
@@ -217,6 +220,7 @@ pub fn client_handshake(
&encode_varint_u32(version),
&encode_varint_u16(cols),
&encode_varint_u16(rows),
+ &encode_varint_u32(0), // RenderEncoding::SemanticFrame
],
);
let framed = frame_message(&hello_payload);