diff options
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/autodetect.rs | 155 | ||||
| -rw-r--r-- | src/server/client_accept.rs | 11 | ||||
| -rw-r--r-- | src/server/client_transport.rs | 211 | ||||
| -rw-r--r-- | src/server/clipboard_image.rs | 41 | ||||
| -rw-r--r-- | src/server/headless.rs | 405 | ||||
| -rw-r--r-- | src/server/mod.rs | 1 | ||||
| -rw-r--r-- | src/server/notifications.rs | 5 | ||||
| -rw-r--r-- | src/server/render_stream.rs | 3 | ||||
| -rw-r--r-- | src/server/socket_paths.rs | 2 |
9 files changed, 686 insertions, 148 deletions
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index a4ea721b..cdba5bd2 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -9,14 +9,14 @@ //! (escape hatch for users who want the traditional single-process behavior). use std::io; -use std::os::unix::net::UnixStream; +#[cfg(unix)] use std::os::unix::process::CommandExt; use std::path::Path; use std::path::PathBuf; use std::process::Command; use std::time::Duration; -use tracing::{info, warn}; +use tracing::info; use super::socket_paths::client_socket_path; @@ -30,6 +30,10 @@ const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(50); /// Timeout for checking the stable JSON API before attaching to the binary protocol socket. const STATUS_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); +/// Private daemon-start hint used to seed a fresh headless server from the +/// directory where the user ran `herdr`. +pub(crate) const STARTUP_CWD_ENV_VAR: &str = "HERDR_STARTUP_CWD"; + // --------------------------------------------------------------------------- // Server detection // --------------------------------------------------------------------------- @@ -39,7 +43,7 @@ const STATUS_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); /// 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` +/// server) are detected because connect returns `ConnectionRefused` /// when nobody is listening. #[allow(dead_code)] // Public API for external use and testing pub fn is_server_listening() -> bool { @@ -48,42 +52,103 @@ pub fn is_server_listening() -> bool { /// 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; + #[cfg(windows)] + { + let _ = socket_path; + return read_server_status().ok().flatten().is_some(); } - 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 + #[cfg(not(windows))] + { + if !socket_path.exists() { + return false; + } + + match crate::ipc::connect_local_stream(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. + tracing::warn!(err = %err, "unexpected error checking server socket"); + false + } } + } +} + +fn read_server_status() -> io::Result<Option<crate::api::RuntimeStatus>> { + crate::api::read_runtime_status_at(&crate::api::socket_path(), STATUS_REQUEST_TIMEOUT) +} + +#[cfg(windows)] +fn client_protocol_accepts_hello(socket_path: &Path) -> io::Result<bool> { + if !socket_path.exists() { + return Ok(false); + } + + let mut stream = match crate::ipc::connect_local_stream(socket_path) { + Ok(stream) => stream, Err(err) if matches!( err.kind(), - io::ErrorKind::ConnectionRefused | io::ErrorKind::TimedOut + io::ErrorKind::ConnectionRefused + | io::ErrorKind::NotFound + | io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock ) => { - // 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 + return Ok(false); } - Err(err) => { - // Other errors (permission denied, etc.) — assume not listening. - warn!(err = %err, "unexpected error checking server socket"); - false + Err(err) => return Err(err), + }; + + let hello = crate::protocol::ClientMessage::Hello { + version: crate::protocol::PROTOCOL_VERSION, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + requested_encoding: crate::protocol::RenderEncoding::SemanticFrame, + keybindings: crate::protocol::ClientKeybindings::Server, + launch_mode: crate::protocol::ClientLaunchMode::App, + }; + + match crate::protocol::write_message(&mut stream, &hello) { + Ok(()) => Ok(true), + Err(crate::protocol::FramingError::Io(err)) + if matches!( + err.kind(), + io::ErrorKind::ConnectionRefused + | io::ErrorKind::NotFound + | io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock + | io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + ) => + { + Ok(false) } + Err(err) => Err(io::Error::other(err.to_string())), } } -fn read_server_status() -> io::Result<Option<crate::api::RuntimeStatus>> { - crate::api::read_runtime_status_at(&crate::api::socket_path(), STATUS_REQUEST_TIMEOUT) -} - fn validate_running_server_compatibility() -> io::Result<()> { let Some(status) = read_server_status()? else { return Err(io::Error::other(format!( @@ -149,13 +214,25 @@ fn build_server_daemon_command(exe: PathBuf) -> Command { let mut command = Command::new(&exe); command .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()); + #[cfg(unix)] + { + // Create a new process group so the server survives the parent's exit + // and doesn't receive SIGHUP when the client's terminal closes. + command.process_group(0); + } + + match std::env::current_dir() { + Ok(cwd) => { + command.env(STARTUP_CWD_ENV_VAR, cwd); + } + Err(_) => { + command.env_remove(STARTUP_CWD_ENV_VAR); + } + } if crate::session::explicit_session_requested() { command @@ -179,6 +256,13 @@ pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Resu let deadline = std::time::Instant::now() + timeout; while std::time::Instant::now() < deadline { + #[cfg(windows)] + if client_protocol_accepts_hello(socket_path)? { + info!(path = %socket_path.display(), "server client protocol ready"); + return Ok(()); + } + + #[cfg(not(windows))] if is_server_listening_at(socket_path) { info!(path = %socket_path.display(), "server socket ready"); return Ok(()); @@ -232,7 +316,7 @@ pub fn auto_detect_launch() -> io::Result<()> { // Tests // --------------------------------------------------------------------------- -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; use std::ffi::OsStr; @@ -290,6 +374,17 @@ mod tests { } #[test] + fn server_daemon_command_passes_current_dir_as_startup_cwd() { + let expected = std::env::current_dir().unwrap(); + let command = build_server_daemon_command(PathBuf::from("/tmp/herdr-test")); + let envs: Vec<_> = command.get_envs().collect(); + + assert!(envs.iter().any(|(key, value)| { + *key == OsStr::new(STARTUP_CWD_ENV_VAR) && value == &Some(expected.as_os_str()) + })); + } + + #[test] fn is_server_listening_returns_true_for_live_socket() { let dir = unique_test_dir("live"); std::fs::create_dir_all(&dir).unwrap(); diff --git a/src/server/client_accept.rs b/src/server/client_accept.rs index afb2c398..ccfe7b1a 100644 --- a/src/server/client_accept.rs +++ b/src/server/client_accept.rs @@ -1,22 +1,23 @@ use std::io; -use std::os::unix::net::UnixListener; use std::sync::{atomic::AtomicBool, Arc}; +use interprocess::local_socket::traits::{Listener as _, Stream as _}; use tokio::sync::mpsc; use tracing::{debug, error, warn}; +use crate::ipc::LocalListener; use crate::server::client_transport::{self, ServerEvent}; /// Accepts pending thin-client connections and starts their handshake readers. pub(crate) fn accept_pending_client_connections( - listener: &UnixListener, + listener: &LocalListener, next_client_id: &mut u64, should_quit: &Arc<AtomicBool>, server_event_tx: &mpsc::Sender<ServerEvent>, ) -> io::Result<()> { loop { match listener.accept() { - Ok((stream, _addr)) => { + Ok(stream) => { let client_id = *next_client_id; *next_client_id = next_client_id.saturating_add(1); @@ -53,10 +54,10 @@ pub(crate) fn accept_pending_client_connections( /// /// During live handoff the old server must not let clients sit in the Unix /// listener backlog waiting for a welcome frame that will never be sent. -pub(crate) fn reject_pending_client_connections(listener: &UnixListener) -> io::Result<()> { +pub(crate) fn reject_pending_client_connections(listener: &LocalListener) -> io::Result<()> { loop { match listener.accept() { - Ok((_stream, _addr)) => {} + Ok(_stream) => {} Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => break, Err(err) => { error!(err = %err, "client listener reject failed"); diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 931f1100..4f0f12bc 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -5,18 +5,20 @@ //! `HeadlessServer`. use std::io::{self, Write}; -use std::os::unix::net::UnixStream; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; +use interprocess::local_socket::traits::Stream as _; +use interprocess::TryClone as _; use tokio::sync::mpsc; use tracing::{debug, warn}; +use crate::ipc::LocalStream; use crate::protocol::{ - self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode, - ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, - MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, AttachScrollDirection, AttachScrollSource, ClientInputEvent, ClientKeybindings, + ClientLaunchMode, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, + MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; /// Minimum accepted attached client size. @@ -35,6 +37,8 @@ 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 +/// Maximum structured input events accepted in one client message. +const MAX_INPUT_EVENT_BATCH: usize = 4096; /// Channels owned by the server side of a client writer thread. #[derive(Clone, Debug)] @@ -62,6 +66,11 @@ pub(crate) enum ServerEvent { }, /// A client sent an input message. ClientInput { client_id: u64, data: Vec<u8> }, + /// A client sent structured input events. + ClientInputEvents { + client_id: u64, + events: Vec<crate::protocol::ClientInputEvent>, + }, /// A client sent local clipboard image bytes to paste into a remote pane. ClientClipboardImage { client_id: u64, @@ -126,12 +135,30 @@ fn parse_client_keybindings( } } +fn input_events_within_limits(events: &[ClientInputEvent]) -> bool { + if events.len() > MAX_INPUT_EVENT_BATCH { + return false; + } + + let mut paste_bytes = 0usize; + for event in events { + if let ClientInputEvent::Paste { text } = event { + paste_bytes = paste_bytes.saturating_add(text.len()); + if paste_bytes > MAX_INPUT_PAYLOAD { + return false; + } + } + } + + true +} + /// 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. pub(crate) fn handle_client_handshake( - mut stream: UnixStream, + mut stream: LocalStream, client_id: u64, server_event_tx: &mpsc::Sender<ServerEvent>, should_quit: &Arc<AtomicBool>, @@ -140,8 +167,9 @@ pub(crate) fn handle_client_handshake( // 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))?; + if let Err(err) = stream.set_recv_timeout(Some(HANDSHAKE_TIMEOUT)) { + debug!(client_id, err = %err, "client handshake read timeout unavailable"); + } // Read the Hello message. let hello: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) { @@ -240,8 +268,9 @@ pub(crate) fn handle_client_handshake( }; protocol::write_message(&mut stream, &welcome).map_err(|e| io::Error::other(e.to_string()))?; - // Clear read timeout for normal operation. - stream.set_read_timeout(None)?; + if let Err(err) = stream.set_recv_timeout(None) { + debug!(client_id, err = %err, "failed to clear client handshake read timeout"); + } // Create separate channels for reliable control messages and droppable renders. let (control_tx, control_rx) = std::sync::mpsc::channel::<Vec<u8>>(); @@ -283,7 +312,7 @@ pub(crate) fn handle_client_handshake( /// The client writer loop — prioritizes control messages over render frames. fn client_writer_loop( - mut stream: UnixStream, + mut stream: LocalStream, client_id: u64, control_rx: std::sync::mpsc::Receiver<Vec<u8>>, render_rx: std::sync::mpsc::Receiver<Vec<u8>>, @@ -349,7 +378,7 @@ fn client_writer_loop( debug!("client writer thread exiting"); } -fn write_framed_bytes(stream: &mut UnixStream, data: &[u8]) -> bool { +fn write_framed_bytes(stream: &mut LocalStream, data: &[u8]) -> bool { if let Err(err) = stream.write_all(data) { debug!(err = %err, "client write failed, closing writer"); return false; @@ -363,7 +392,7 @@ fn write_framed_bytes(stream: &mut UnixStream, data: &[u8]) -> bool { /// The client read loop — reads messages from the client and forwards to the server event channel. fn client_read_loop( - mut stream: UnixStream, + mut stream: LocalStream, client_id: u64, server_event_tx: &mpsc::Sender<ServerEvent>, should_quit: &Arc<AtomicBool>, @@ -411,6 +440,20 @@ fn client_read_loop( ServerEvent::ClientInput { client_id, data } } } + ClientMessage::InputEvents { events } => { + if !input_events_within_limits(&events) { + warn!( + client_id, + count = events.len(), + "oversized input events from client, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } else { + ServerEvent::ClientInputEvents { client_id, events } + } + } ClientMessage::ClipboardImage { extension, data } => { if data.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { warn!( @@ -487,6 +530,23 @@ fn client_read_loop( #[cfg(test)] mod tests { use super::*; + use interprocess::local_socket::traits::Listener as _; + + fn unique_test_path(name: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) + } + + fn local_stream_pair(name: &str) -> (LocalStream, LocalStream, std::path::PathBuf) { + let path = unique_test_path(name); + let listener = crate::ipc::bind_local_listener(&path).unwrap(); + let client = crate::ipc::connect_local_stream(&path).unwrap(); + let server = listener.accept().unwrap(); + (client, server, path) + } #[test] fn clamp_terminal_size_zero_zero() { @@ -569,7 +629,7 @@ new_tab = "ctrl+notakey" #[test] fn handshake_negotiates_terminal_ansi_encoding() { - let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair"); + let (mut client_stream, server_stream, _path) = local_stream_pair("client-handshake-ansi"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); let should_quit = Arc::new(AtomicBool::new(false)); let handshake_quit = should_quit.clone(); @@ -643,7 +703,8 @@ new_tab = "ctrl+notakey" #[test] fn handshake_marks_terminal_attach_launch_mode() { - let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair"); + let (mut client_stream, server_stream, _path) = + local_stream_pair("client-handshake-terminal-attach"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); let should_quit = Arc::new(AtomicBool::new(false)); let handshake_quit = should_quit.clone(); @@ -706,7 +767,7 @@ new_tab = "ctrl+notakey" #[test] fn client_read_loop_rejects_oversized_input() { - let (mut client_stream, server_stream) = UnixStream::pair().expect("socket pair"); + let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-oversized"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); let should_quit = Arc::new(AtomicBool::new(false)); let read_quit = should_quit.clone(); @@ -739,6 +800,126 @@ new_tab = "ctrl+notakey" } #[test] + fn client_read_loop_forwards_input_events() { + let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-events"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let read_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + client_read_loop(server_stream, 7, &server_event_tx, &read_quit) + }); + let events = vec![ + ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + }, + ClientInputEvent::FocusGained, + ]; + + protocol::write_message( + &mut client_stream, + &ClientMessage::InputEvents { + events: events.clone(), + }, + ) + .expect("write input events"); + + match server_event_rx + .blocking_recv() + .expect("client input events event") + { + ServerEvent::ClientInputEvents { + client_id, + events: actual, + } => { + assert_eq!(client_id, 7); + assert_eq!(actual, events); + } + other => panic!("expected ClientInputEvents, got {other:?}"), + } + + drop(client_stream); + should_quit.store(true, Ordering::Release); + handle + .join() + .expect("read thread join") + .expect("read thread result"); + } + + #[test] + fn client_read_loop_rejects_oversized_input_event_batch() { + let (mut client_stream, server_stream, _path) = + local_stream_pair("client-read-oversized-events"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let read_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + client_read_loop(server_stream, 7, &server_event_tx, &read_quit) + }); + + protocol::write_message( + &mut client_stream, + &ClientMessage::InputEvents { + events: vec![ClientInputEvent::FocusGained; MAX_INPUT_EVENT_BATCH + 1], + }, + ) + .expect("write oversized input events"); + + match server_event_rx + .blocking_recv() + .expect("client disconnected event") + { + ServerEvent::ClientDisconnected { client_id } => assert_eq!(client_id, 7), + other => panic!("expected ClientDisconnected, got {other:?}"), + } + + drop(client_stream); + should_quit.store(true, Ordering::Release); + handle + .join() + .expect("read thread join") + .expect("read thread result"); + } + + #[test] + fn client_read_loop_rejects_oversized_input_event_paste() { + let (mut client_stream, server_stream, _path) = + local_stream_pair("client-read-oversized-paste"); + let (server_event_tx, mut server_event_rx) = mpsc::channel(4); + let should_quit = Arc::new(AtomicBool::new(false)); + let read_quit = should_quit.clone(); + let handle = std::thread::spawn(move || { + client_read_loop(server_stream, 7, &server_event_tx, &read_quit) + }); + + protocol::write_message( + &mut client_stream, + &ClientMessage::InputEvents { + events: vec![ClientInputEvent::Paste { + text: "x".repeat(MAX_INPUT_PAYLOAD + 1), + }], + }, + ) + .expect("write oversized paste event"); + + match server_event_rx + .blocking_recv() + .expect("client disconnected event") + { + ServerEvent::ClientDisconnected { client_id } => assert_eq!(client_id, 7), + other => panic!("expected ClientDisconnected, got {other:?}"), + } + + drop(client_stream); + should_quit.store(true, Ordering::Release); + handle + .join() + .expect("read thread join") + .expect("read thread result"); + } + + #[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 diff --git a/src/server/clipboard_image.rs b/src/server/clipboard_image.rs index 299267d3..70072f2d 100644 --- a/src/server/clipboard_image.rs +++ b/src/server/clipboard_image.rs @@ -15,8 +15,6 @@ pub(crate) fn stage( extension: &str, data: &[u8], ) -> io::Result<StagedClipboardImage> { - use std::os::unix::fs::OpenOptionsExt; - let extension = sanitize_extension(extension); let dir = ensure_staging_dir()?; cleanup_stale(&dir); @@ -30,12 +28,10 @@ pub(crate) fn stage( let path = dir.join(format!( "client-{client_id}-clipboard-{unique}-{attempt}.{extension}" )); - let mut file = match fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&path) - { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + restrict_file_options(&mut options); + let mut file = match options.open(&path) { Ok(file) => file, Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, Err(err) => return Err(err), @@ -76,13 +72,14 @@ fn sanitize_extension(extension: &str) -> &'static str { } fn staging_dir() -> PathBuf { + #[cfg(unix)] let user_id = unsafe { libc::geteuid() }; + #[cfg(windows)] + let user_id = std::process::id(); std::env::temp_dir().join(format!("herdr-clipboard-images-{user_id}")) } fn ensure_staging_dir() -> io::Result<PathBuf> { - use std::os::unix::fs::PermissionsExt; - let dir = staging_dir(); fs::create_dir_all(&dir)?; let metadata = fs::metadata(&dir)?; @@ -92,10 +89,32 @@ fn ensure_staging_dir() -> io::Result<PathBuf> { dir.display() ))); } - fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?; + restrict_dir_permissions(&dir)?; Ok(dir) } +#[cfg(unix)] +fn restrict_file_options(options: &mut fs::OpenOptions) { + use std::os::unix::fs::OpenOptionsExt; + + options.mode(0o600); +} + +#[cfg(windows)] +fn restrict_file_options(_options: &mut fs::OpenOptions) {} + +#[cfg(unix)] +fn restrict_dir_permissions(dir: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) +} + +#[cfg(windows)] +fn restrict_dir_permissions(_dir: &Path) -> io::Result<()> { + Ok(()) +} + fn cleanup_stale(dir: &Path) { let Ok(entries) = fs::read_dir(dir) else { return; diff --git a/src/server/headless.rs b/src/server/headless.rs index c78df8a1..f313161f 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -16,13 +16,17 @@ use std::collections::HashMap; use std::io; -use std::os::unix::net::UnixListener; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use crossterm::event::{KeyModifiers, MouseEventKind}; +use interprocess::local_socket::traits::Listener as _; +#[cfg(windows)] +use interprocess::local_socket::traits::Stream as _; +#[cfg(unix)] +use interprocess::local_socket::ListenerNonblockingMode; use ratatui::layout::Rect; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; @@ -34,11 +38,15 @@ use crate::api; use crate::app; use crate::config; use crate::events::AppEvent; -use crate::ipc::{remove_socket_file_if_owned, socket_file_identity, SocketFileIdentity}; +use crate::ipc::{ + bind_local_listener, remove_socket_file_if_owned, socket_file_identity, LocalListener, + SocketFileIdentity, +}; use crate::protocol::{ self, AttachScrollDirection, AttachScrollSource, FrameData, ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, }; +#[cfg(unix)] use crate::server::client_accept::{ accept_pending_client_connections, reject_pending_client_connections, }; @@ -167,7 +175,7 @@ const MIN_ROWS: u16 = 24; #[allow(dead_code)] const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5); -/// How often the idle headless loop wakes to poll the std UnixListener for new +/// How often the idle headless loop wakes to poll the local listener for new /// client connections. /// /// The listener is non-blocking and not integrated into `tokio::select!`, so @@ -183,12 +191,16 @@ const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250); /// The headless server — runs the herdr event loop without a real terminal. pub struct HeadlessServer { app: app::App, + #[cfg(unix)] api_tx: Option<api::ApiRequestSender>, + #[cfg(unix)] api_server: Option<api::ServerHandle>, - client_listener: UnixListener, + #[cfg(unix)] + client_listener: LocalListener, client_socket_path: PathBuf, client_socket_identity: SocketFileIdentity, clients: HashMap<u64, ClientConnection>, + #[cfg(unix)] next_client_id: u64, /// The client currently driving the shared pane runtime size, theme, and input keybindings. foreground_client_id: Option<u64>, @@ -210,6 +222,7 @@ pub struct HeadlessServer { /// Flag set while exporting live PTYs to a replacement server. handoff_in_progress: bool, /// Imported panes get one app-safe resize nudge after the first client attaches. + #[cfg(unix)] pending_handoff_repaint_nudge: bool, /// Flag set by Ctrl+C or `server stop` signal. should_quit: Arc<AtomicBool>, @@ -292,6 +305,51 @@ fn apply_terminal_attach_input( .map_err(|err| format!("terminal attach input failed: {err}")) } +#[cfg(windows)] +fn spawn_windows_client_accept_thread( + listener: LocalListener, + should_quit: Arc<AtomicBool>, + server_event_tx: mpsc::Sender<ServerEvent>, +) { + std::thread::spawn(move || { + let mut next_client_id = 1_u64; + while !should_quit.load(Ordering::Acquire) { + let stream = match listener.accept() { + Ok(stream) => stream, + Err(err) => { + if should_quit.load(Ordering::Acquire) { + break; + } + error!(err = %err, "client listener accept failed"); + std::thread::sleep(Duration::from_millis(50)); + continue; + } + }; + + let client_id = next_client_id; + next_client_id = next_client_id.saturating_add(1); + + if let Err(err) = stream.set_nonblocking(true) { + warn!(err = %err, "failed to set client stream nonblocking"); + continue; + } + + let should_quit = should_quit.clone(); + let server_event_tx = server_event_tx.clone(); + std::thread::spawn(move || { + if let Err(err) = crate::server::client_transport::handle_client_handshake( + stream, + client_id, + &server_event_tx, + &should_quit, + ) { + debug!(client_id, err = %err, "client handshake failed"); + } + }); + } + }); +} + impl HeadlessServer { /// Creates and starts the headless server. /// @@ -308,30 +366,40 @@ impl HeadlessServer { let client_path = client_socket_path(); prepare_socket_path(&client_path)?; - let listener = UnixListener::bind(&client_path)?; + let listener = bind_local_listener(&client_path)?; restrict_socket_permissions(&client_path)?; let client_socket_identity = socket_file_identity(&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)?; + // Set non-blocking on Unix so we can poll it from the event loop. + #[cfg(unix)] + listener.set_nonblocking(ListenerNonblockingMode::Accept)?; 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); + #[cfg(windows)] + spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()); + let server_keybindings = app_keybindings(&app); let (server_config_diagnostic, server_config_diagnostic_without_keybindings) = server_config_diagnostic_summaries(config_diagnostics); + #[cfg(not(unix))] + let _ = (&api_tx, &api_server); Ok(Self { app, + #[cfg(unix)] api_tx, + #[cfg(unix)] api_server, + #[cfg(unix)] client_listener: listener, client_socket_path: client_path, client_socket_identity, clients: HashMap::new(), + #[cfg(unix)] next_client_id: 1, foreground_client_id: None, server_keybindings, @@ -342,6 +410,7 @@ impl HeadlessServer { effective_size: (MIN_COLS, MIN_ROWS), shutting_down: false, handoff_in_progress: false, + #[cfg(unix)] pending_handoff_repaint_nudge: false, should_quit, server_event_rx, @@ -891,7 +960,7 @@ impl HeadlessServer { } else { let _ = std::fs::remove_file(crate::api::socket_path()); } - let _ = remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity); + let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity); if let Err(err) = crate::server::handoff::wait_ready(&mut stream) { crate::server::handoff::cleanup_failed_import_child(&mut import_child); match self.wait_then_restore_public_sockets_after_failed_handoff() { @@ -972,10 +1041,10 @@ impl HeadlessServer { let client_path = client_socket_path(); prepare_socket_path(&client_path)?; - let listener = UnixListener::bind(&client_path)?; + let listener = bind_local_listener(&client_path)?; restrict_socket_permissions(&client_path)?; let client_socket_identity = socket_file_identity(&client_path)?; - listener.set_nonblocking(true)?; + listener.set_nonblocking(ListenerNonblockingMode::Accept)?; self.api_server = Some(api_server); self.client_listener = listener; @@ -1188,6 +1257,7 @@ impl HeadlessServer { } /// Accepts pending client connections from the non-blocking listener. + #[cfg(unix)] fn accept_client_connections(&mut self) -> io::Result<()> { if self.handoff_in_progress { return reject_pending_client_connections(&self.client_listener); @@ -1200,6 +1270,13 @@ impl HeadlessServer { ) } + /// Windows named-pipe clients can block in connect unless the server has a + /// pending blocking accept. The dedicated accept thread handles that path. + #[cfg(windows)] + fn accept_client_connections(&mut self) -> io::Result<()> { + Ok(()) + } + /// Drains server events from the dedicated channel. /// /// Returns true if any input was processed (requiring a re-render). @@ -1863,6 +1940,7 @@ impl HeadlessServer { } } + #[cfg(unix)] fn disconnect_all_clients_for_handoff(&mut self) { let client_ids = self.clients.keys().copied().collect::<Vec<_>>(); for client_id in client_ids { @@ -1961,6 +2039,68 @@ impl HeadlessServer { } /// Handles a server event. Returns true if the event requires a re-render. + fn handle_client_input_events( + &mut self, + client_id: u64, + events: Vec<crate::raw_input::RawInputEvent>, + ) -> bool { + let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw( + &events, + self.app.state.redraw_on_focus_gained, + ); + if let Some(client) = self.clients.get_mut(&client_id) { + if host_surface_redraw { + client.request_full_redraw(); + client.render_pending = true; + } else { + // Ensure semantic clients receive one post-input frame even if the + // semantic buffer compares equal. Terminal-ANSI clients must keep their + // server-side blit baseline; resetting it here forces a full redraw on + // every keypress and makes remote sessions feel extremely slow. + client.request_semantic_redraw_after_input(); + } + } + self.update_client_outer_focus_from_events(client_id, &events); + let interaction = 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_before_input(); + } + 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)); + if self.app.take_config_reloaded_from_disk() { + self.reload_server_config(false); + } else { + self.sync_foreground_client_state(); + } + + if self.app.state.detach_requested { + self.app.state.detach_requested = false; + info!(client_id, "client detach requested via keybind"); + + self.send_client_graphics_cleanup(client_id); + self.send_to_client( + client_id, + ServerMessage::ServerShutdown { + reason: Some("detached".to_owned()), + }, + ); + + if let Some(client) = self.clients.get_mut(&client_id) { + client.writer = None; + } + + false + } else { + foreground_changed || theme_changed || interaction + } + } + fn handle_server_event(&mut self, ev: ServerEvent) -> bool { if self.handoff_in_progress && Self::ignore_client_event_during_handoff(&ev) { return false; @@ -2079,78 +2219,27 @@ impl HeadlessServer { } else { Vec::new() }; - let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw( - &events, - self.app.state.redraw_on_focus_gained, - ); - if let Some(client) = self.clients.get_mut(&client_id) { - if host_surface_redraw { - client.request_full_redraw(); - client.render_pending = true; - } else { - // Ensure semantic clients receive one post-input frame even if the - // semantic buffer compares equal. Terminal-ANSI clients must keep their - // server-side blit baseline; resetting it here forces a full redraw on - // every keypress and makes remote sessions feel extremely slow. - client.request_semantic_redraw_after_input(); - } - } - self.update_client_outer_focus_from_events(client_id, &events); - let interaction = 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_before_input(); - } - 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)); - if self.app.take_config_reloaded_from_disk() { - self.reload_server_config(false); - } else { - self.sync_foreground_client_state(); - } - - // 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"); - - // Clear client-local host graphics before sending ServerShutdown - // so the outer terminal does not retain stale images. - self.send_client_graphics_cleanup(client_id); - - // 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( + self.handle_client_input_events(client_id, events) + } + ServerEvent::ClientInputEvents { client_id, events } => { + if self.handoff_in_progress { + debug!( client_id, - ServerMessage::ServerShutdown { - reason: Some("detached".to_owned()), - }, + len = events.len(), + "ignored client input events during handoff" ); - - // 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 + return false; } + debug!( + client_id, + len = events.len(), + "client input events received" + ); + let events = events + .iter() + .map(crate::protocol::ClientInputEvent::to_raw_input_event) + .collect(); + self.handle_client_input_events(client_id, events) } ServerEvent::ClientClipboardImage { client_id, @@ -3241,7 +3330,7 @@ impl HeadlessServer { /// Removes socket files created by the server. fn cleanup_sockets(&self) -> io::Result<()> { if let Err(err) = - remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity) + remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity) { if err.kind() != io::ErrorKind::NotFound { warn!( @@ -3389,6 +3478,7 @@ pub fn run_server() -> io::Result<()> { api_rx, event_hub, ); + seed_startup_workspace_if_empty(&mut app); // The server runs headless — disable local notification side effects. // Sound and terminal notifications are forwarded to connected clients @@ -3427,6 +3517,36 @@ pub fn run_server() -> io::Result<()> { result } +fn seed_startup_workspace_if_empty(app: &mut app::App) { + let Some(cwd) = take_startup_cwd() else { + return; + }; + + if !app.state.workspaces.is_empty() { + info!( + cwd = %cwd.display(), + "restored session already has workspaces; ignoring startup cwd" + ); + return; + } + + match app.create_workspace_with_options(cwd.clone(), true) { + Ok(_) => { + info!(cwd = %cwd.display(), "created startup workspace"); + } + Err(err) => { + warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace"); + app.state.mode = app::Mode::Navigate; + } + } +} + +fn take_startup_cwd() -> Option<PathBuf> { + let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?; + std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR); + (!cwd.is_empty()).then(|| PathBuf::from(cwd)) +} + #[cfg(unix)] fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> { let loaded_config = config::Config::load(); @@ -3499,14 +3619,13 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> #[cfg(unix)] fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> { - use std::os::unix::net::UnixStream; - let deadline = Instant::now() + timeout; let api_socket = api::socket_path(); let client_socket = client_socket_path(); while Instant::now() < deadline { - let api_open = api_socket.exists() && UnixStream::connect(&api_socket).is_ok(); - let client_open = client_socket.exists() && UnixStream::connect(&client_socket).is_ok(); + let api_open = api_socket.exists() && crate::ipc::connect_local_stream(&api_socket).is_ok(); + let client_open = + client_socket.exists() && crate::ipc::connect_local_stream(&client_socket).is_ok(); if !api_open && !client_open { return Ok(()); } @@ -3570,23 +3689,32 @@ mod tests { let _ = fs::create_dir_all(&dir); let socket_path = dir.join("client.sock"); let _ = fs::remove_file(&socket_path); - let listener = UnixListener::bind(&socket_path).expect("bind test listener"); + let listener = bind_local_listener(&socket_path).expect("bind test listener"); let client_socket_identity = socket_file_identity(&socket_path).expect("test listener socket identity"); + #[cfg(unix)] listener - .set_nonblocking(true) + .set_nonblocking(ListenerNonblockingMode::Accept) .expect("set listener nonblocking"); let (server_event_tx, server_event_rx) = mpsc::channel(64); + #[cfg(windows)] + let should_quit = Arc::new(AtomicBool::new(false)); + #[cfg(windows)] + spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()); let server_keybindings = app_keybindings(&app); HeadlessServer { app, + #[cfg(unix)] api_tx: None, + #[cfg(unix)] api_server: None, + #[cfg(unix)] client_listener: listener, client_socket_path: socket_path, client_socket_identity, clients: HashMap::new(), + #[cfg(unix)] next_client_id: 1, foreground_client_id: None, server_keybindings, @@ -3597,8 +3725,12 @@ mod tests { effective_size: (MIN_COLS, MIN_ROWS), shutting_down: false, handoff_in_progress: false, + #[cfg(unix)] pending_handoff_repaint_nudge: false, + #[cfg(unix)] should_quit: Arc::new(AtomicBool::new(false)), + #[cfg(windows)] + should_quit, server_event_rx, server_event_tx, } @@ -5186,6 +5318,107 @@ next_tab = "" assert_eq!(server.app.state.mode, crate::app::Mode::Terminal); } + #[test] + fn semantic_client_input_events_route_through_app_input() { + let mut server = test_headless_server(); + server.app.state.mode = crate::app::Mode::Onboarding; + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + Some(true), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + assert!(server.handle_server_event(ServerEvent::ClientInputEvents { + client_id: 1, + events: vec![crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + }], + })); + + assert_eq!(server.app.state.mode, crate::app::Mode::Settings); + assert_eq!( + server.app.state.settings.section, + crate::app::state::SettingsSection::Integrations + ); + } + + #[test] + fn semantic_client_escape_closes_keybind_help() { + let mut server = test_headless_server(); + server.app.state.mode = crate::app::Mode::KeybindHelp; + server.clients.insert( + 1, + ClientConnection::new( + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + Some(true), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + + assert!(server.handle_server_event(ServerEvent::ClientInputEvents { + client_id: 1, + events: vec![crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Esc, + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + }], + })); + + assert_eq!(server.app.state.mode, crate::app::Mode::Navigate); + } + + #[test] + fn semantic_client_down_scrolls_keybind_help() { + let mut server = test_headless_server(); + server.app.state.mode = crate::app::Mode::KeybindHelp; + server.clients.insert( + 1, + ClientConnection::new( + (100, 30), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + Some(true), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + server.resize_shared_runtime_to_effective_size(); + + assert!(server.app.state.keybind_help_max_scroll() > 0); + assert!(server.handle_server_event(ServerEvent::ClientInputEvents { + client_id: 1, + events: vec![crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Down, + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + }], + })); + + assert_eq!(server.app.state.mode, crate::app::Mode::KeybindHelp); + assert_eq!(server.app.state.keybind_help.scroll, 1); + } + #[tokio::test] async fn split_default_background_response_updates_theme_without_forwarding_tail() { let mut server = test_headless_server(); diff --git a/src/server/mod.rs b/src/server/mod.rs index 3386d0d7..58b552e2 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,4 +1,5 @@ pub mod autodetect; +#[cfg(unix)] pub(crate) mod client_accept; pub(crate) mod client_transport; pub(crate) mod clients; diff --git a/src/server/notifications.rs b/src/server/notifications.rs index f0b2d746..711087f5 100644 --- a/src/server/notifications.rs +++ b/src/server/notifications.rs @@ -64,10 +64,14 @@ fn toast_event_text(kind: app::state::ToastKind) -> &'static str { #[cfg(test)] mod tests { + #[cfg(unix)] use super::*; + #[cfg(unix)] use crate::detect::Agent; + #[cfg(unix)] use crate::terminal::TerminalState; + #[cfg(unix)] fn init_repo(path: &std::path::Path) { let status = std::process::Command::new("git") .args(["init", "-q"]) @@ -77,6 +81,7 @@ mod tests { assert!(status.success(), "git init failed for {}", path.display()); } + #[cfg(unix)] #[tokio::test] async fn toast_message_uses_live_root_runtime_cwd_label() { let mut state = AppState::test_new(); diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index 97baf38d..e158a6d4 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -351,6 +351,9 @@ pub(crate) fn focused_terminal_cursor( .pane_infos .iter() .find(|info| info.is_focused)?; + if !app_state.pane_exposes_host_cursor(ws_idx, info.id) { + return None; + } let rt = app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?; let scrolled_back = crate::ui::pane_is_scrolled_back(rt); diff --git a/src/server/socket_paths.rs b/src/server/socket_paths.rs index 1630ae7c..8780f932 100644 --- a/src/server/socket_paths.rs +++ b/src/server/socket_paths.rs @@ -82,7 +82,7 @@ pub(crate) fn restrict_socket_permissions(path: &Path) -> io::Result<()> { crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; use std::fs; |
