aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'src/server')
-rw-r--r--src/server/autodetect.rs296
-rw-r--r--src/server/headless.rs2272
-rw-r--r--src/server/mod.rs3
-rw-r--r--src/server/protocol.rs1212
4 files changed, 3783 insertions, 0 deletions
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs
new file mode 100644
index 00000000..b0ca2a2e
--- /dev/null
+++ b/src/server/autodetect.rs
@@ -0,0 +1,296 @@
+//! Auto-detect launch behavior for the `herdr` command.
+//!
+//! When the user runs `herdr` with no subcommand:
+//! 1. Check if a server is already listening on the client socket
+//! 2. If no server → spawn one as a background daemon → wait for socket readiness (up to 5s)
+//! 3. Attach as a thin client to the server
+//!
+//! The `--no-session` flag bypasses server/client entirely and runs monolithically
+//! (escape hatch for users who want the traditional single-process behavior).
+
+use std::io;
+use std::os::unix::net::UnixStream;
+use std::os::unix::process::CommandExt;
+use std::path::Path;
+use std::process::Command;
+use std::time::Duration;
+
+use tracing::{info, warn};
+
+use super::headless::client_socket_path;
+
+/// Maximum time to wait for the server's client socket to become ready
+/// after spawning the server process.
+const SERVER_READY_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// Poll interval when waiting for the server socket to appear.
+const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(50);
+
+// ---------------------------------------------------------------------------
+// Server detection
+// ---------------------------------------------------------------------------
+
+/// Checks whether a herdr server is currently listening on the client socket.
+///
+/// This works by attempting to connect to the client socket. If the connection
+/// succeeds, a server is running. If the socket file doesn't exist or the
+/// connection is refused, no server is running. Stale sockets (from a crashed
+/// server) are detected because `UnixStream::connect` returns `ConnectionRefused`
+/// when nobody is listening.
+#[allow(dead_code)] // Public API for external use and testing
+pub fn is_server_listening() -> bool {
+ is_server_listening_at(&client_socket_path())
+}
+
+/// Checks whether a herdr server is listening at a specific socket path.
+fn is_server_listening_at(socket_path: &Path) -> bool {
+ if !socket_path.exists() {
+ return false;
+ }
+
+ match UnixStream::connect(socket_path) {
+ Ok(_) => {
+ // Server is listening. Close the test connection immediately.
+ // The server's handshake handler will time out on this connection
+ // since we don't send Hello, which is fine.
+ true
+ }
+ Err(err)
+ if matches!(
+ err.kind(),
+ io::ErrorKind::ConnectionRefused | io::ErrorKind::TimedOut
+ ) =>
+ {
+ // Socket file exists but nobody is listening — stale socket.
+ false
+ }
+ Err(err) if err.kind() == io::ErrorKind::NotFound => {
+ // Socket file disappeared between exists() and connect().
+ false
+ }
+ Err(err) => {
+ // Other errors (permission denied, etc.) — assume not listening.
+ warn!(err = %err, "unexpected error checking server socket");
+ false
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Server spawning
+// ---------------------------------------------------------------------------
+
+/// Spawns the herdr server as a background daemon process.
+///
+/// The server process is fully detached:
+/// - Runs in its own session (setsid) so it survives the client exiting
+/// - Stdin/stdout/stderr are redirected to /dev/null
+/// - Inherits all relevant environment variables
+/// (`HERDR_SOCKET_PATH`, `HERDR_CLIENT_SOCKET_PATH`, `XDG_CONFIG_HOME`, etc.)
+///
+/// Returns the PID of the spawned server process.
+pub fn spawn_server_daemon() -> io::Result<u32> {
+ let exe = std::env::current_exe().map_err(|err| {
+ io::Error::new(
+ err.kind(),
+ format!("failed to determine herdr executable path: {err}"),
+ )
+ })?;
+
+ info!(exe = %exe.display(), "spawning server daemon");
+
+ let child = Command::new(&exe)
+ .arg("server")
+ // Create a new process group so the server survives the parent's exit
+ // and doesn't receive SIGHUP when the client's terminal closes.
+ .process_group(0)
+ // Redirect stdio to /dev/null
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .spawn()
+ .map_err(|err: io::Error| {
+ io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}"))
+ })?;
+
+ let pid = child.id();
+ info!(pid, "server daemon spawned");
+
+ Ok(pid)
+}
+
+// ---------------------------------------------------------------------------
+// Socket readiness
+// ---------------------------------------------------------------------------
+
+/// Waits for the server's client socket to become ready for connections.
+///
+/// Polls the socket path at regular intervals until a connection succeeds
+/// or the timeout elapses. Returns an error if the server doesn't become
+/// ready within the timeout.
+pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Result<()> {
+ let deadline = std::time::Instant::now() + timeout;
+
+ while std::time::Instant::now() < deadline {
+ if is_server_listening_at(socket_path) {
+ info!(path = %socket_path.display(), "server socket ready");
+ return Ok(());
+ }
+ std::thread::sleep(SOCKET_POLL_INTERVAL);
+ }
+
+ Err(io::Error::new(
+ io::ErrorKind::TimedOut,
+ format!(
+ "server did not become ready within {}s (socket: {})",
+ timeout.as_secs(),
+ socket_path.display()
+ ),
+ ))
+}
+
+// ---------------------------------------------------------------------------
+// Auto-detect launch
+// ---------------------------------------------------------------------------
+
+/// Performs auto-detect launch: check for server, spawn if needed, then
+/// attach as a thin client.
+///
+/// This is the entry point called from `main.rs` when the user runs `herdr`
+/// without `--no-session` and without a subcommand.
+///
+/// Flow:
+/// 1. Check if a server is listening on the client socket
+/// 2. If no server → spawn server daemon → wait for socket readiness
+/// 3. Run the thin client (which connects to the server)
+pub fn auto_detect_launch() -> io::Result<()> {
+ let socket_path = client_socket_path();
+ info!(path = %socket_path.display(), "auto-detect launch starting");
+
+ if is_server_listening_at(&socket_path) {
+ info!("server already running, attaching as client");
+ } else {
+ info!("no server running, spawning server daemon");
+ spawn_server_daemon()?;
+ wait_for_server_socket(&socket_path, SERVER_READY_TIMEOUT)?;
+ info!("server ready, attaching as client");
+ }
+
+ // Now attach as a thin client.
+ crate::client::run_client()
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::os::unix::net::UnixListener;
+
+ fn temp_socket_path(name: &str) -> std::path::PathBuf {
+ let unique = format!(
+ "herdr-autodetect-test-{}-{}-{}",
+ name,
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ );
+ std::env::temp_dir().join(unique).join("test.sock")
+ }
+
+ #[test]
+ fn is_server_listening_returns_false_for_nonexistent_path() {
+ let path = temp_socket_path("nonexistent");
+ assert!(!is_server_listening_at(&path));
+ }
+
+ #[test]
+ fn is_server_listening_returns_true_for_live_socket() {
+ let dir = temp_socket_path("live");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("test.sock");
+
+ let _listener = UnixListener::bind(&path).unwrap();
+ assert!(is_server_listening_at(&path));
+ }
+
+ #[test]
+ fn is_server_listening_returns_false_for_stale_socket() {
+ let dir = temp_socket_path("stale");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("test.sock");
+
+ // Create a socket and immediately drop the listener.
+ // This leaves a stale socket file with nobody listening.
+ {
+ let _listener = UnixListener::bind(&path).unwrap();
+ }
+
+ // The socket file exists but nobody is listening.
+ assert!(!is_server_listening_at(&path));
+ }
+
+ #[test]
+ fn is_server_listening_returns_false_when_listener_dropped() {
+ let dir = temp_socket_path("dropped");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("test.sock");
+
+ // Bind and immediately drop the listener.
+ drop(UnixListener::bind(&path).unwrap());
+
+ // Socket is stale — should return false.
+ assert!(!is_server_listening_at(&path));
+
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn wait_for_server_socket_succeeds_immediately() {
+ let dir = temp_socket_path("wait-ok");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("test.sock");
+
+ let _listener = UnixListener::bind(&path).unwrap();
+
+ // Should succeed immediately (socket is already ready).
+ let result = wait_for_server_socket(&path, Duration::from_millis(100));
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn wait_for_server_socket_times_out() {
+ let dir = temp_socket_path("wait-timeout");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("test.sock");
+
+ // No listener — should time out.
+ let result = wait_for_server_socket(&path, Duration::from_millis(50));
+ assert!(result.is_err());
+ assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut);
+ }
+
+ #[test]
+ fn wait_for_server_socket_succeeds_after_delay() {
+ let dir = temp_socket_path("wait-delay");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("test.sock");
+
+ // Spawn a thread that will create the listener after a short delay.
+ let path_clone = path.clone();
+ std::thread::spawn(move || {
+ std::thread::sleep(Duration::from_millis(50));
+ let _listener = UnixListener::bind(&path_clone).unwrap();
+ // Keep the listener alive for a bit.
+ std::thread::sleep(Duration::from_secs(1));
+ });
+
+ // Wait with a generous timeout — should succeed.
+ let result = wait_for_server_socket(&path, Duration::from_secs(2));
+ assert!(result.is_ok());
+ }
+}
diff --git a/src/server/headless.rs b/src/server/headless.rs
new file mode 100644
index 00000000..ba6098df
--- /dev/null
+++ b/src/server/headless.rs
@@ -0,0 +1,2272 @@
+//! Headless server mode — runs the herdr event loop without a real terminal.
+//!
+//! The server:
+//! - Does not enter raw mode or read stdin
+//! - Creates and listens on both `herdr.sock` (existing JSON API) and
+//! `herdr-client.sock` (new binary protocol)
+//! - Initializes AppState and all PTYs from session restore or fresh state
+//! - Runs the main event loop (drain events, drain API requests, scheduled tasks)
+//! - Renders to a virtual ratatui Buffer in memory
+//! - Accepts client connections on the client socket
+//! - Streams frames to connected clients after each render
+//! - Routes client input events through the existing input pipeline
+//! - Continues running after client disconnect
+//! - Handles stale socket cleanup, explicit server stop, minimum terminal size,
+//! and pane spawn failure during restore
+
+use std::collections::HashMap;
+use std::fs;
+use std::io::{self, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize};
+use ratatui::layout::{Position, Rect, Size};
+use tokio::sync::mpsc;
+use tracing::{debug, error, info, warn};
+
+use base64::Engine;
+
+use crate::api;
+use crate::app;
+use crate::app::state::AppState;
+use crate::config;
+use crate::events::AppEvent;
+use crate::server::protocol::{
+ self, ClientMessage, CursorState, FrameData, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION,
+};
+
+// ---------------------------------------------------------------------------
+// Loop event enum for the headless server event loop
+// ---------------------------------------------------------------------------
+
+/// Events that the headless server event loop can process.
+enum LoopEvent {
+ Timer,
+ Internal(AppEvent),
+ Api(api::ApiRequestMessage),
+ ServerEvent(ServerEvent),
+ RenderRequested,
+}
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+/// Default shared runtime size (columns, rows) when no clients are attached.
+const MIN_COLS: u16 = 80;
+const MIN_ROWS: u16 = 24;
+
+/// Minimum accepted attached client size.
+///
+/// Narrow observers must be allowed to drive narrow renders, otherwise the
+/// server wraps pane content against a wider width and the client sees the
+/// right edge clipped.
+const MIN_CLIENT_COLS: u16 = 1;
+const MIN_CLIENT_ROWS: u16 = 1;
+
+/// Legacy environment variable for overriding the client socket path.
+///
+/// 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";
+
+/// Socket permission mode (owner read/write only).
+const SOCKET_PERMISSION_MODE: u32 = 0o600;
+
+/// Timeout for in-flight API requests during shutdown.
+#[allow(dead_code)]
+const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// How long to wait for a client handshake before closing the connection.
+/// Set to 4 seconds (rather than 5) to guarantee the connection is closed
+/// within the 5-second deadline, even with
+/// OS timer slack, thread scheduling, and cleanup overhead.
+const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4);
+
+/// Maximum input payload size (bytes) for a single `ClientMessage::Input`.
+const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB
+
+// ---------------------------------------------------------------------------
+// Socket path helpers
+// ---------------------------------------------------------------------------
+
+/// Returns the path for the client protocol socket.
+///
+/// Contract-aligned override behavior:
+/// 1. If `HERDR_SOCKET_PATH` is set, derive the client socket path from it by
+/// inserting `-client` before `.sock` (e.g. `herdr.sock` -> `herdr-client.sock`).
+/// This keeps JSON API and client socket overrides consistent.
+/// 2. Otherwise, honor `HERDR_CLIENT_SOCKET_PATH` (legacy/testing fallback).
+/// 3. Otherwise, use the app config directory.
+pub fn client_socket_path() -> PathBuf {
+ client_socket_path_from_overrides(
+ std::env::var(api::SOCKET_PATH_ENV_VAR).ok().as_deref(),
+ std::env::var(CLIENT_SOCKET_PATH_ENV_VAR).ok().as_deref(),
+ )
+}
+
+fn client_socket_path_from_overrides(
+ api_socket_override: Option<&str>,
+ client_socket_override: Option<&str>,
+) -> PathBuf {
+ if let Some(api_socket_override) = api_socket_override {
+ return derive_client_socket_from_api_socket(Path::new(api_socket_override));
+ }
+
+ if let Some(client_socket_override) = client_socket_override {
+ return PathBuf::from(client_socket_override);
+ }
+
+ config::config_dir().join("herdr-client.sock")
+}
+
+fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf {
+ let stem = api_socket_path
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or("herdr");
+ let parent = api_socket_path.parent().unwrap_or_else(|| Path::new(""));
+
+ if api_socket_path
+ .extension()
+ .and_then(|ext| ext.to_str())
+ .is_some_and(|ext| ext == "sock")
+ {
+ return parent.join(format!("{stem}-client.sock"));
+ }
+
+ parent.join(format!("{stem}-client.sock"))
+}
+
+/// Clamp client-reported terminal dimensions to a minimum viable size.
+pub fn clamp_terminal_size(cols: u16, rows: u16) -> (u16, u16) {
+ let clamped_cols = cols.max(MIN_CLIENT_COLS);
+ let clamped_rows = rows.max(MIN_CLIENT_ROWS);
+ (clamped_cols, clamped_rows)
+}
+
+// ---------------------------------------------------------------------------
+// Connected client state
+// ---------------------------------------------------------------------------
+
+/// A connected client tracked by the server.
+struct ClientConnection {
+ /// The client's terminal size (after clamping).
+ terminal_size: (u16, u16),
+ /// Last known host terminal default colors for this client.
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ /// Monotonic activity stamp used to choose the fallback foreground client.
+ last_activity: u64,
+ /// Channel for sending framed ServerMessage data to the client writer thread.
+ writer: Option<std::sync::mpsc::Sender<Vec<u8>>>,
+}
+
+// ---------------------------------------------------------------------------
+// Stale socket cleanup
+// ---------------------------------------------------------------------------
+
+/// Prepares a socket path for binding: creates parent directories,
+/// removes stale socket files (where no server is listening), and
+/// returns an error if a live server is already bound.
+fn prepare_socket_path(path: &Path) -> io::Result<()> {
+ crate::ipc::prepare_socket_path(path, |path| {
+ format!(
+ "herdr server is already running (socket busy at {})",
+ path.display()
+ )
+ })
+}
+
+/// Restricts socket file permissions to owner-only (0o600).
+fn restrict_socket_permissions(path: &Path) -> io::Result<()> {
+ crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE)
+}
+
+// ---------------------------------------------------------------------------
+// Virtual rendering
+// ---------------------------------------------------------------------------
+
+struct CursorTrackingBackend {
+ inner: TestBackend,
+ rendered_cursor: Option<Position>,
+}
+
+impl CursorTrackingBackend {
+ fn new(width: u16, height: u16) -> Self {
+ Self {
+ inner: TestBackend::new(width, height),
+ rendered_cursor: None,
+ }
+ }
+
+ fn buffer(&self) -> &ratatui::buffer::Buffer {
+ self.inner.buffer()
+ }
+
+ fn rendered_cursor(&self) -> Option<CursorState> {
+ self.rendered_cursor.map(|pos| CursorState {
+ x: pos.x,
+ y: pos.y,
+ visible: true,
+ })
+ }
+}
+
+impl Backend for CursorTrackingBackend {
+ type Error = std::convert::Infallible;
+
+ fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
+ where
+ I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>,
+ {
+ self.inner.draw(content)
+ }
+
+ fn append_lines(&mut self, n: u16) -> Result<(), Self::Error> {
+ self.inner.append_lines(n)
+ }
+
+ fn hide_cursor(&mut self) -> Result<(), Self::Error> {
+ self.inner.hide_cursor()?;
+ self.rendered_cursor = None;
+ Ok(())
+ }
+
+ fn show_cursor(&mut self) -> Result<(), Self::Error> {
+ self.inner.show_cursor()
+ }
+
+ fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
+ self.inner.get_cursor_position()
+ }
+
+ fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
+ let position = position.into();
+ self.inner.set_cursor_position(position)?;
+ self.rendered_cursor = Some(position);
+ Ok(())
+ }
+
+ fn clear(&mut self) -> Result<(), Self::Error> {
+ self.inner.clear()
+ }
+
+ fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
+ self.inner.clear_region(clear_type)
+ }
+
+ fn size(&self) -> Result<Size, Self::Error> {
+ self.inner.size()
+ }
+
+ fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
+ self.inner.window_size()
+ }
+
+ fn flush(&mut self) -> Result<(), Self::Error> {
+ self.inner.flush()
+ }
+}
+
+/// Renders the AppState to an in-memory ratatui Buffer.
+///
+/// This produces the same output as the monolithic binary's terminal draw,
+/// but writes to a `Buffer` instead of stdout. Cursor visibility is captured
+/// from explicit frame cursor intent rather than incidental backend state.
+fn render_virtual(
+ app_state: &mut AppState,
+ area: Rect,
+ resize_panes: bool,
+) -> (ratatui::buffer::Buffer, Option<CursorState>) {
+ if resize_panes {
+ crate::ui::compute_view(app_state, area);
+ } else {
+ crate::ui::compute_view_without_resizing_panes(app_state, area);
+ }
+
+ let backend = CursorTrackingBackend::new(area.width, area.height);
+ let mut terminal = ratatui::Terminal::new(backend).expect("TestBackend::new should never fail");
+
+ terminal
+ .draw(|frame| {
+ crate::ui::render(app_state, frame);
+ })
+ .expect("render to TestBackend should never fail");
+
+ let buffer = terminal.backend().buffer().clone();
+ let cursor = terminal.backend().rendered_cursor();
+
+ (buffer, cursor)
+}
+
+// ---------------------------------------------------------------------------
+// Headless server
+// ---------------------------------------------------------------------------
+
+/// The headless server — runs the herdr event loop without a real terminal.
+pub struct HeadlessServer {
+ app: app::App,
+ client_listener: UnixListener,
+ client_socket_path: PathBuf,
+ clients: HashMap<u64, ClientConnection>,
+ next_client_id: u64,
+ /// The client currently driving the shared pane runtime size and theme.
+ foreground_client_id: Option<u64>,
+ /// Monotonic activity counter used to pick the most recently active client.
+ next_activity_stamp: u64,
+ /// Shared pane runtime size derived from the foreground client,
+ /// or MIN_COLS × MIN_ROWS when no clients are connected.
+ effective_size: (u16, u16),
+ /// Flag set when shutdown is initiated.
+ shutting_down: bool,
+ /// Flag set by Ctrl+C or `server stop` signal.
+ should_quit: Arc<AtomicBool>,
+ /// Channel for receiving server events from client connection threads.
+ server_event_rx: mpsc::Receiver<ServerEvent>,
+ /// Sender for server events (cloned for each client thread).
+ server_event_tx: mpsc::Sender<ServerEvent>,
+}
+
+impl HeadlessServer {
+ /// Creates and starts the headless server.
+ ///
+ /// This:
+ /// 1. Prepares the client socket path (cleans up stale sockets)
+ /// 2. Binds the client socket listener
+ /// 3. Returns the server ready to run
+ pub fn new(app: app::App) -> io::Result<Self> {
+ let client_path = client_socket_path();
+ prepare_socket_path(&client_path)?;
+
+ let listener = UnixListener::bind(&client_path)?;
+ restrict_socket_permissions(&client_path)?;
+ info!(path = %client_path.display(), "client protocol socket listening");
+
+ // Set non-blocking on the listener so we can poll it from the event loop.
+ listener.set_nonblocking(true)?;
+
+ let should_quit = Arc::new(AtomicBool::new(false));
+
+ // Channel for server events from client threads.
+ let (server_event_tx, server_event_rx) = mpsc::channel(64);
+
+ Ok(Self {
+ app,
+ client_listener: listener,
+ client_socket_path: client_path,
+ clients: HashMap::new(),
+ next_client_id: 1,
+ foreground_client_id: None,
+ next_activity_stamp: 1,
+ effective_size: (MIN_COLS, MIN_ROWS),
+ shutting_down: false,
+ should_quit,
+ server_event_rx,
+ server_event_tx,
+ })
+ }
+
+ /// Runs the headless server event loop until shutdown.
+ ///
+ /// This is the server's main loop — analogous to `App::run()` but without
+ /// a real terminal. It:
+ /// - Drains internal events (pane death, state changes)
+ /// - Drains API requests (from the JSON socket)
+ /// - Accepts new client connections
+ /// - Reads client messages and routes input
+ /// - Handles scheduled tasks (resize poll, animation, session save, etc.)
+ /// - Renders virtually and streams frames to clients
+ pub async fn run(&mut self) -> io::Result<()> {
+ info!("headless server starting");
+
+ // Register SIGINT handler for graceful shutdown.
+ let should_quit = self.should_quit.clone();
+ let quit_notify = self.server_event_tx.clone();
+ ctrlc_handler(should_quit, quit_notify);
+
+ // No input_rx needed — server doesn't read stdin.
+ // We use None for input_rx so the event loop doesn't try to read from stdin.
+ self.app.input_rx = None;
+
+ let mut needs_render = true;
+
+ loop {
+ // If shutdown has been initiated, complete it and exit.
+ if self.shutting_down {
+ self.complete_shutdown()?;
+ break;
+ }
+
+ // Check if we should start shutting down.
+ if self.app.state.should_quit || self.should_quit.load(Ordering::Acquire) {
+ self.initiate_shutdown();
+ continue;
+ }
+
+ // 1. Check render_dirty flag from PTY reader tasks.
+ if self.app.render_dirty.load(Ordering::Acquire) {
+ needs_render = true;
+ }
+
+ // 2. Drain internal events.
+ if self.drain_internal_events_with_forwarding() {
+ needs_render = true;
+ }
+
+ // 3. Drain API requests.
+ if self.drain_api_requests_with_shutdown_check() {
+ needs_render = true;
+ }
+
+ self.app.sync_focus_events();
+ self.app.sync_session_save_schedule();
+
+ // 4. Accept new client connections.
+ self.accept_client_connections()?;
+
+ // 5. Drain server events from client threads.
+ if self.drain_server_events() {
+ needs_render = true;
+ }
+
+ // 6. Handle scheduled tasks.
+ let now = Instant::now();
+ if self.handle_scheduled_tasks_headless(now) {
+ needs_render = true;
+ }
+
+ // Handle deferred requests.
+ if self.app.state.request_complete_onboarding {
+ self.app.state.request_complete_onboarding = false;
+ self.app.complete_onboarding();
+ needs_render = true;
+ }
+
+ if self.app.state.request_new_workspace {
+ self.app.state.request_new_workspace = false;
+ self.app.create_workspace();
+ needs_render = true;
+ }
+
+ if self.app.state.request_new_tab {
+ self.app.state.request_new_tab = false;
+ self.app.create_tab();
+ needs_render = true;
+ }
+
+ if self.app.state.request_reload_keybinds {
+ self.app.state.request_reload_keybinds = false;
+ self.app.reload_keybinds();
+ needs_render = true;
+ }
+
+ self.app.sync_animation_timer(now);
+
+ // 7. Render virtually and stream frames.
+ if needs_render && self.app.can_render_now(now) {
+ self.app.render_dirty.swap(false, Ordering::AcqRel);
+ self.render_and_stream();
+ self.app.last_render_at = Some(now);
+ needs_render = false;
+ continue;
+ }
+
+ // 8. Wait for next event.
+ let next_deadline = self.app.next_loop_deadline(now, needs_render);
+ let event = {
+ tokio::select! {
+ maybe_api = self.app.api_rx.recv() => match maybe_api {
+ Some(msg) => LoopEvent::Api(msg),
+ None => LoopEvent::Timer,
+ },
+ maybe_ev = self.app.event_rx.recv() => match maybe_ev {
+ Some(ev) => LoopEvent::Internal(ev),
+ None => LoopEvent::Timer,
+ },
+ maybe_server_ev = self.server_event_rx.recv() => match maybe_server_ev {
+ Some(ev) => LoopEvent::ServerEvent(ev),
+ None => LoopEvent::Timer,
+ },
+ _ = sleep_until_or_pending(next_deadline) => LoopEvent::Timer,
+ _ = self.app.render_notify.notified() => LoopEvent::RenderRequested,
+ }
+ };
+
+ match event {
+ LoopEvent::Timer => {}
+ LoopEvent::Internal(ev) => {
+ if self.handle_internal_event_with_forwarding(ev) {
+ needs_render = true;
+ }
+ }
+ LoopEvent::Api(msg) => {
+ if self.handle_api_request_with_shutdown_check(msg) {
+ needs_render = true;
+ }
+ }
+ LoopEvent::ServerEvent(ev) => {
+ if self.handle_server_event(ev) {
+ needs_render = true;
+ }
+ }
+ LoopEvent::RenderRequested => {
+ if self.app.render_dirty.load(Ordering::Acquire) {
+ needs_render = true;
+ }
+ }
+ }
+ }
+
+ // Save session on exit.
+ if !self.app.no_session {
+ self.app.save_session_now();
+ }
+
+ info!("headless server exiting");
+ Ok(())
+ }
+
+ fn allocate_activity_stamp(&mut self) -> u64 {
+ let stamp = self.next_activity_stamp;
+ self.next_activity_stamp = self.next_activity_stamp.saturating_add(1);
+ stamp
+ }
+
+ fn resize_shared_runtime_to_effective_size(&mut self) {
+ if self.foreground_client_id.is_none() {
+ return;
+ }
+ let (cols, rows) = self.effective_size;
+ let area = Rect::new(0, 0, cols, rows);
+ crate::ui::compute_view(&mut self.app.state, area);
+ }
+
+ fn sync_foreground_client_state(&mut self) {
+ let Some(client_id) = self.foreground_client_id else {
+ self.effective_size = (MIN_COLS, MIN_ROWS);
+ return;
+ };
+ let Some(client) = self.clients.get(&client_id) else {
+ self.foreground_client_id = None;
+ self.effective_size = (MIN_COLS, MIN_ROWS);
+ return;
+ };
+
+ self.effective_size = client.terminal_size;
+ if !client.host_terminal_theme.is_empty() {
+ self.app.set_host_terminal_theme(client.host_terminal_theme);
+ }
+ }
+
+ fn promote_client_to_foreground(&mut self, client_id: u64) -> bool {
+ let stamp = self.allocate_activity_stamp();
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+ client.last_activity = stamp;
+
+ let changed = self.foreground_client_id != Some(client_id);
+ self.foreground_client_id = Some(client_id);
+ self.sync_foreground_client_state();
+ changed
+ }
+
+ fn promote_latest_remaining_client(&mut self) -> bool {
+ let next_foreground = self
+ .clients
+ .iter()
+ .max_by_key(|(_, client)| client.last_activity)
+ .map(|(&client_id, _)| client_id);
+ let changed = next_foreground != self.foreground_client_id;
+ self.foreground_client_id = next_foreground;
+ self.sync_foreground_client_state();
+ changed
+ }
+
+ fn remove_client(&mut self, client_id: u64) -> bool {
+ let was_foreground = self.foreground_client_id == Some(client_id);
+ self.clients.remove(&client_id);
+ if was_foreground {
+ self.promote_latest_remaining_client()
+ } else {
+ false
+ }
+ }
+
+ fn update_client_host_theme_from_events(
+ &mut self,
+ client_id: u64,
+ events: &[crate::raw_input::RawInputEvent],
+ ) -> bool {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+
+ let mut next_theme = client.host_terminal_theme;
+ for event in events {
+ if let crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } = event {
+ next_theme = next_theme.with_color(*kind, *color);
+ }
+ }
+
+ if next_theme == client.host_terminal_theme {
+ return false;
+ }
+
+ client.host_terminal_theme = next_theme;
+ if self.foreground_client_id == Some(client_id) {
+ self.app.set_host_terminal_theme(next_theme)
+ } else {
+ false
+ }
+ }
+
+ fn events_include_interaction(events: &[crate::raw_input::RawInputEvent]) -> bool {
+ events.iter().any(|event| {
+ matches!(
+ event,
+ crate::raw_input::RawInputEvent::Key(_)
+ | crate::raw_input::RawInputEvent::Mouse(_)
+ | crate::raw_input::RawInputEvent::Paste(_)
+ )
+ })
+ }
+
+ /// Accepts pending client connections from the non-blocking listener.
+ fn accept_client_connections(&mut self) -> io::Result<()> {
+ loop {
+ match self.client_listener.accept() {
+ Ok((stream, _addr)) => {
+ let client_id = self.next_client_id;
+ self.next_client_id += 1;
+
+ if let Err(err) = stream.set_nonblocking(true) {
+ warn!(err = %err, "failed to set client stream nonblocking");
+ continue;
+ }
+
+ // Spawn a thread for the handshake and read loop.
+ let should_quit = self.should_quit.clone();
+ let server_event_tx = self.server_event_tx.clone();
+ std::thread::spawn(move || {
+ if let Err(err) = handle_client_handshake(
+ stream,
+ client_id,
+ &server_event_tx,
+ &should_quit,
+ ) {
+ debug!(client_id, err = %err, "client handshake failed");
+ }
+ });
+ }
+ Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => {
+ // No more pending connections.
+ break;
+ }
+ Err(err) => {
+ error!(err = %err, "client listener accept failed");
+ break;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ /// Drains server events from the dedicated channel.
+ ///
+ /// Returns true if any input was processed (requiring a re-render).
+ fn drain_server_events(&mut self) -> bool {
+ let mut changed = false;
+ while let Ok(ev) = self.server_event_rx.try_recv() {
+ changed |= self.handle_server_event(ev);
+ }
+ changed
+ }
+
+ /// Handles a single internal event with forwarding logic for clipboard,
+ /// sound, and toast notifications to connected clients.
+ ///
+ /// ALL internal events MUST be routed through this method to ensure
+ /// clipboard/notify forwarding is never bypassed. Do not call
+ /// `self.app.handle_internal_event()` directly for any internal event
+ /// in the headless server — use this method instead.
+ ///
+ /// Returns true if the event changed visual state (requiring a re-render).
+ fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool {
+ match &ev {
+ AppEvent::ClipboardWrite { content } => {
+ // Clipboard writes are client-local side effects. Forward them only to
+ // the foreground client instead of broadcasting to every attached client.
+ if let Some(client_id) = self.foreground_client_id {
+ let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice());
+ self.send_to_client(client_id, ServerMessage::Clipboard { data });
+ }
+ // ClipboardWrite doesn't change visual state — no render needed.
+ false
+ }
+ AppEvent::StateChanged {
+ pane_id,
+ agent,
+ state,
+ } => {
+ // Capture toast before handling.
+ let toast_before = self.app.state.toast.clone();
+ let pane_id_val = *pane_id;
+ let agent_val = *agent;
+ let state_val = *state;
+
+ // Find the previous state of this pane before the event
+ // is processed. We need this to determine if a sound
+ // notification would be triggered.
+ let prev_state = self
+ .app
+ .state
+ .workspaces
+ .iter()
+ .find_map(|ws| {
+ ws.tabs
+ .iter()
+ .find_map(|tab| tab.panes.get(&pane_id_val).map(|p| p.state))
+ })
+ .unwrap_or(crate::detect::AgentState::Unknown);
+
+ // Handle the state change (updates pane state, sets toast on AppState).
+ // Note: apply_pane_state_change inside handle_internal_event will try
+ // to play sounds, but sound.enabled=false in the headless server, so
+ // sound::play is never called. Toast may still be set on AppState if
+ // toast_config.enabled is true.
+ self.app.handle_internal_event(ev);
+
+ // Forward sound notification to clients.
+ // We check the agent-specific sound setting but NOT sound.enabled,
+ // because the server sets enabled=false to prevent local playback —
+ // clients should still receive sound notifications and decide
+ // locally whether to play them based on their own config.
+ let is_active_tab = self
+ .app
+ .state
+ .active
+ .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
+ .is_some_and(|ws| {
+ ws.find_tab_index_for_pane(pane_id_val)
+ .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
+ });
+
+ if !matches!(
+ self.app.state.sound.agents.for_agent(agent_val),
+ crate::config::AgentSoundSetting::Off
+ ) {
+ if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
+ is_active_tab,
+ prev_state,
+ state_val,
+ ) {
+ let msg = match sound {
+ crate::sound::Sound::Done => "agent done",
+ crate::sound::Sound::Request => "agent attention",
+ };
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Sound,
+ message: msg.to_owned(),
+ });
+ }
+ }
+
+ // Forward any new toast as a notification.
+ if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
+ if let Some(toast) = &self.app.state.toast {
+ let msg = format!("{}: {}", toast.title, toast.context);
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Toast,
+ message: msg,
+ });
+ }
+ }
+
+ true
+ }
+ AppEvent::HookStateReported {
+ pane_id,
+ agent_label,
+ state,
+ ..
+ } => {
+ // The hook authority may not change the effective pane state if
+ // the detected agent doesn't match. We forward based on the
+ // hook-reported state transition regardless.
+ let toast_before = self.app.state.toast.clone();
+ let pane_id_val = *pane_id;
+ let agent_val = crate::detect::parse_agent_label(agent_label);
+ let hook_state_val = *state;
+
+ // Capture the previous hook authority state for this pane.
+ // If no hook authority exists, use the effective state.
+ let prev_hook_state = self
+ .app
+ .state
+ .workspaces
+ .iter()
+ .find_map(|ws| {
+ ws.tabs.iter().find_map(|tab| {
+ tab.panes.get(&pane_id_val).map(|p| {
+ p.hook_authority
+ .as_ref()
+ .map(|h| h.state)
+ .unwrap_or(p.state)
+ })
+ })
+ })
+ .unwrap_or(crate::detect::AgentState::Unknown);
+
+ self.app.handle_internal_event(ev);
+
+ // Forward sound notification based on hook state transition.
+ // This ensures API-reported state changes (pane.report_agent)
+ // produce notifications even before fallback detection confirms.
+ let is_active_tab = self
+ .app
+ .state
+ .active
+ .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
+ .is_some_and(|ws| {
+ ws.find_tab_index_for_pane(pane_id_val)
+ .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
+ });
+
+ if !matches!(
+ self.app.state.sound.agents.for_agent(agent_val),
+ crate::config::AgentSoundSetting::Off
+ ) {
+ if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
+ is_active_tab,
+ prev_hook_state,
+ hook_state_val,
+ ) {
+ let msg = match sound {
+ crate::sound::Sound::Done => "agent done",
+ crate::sound::Sound::Request => "agent attention",
+ };
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Sound,
+ message: msg.to_owned(),
+ });
+ }
+ }
+
+ // Forward any new toast as a notification.
+ if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
+ if let Some(toast) = &self.app.state.toast {
+ let msg = format!("{}: {}", toast.title, toast.context);
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Toast,
+ message: msg,
+ });
+ }
+ }
+
+ true
+ }
+ AppEvent::UpdateReady { version: _ } => {
+ let toast_before = self.app.state.toast.clone();
+
+ self.app.handle_internal_event(ev);
+
+ // Forward the update toast notification.
+ if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
+ if let Some(toast) = &self.app.state.toast {
+ let msg = format!("{}: {}", toast.title, toast.context);
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Toast,
+ message: msg,
+ });
+ }
+ }
+
+ true
+ }
+ _ => {
+ self.app.handle_internal_event(ev);
+ true
+ }
+ }
+ }
+
+ /// Drains internal events, forwarding clipboard, sound, and toast
+ /// notifications to connected clients instead of processing them locally.
+ ///
+ /// In the monolithic mode:
+ /// - `ClipboardWrite` events are written to stdout via `write_osc52_bytes`.
+ /// - Sound notifications are played locally via `sound::play`.
+ /// - Toast notifications are set on AppState and rendered into the frame.
+ ///
+ /// In the headless server, there is no stdout terminal or audio subsystem,
+ /// so we:
+ /// - Forward `ClipboardWrite` as `ServerMessage::Clipboard` to the
+ /// foreground client only.
+ /// - Detect when a sound would be played and forward as
+ /// `ServerMessage::Notify { kind: Sound }` to all clients.
+ /// - Detect when a toast is set on AppState and forward as
+ /// `ServerMessage::Notify { kind: Toast }` to all clients.
+ fn drain_internal_events_with_forwarding(&mut self) -> bool {
+ let mut changed = false;
+ while let Ok(ev) = self.app.event_rx.try_recv() {
+ changed |= self.handle_internal_event_with_forwarding(ev);
+ }
+ changed
+ }
+
+ /// Encodes a server message into a length-prefixed frame.
+ fn frame_server_message(msg: &ServerMessage) -> Result<Vec<u8>, protocol::FramingError> {
+ let mut framed = Vec::new();
+ protocol::write_message(&mut framed, msg)?;
+ Ok(framed)
+ }
+
+ /// Sends a message to all connected clients.
+ /// Broken connections are tracked and cleaned up.
+ fn send_to_all_clients(&mut self, msg: ServerMessage) {
+ let serialized = match Self::frame_server_message(&msg) {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(err = %err, "failed to serialize message for clients");
+ return;
+ }
+ };
+
+ 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() {
+ debug!(client_id, "client writer channel closed during broadcast");
+ broken_clients.push(client_id);
+ }
+ }
+ }
+
+ // Remove broken clients.
+ for client_id in broken_clients {
+ let foreground_changed = self.remove_client(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ }
+ }
+
+ /// Sends a message to a specific client. Returns false if the client
+ /// was not found or the send failed (client removed).
+ fn send_to_client(&mut self, client_id: u64, msg: ServerMessage) -> bool {
+ let serialized = match Self::frame_server_message(&msg) {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to serialize message for client");
+ return false;
+ }
+ };
+
+ if let Some(client) = self.clients.get(&client_id) {
+ if let Some(writer) = &client.writer {
+ if writer.send(serialized).is_err() {
+ debug!(
+ client_id,
+ "client writer channel closed during targeted send"
+ );
+ let foreground_changed = self.remove_client(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ return false;
+ }
+ }
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Handles a server event. Returns true if the event requires a re-render.
+ fn handle_server_event(&mut self, ev: ServerEvent) -> bool {
+ match ev {
+ ServerEvent::ClientConnected {
+ client_id,
+ cols,
+ rows,
+ writer,
+ } => {
+ info!(client_id, cols, rows, "client connected");
+ let last_activity = self.allocate_activity_stamp();
+ self.clients.insert(
+ client_id,
+ ClientConnection {
+ terminal_size: (cols, rows),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity,
+ writer: Some(writer),
+ },
+ );
+ self.foreground_client_id = Some(client_id);
+ self.sync_foreground_client_state();
+ self.resize_shared_runtime_to_effective_size();
+ true
+ }
+ ServerEvent::ClientInput { client_id, data } => {
+ debug!(client_id, len = data.len(), "client input received");
+ let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
+ let interaction = Self::events_include_interaction(&events);
+ let foreground_changed = if interaction {
+ self.promote_client_to_foreground(client_id)
+ } else {
+ false
+ };
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ let theme_changed = self.update_client_host_theme_from_events(client_id, &events);
+ self.app
+ .route_client_events(events, self.foreground_client_id == Some(client_id));
+
+ // Check if the detach keybind was triggered during input processing.
+ if self.app.state.detach_requested {
+ self.app.state.detach_requested = false;
+ info!(client_id, "client detach requested via keybind");
+
+ // Send a ServerShutdown with "detached" reason to this client
+ // so it exits cleanly (not with a connection-lost error).
+ // The client will close its connection after receiving this,
+ // which triggers a ClientDisconnected event that removes it.
+ self.send_to_client(
+ client_id,
+ ServerMessage::ServerShutdown {
+ reason: Some("detached".to_owned()),
+ },
+ );
+
+ // Don't remove the client here — let the client disconnect
+ // naturally after receiving the ServerShutdown. The client's
+ // read loop will see EOF and the server will get a
+ // ClientDisconnected event which handles cleanup.
+ //
+ // However, we do need to stop sending frames to this client
+ // since it's detaching. Drop the writer channel so no more
+ // frames are queued for this client.
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.writer = None;
+ }
+
+ // No re-render needed for remaining clients.
+ false
+ } else {
+ foreground_changed || theme_changed || interaction
+ }
+ }
+ ServerEvent::ClientResize {
+ client_id,
+ cols,
+ rows,
+ } => {
+ info!(client_id, cols, rows, "client resize");
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.terminal_size = (cols, rows);
+ }
+ self.promote_client_to_foreground(client_id);
+ self.resize_shared_runtime_to_effective_size();
+ true
+ }
+ ServerEvent::ClientDetach { client_id } => {
+ info!(client_id, "client detached");
+ let foreground_changed = self.remove_client(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ true
+ }
+ ServerEvent::ClientDisconnected { client_id } => {
+ info!(client_id, "client disconnected");
+ let foreground_changed = self.remove_client(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ true
+ }
+ ServerEvent::QuitSignal => {
+ // The quit check at the top of the loop handles this.
+ // No render needed — the next iteration will initiate shutdown.
+ false
+ }
+ }
+ }
+
+ /// Drains API requests with shutdown awareness.
+ ///
+ /// During shutdown, remaining requests get a `server_unavailable` error.
+ fn drain_api_requests_with_shutdown_check(&mut self) -> bool {
+ let mut changed = false;
+ while let Ok(msg) = self.app.api_rx.try_recv() {
+ changed |= self.handle_api_request_with_shutdown_check(msg);
+ }
+ changed
+ }
+
+ /// Handles a single API request with shutdown awareness.
+ ///
+ /// Also forwards any toast/sound notifications that result from the API
+ /// request to connected clients. API methods like `pane.report_agent`
+ /// trigger internal events that may set toast state or would normally
+ /// play sounds — in headless mode we forward these to clients instead.
+ fn handle_api_request_with_shutdown_check(&mut self, msg: api::ApiRequestMessage) -> bool {
+ if self.shutting_down {
+ // During shutdown, respond with server_unavailable.
+ let response = serde_json::to_string(&api::schema::ErrorResponse {
+ id: msg.request.id,
+ error: api::schema::ErrorBody {
+ code: "server_unavailable".into(),
+ message: "server is shutting down".into(),
+ },
+ })
+ .unwrap_or_else(|_| {
+ r#"{"id":"","error":{"code":"server_unavailable","message":"server is shutting down"}}"#
+ .to_string()
+ });
+ let _ = msg.respond_to.send(response);
+ return false;
+ }
+
+ let changed = api::request_changes_ui(&msg.request);
+
+ // Capture toast and pane agent states before the API call so we can
+ // forward any resulting notifications to connected clients.
+ // API requests like pane.report_agent trigger handle_internal_event
+ // internally, which bypasses drain_internal_events_with_forwarding.
+ // Since sound.enabled=false in the headless server, sounds would be
+ // silently dropped; toasts may be set but not forwarded.
+ //
+ // Note: pane.report_agent sets hook_authority on the pane, but the
+ // effective state may not change until the fallback detector confirms
+ // the agent (detected_agent must match). So we capture both the
+ // effective state AND the hook authority state for comparison.
+ let toast_before = self.app.state.toast.clone();
+ let pane_states_before: Vec<(
+ usize,
+ crate::layout::PaneId,
+ crate::detect::AgentState,
+ Option<crate::detect::AgentState>,
+ )> = self
+ .app
+ .state
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, ws)| {
+ ws.tabs.iter().flat_map(move |tab| {
+ tab.panes.iter().map(move |(&pane_id, pane)| {
+ (
+ ws_idx,
+ pane_id,
+ pane.state,
+ pane.hook_authority.as_ref().map(|h| h.state),
+ )
+ })
+ })
+ })
+ .collect();
+
+ let response = self.app.handle_api_request(msg.request);
+ let _ = msg.respond_to.send(response);
+
+ // Forward any new toast as a notification to clients.
+ let toast_after = self.app.state.toast.clone();
+ if toast_after.is_some() && toast_after != toast_before {
+ if let Some(toast) = &toast_after {
+ let msg_text = format!("{}: {}", toast.title, toast.context);
+ debug!(msg = %msg_text, "forwarding toast notification from API request");
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Toast,
+ message: msg_text,
+ });
+ }
+ }
+
+ // Forward sound notifications for any pane state changes that occurred
+ // during the API request. Compare before/after pane states (including
+ // hook authority state) to find transitions that would trigger a sound.
+ //
+ // pane.report_agent sets hook_authority on the pane, but the effective
+ // state may not change until the fallback detector confirms the agent
+ // (detected_agent must match hook_authority.agent). We check BOTH the
+ // effective state AND the hook authority state so that API-reported
+ // state changes trigger notifications even before fallback confirmation.
+ for (ws_idx, pane_id, prev_effective_state, prev_hook_state) in &pane_states_before {
+ let pane_after = self
+ .app
+ .state
+ .workspaces
+ .get(*ws_idx)
+ .and_then(|ws| ws.tabs.iter().find_map(|tab| tab.panes.get(pane_id)));
+
+ let Some(pane_after) = pane_after else {
+ continue;
+ };
+
+ let new_effective_state = pane_after.state;
+ let new_hook_state = pane_after.hook_authority.as_ref().map(|h| h.state);
+
+ // Check effective state change first.
+ let effective_changed = new_effective_state != *prev_effective_state;
+ // Check hook authority state change — this catches API-reported
+ // state changes that haven't been confirmed by fallback detection.
+ let hook_changed = new_hook_state != *prev_hook_state;
+
+ if effective_changed || hook_changed {
+ // Use the hook state if available (it reflects the API-reported
+ // state), otherwise use the effective state.
+ let prev_state = prev_hook_state.unwrap_or(*prev_effective_state);
+ let new_state = new_hook_state.unwrap_or(new_effective_state);
+
+ // Skip if the derived transition is a no-op.
+ if prev_state == new_state {
+ continue;
+ }
+
+ let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id);
+
+ // Get the known agent for sound settings. Unknown custom labels
+ // fall back to None so clients use the generic sound behavior.
+ let agent = pane_after.effective_known_agent();
+
+ debug!(
+ ws_idx,
+ pane_id = pane_id.raw(),
+ prev_state = ?prev_state,
+ new_state = ?new_state,
+ agent = ?agent,
+ effective_changed,
+ hook_changed,
+ "pane state changed during API request, checking sound notification"
+ );
+
+ // Check agent-specific sound setting but NOT sound.enabled,
+ // because the server sets enabled=false to prevent local playback.
+ // Clients decide locally whether to play sounds.
+ if !matches!(
+ self.app.state.sound.agents.for_agent(agent),
+ crate::config::AgentSoundSetting::Off
+ ) {
+ if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
+ is_active_tab,
+ prev_state,
+ new_state,
+ ) {
+ let msg_text = match sound {
+ crate::sound::Sound::Done => "agent done",
+ crate::sound::Sound::Request => "agent attention",
+ };
+ debug!(sound = ?sound, "forwarding sound notification from API request");
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Sound,
+ message: msg_text.to_owned(),
+ });
+ }
+ }
+ }
+ }
+
+ changed
+ }
+
+ /// Renders the current state to client-sized virtual buffers and streams
+ /// frames to all connected clients.
+ fn render_and_stream(&mut self) {
+ let foreground_client_id = self.foreground_client_id;
+ let mut render_targets: Vec<(u64, (u16, u16), std::sync::mpsc::Sender<Vec<u8>>, bool)> =
+ self.clients
+ .iter()
+ .filter_map(|(&client_id, client)| {
+ client.writer.as_ref().map(|writer| {
+ (
+ client_id,
+ client.terminal_size,
+ writer.clone(),
+ foreground_client_id == Some(client_id),
+ )
+ })
+ })
+ .collect();
+
+ render_targets.sort_by_key(|(client_id, _, _, is_foreground)| (*is_foreground, *client_id));
+
+ if render_targets.is_empty() {
+ let (cols, rows) = self.effective_size;
+ let area = Rect::new(0, 0, cols, rows);
+ let _ = render_virtual(&mut self.app.state, area, true);
+ debug!(
+ cols,
+ rows, "rendered virtual frame with no attached clients"
+ );
+ return;
+ }
+
+ let mut broken_clients: Vec<u64> = Vec::new();
+ for (client_id, (cols, rows), writer, is_foreground) in render_targets {
+ let area = Rect::new(0, 0, cols, rows);
+ let (buffer, cursor) = render_virtual(&mut self.app.state, area, is_foreground);
+ let message = ServerMessage::Frame(FrameData::from_ratatui_buffer(&buffer, cursor));
+ let serialized = match Self::frame_server_message(&message) {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to serialize frame for client");
+ broken_clients.push(client_id);
+ continue;
+ }
+ };
+
+ if writer.send(serialized).is_err() {
+ debug!(client_id, "client writer channel closed, marking as broken");
+ broken_clients.push(client_id);
+ }
+ }
+
+ if !broken_clients.is_empty() {
+ for client_id in broken_clients {
+ let foreground_changed = self.remove_client(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ }
+ }
+
+ let (cols, rows) = self.effective_size;
+ debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)");
+ }
+
+ /// Handle scheduled tasks for the headless server.
+ ///
+ /// Similar to `App::handle_scheduled_tasks` but without resize polling
+ /// (the server doesn't have a terminal to resize).
+ fn handle_scheduled_tasks_headless(&mut self, now: Instant) -> bool {
+ let mut changed = false;
+
+ self.app.sync_animation_timer(now);
+
+ // No resize polling needed — server has no terminal.
+ // Client resize messages drive size changes instead.
+
+ if self
+ .app
+ .config_diagnostic_deadline
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.app.config_diagnostic_deadline = None;
+ self.app.state.config_diagnostic = None;
+ changed = true;
+ }
+
+ if self
+ .app
+ .toast_deadline
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.app.toast_deadline = None;
+ self.app.state.toast = None;
+ changed = true;
+ }
+
+ if self
+ .app
+ .next_animation_tick
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.app.state.spinner_tick = self.app.state.spinner_tick.wrapping_add(1);
+ self.app.next_animation_tick = Some(now + app::ANIMATION_INTERVAL);
+ changed = true;
+ }
+
+ if self
+ .app
+ .git_refresh_deadline()
+ .is_some_and(|deadline| now >= deadline)
+ {
+ for ws in &mut self.app.state.workspaces {
+ ws.refresh_git_ahead_behind();
+ }
+ self.app.last_git_remote_status_refresh = now;
+ changed = true;
+ }
+
+ if self
+ .app
+ .next_auto_update_check
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.app.run_auto_update_check();
+ }
+
+ if self
+ .app
+ .session_save_deadline
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.app.save_session_now();
+ }
+
+ self.app.sync_animation_timer(now);
+ changed
+ }
+
+ /// Initiates graceful shutdown.
+ fn initiate_shutdown(&mut self) {
+ if self.shutting_down {
+ return;
+ }
+ info!("server shutdown initiated");
+ self.shutting_down = true;
+
+ // Send ServerShutdown to all connected clients.
+ let shutdown_msg = ServerMessage::ServerShutdown {
+ reason: Some("server is shutting down".to_owned()),
+ };
+ self.send_to_all_clients(shutdown_msg);
+
+ // Give client writer threads a moment to flush the shutdown message.
+ // A short sleep ensures the message is written to the socket before
+ // we close the connections.
+ std::thread::sleep(Duration::from_millis(50));
+
+ // Signal the main loop to exit.
+ self.should_quit.store(true, Ordering::Release);
+ self.app.state.should_quit = true;
+ }
+
+ /// Completes the shutdown sequence: send ServerShutdown to clients,
+ /// close client connections, remove socket files, and clean up.
+ fn complete_shutdown(&mut self) -> io::Result<()> {
+ info!("completing server shutdown");
+
+ // Send ServerShutdown to all remaining clients.
+ if !self.clients.is_empty() {
+ let shutdown_msg = ServerMessage::ServerShutdown {
+ reason: Some("server is shutting down".to_owned()),
+ };
+ self.send_to_all_clients(shutdown_msg);
+
+ // Give writer threads a moment to flush before closing.
+ std::thread::sleep(Duration::from_millis(50));
+ }
+
+ // Drain remaining API requests with server_unavailable.
+ self.drain_api_requests_with_shutdown_check();
+
+ // Close all client connections.
+ self.clients.clear();
+
+ // Remove socket files.
+ self.cleanup_sockets()?;
+
+ Ok(())
+ }
+
+ /// Removes socket files created by the server.
+ fn cleanup_sockets(&self) -> io::Result<()> {
+ if let Err(err) = fs::remove_file(&self.client_socket_path) {
+ if err.kind() != io::ErrorKind::NotFound {
+ warn!(
+ path = %self.client_socket_path.display(),
+ err = %err,
+ "failed to remove client socket on shutdown"
+ );
+ }
+ }
+ Ok(())
+ }
+}
+
+impl Drop for HeadlessServer {
+ fn drop(&mut self) {
+ let _ = self.cleanup_sockets();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Client handshake
+// ---------------------------------------------------------------------------
+
+/// Internal event sent from the handshake thread to the main event loop.
+#[derive(Debug)]
+pub enum ServerEvent {
+ /// A new client completed the handshake.
+ ClientConnected {
+ client_id: u64,
+ cols: u16,
+ rows: u16,
+ writer: std::sync::mpsc::Sender<Vec<u8>>,
+ },
+ /// A client sent an input message.
+ ClientInput { client_id: u64, data: Vec<u8> },
+ /// A client sent a resize message.
+ ClientResize {
+ client_id: u64,
+ cols: u16,
+ rows: u16,
+ },
+ /// A client detached gracefully.
+ ClientDetach { client_id: u64 },
+ /// A client connection was lost.
+ ClientDisconnected { client_id: u64 },
+ /// Ctrl+C or external shutdown signal received.
+ QuitSignal,
+}
+
+/// Handles the client handshake on a blocking thread.
+///
+/// Reads the `Hello` message, validates the version, sends `Welcome`,
+/// and then enters a read loop forwarding messages to the server event channel.
+fn handle_client_handshake(
+ mut stream: UnixStream,
+ client_id: u64,
+ server_event_tx: &mpsc::Sender<ServerEvent>,
+ should_quit: &Arc<AtomicBool>,
+) -> io::Result<()> {
+ // Reset to blocking mode — the accept loop sets nonblocking but
+ // the handshake thread needs blocking I/O for read_message/write_message.
+ stream.set_nonblocking(false)?;
+
+ // Set a read timeout for the handshake.
+ stream.set_read_timeout(Some(HANDSHAKE_TIMEOUT))?;
+
+ // Read the Hello message.
+ let hello: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) {
+ Ok(msg) => msg,
+ Err(protocol::FramingError::UnexpectedEof) => {
+ debug!(client_id, "client disconnected before handshake");
+ return Ok(());
+ }
+ Err(protocol::FramingError::Oversized { claimed, max }) => {
+ warn!(client_id, claimed, max, "oversized handshake from client");
+ return Ok(());
+ }
+ Err(err) => {
+ debug!(client_id, err = %err, "failed to read client hello");
+ return Ok(());
+ }
+ };
+
+ let (client_cols, client_rows) = match hello {
+ ClientMessage::Hello {
+ version,
+ cols,
+ rows,
+ } => {
+ // Version check.
+ match protocol::check_client_version(version) {
+ protocol::VersionCheck::Compatible => {}
+ protocol::VersionCheck::Incompatible(reason) => {
+ // Send rejection Welcome.
+ let welcome = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ error: Some(reason),
+ };
+ let _ = protocol::write_message(&mut stream, &welcome);
+ return Ok(());
+ }
+ }
+
+ // Clamp size.
+ let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
+ (clamped_cols, clamped_rows)
+ }
+ _ => {
+ // First message must be Hello.
+ debug!(client_id, "first message was not Hello, closing");
+ let welcome = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ error: Some("expected Hello as first message".to_owned()),
+ };
+ let _ = protocol::write_message(&mut stream, &welcome);
+ return Ok(());
+ }
+ };
+
+ // Send Welcome.
+ let welcome = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ error: None,
+ };
+ protocol::write_message(&mut stream, &welcome)
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
+
+ // 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>>();
+
+ // 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,
+ });
+
+ // Spawn a writer thread that forwards frames from the channel to the stream.
+ let write_stream = stream.try_clone()?;
+ let write_quit = should_quit.clone();
+ std::thread::spawn(move || {
+ client_writer_loop(write_stream, frame_rx, &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.
+fn client_writer_loop(
+ mut stream: UnixStream,
+ frame_rx: std::sync::mpsc::Receiver<Vec<u8>>,
+ should_quit: &Arc<AtomicBool>,
+) {
+ while !should_quit.load(Ordering::Acquire) {
+ match frame_rx.recv_timeout(Duration::from_millis(100)) {
+ Ok(data) => {
+ if let Err(err) = stream.write_all(&data) {
+ debug!(err = %err, "client write failed, closing writer");
+ break;
+ }
+ if let Err(err) = stream.flush() {
+ debug!(err = %err, "client flush failed, closing writer");
+ break;
+ }
+ }
+ Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
+ Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
+ }
+ }
+ debug!("client writer thread exiting");
+}
+
+/// The client read loop — reads messages from the client and forwards to the server event channel.
+fn client_read_loop(
+ mut stream: UnixStream,
+ client_id: u64,
+ server_event_tx: &mpsc::Sender<ServerEvent>,
+ should_quit: &Arc<AtomicBool>,
+) -> io::Result<()> {
+ while !should_quit.load(Ordering::Acquire) {
+ let msg: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) {
+ Ok(msg) => msg,
+ Err(protocol::FramingError::UnexpectedEof) => {
+ // Client disconnected.
+ let _ =
+ server_event_tx.blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ Err(protocol::FramingError::Oversized { claimed, max }) => {
+ warn!(
+ client_id,
+ claimed, max, "oversized message from client, closing"
+ );
+ let _ =
+ server_event_tx.blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ Err(err) => {
+ debug!(client_id, err = %err, "client read error, closing");
+ let _ =
+ server_event_tx.blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ };
+
+ let event = match msg {
+ ClientMessage::Input { data } => {
+ // Validate input size.
+ if data.len() > MAX_INPUT_PAYLOAD {
+ warn!(
+ client_id,
+ size = data.len(),
+ "oversized input from client, closing"
+ );
+ ServerEvent::ClientDisconnected { client_id }
+ } else {
+ ServerEvent::ClientInput { client_id, data }
+ }
+ }
+ ClientMessage::Resize { cols, rows } => {
+ let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
+ ServerEvent::ClientResize {
+ client_id,
+ cols: clamped_cols,
+ rows: clamped_rows,
+ }
+ }
+ ClientMessage::Detach => ServerEvent::ClientDetach { client_id },
+ ClientMessage::Hello { .. } => {
+ // Duplicate Hello — ignore.
+ continue;
+ }
+ };
+
+ if server_event_tx.blocking_send(event).is_err() {
+ break; // Main loop gone.
+ }
+ }
+
+ debug!(client_id, "client read thread exiting");
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/// Installs a Ctrl+C handler that sets the should_quit flag and wakes up
+/// the event loop by sending a QuitSignal on the server event channel.
+fn ctrlc_handler(should_quit: Arc<AtomicBool>, server_event_tx: mpsc::Sender<ServerEvent>) {
+ let _ = ctrlc::set_handler(move || {
+ should_quit.store(true, Ordering::Release);
+ // Wake up the event loop so the quit flag is checked promptly.
+ let _ = server_event_tx.try_send(ServerEvent::QuitSignal);
+ });
+}
+
+/// Sleep until a deadline, or return pending if none.
+async fn sleep_until_or_pending(deadline: Option<Instant>) {
+ match deadline {
+ Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await,
+ None => std::future::pending().await,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+/// Run the headless server. This is the entry point called from main.rs.
+pub fn run_server() -> io::Result<()> {
+ init_logging();
+
+ let loaded_config = config::Config::load();
+ let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let event_hub = api::EventHub::default();
+
+ // Start the JSON API socket server.
+ let _api_server = match api::start_server(api_tx, event_hub.clone()) {
+ Ok(server) => server,
+ Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
+ eprintln!("error: herdr server is already running");
+ eprintln!("api socket: {}", api::socket_path().display());
+ std::process::exit(1);
+ }
+ Err(err) => return Err(err),
+ };
+
+ let no_session = false; // Server always does session persistence.
+ let _show_changelog = false;
+
+ let rt = tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
+
+ let result = rt.block_on(async {
+ // Create the App (with AppState, event channels, etc.).
+ let mut app = app::App::new(
+ &loaded_config.config,
+ no_session,
+ None, // config_diagnostic
+ None, // startup_release_notes
+ api_rx,
+ event_hub,
+ );
+
+ // The server runs headless — disable local sound playback.
+ // Sound notifications are forwarded to connected clients as
+ // ServerMessage::Notify instead of played locally.
+ app.state.sound.enabled = false;
+
+ // Create the headless server.
+ let mut server = match HeadlessServer::new(app) {
+ Ok(server) => server,
+ Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
+ eprintln!("error: herdr server is already running");
+ eprintln!("client socket: {}", client_socket_path().display());
+ std::process::exit(1);
+ }
+ Err(err) => return Err(err),
+ };
+
+ info!(
+ api_socket = %api::socket_path().display(),
+ client_socket = %client_socket_path().display(),
+ "herdr server started"
+ );
+
+ server.run().await
+ });
+
+ rt.shutdown_timeout(Duration::from_millis(100));
+ result
+}
+
+/// Initialize logging for the server process.
+fn init_logging() {
+ crate::logging::init_file_logging("herdr-server.log");
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use crate::server::protocol::CursorState;
+
+ fn test_headless_server() -> HeadlessServer {
+ let config = crate::config::Config::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let app = crate::app::App::new(&config, true, None, None, api_rx, api::EventHub::default());
+
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-headless-test-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ let _ = fs::create_dir_all(&dir);
+ let socket_path = dir.join("client.sock");
+ let listener = UnixListener::bind(&socket_path).expect("bind test listener");
+ listener
+ .set_nonblocking(true)
+ .expect("set listener nonblocking");
+ let (server_event_tx, server_event_rx) = mpsc::channel(64);
+
+ HeadlessServer {
+ app,
+ client_listener: listener,
+ client_socket_path: socket_path,
+ clients: HashMap::new(),
+ next_client_id: 1,
+ foreground_client_id: None,
+ next_activity_stamp: 1,
+ effective_size: (MIN_COLS, MIN_ROWS),
+ shutting_down: false,
+ should_quit: Arc::new(AtomicBool::new(false)),
+ server_event_rx,
+ server_event_tx,
+ }
+ }
+
+ fn read_server_message(bytes: Vec<u8>) -> ServerMessage {
+ let mut cursor = std::io::Cursor::new(bytes);
+ protocol::read_message(&mut cursor, MAX_FRAME_SIZE).expect("decode server message")
+ }
+
+ fn read_server_frame(bytes: Vec<u8>) -> FrameData {
+ match read_server_message(bytes) {
+ ServerMessage::Frame(frame) => frame,
+ other => panic!("expected frame, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn clamp_terminal_size_zero_zero() {
+ assert_eq!(
+ clamp_terminal_size(0, 0),
+ (MIN_CLIENT_COLS, MIN_CLIENT_ROWS)
+ );
+ }
+
+ #[test]
+ fn clamp_terminal_size_one_one() {
+ assert_eq!(clamp_terminal_size(1, 1), (1, 1));
+ }
+
+ #[test]
+ fn clamp_terminal_size_preserves_narrow_client_size() {
+ assert_eq!(clamp_terminal_size(40, 12), (40, 12));
+ }
+
+ #[test]
+ fn clamp_terminal_size_valid() {
+ assert_eq!(clamp_terminal_size(120, 40), (120, 40));
+ }
+
+ #[test]
+ fn clamp_terminal_size_exact_minimum() {
+ assert_eq!(
+ clamp_terminal_size(MIN_CLIENT_COLS, MIN_CLIENT_ROWS),
+ (MIN_CLIENT_COLS, MIN_CLIENT_ROWS)
+ );
+ }
+
+ #[test]
+ fn client_socket_path_derived_from_api_socket_override() {
+ let path = client_socket_path_from_overrides(Some("/tmp/test-herdr.sock"), None);
+ assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock"));
+ }
+
+ #[test]
+ fn client_socket_path_api_override_takes_precedence_over_legacy_client_override() {
+ let path = client_socket_path_from_overrides(
+ Some("/tmp/test-herdr.sock"),
+ Some("/tmp/legacy-client.sock"),
+ );
+ assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock"));
+ }
+
+ #[test]
+ fn client_socket_path_respects_legacy_client_override_without_api_override() {
+ let path = client_socket_path_from_overrides(None, Some("/tmp/test-herdr-client.sock"));
+ assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock"));
+ }
+
+ #[test]
+ fn client_socket_path_defaults_to_config_dir() {
+ let path = client_socket_path_from_overrides(None, None);
+ assert_eq!(path, config::config_dir().join("herdr-client.sock"));
+ }
+
+ #[test]
+ fn derive_client_socket_from_api_socket_without_sock_extension() {
+ let derived = derive_client_socket_from_api_socket(Path::new("/tmp/custom-api"));
+ assert_eq!(derived, PathBuf::from("/tmp/custom-api-client.sock"));
+ }
+
+ #[test]
+ fn prepare_socket_path_removes_stale_socket() {
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-test-stale-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ let _ = fs::create_dir_all(&dir);
+ let socket_path = dir.join("stale.sock");
+
+ // Create a socket file that nobody is listening on.
+ let _ = UnixListener::bind(&socket_path);
+ // The listener goes out of scope, so the socket becomes stale.
+
+ // prepare_socket_path should remove it without error.
+ let result = prepare_socket_path(&socket_path);
+ assert!(result.is_ok(), "should remove stale socket: {result:?}");
+
+ // Socket file should be gone.
+ assert!(!socket_path.exists());
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn prepare_socket_path_rejects_live_socket() {
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-test-live-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ let _ = fs::create_dir_all(&dir);
+ let socket_path = dir.join("live.sock");
+
+ // Bind a live listener.
+ let _listener = UnixListener::bind(&socket_path).expect("bind");
+
+ let result = prepare_socket_path(&socket_path);
+ assert!(result.is_err());
+ assert_eq!(result.unwrap_err().kind(), io::ErrorKind::AddrInUse);
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn virtual_render_produces_nonempty_buffer() {
+ let mut state = AppState::test_new();
+ let area = Rect::new(0, 0, 80, 24);
+ let (buffer, _cursor) = render_virtual(&mut state, area, true);
+ assert_eq!(buffer.area.width, 80);
+ assert_eq!(buffer.area.height, 24);
+ }
+
+ #[test]
+ fn virtual_render_without_frame_cursor_keeps_cursor_hidden() {
+ let mut state = AppState::test_new();
+ let area = Rect::new(0, 0, 80, 24);
+ let (_buffer, cursor) = render_virtual(&mut state, area, true);
+
+ assert_eq!(cursor, None);
+ }
+
+ #[tokio::test]
+ async fn virtual_render_preserves_explicit_frame_cursor_position() {
+ let mut state = AppState::test_new();
+ let mut ws = crate::workspace::Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ ws.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"left"),
+ );
+
+ state.workspaces = vec![ws];
+ state.active = Some(0);
+ state.selected = 0;
+ state.mode = crate::app::Mode::Terminal;
+
+ let area = Rect::new(0, 0, 80, 24);
+ let (_buffer, cursor) = render_virtual(&mut state, area, true);
+ let pane = state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == pane_id)
+ .expect("focused pane info");
+
+ assert_eq!(
+ cursor,
+ Some(CursorState {
+ x: pane.inner_rect.x + 4,
+ y: pane.inner_rect.y,
+ visible: true,
+ })
+ );
+ }
+
+ #[test]
+ fn latest_active_client_drives_shared_size_theme_and_fallback() {
+ let mut server = test_headless_server();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (160, 45),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 0xaa,
+ g: 0xbb,
+ b: 0xcc,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0x11,
+ g: 0x22,
+ b: 0x33,
+ }),
+ },
+ last_activity: 1,
+ writer: None,
+ },
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 0x10,
+ g: 0x20,
+ b: 0x30,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0xdd,
+ g: 0xee,
+ b: 0xff,
+ }),
+ },
+ last_activity: 2,
+ writer: None,
+ },
+ );
+
+ assert!(server.promote_client_to_foreground(1));
+ assert_eq!(server.foreground_client_id, Some(1));
+ assert_eq!(server.effective_size, (160, 45));
+ assert_eq!(
+ server.app.state.host_terminal_theme,
+ server.clients[&1].host_terminal_theme
+ );
+
+ assert!(server.promote_client_to_foreground(2));
+ assert_eq!(server.foreground_client_id, Some(2));
+ assert_eq!(server.effective_size, (80, 24));
+ assert_eq!(
+ server.app.state.host_terminal_theme,
+ server.clients[&2].host_terminal_theme
+ );
+
+ assert!(server.remove_client(2));
+ assert_eq!(server.foreground_client_id, Some(1));
+ assert_eq!(server.effective_size, (160, 45));
+ assert_eq!(
+ server.app.state.host_terminal_theme,
+ server.clients[&1].host_terminal_theme
+ );
+ }
+
+ #[test]
+ fn render_and_stream_uses_each_client_terminal_size() {
+ let mut server = test_headless_server();
+ server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")];
+ server.app.state.active = Some(0);
+ 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();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (120, 40),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 1,
+ writer: Some(desktop_tx),
+ },
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 2,
+ writer: Some(phone_tx),
+ },
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+ server.resize_shared_runtime_to_effective_size();
+
+ server.render_and_stream();
+
+ let desktop_frame = read_server_frame(desktop_rx.recv().expect("desktop frame"));
+ let phone_frame = read_server_frame(phone_rx.recv().expect("phone frame"));
+
+ assert_eq!((desktop_frame.width, desktop_frame.height), (120, 40));
+ assert_eq!((phone_frame.width, phone_frame.height), (80, 24));
+ }
+
+ #[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();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (120, 40),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 1,
+ writer: Some(background_tx),
+ },
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 2,
+ writer: Some(foreground_tx),
+ },
+ );
+ server.foreground_client_id = Some(2);
+ server.sync_foreground_client_state();
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite {
+ content: b"test".to_vec(),
+ });
+
+ assert!(!changed);
+ match read_server_message(
+ foreground_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("foreground clipboard message"),
+ ) {
+ ServerMessage::Clipboard { data } => assert_eq!(data, "dGVzdA=="),
+ other => panic!("expected clipboard message, got {other:?}"),
+ }
+ assert!(
+ background_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
+ "background client should not receive clipboard writes"
+ );
+ }
+
+ #[test]
+ fn handshake_timeout_is_within_five_second_deadline() {
+ // The handshake timeout must be short enough that
+ // the connection is guaranteed to close within 5 seconds even with
+ // OS overhead (thread scheduling, timer slack, cleanup).
+ assert!(
+ HANDSHAKE_TIMEOUT < Duration::from_secs(5),
+ "HANDSHAKE_TIMEOUT ({:?}) must be less than 5 seconds to guarantee \
+ connection close within the 5-second deadline",
+ HANDSHAKE_TIMEOUT
+ );
+ }
+
+ /// Verify that no direct calls to `self.app.handle_internal_event`
+ /// exist outside of `handle_internal_event_with_forwarding` in this
+ /// module. This ensures the forwarding bypass cannot be reintroduced.
+ ///
+ /// The search pattern looks for `handle_internal_event` calls that
+ /// are NOT inside the `handle_internal_event_with_forwarding` method.
+ #[test]
+ fn no_handle_internal_event_bypass_in_module() {
+ let source = include_str!("headless.rs");
+
+ // Find all lines containing handle_internal_event
+ let mut bypass_lines: Vec<String> = Vec::new();
+ let mut inside_forwarding_method = false;
+ let mut forwarding_method_brace_depth = 0u32;
+
+ for (i, line) in source.lines().enumerate() {
+ let line_num = i + 1;
+
+ // Track when we're inside handle_internal_event_with_forwarding
+ if line.contains("fn handle_internal_event_with_forwarding") {
+ inside_forwarding_method = true;
+ forwarding_method_brace_depth = 0;
+ }
+
+ if inside_forwarding_method {
+ // Count braces to track when we exit the method
+ for ch in line.chars() {
+ match ch {
+ '{' => forwarding_method_brace_depth += 1,
+ '}' => {
+ forwarding_method_brace_depth =
+ forwarding_method_brace_depth.saturating_sub(1);
+ if forwarding_method_brace_depth == 0 {
+ inside_forwarding_method = false;
+ }
+ }
+ _ => {}
+ }
+ }
+ } else if line.contains("self.app.handle_internal_event(")
+ && !line.trim().starts_with("///")
+ && !line.contains("contains(")
+ {
+ // Direct call to handle_internal_event outside the forwarding method
+ bypass_lines.push(format!("line {}: {}", line_num, line.trim()));
+ }
+ }
+
+ assert!(
+ bypass_lines.is_empty(),
+ "Found direct calls to self.app.handle_internal_event outside \
+ handle_internal_event_with_forwarding (bypass risk):\n {}",
+ bypass_lines.join("\n ")
+ );
+ }
+}
diff --git a/src/server/mod.rs b/src/server/mod.rs
new file mode 100644
index 00000000..5b5f1d4e
--- /dev/null
+++ b/src/server/mod.rs
@@ -0,0 +1,3 @@
+pub mod autodetect;
+pub mod headless;
+pub mod protocol;
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
new file mode 100644
index 00000000..f3784a5e
--- /dev/null
+++ b/src/server/protocol.rs
@@ -0,0 +1,1212 @@
+//! Wire protocol for herdr server/client communication.
+//!
+//! Defines the message types, framing, version negotiation, and safety
+//! constraints for the binary protocol over Unix domain sockets.
+
+use std::io::{self, Read, Write};
+
+use serde::{Deserialize, Serialize};
+
+// ---------------------------------------------------------------------------
+// Protocol constants
+// ---------------------------------------------------------------------------
+
+/// Current protocol version. Bumped when wire format changes incompatibly.
+pub const PROTOCOL_VERSION: u32 = 1;
+
+/// Maximum allowed frame payload size (2 MB). Frames larger than this are
+/// rejected to prevent denial-of-service via oversized length prefixes.
+pub const MAX_FRAME_SIZE: usize = 2 * 1024 * 1024;
+
+/// Length of the u32 little-endian length prefix in bytes.
+const LENGTH_PREFIX_BYTES: usize = 4;
+
+// ---------------------------------------------------------------------------
+// Client → Server messages
+// ---------------------------------------------------------------------------
+
+/// Messages sent from the client to the server over the client protocol socket.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientMessage {
+ /// Handshake: client announces its protocol version and terminal dimensions.
+ Hello {
+ /// Protocol version the client speaks.
+ version: u32,
+ /// Terminal width in columns.
+ cols: u16,
+ /// Terminal height in rows.
+ rows: u16,
+ },
+
+ /// Raw input bytes read from the client's stdin.
+ Input {
+ /// Raw terminal input (possibly multi-byte escape sequences).
+ data: Vec<u8>,
+ },
+
+ /// Terminal resize notification from the client.
+ Resize {
+ /// New terminal width in columns.
+ cols: u16,
+ /// New terminal height in rows.
+ rows: u16,
+ },
+
+ /// Graceful disconnect request.
+ Detach,
+}
+
+// ---------------------------------------------------------------------------
+// Server → Client messages
+// ---------------------------------------------------------------------------
+
+/// A single cell in a rendered frame, serialized independently from ratatui's
+/// `Cell` type to keep the wire protocol stable.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CellData {
+ /// Grapheme cluster displayed in this cell (usually 1–2 chars).
+ pub symbol: String,
+ /// Foreground color as a packed u32 (0xAARRGGBB or ratatui Color index).
+ pub fg: u32,
+ /// Background color as a packed u32.
+ pub bg: u32,
+ /// Bitmask of style modifiers (bold, italic, etc.).
+ pub modifier: u16,
+ /// Whether this cell should be skipped during diff-based rendering.
+ pub skip: bool,
+}
+
+/// Cursor position within a rendered frame.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CursorState {
+ /// Column offset (0-based) of the cursor.
+ pub x: u16,
+ /// Row offset (0-based) of the cursor.
+ pub y: u16,
+ /// Whether the cursor is visible.
+ pub visible: bool,
+}
+
+/// A rendered frame to be displayed by the client.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct FrameData {
+ /// Cells in row-major order. Length must equal `width * height`.
+ pub cells: Vec<CellData>,
+ /// Frame width in columns.
+ pub width: u16,
+ /// Frame height in rows.
+ pub height: u16,
+ /// Cursor state for this frame, if applicable.
+ pub cursor: Option<CursorState>,
+}
+
+impl FrameData {
+ /// Creates a `FrameData` from a ratatui `Buffer` and optional cursor.
+ ///
+ /// This converts ratatui's internal cell representation into the
+ /// wire-protocol cell format. The conversion is lossless for all
+ /// commonly used cell attributes.
+ pub fn from_ratatui_buffer(
+ buffer: &ratatui::buffer::Buffer,
+ cursor: Option<CursorState>,
+ ) -> Self {
+ let area = buffer.area;
+ let width = area.width;
+ let height = area.height;
+
+ let mut cells = Vec::with_capacity((width as usize) * (height as usize));
+ for row in 0..height {
+ for col in 0..width {
+ let cell = buffer.cell((col, row)).expect("cell within bounds");
+ cells.push(CellData {
+ symbol: cell.symbol().to_owned(),
+ fg: color_to_u32(cell.fg),
+ bg: color_to_u32(cell.bg),
+ modifier: modifier_to_u16(cell.modifier),
+ skip: cell.skip,
+ });
+ }
+ }
+
+ FrameData {
+ cells,
+ width,
+ height,
+ cursor,
+ }
+ }
+
+ /// Reconstructs a ratatui `Buffer` from this frame data.
+ ///
+ /// Returns `None` if the cells vector length doesn't match `width * height`.
+ #[cfg(test)]
+ pub fn to_ratatui_buffer(&self) -> Option<ratatui::buffer::Buffer> {
+ let expected = (self.width as usize) * (self.height as usize);
+ if self.cells.len() != expected {
+ return None;
+ }
+
+ let area = ratatui::layout::Rect::new(0, 0, self.width, self.height);
+ let mut buffer = ratatui::buffer::Buffer::filled(area, ratatui::buffer::Cell::new(" "));
+
+ for row in 0..self.height {
+ for col in 0..self.width {
+ let idx = (row as usize) * (self.width as usize) + (col as usize);
+ let cell_data = &self.cells[idx];
+ let cell = buffer.cell_mut((col, row)).expect("cell within bounds");
+ cell.set_symbol(&cell_data.symbol);
+ cell.fg = u32_to_color(cell_data.fg);
+ cell.bg = u32_to_color(cell_data.bg);
+ cell.modifier = u16_to_modifier(cell_data.modifier);
+ cell.skip = cell_data.skip;
+ }
+ }
+
+ Some(buffer)
+ }
+}
+
+/// Notification kind forwarded from server to client.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum NotifyKind {
+ /// Play a sound (bell/agent-done, etc.).
+ Sound,
+ /// Display a toast message.
+ Toast,
+}
+
+/// Messages sent from the server to the client over the client protocol socket.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ServerMessage {
+ /// Handshake response: server acknowledges (or rejects) the client.
+ Welcome {
+ /// Protocol version the server speaks.
+ version: u32,
+ /// 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.
+ Frame(FrameData),
+
+ /// Server is shutting down. Clients should exit gracefully.
+ ServerShutdown {
+ /// Optional reason for the shutdown.
+ reason: Option<String>,
+ },
+
+ /// A notification event (sound/toast) to be rendered locally by the client.
+ Notify {
+ /// What kind of notification.
+ kind: NotifyKind,
+ /// Human-readable message.
+ message: String,
+ },
+
+ /// OSC 52 clipboard data forwarded from a PTY through the server.
+ Clipboard {
+ /// Base64-encoded clipboard data.
+ data: String,
+ },
+}
+
+// ---------------------------------------------------------------------------
+// Color / Modifier conversion helpers
+// ---------------------------------------------------------------------------
+
+/// Converts a ratatui `Color` to a packed u32 for wire transport.
+///
+/// Encoding:
+/// - Named colors (Reset, Black, …, White) → `0x00_00_00_XX` where XX is 0..=16
+/// - Indexed palette → `0x01_00_00_XX` where XX is the palette index
+/// - RGB → `0x02_RR_GG_BB` with components in the lower 3 bytes
+fn color_to_u32(color: ratatui::style::Color) -> u32 {
+ match color {
+ ratatui::style::Color::Reset => 0x00_00_00_00,
+ ratatui::style::Color::Black => 0x00_00_00_01,
+ ratatui::style::Color::Red => 0x00_00_00_02,
+ ratatui::style::Color::Green => 0x00_00_00_03,
+ ratatui::style::Color::Yellow => 0x00_00_00_04,
+ ratatui::style::Color::Blue => 0x00_00_00_05,
+ ratatui::style::Color::Magenta => 0x00_00_00_06,
+ ratatui::style::Color::Cyan => 0x00_00_00_07,
+ ratatui::style::Color::Gray => 0x00_00_00_08,
+ ratatui::style::Color::DarkGray => 0x00_00_00_09,
+ ratatui::style::Color::LightRed => 0x00_00_00_0A,
+ ratatui::style::Color::LightGreen => 0x00_00_00_0B,
+ ratatui::style::Color::LightYellow => 0x00_00_00_0C,
+ ratatui::style::Color::LightBlue => 0x00_00_00_0D,
+ ratatui::style::Color::LightMagenta => 0x00_00_00_0E,
+ ratatui::style::Color::LightCyan => 0x00_00_00_0F,
+ ratatui::style::Color::White => 0x00_00_00_10,
+ ratatui::style::Color::Indexed(i) => 0x01_00_00_00 | (i as u32),
+ ratatui::style::Color::Rgb(r, g, b) => {
+ 0x02_00_00_00 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32)
+ }
+ }
+}
+
+/// Converts a packed u32 back to a ratatui `Color`.
+#[cfg(test)]
+fn u32_to_color(val: u32) -> ratatui::style::Color {
+ match val >> 24 {
+ 0x00 => match val & 0xFF {
+ 0x00 => ratatui::style::Color::Reset,
+ 0x01 => ratatui::style::Color::Black,
+ 0x02 => ratatui::style::Color::Red,
+ 0x03 => ratatui::style::Color::Green,
+ 0x04 => ratatui::style::Color::Yellow,
+ 0x05 => ratatui::style::Color::Blue,
+ 0x06 => ratatui::style::Color::Magenta,
+ 0x07 => ratatui::style::Color::Cyan,
+ 0x08 => ratatui::style::Color::Gray,
+ 0x09 => ratatui::style::Color::DarkGray,
+ 0x0A => ratatui::style::Color::LightRed,
+ 0x0B => ratatui::style::Color::LightGreen,
+ 0x0C => ratatui::style::Color::LightYellow,
+ 0x0D => ratatui::style::Color::LightBlue,
+ 0x0E => ratatui::style::Color::LightMagenta,
+ 0x0F => ratatui::style::Color::LightCyan,
+ 0x10 => ratatui::style::Color::White,
+ _ => ratatui::style::Color::Reset, // unknown named → Reset
+ },
+ 0x01 => ratatui::style::Color::Indexed((val & 0xFF) as u8),
+ 0x02 => {
+ let r = ((val >> 16) & 0xFF) as u8;
+ let g = ((val >> 8) & 0xFF) as u8;
+ let b = (val & 0xFF) as u8;
+ ratatui::style::Color::Rgb(r, g, b)
+ }
+ _ => ratatui::style::Color::Reset, // unknown tag → Reset
+ }
+}
+
+/// Converts a ratatui `Modifier` bitmask to a u16 for wire transport.
+fn modifier_to_u16(modifier: ratatui::style::Modifier) -> u16 {
+ modifier.bits()
+}
+
+/// Converts a u16 back to a ratatui `Modifier`.
+#[cfg(test)]
+fn u16_to_modifier(val: u16) -> ratatui::style::Modifier {
+ ratatui::style::Modifier::from_bits_truncate(val)
+}
+
+// ---------------------------------------------------------------------------
+// Framing: length-prefixed binary messages
+// ---------------------------------------------------------------------------
+
+/// Errors that can occur during framing operations.
+#[derive(Debug)]
+pub enum FramingError {
+ /// The decoded payload length exceeds the configured maximum frame size.
+ Oversized { claimed: usize, max: usize },
+ /// An I/O error occurred while reading or writing.
+ Io(io::Error),
+ /// Bincode serialization or deserialization failed.
+ Bincode(String),
+ /// The connection was closed before a complete frame could be read.
+ UnexpectedEof,
+}
+
+impl std::fmt::Display for FramingError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ FramingError::Oversized { claimed, max } => {
+ write!(f, "frame size {claimed} exceeds maximum {max}")
+ }
+ FramingError::Io(e) => write!(f, "I/O error: {e}"),
+ FramingError::Bincode(e) => write!(f, "bincode error: {e}"),
+ FramingError::UnexpectedEof => write!(f, "unexpected end of stream"),
+ }
+ }
+}
+
+impl std::error::Error for FramingError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ FramingError::Io(e) => Some(e),
+ _ => None,
+ }
+ }
+}
+
+impl From<io::Error> for FramingError {
+ fn from(e: io::Error) -> Self {
+ FramingError::Io(e)
+ }
+}
+
+/// Serializes a message and writes it as a length-prefixed frame:
+/// `[u32LE length][bincode payload]`.
+///
+/// This is a blocking/synchronous write suitable for use with `std::os::unix::net::UnixStream`
+/// in blocking mode, or with any `Write` implementor.
+///
+/// # Errors
+///
+/// Returns `FramingError::Bincode` if the payload length exceeds `u32::MAX`
+/// (would be truncated by the length prefix cast).
+pub fn write_message<W: Write, M: Serialize>(writer: &mut W, msg: &M) -> Result<(), FramingError> {
+ let payload = bincode::serde::encode_to_vec(msg, bincode::config::standard())
+ .map_err(|e| FramingError::Bincode(e.to_string()))?;
+
+ let len = payload.len();
+ if len > u32::MAX as usize {
+ return Err(FramingError::Bincode(format!(
+ "payload length {len} exceeds u32::MAX ({}), would be truncated by length prefix",
+ u32::MAX
+ )));
+ }
+
+ writer.write_all(&(len as u32).to_le_bytes())?;
+ writer.write_all(&payload)?;
+ writer.flush()?;
+ Ok(())
+}
+
+/// Reads and deserializes a length-prefixed frame from a reader.
+///
+/// Reassembles partial reads correctly. Rejects frames whose declared
+/// length exceeds `max_frame_size` without panicking or allocating
+/// oversized buffers.
+pub fn read_message<R: Read, M: for<'de> Deserialize<'de>>(
+ reader: &mut R,
+ max_frame_size: usize,
+) -> Result<M, FramingError> {
+ // Read the 4-byte length prefix, reassembling partial reads.
+ let mut len_buf = [0u8; LENGTH_PREFIX_BYTES];
+ read_exact_or_eof(reader, &mut len_buf)?;
+ let claimed_len = u32::from_le_bytes(len_buf) as usize;
+
+ if claimed_len > max_frame_size {
+ return Err(FramingError::Oversized {
+ claimed: claimed_len,
+ max: max_frame_size,
+ });
+ }
+
+ // Read the payload, reassembling partial reads.
+ let mut payload = vec![0u8; claimed_len];
+ read_exact_or_eof(reader, &mut payload)?;
+
+ let (msg, consumed) = bincode::serde::decode_from_slice(&payload, bincode::config::standard())
+ .map_err(|e| FramingError::Bincode(e.to_string()))?;
+
+ // Enforce that the decoder consumed the full payload.
+ // Trailing bytes after the decoded message indicate a protocol violation
+ // (e.g., a corrupted length prefix or concatenated payloads).
+ if consumed != claimed_len {
+ return Err(FramingError::Bincode(format!(
+ "decoded {} bytes but payload length was {claimed_len}; trailing bytes are not allowed",
+ consumed
+ )));
+ }
+
+ Ok(msg)
+}
+
+/// Like `Read::read_exact`, but returns `FramingError::UnexpectedEof`
+/// when the reader hits end-of-stream before filling the buffer, instead
+/// of the generic `io::ErrorKind::UnexpectedEof`.
+fn read_exact_or_eof<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<(), FramingError> {
+ reader.read_exact(buf).map_err(|e| {
+ if e.kind() == io::ErrorKind::UnexpectedEof {
+ FramingError::UnexpectedEof
+ } else {
+ FramingError::Io(e)
+ }
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Version negotiation
+// ---------------------------------------------------------------------------
+
+/// 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 }`.
+ Compatible,
+ /// Versions are incompatible. The server should reply
+ /// `Welcome { version, error: Some(reason) }` and close the connection.
+ Incompatible(String),
+}
+
+/// Checks whether a client's protocol version is compatible with this server.
+///
+/// Current rules:
+/// - Version 0 (pre-persistence client) is always rejected.
+/// - Matching major versions are accepted.
+/// - A client with a newer version than the server is rejected.
+/// - A client with an older version than the server is rejected
+/// (backward compatibility is not yet supported).
+pub fn check_client_version(client_version: u32) -> VersionCheck {
+ if client_version == 0 {
+ return VersionCheck::Incompatible(
+ "pre-persistence client (version 0) is not supported".to_owned(),
+ );
+ }
+
+ if client_version == PROTOCOL_VERSION {
+ VersionCheck::Compatible
+ } else if client_version < PROTOCOL_VERSION {
+ VersionCheck::Incompatible(format!(
+ "client version {client_version} is older than server version {PROTOCOL_VERSION}; please upgrade your herdr client"
+ ))
+ } else {
+ VersionCheck::Incompatible(format!(
+ "client version {client_version} is newer than server version {PROTOCOL_VERSION}; please upgrade the herdr server"
+ ))
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use ratatui::style::{Color, Modifier};
+
+ // ---- Round-trip: ClientMessage ----
+
+ #[test]
+ fn client_hello_roundtrip() {
+ let msg = ClientMessage::Hello {
+ version: 1,
+ cols: 80,
+ rows: 24,
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_input_roundtrip() {
+ let msg = ClientMessage::Input {
+ data: vec![0x1b, 0x5b, 0x41], // ESC [ A (up arrow)
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_resize_roundtrip() {
+ let msg = ClientMessage::Resize { cols: 80, rows: 24 };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_detach_roundtrip() {
+ let msg = ClientMessage::Detach;
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ // ---- Round-trip: ServerMessage ----
+
+ #[test]
+ fn server_welcome_roundtrip() {
+ let msg = ServerMessage::Welcome {
+ version: 1,
+ error: None,
+ };
+ 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_welcome_with_error_roundtrip() {
+ let msg = ServerMessage::Welcome {
+ version: 1,
+ error: Some("incompatible version".to_owned()),
+ };
+ 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_frame_roundtrip_nontrivial() {
+ // Build a 3×2 frame with varied styles (≥2×2).
+ let frame = FrameData {
+ cells: vec![
+ CellData {
+ symbol: "H".into(),
+ fg: color_to_u32(Color::Red),
+ bg: color_to_u32(Color::Black),
+ modifier: Modifier::BOLD.bits(),
+ skip: false,
+ },
+ CellData {
+ symbol: "i".into(),
+ fg: color_to_u32(Color::Green),
+ bg: color_to_u32(Color::Reset),
+ modifier: Modifier::ITALIC.bits(),
+ skip: false,
+ },
+ CellData {
+ symbol: "!".into(),
+ fg: color_to_u32(Color::Rgb(255, 128, 0)),
+ bg: color_to_u32(Color::Indexed(220)),
+ modifier: (Modifier::BOLD | Modifier::UNDERLINED).bits(),
+ skip: false,
+ },
+ CellData {
+ symbol: " ".into(),
+ fg: color_to_u32(Color::Reset),
+ bg: color_to_u32(Color::Reset),
+ modifier: Modifier::empty().bits(),
+ skip: true,
+ },
+ CellData {
+ symbol: "→".into(), // multi-byte grapheme
+ fg: color_to_u32(Color::Cyan),
+ bg: color_to_u32(Color::Blue),
+ modifier: Modifier::REVERSED.bits(),
+ skip: false,
+ },
+ CellData {
+ symbol: "🦀".into(), // emoji, wide grapheme cluster
+ fg: color_to_u32(Color::Yellow),
+ bg: color_to_u32(Color::Magenta),
+ modifier: Modifier::empty().bits(),
+ skip: false,
+ },
+ ],
+ width: 3,
+ height: 2,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ }),
+ };
+ let msg = ServerMessage::Frame(frame.clone());
+ 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_shutdown_roundtrip() {
+ let msg = ServerMessage::ServerShutdown {
+ reason: Some("updating".to_owned()),
+ };
+ 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_notify_roundtrip() {
+ let msg = ServerMessage::Notify {
+ kind: NotifyKind::Sound,
+ message: "agent done".to_owned(),
+ };
+ 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_clipboard_roundtrip() {
+ let msg = ServerMessage::Clipboard {
+ data: "dGVzdA==".to_owned(), // base64 "test"
+ };
+ 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);
+ }
+
+ // ---- Framing ----
+
+ #[test]
+ fn framing_small_message_roundtrip() {
+ let msg = ClientMessage::Hello {
+ version: 1,
+ cols: 80,
+ rows: 24,
+ };
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+ let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn framing_large_payload_roundtrip() {
+ // Create a Frame message that is ≥128 KB.
+ // Use a large frame with verbose cell data to exceed 128 KB after bincode encoding.
+ // 200×50 = 10000 cells. With varied symbols and styles, this should easily exceed 128 KB.
+ let width: u16 = 200;
+ let height: u16 = 50;
+ let cells: Vec<CellData> = (0..(width as usize) * (height as usize))
+ .map(|i| CellData {
+ symbol: if i % 256 < 32 {
+ " ".to_owned()
+ } else {
+ format!("{:03}", i % 1000)
+ },
+ fg: color_to_u32(Color::Rgb((i % 256) as u8, ((i / 256) % 256) as u8, 128)),
+ bg: color_to_u32(Color::Indexed((i % 256) as u8)),
+ modifier: ((i % 16) as u16),
+ skip: i % 100 == 0,
+ })
+ .collect();
+
+ let frame = FrameData {
+ cells,
+ width,
+ height,
+ cursor: Some(CursorState {
+ x: 10,
+ y: 5,
+ visible: true,
+ }),
+ };
+ let msg = ServerMessage::Frame(frame);
+
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+ // Verify the payload is at least 128 KB
+ assert!(
+ buf.len() >= 128 * 1024,
+ "framed payload should be >= 128 KB, got {} bytes",
+ buf.len()
+ );
+
+ let decoded: ServerMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn framing_multiple_messages_sequential() {
+ // Write 100+ messages of varying types and read them back.
+ let mut buf = Vec::new();
+ let mut expected = Vec::new();
+
+ for i in 0..150u32 {
+ let msg = match i % 4 {
+ 0 => ClientMessage::Hello {
+ version: 1,
+ cols: (80 + (i % 40) as u16),
+ rows: (24 + (i % 20) as u16),
+ },
+ 1 => ClientMessage::Input {
+ data: vec![(i % 256) as u8; (i as usize % 50) + 1],
+ },
+ 2 => ClientMessage::Resize {
+ cols: (100 + (i % 30) as u16),
+ rows: (30 + (i % 10) as u16),
+ },
+ 3 => ClientMessage::Detach,
+ _ => unreachable!(),
+ };
+ write_message(&mut buf, &msg).unwrap();
+ expected.push(msg);
+ }
+
+ let mut cursor = buf.as_slice();
+ for expected_msg in &expected {
+ let decoded: ClientMessage = read_message(&mut cursor, MAX_FRAME_SIZE).unwrap();
+ assert_eq!(*expected_msg, decoded);
+ }
+ }
+
+ #[test]
+ fn framing_oversized_rejected_without_panic() {
+ // Craft a frame with a huge length prefix (4 GB claim).
+ let mut buf: Vec<u8> = (u32::MAX).to_le_bytes().to_vec();
+ // Add a few garbage bytes after the length prefix.
+ buf.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ match result {
+ Err(FramingError::Oversized { claimed, max }) => {
+ assert_eq!(claimed, u32::MAX as usize);
+ assert_eq!(max, MAX_FRAME_SIZE);
+ }
+ other => panic!("expected Oversized error, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn framing_malformed_payload_rejected_without_panic() {
+ // Valid length prefix pointing to garbage data.
+ let payload = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
+ let mut buf = (payload.len() as u32).to_le_bytes().to_vec();
+ buf.extend_from_slice(&payload);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ assert!(result.is_err(), "malformed payload should be rejected");
+ match result {
+ Err(FramingError::Bincode(_)) => {} // expected
+ other => panic!("expected Bincode error, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn framing_truncated_stream_returns_unexpected_eof() {
+ // Write a length prefix claiming 100 bytes, but only provide 4.
+ let mut buf: Vec<u8> = 100u32.to_le_bytes().to_vec();
+ buf.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ match result {
+ Err(FramingError::UnexpectedEof) => {}
+ other => panic!("expected UnexpectedEof, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn framing_zero_length_message() {
+ // A 1-byte message (smallest possible valid bincode payload).
+ // Actually, let's test with the smallest real message: Detach.
+ let msg = ClientMessage::Detach;
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+
+ // Verify the length prefix is correct
+ let len = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize;
+ assert_eq!(
+ len,
+ buf.len() - 4,
+ "length prefix should match payload size"
+ );
+
+ let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn framing_partial_read_reassembly() {
+ // Simulate partial reads by using a reader that yields small chunks.
+ let msg = ClientMessage::Input {
+ data: vec![42; 500], // 500-byte input payload
+ };
+ let mut full_buf = Vec::new();
+ write_message(&mut full_buf, &msg).unwrap();
+
+ // Wrap in a chunked reader that only yields 7 bytes at a time.
+ let mut chunked = ChunkedReader::new(full_buf, 7);
+ let decoded: ClientMessage = read_message(&mut chunked, MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ // ---- Version negotiation ----
+
+ #[test]
+ fn version_compatible() {
+ assert_eq!(check_client_version(1), VersionCheck::Compatible);
+ }
+
+ #[test]
+ fn version_older_client_rejected() {
+ match check_client_version(1) {
+ VersionCheck::Compatible => {} // current version
+ _ => panic!("current version should be compatible"),
+ }
+ // If we imagine a future server version 2, a v1 client would be rejected.
+ // We test this by checking the logic directly.
+ let result = check_client_version(0);
+ assert!(
+ matches!(result, VersionCheck::Incompatible(_)),
+ "version 0 should be incompatible"
+ );
+ }
+
+ #[test]
+ fn version_newer_client_rejected() {
+ let result = check_client_version(PROTOCOL_VERSION + 1);
+ assert!(matches!(result, VersionCheck::Incompatible(_)));
+ if let VersionCheck::Incompatible(msg) = result {
+ assert!(msg.contains("newer"), "error should mention newer version");
+ }
+ }
+
+ // ---- Pre-persistence client rejection ----
+
+ #[test]
+ fn prepersistence_version_zero_rejected() {
+ let result = check_client_version(0);
+ match result {
+ VersionCheck::Incompatible(msg) => {
+ assert!(
+ msg.contains("pre-persistence"),
+ "error should mention pre-persistence: {msg}"
+ );
+ }
+ _ => panic!("version 0 should be rejected as incompatible"),
+ }
+ }
+
+ #[test]
+ fn prepersistence_version_zero_welcome_has_error() {
+ // Simulating what the server would send to a v0 client.
+ let check = check_client_version(0);
+ let response = match check {
+ VersionCheck::Compatible => ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ error: None,
+ },
+ VersionCheck::Incompatible(reason) => ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ error: Some(reason),
+ },
+ };
+
+ match response {
+ ServerMessage::Welcome { error: Some(_), .. } => {}
+ other => panic!("expected Welcome with error, got: {other:?}"),
+ }
+ }
+
+ // ---- Malformed/oversized input ----
+
+ #[test]
+ fn oversized_frame_does_not_panic() {
+ // Claim 4GB payload — should return Oversized error, not panic.
+ let mut buf: Vec<u8> = 0xFFC00000u32.to_le_bytes().to_vec(); // ~4 GB claim
+ buf.extend_from_slice(&[0; 8]);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ assert!(result.is_err());
+ // Did not panic — test passing is proof.
+ }
+
+ #[test]
+ fn malformed_frame_does_not_panic() {
+ // Random garbage bytes after a valid-ish length prefix.
+ let garbage: Vec<u8> = (0..200).map(|i| (i ^ 0xAA) as u8).collect();
+ let mut buf = (garbage.len() as u32).to_le_bytes().to_vec();
+ buf.extend_from_slice(&garbage);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ assert!(result.is_err());
+ // Did not panic.
+ }
+
+ #[test]
+ fn oversized_input_rejected_custom_max() {
+ // Verify a custom (small) max_frame_size is enforced.
+ let msg = ClientMessage::Input {
+ data: vec![0x41; 1000],
+ };
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+
+ let result: Result<ClientMessage, FramingError> = read_message(&mut buf.as_slice(), 64);
+ // The actual bincode payload for 1000 bytes of input will be > 64 bytes.
+ assert!(
+ matches!(result, Err(FramingError::Oversized { .. })),
+ "expected Oversized with small max_frame_size"
+ );
+ }
+
+ // ---- FrameData ↔ ratatui Buffer conversion ----
+
+ #[test]
+ fn frame_data_roundtrip_through_ratatui_buffer() {
+ let area = ratatui::layout::Rect::new(0, 0, 5, 3);
+ let mut buffer = ratatui::buffer::Buffer::filled(area, ratatui::buffer::Cell::new(" "));
+
+ // Write some styled content.
+ buffer.cell_mut((0, 0)).unwrap().set_symbol("H");
+ buffer.cell_mut((0, 0)).unwrap().fg = Color::Red;
+ buffer.cell_mut((0, 0)).unwrap().modifier = Modifier::BOLD;
+
+ buffer.cell_mut((1, 0)).unwrap().set_symbol("i");
+ buffer.cell_mut((1, 0)).unwrap().fg = Color::Green;
+ buffer.cell_mut((1, 0)).unwrap().modifier = Modifier::ITALIC;
+
+ buffer.cell_mut((2, 0)).unwrap().set_symbol("!");
+ buffer.cell_mut((2, 0)).unwrap().fg = Color::Rgb(255, 128, 0);
+ buffer.cell_mut((2, 0)).unwrap().bg = Color::Indexed(220);
+
+ let cursor = CursorState {
+ x: 1,
+ y: 0,
+ visible: true,
+ };
+ let frame = FrameData::from_ratatui_buffer(&buffer, Some(cursor.clone()));
+
+ // Verify frame dimensions.
+ assert_eq!(frame.width, 5);
+ assert_eq!(frame.height, 3);
+ assert_eq!(frame.cells.len(), 15);
+ assert_eq!(frame.cursor, Some(cursor));
+
+ // Verify specific cells survived the conversion.
+ assert_eq!(frame.cells[0].symbol, "H");
+ assert_eq!(frame.cells[0].fg, color_to_u32(Color::Red));
+ assert_eq!(frame.cells[0].modifier, Modifier::BOLD.bits());
+
+ assert_eq!(frame.cells[1].symbol, "i");
+ assert_eq!(frame.cells[1].fg, color_to_u32(Color::Green));
+ assert_eq!(frame.cells[1].modifier, Modifier::ITALIC.bits());
+
+ assert_eq!(frame.cells[2].symbol, "!");
+ assert_eq!(frame.cells[2].fg, color_to_u32(Color::Rgb(255, 128, 0)));
+ assert_eq!(frame.cells[2].bg, color_to_u32(Color::Indexed(220)));
+
+ // Convert back to ratatui buffer and compare.
+ let restored = frame.to_ratatui_buffer().expect("should reconstruct");
+ assert_eq!(restored.area, area);
+ assert_eq!(restored.cell((0, 0)).unwrap().symbol(), "H");
+ assert_eq!(restored.cell((0, 0)).unwrap().fg, Color::Red);
+ assert_eq!(restored.cell((0, 0)).unwrap().modifier, Modifier::BOLD);
+ assert_eq!(restored.cell((1, 0)).unwrap().symbol(), "i");
+ assert_eq!(restored.cell((2, 0)).unwrap().symbol(), "!");
+ assert_eq!(restored.cell((2, 0)).unwrap().fg, Color::Rgb(255, 128, 0));
+ }
+
+ #[test]
+ fn frame_data_rejects_mismatched_cell_count() {
+ let frame = FrameData {
+ cells: vec![
+ CellData {
+ symbol: "X".into(),
+ fg: 0,
+ bg: 0,
+ modifier: 0,
+ skip: false,
+ };
+ 5
+ ], // 5 cells but 3×2 = 6 expected
+ width: 3,
+ height: 2,
+ cursor: None,
+ };
+ assert!(frame.to_ratatui_buffer().is_none());
+ }
+
+ // ---- Color conversion coverage ----
+
+ #[test]
+ fn color_roundtrip_all_named_colors() {
+ let named = [
+ Color::Reset,
+ Color::Black,
+ Color::Red,
+ Color::Green,
+ Color::Yellow,
+ Color::Blue,
+ Color::Magenta,
+ Color::Cyan,
+ Color::Gray,
+ Color::DarkGray,
+ Color::LightRed,
+ Color::LightGreen,
+ Color::LightYellow,
+ Color::LightBlue,
+ Color::LightMagenta,
+ Color::LightCyan,
+ Color::White,
+ ];
+ for c in named {
+ assert_eq!(
+ u32_to_color(color_to_u32(c)),
+ c,
+ "roundtrip failed for {c:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn color_roundtrip_indexed() {
+ for i in 0..=255u8 {
+ let c = Color::Indexed(i);
+ assert_eq!(
+ u32_to_color(color_to_u32(c)),
+ c,
+ "roundtrip failed for Indexed({i})"
+ );
+ }
+ }
+
+ #[test]
+ fn color_roundtrip_rgb() {
+ let c = Color::Rgb(0xAB, 0xCD, 0xEF);
+ assert_eq!(u32_to_color(color_to_u32(c)), c);
+
+ let c = Color::Rgb(0, 0, 0);
+ assert_eq!(u32_to_color(color_to_u32(c)), c);
+
+ let c = Color::Rgb(255, 255, 255);
+ assert_eq!(u32_to_color(color_to_u32(c)), c);
+ }
+
+ // ---- Modifier conversion ----
+
+ #[test]
+ fn modifier_roundtrip() {
+ let all_mods = [
+ Modifier::BOLD,
+ Modifier::ITALIC,
+ Modifier::REVERSED,
+ Modifier::UNDERLINED,
+ Modifier::DIM,
+ Modifier::SLOW_BLINK,
+ Modifier::CROSSED_OUT,
+ Modifier::BOLD | Modifier::ITALIC,
+ Modifier::BOLD | Modifier::UNDERLINED | Modifier::REVERSED,
+ Modifier::empty(),
+ ];
+ for m in all_mods {
+ assert_eq!(
+ u16_to_modifier(modifier_to_u16(m)),
+ m,
+ "roundtrip failed for {m:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn read_message_rejects_trailing_bytes() {
+ // Encode a valid message, then append an extra byte after it.
+ let msg = ClientMessage::Detach;
+ let mut payload = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let original_len = payload.len();
+ payload.push(0xDE); // trailing garbage
+
+ // Frame it with the inflated length (original + 1).
+ let mut buf = (payload.len() as u32).to_le_bytes().to_vec();
+ buf.extend_from_slice(&payload);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ match result {
+ Err(FramingError::Bincode(msg)) => {
+ assert!(
+ msg.contains("trailing bytes"),
+ "error should mention trailing bytes: {msg}"
+ );
+ assert!(
+ msg.contains(&format!("decoded {original_len}")),
+ "error should mention decoded byte count: {msg}"
+ );
+ }
+ other => panic!("expected Bincode error about trailing bytes, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn read_message_accepts_exact_payload() {
+ // A normally-framed message should decode without error.
+ let msg = ClientMessage::Hello {
+ version: 1,
+ cols: 80,
+ rows: 24,
+ };
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+ let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn write_message_rejects_oversized_payload() {
+ // We can't easily create a message that exceeds u32::MAX in a test,
+ // but we can verify the check exists by testing that normal messages
+ // have lengths well within the limit and the function doesn't fail.
+ let msg = ClientMessage::Detach;
+ let mut buf = Vec::new();
+ assert!(write_message(&mut buf, &msg).is_ok());
+ }
+
+ // ---- Unix socketpair integration test ----
+
+ #[test]
+ fn framing_over_unix_socketpair() {
+ use std::os::unix::net::UnixStream;
+
+ let (mut a, mut b) = UnixStream::pair().expect("socketpair");
+
+ let messages = vec![
+ ClientMessage::Hello {
+ version: 1,
+ cols: 200,
+ rows: 60,
+ },
+ ClientMessage::Input {
+ data: b"hello world".to_vec(),
+ },
+ ClientMessage::Resize {
+ cols: 100,
+ rows: 30,
+ },
+ ClientMessage::Detach,
+ ];
+
+ // Set non-blocking so we can write and read in the same test.
+ a.set_nonblocking(false).unwrap();
+ b.set_nonblocking(false).unwrap();
+
+ for msg in &messages {
+ write_message(&mut a, msg).unwrap();
+ }
+
+ for expected in &messages {
+ let decoded: ClientMessage = read_message(&mut b, MAX_FRAME_SIZE).unwrap();
+ assert_eq!(*expected, decoded);
+ }
+ }
+
+ // ---- Helper: chunked reader for simulating partial reads ----
+
+ /// A `Read` wrapper that yields at most `chunk_size` bytes per `read()` call,
+ /// simulating partial reads on a real socket.
+ struct ChunkedReader {
+ data: Vec<u8>,
+ pos: usize,
+ chunk_size: usize,
+ }
+
+ impl ChunkedReader {
+ fn new(data: Vec<u8>, chunk_size: usize) -> Self {
+ Self {
+ data,
+ pos: 0,
+ chunk_size,
+ }
+ }
+ }
+
+ impl Read for ChunkedReader {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+ if self.pos >= self.data.len() {
+ return Ok(0);
+ }
+ let remaining = self.data.len() - self.pos;
+ let to_read = buf.len().min(remaining).min(self.chunk_size);
+ buf[..to_read].copy_from_slice(&self.data[self.pos..self.pos + to_read]);
+ self.pos += to_read;
+ Ok(to_read)
+ }
+ }
+}