use std::io::{self, Read, 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 tracing::{debug, error, info, warn}; #[cfg(test)] use std::fs; use crate::api::schema::{ ErrorBody, ErrorResponse, Method, Request, ResponseResult, ServerCapabilities, SuccessResponse, }; use crate::api::subscriptions::ActiveSubscription; use crate::api::wait::wait_for_output; use crate::api::{request_changes_ui, socket_path, ApiRequestMessage, ApiRequestSender, EventHub}; use crate::ipc::{remove_socket_file_if_owned, socket_file_identity, SocketFileIdentity}; const SOCKET_PERMISSION_MODE: u32 = 0o600; pub(super) const CONNECTION_POLL_INTERVAL: Duration = Duration::from_millis(100); pub(super) const APP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); const INITIAL_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5); const MAX_INITIAL_REQUEST_BYTES: usize = 1024 * 1024; pub struct ServerHandle { _thread: std::thread::JoinHandle<()>, path: PathBuf, identity: SocketFileIdentity, running: Arc, } impl Drop for ServerHandle { fn drop(&mut self) { self.running.store(false, Ordering::Relaxed); if let Err(err) = self.remove_socket_file_if_owned() { if err.kind() != std::io::ErrorKind::NotFound { warn!(path = %self.path.display(), err = %err, "failed to remove api socket on shutdown"); } } } } impl ServerHandle { pub(crate) fn remove_socket_file_if_owned(&self) -> std::io::Result<()> { remove_socket_file_if_owned(&self.path, self.identity) } } pub fn start_server( api_tx: ApiRequestSender, event_hub: EventHub, ) -> std::io::Result { start_server_with_capabilities( api_tx, event_hub, Some(ServerCapabilities { live_handoff: true }), ) } pub fn start_server_with_capabilities( api_tx: ApiRequestSender, event_hub: EventHub, capabilities: Option, ) -> std::io::Result { let path = socket_path(); prepare_socket_path(&path)?; let listener = UnixListener::bind(&path)?; restrict_socket_permissions(&path)?; let identity = socket_file_identity(&path)?; info!(path = %path.display(), "api server listening"); let running = Arc::new(AtomicBool::new(true)); let listener_running = Arc::clone(&running); let thread = std::thread::spawn(move || { for stream in listener.incoming() { match stream { Ok(stream) => { let api_tx = api_tx.clone(); let event_hub = event_hub.clone(); let capabilities = capabilities.clone(); let connection_running = Arc::clone(&listener_running); std::thread::spawn(move || { if let Err(err) = handle_connection( stream, &api_tx, &event_hub, &connection_running, capabilities, ) { warn!(err = %err, "api connection failed"); } }); } Err(err) => { error!(err = %err, "api listener accept failed"); break; } } } debug!("api server thread exiting"); }); Ok(ServerHandle { _thread: thread, path, identity, running, }) } fn prepare_socket_path(path: &Path) -> std::io::Result<()> { crate::ipc::prepare_socket_path(path, |path| { format!( "herdr is already running (socket busy at {})", path.display() ) }) } fn restrict_socket_permissions(path: &Path) -> std::io::Result<()> { crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE) } fn handle_connection( mut stream: UnixStream, api_tx: &ApiRequestSender, event_hub: &EventHub, running: &Arc, capabilities: Option, ) -> std::io::Result<()> { if let Err(err) = stream.set_write_timeout(Some(STREAM_WRITE_TIMEOUT)) { debug!(err = %err, "api connection write timeout unavailable"); } let Some(line) = read_initial_request_line(&mut stream)? else { return Ok(()); }; let line = line.trim(); if line.is_empty() { return Ok(()); } let request = match serde_json::from_str::(line) { Ok(request) => request, Err(err) => { write_json_line_allow_disconnect( &mut stream, &ErrorResponse { id: String::new(), error: ErrorBody { code: "invalid_request".into(), message: format!("invalid request: {err}"), }, }, )?; return Ok(()); } }; let request_id = request.id.clone(); let method = api_method_name(&request.method); let changes_ui = request_changes_ui(&request); crate::logging::api_request_started(&request_id, method, changes_ui); match request.method { Method::EventsSubscribe(params) => { let result = stream_subscriptions( stream, request_id.clone(), params, api_tx, event_hub, running, ); match &result { Ok(()) => crate::logging::api_request_completed( &request_id, method, "stream_closed", changes_ui, ), Err(err) => { crate::logging::api_request_failed(&request_id, method, &err.to_string()) } } result } Method::PaneWaitForOutput(params) => { let Some(response) = wait_for_output(request_id.clone(), params, &mut stream, api_tx, running)? else { crate::logging::api_request_completed( &request_id, method, "client_disconnected", changes_ui, ); return Ok(()); }; let result = write_text_line_allow_disconnect(&mut stream, &response); match &result { Ok(()) => crate::logging::api_request_completed( &request_id, method, api_response_outcome(&response), changes_ui, ), Err(err) => { crate::logging::api_request_failed(&request_id, method, &err.to_string()) } } result } method_body => { let response = handle_request( Request { id: request_id.clone(), method: method_body, }, api_tx, capabilities, ); let result = write_text_line_allow_disconnect(&mut stream, &response); match &result { Ok(()) => crate::logging::api_request_completed( &request_id, method, api_response_outcome(&response), changes_ui, ), Err(err) => { crate::logging::api_request_failed(&request_id, method, &err.to_string()) } } result } } } fn handle_request( request: Request, api_tx: &ApiRequestSender, capabilities: Option, ) -> String { match request.method { Method::Ping(_) => serde_json::to_string(&SuccessResponse { id: request.id, result: ResponseResult::Pong { version: env!("CARGO_PKG_VERSION").into(), protocol: crate::protocol::PROTOCOL_VERSION, capabilities, }, }) .unwrap_or_else(|_| { r#"{"id":"","error":{"code":"internal_error","message":"failed to encode response"}}"# .to_string() }), _ => dispatch_to_app(request, api_tx), } } fn api_method_name(method: &Method) -> &'static str { match method { Method::Ping(_) => "ping", Method::ServerStop(_) => "server.stop", Method::ServerLiveHandoff(_) => "server.live_handoff", Method::ServerReloadConfig(_) => "server.reload_config", Method::WorkspaceCreate(_) => "workspace.create", Method::WorkspaceList(_) => "workspace.list", Method::WorkspaceGet(_) => "workspace.get", Method::WorkspaceFocus(_) => "workspace.focus", Method::WorkspaceRename(_) => "workspace.rename", Method::WorkspaceClose(_) => "workspace.close", Method::WorktreeList(_) => "worktree.list", Method::WorktreeCreate(_) => "worktree.create", Method::WorktreeOpen(_) => "worktree.open", Method::WorktreeRemove(_) => "worktree.remove", Method::TabCreate(_) => "tab.create", Method::TabList(_) => "tab.list", Method::TabGet(_) => "tab.get", Method::TabFocus(_) => "tab.focus", Method::TabRename(_) => "tab.rename", Method::TabClose(_) => "tab.close", Method::AgentList(_) => "agent.list", Method::AgentGet(_) => "agent.get", Method::AgentRead(_) => "agent.read", Method::AgentSend(_) => "agent.send", Method::AgentRename(_) => "agent.rename", Method::AgentFocus(_) => "agent.focus", Method::AgentStart(_) => "agent.start", Method::PaneSplit(_) => "pane.split", Method::PaneList(_) => "pane.list", Method::PaneGet(_) => "pane.get", Method::PaneRename(_) => "pane.rename", Method::PaneSendText(_) => "pane.send_text", Method::PaneSendKeys(_) => "pane.send_keys", Method::PaneSendInput(_) => "pane.send_input", Method::PaneRead(_) => "pane.read", Method::PaneReportAgent(_) => "pane.report_agent", Method::PaneReportAgentSession(_) => "pane.report_agent_session", Method::PaneReportMetadata(_) => "pane.report_metadata", Method::PaneClearAgentAuthority(_) => "pane.clear_agent_authority", Method::PaneReleaseAgent(_) => "pane.release_agent", Method::PaneClose(_) => "pane.close", Method::EventsSubscribe(_) => "events.subscribe", Method::EventsWait(_) => "events.wait", Method::PaneWaitForOutput(_) => "pane.wait_for_output", Method::IntegrationInstall(_) => "integration.install", Method::IntegrationUninstall(_) => "integration.uninstall", } } fn api_response_outcome(response: &str) -> &'static str { let Ok(value) = serde_json::from_str::(response) else { return "error"; }; match value .get("error") .and_then(|error| error.get("code")) .and_then(|code| code.as_str()) { Some("timeout") => "timeout", Some(_) => "error", None => "ok", } } fn read_initial_request_line(stream: &mut UnixStream) -> std::io::Result> { stream.set_nonblocking(true)?; let deadline = Instant::now() + INITIAL_REQUEST_TIMEOUT; let mut bytes = Vec::new(); let mut byte = [0u8; 1]; loop { match stream.read(&mut byte) { Ok(0) => { stream.set_nonblocking(false)?; return Ok(None); } Ok(_) => { bytes.push(byte[0]); if byte[0] == b'\n' { stream.set_nonblocking(false)?; return String::from_utf8(bytes) .map(Some) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)); } if bytes.len() > MAX_INITIAL_REQUEST_BYTES { stream.set_nonblocking(false)?; return Err(io::Error::new( io::ErrorKind::InvalidData, "api request line is too large", )); } } Err(err) if err.kind() == io::ErrorKind::WouldBlock => { if Instant::now() >= deadline { stream.set_nonblocking(false)?; return Err(io::Error::new( io::ErrorKind::TimedOut, "timed out reading api request", )); } std::thread::sleep(CONNECTION_POLL_INTERVAL); } Err(err) => { stream.set_nonblocking(false)?; return Err(err); } } } } fn stream_subscriptions( mut stream: UnixStream, request_id: String, params: crate::api::schema::EventsSubscribeParams, api_tx: &ApiRequestSender, event_hub: &EventHub, running: &Arc, ) -> std::io::Result<()> { let mut subscriptions = Vec::with_capacity(params.subscriptions.len()); for (index, subscription) in params.subscriptions.into_iter().enumerate() { let active = match ActiveSubscription::new(subscription, &request_id, index, api_tx, event_hub) { Ok(active) => active, Err(response) => { if let Err(err) = write_json_line(&mut stream, &response) { if is_connection_closed_error(&err) { return Ok(()); } return Err(err); } return Ok(()); } }; subscriptions.push(active); } if let Err(err) = write_json_line( &mut stream, &SuccessResponse { id: request_id, result: ResponseResult::SubscriptionStarted {}, }, ) { if is_connection_closed_error(&err) { return Ok(()); } return Err(err); } loop { if should_stop_connection(&mut stream, running)? { return Ok(()); } for subscription in &mut subscriptions { if let Some(event) = subscription.poll(api_tx, event_hub) { if let Err(err) = write_json_line(&mut stream, &event) { if is_connection_closed_error(&err) { return Ok(()); } return Err(err); } } } std::thread::sleep(CONNECTION_POLL_INTERVAL); } } fn write_text_line(stream: &mut UnixStream, value: &str) -> std::io::Result<()> { stream.write_all(value.as_bytes())?; stream.write_all(b"\n")?; stream.flush() } fn write_text_line_allow_disconnect(stream: &mut UnixStream, value: &str) -> std::io::Result<()> { match write_text_line(stream, value) { Err(err) if is_connection_closed_error(&err) => Ok(()), result => result, } } fn write_json_line(stream: &mut UnixStream, value: &T) -> std::io::Result<()> { let encoded = serde_json::to_string(value) .map_err(|err| std::io::Error::other(format!("failed to encode json: {err}")))?; write_text_line(stream, &encoded) } fn write_json_line_allow_disconnect( stream: &mut UnixStream, value: &T, ) -> std::io::Result<()> { let encoded = serde_json::to_string(value) .map_err(|err| std::io::Error::other(format!("failed to encode json: {err}")))?; write_text_line_allow_disconnect(stream, &encoded) } pub(super) fn should_stop_connection( stream: &mut UnixStream, running: &Arc, ) -> std::io::Result { if !running.load(Ordering::Relaxed) { return Ok(true); } probe_stream_closed(stream) } fn probe_stream_closed(stream: &mut UnixStream) -> std::io::Result { stream.set_nonblocking(true)?; let mut probe = [0u8; 1]; let status = match stream.read(&mut probe) { Ok(0) => Ok(true), Ok(_) => Ok(true), Err(err) if matches!( err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted ) => { Ok(false) } Err(err) if is_connection_closed_error(&err) => Ok(true), Err(err) => Err(err), }; stream.set_nonblocking(false)?; status } fn is_connection_closed_error(err: &std::io::Error) -> bool { matches!( err.kind(), std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionAborted | std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::NotConnected | std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::WriteZero ) } fn dispatch_to_app(request: Request, api_tx: &ApiRequestSender) -> String { dispatch_to_app_with_timeout(request, api_tx, None) } pub(super) fn dispatch_to_app_with_timeout( request: Request, api_tx: &ApiRequestSender, timeout: Option, ) -> String { let request_id = request.id.clone(); let (respond_to, response_rx) = std::sync::mpsc::channel(); if let Err(err) = api_tx.send(ApiRequestMessage { request, respond_to, }) { return error_response_json( request_id, "server_unavailable", format!("failed to dispatch request: {err}"), ); } let response = match timeout { Some(timeout) => response_rx.recv_timeout(timeout).map_err(|err| match err { std::sync::mpsc::RecvTimeoutError::Timeout => std::io::Error::new( std::io::ErrorKind::TimedOut, format!( "timed out waiting for app response after {} ms", timeout.as_millis() ), ), std::sync::mpsc::RecvTimeoutError::Disconnected => std::io::Error::new( std::io::ErrorKind::BrokenPipe, "app response channel closed", ), }), None => response_rx .recv() .map_err(|err| std::io::Error::new(std::io::ErrorKind::BrokenPipe, err)), }; match response { Ok(response) => response, Err(err) => error_response_json( request_id, "server_unavailable", format!("request handling failed: {err}"), ), } } fn error_response_json(id: String, code: &str, message: String) -> String { serde_json::to_string(&ErrorResponse { id, error: ErrorBody { code: code.into(), message, }, }) .unwrap_or_else(|_| { r#"{"id":"","error":{"code":"internal_error","message":"failed to encode error response"}}"# .to_string() }) } #[cfg(test)] mod tests { use super::*; use std::io::{BufRead, BufReader}; use std::os::unix::fs::PermissionsExt; use std::sync::{Mutex, OnceLock}; use tokio::sync::mpsc; fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } fn unique_test_path(name: &str) -> 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 read_line(stream: &mut UnixStream) -> String { let mut reader = BufReader::new(stream); let mut line = String::new(); reader.read_line(&mut line).unwrap(); line } #[test] fn socket_path_prefers_explicit_env_override() { let _guard = env_lock().lock().unwrap(); let unique = format!("/tmp/herdr-test-{}.sock", std::process::id()); std::env::remove_var(crate::session::SESSION_ENV_VAR); crate::session::clear_explicit_session_for_test(); std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, &unique); assert_eq!(socket_path(), PathBuf::from(&unique)); std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); } #[test] fn socket_path_defaults_to_config_dir_even_when_xdg_runtime_dir_is_set() { let _guard = env_lock().lock().unwrap(); let config_home = unique_test_path("socket-default-config-home"); let runtime_dir = unique_test_path("socket-default-runtime"); std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); std::env::remove_var(crate::session::SESSION_ENV_VAR); crate::session::clear_explicit_session_for_test(); std::env::set_var("XDG_CONFIG_HOME", &config_home); std::env::set_var("XDG_RUNTIME_DIR", &runtime_dir); let expected = config_home .join(crate::config::app_dir_name()) .join("herdr.sock"); assert_eq!(socket_path(), expected); std::env::remove_var("XDG_CONFIG_HOME"); std::env::remove_var("XDG_RUNTIME_DIR"); } #[test] fn socket_path_uses_named_session_dir() { let _guard = env_lock().lock().unwrap(); let config_home = unique_test_path("socket-named-config-home"); std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); crate::session::clear_explicit_session_for_test(); std::env::set_var(crate::session::SESSION_ENV_VAR, "work"); std::env::set_var("XDG_CONFIG_HOME", &config_home); let expected = config_home .join(crate::config::app_dir_name()) .join("sessions") .join("work") .join("herdr.sock"); assert_eq!(socket_path(), expected); std::env::remove_var(crate::session::SESSION_ENV_VAR); std::env::remove_var("XDG_CONFIG_HOME"); } #[test] fn restrict_socket_permissions_sets_user_only_mode() { let dir = unique_test_path("socket-perms"); fs::create_dir_all(&dir).unwrap(); let path = dir.join("api.sock"); let _listener = UnixListener::bind(&path).unwrap(); restrict_socket_permissions(&path).unwrap(); let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, SOCKET_PERMISSION_MODE); drop(_listener); let _ = fs::remove_file(&path); let _ = fs::remove_dir_all(&dir); } #[test] fn api_response_outcome_uses_top_level_error_shape() { let ok_with_error_text = r#"{"id":"req","result":{"read":{"text":"user said \"error\": \"timeout\"","revision":1}}}"#; assert_eq!(api_response_outcome(ok_with_error_text), "ok"); let timeout = r#"{"id":"req","error":{"code":"timeout","message":"timed out waiting for output match"}}"#; assert_eq!(api_response_outcome(timeout), "timeout"); let generic_error = r#"{"id":"req","error":{"code":"server_unavailable","message":"boom"}}"#; assert_eq!(api_response_outcome(generic_error), "error"); } #[test] fn ping_request_returns_pong() { let (tx, _rx) = mpsc::unbounded_channel(); let response = handle_request( Request { id: "req_1".into(), method: Method::Ping(crate::api::schema::PingParams::default()), }, &tx, Some(ServerCapabilities { live_handoff: true }), ); let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(parsed.id, "req_1"); assert!(matches!(parsed.result, ResponseResult::Pong { .. })); } #[test] fn request_dispatches_to_app_channel() { let (tx, mut rx) = mpsc::unbounded_channel(); let request = Request { id: "req_2".into(), method: Method::WorkspaceList(crate::api::schema::EmptyParams::default()), }; let request_for_thread = request.clone(); let thread = std::thread::spawn(move || handle_request(request_for_thread, &tx, None)); let msg = rx.blocking_recv().unwrap(); assert_eq!(msg.request.id, "req_2"); msg.respond_to .send( serde_json::to_string(&SuccessResponse { id: "req_2".into(), result: ResponseResult::Ok {}, }) .unwrap(), ) .unwrap(); let response = thread.join().unwrap(); let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(parsed.id, "req_2"); } #[test] fn wait_for_output_stops_when_client_disconnects() { let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); let (first_read_tx, first_read_rx) = std::sync::mpsc::channel(); let responder = std::thread::spawn(move || { let mut notified = false; while let Some(msg) = api_rx.blocking_recv() { assert!(matches!(msg.request.method, Method::PaneRead(_))); if !notified { first_read_tx.send(()).unwrap(); notified = true; } msg.respond_to .send( serde_json::to_string(&SuccessResponse { id: msg.request.id, result: ResponseResult::PaneRead { read: crate::api::schema::PaneReadResult { pane_id: "pane_1".into(), workspace_id: "ws_1".into(), tab_id: "tab_1".into(), source: crate::api::schema::ReadSource::RecentUnwrapped, format: crate::api::schema::ReadFormat::Text, text: String::new(), revision: 0, truncated: false, }, }, }) .unwrap(), ) .unwrap(); } }); let (mut client, server) = UnixStream::pair().unwrap(); client .write_all(br#"{"id":"req_wait","method":"pane.wait_for_output","params":{"pane_id":"pane_1","source":"recent","match":{"type":"substring","value":"never"}}}"#) .unwrap(); client.write_all(b"\n").unwrap(); client.flush().unwrap(); let running = Arc::new(AtomicBool::new(true)); let server_running = Arc::clone(&running); let event_hub = EventHub::default(); let (done_tx, done_rx) = std::sync::mpsc::channel(); let server_thread = std::thread::spawn(move || { let result = handle_connection(server, &api_tx, &event_hub, &server_running, None); done_tx.send(result).unwrap(); }); first_read_rx.recv_timeout(Duration::from_secs(2)).unwrap(); drop(client); let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); assert!(result.is_ok()); server_thread.join().unwrap(); drop(running); responder.join().unwrap(); } #[test] fn subscriptions_stop_when_client_disconnects() { let (api_tx, _api_rx) = mpsc::unbounded_channel::(); let (mut client, server) = UnixStream::pair().unwrap(); client .write_all( br#"{"id":"sub_1","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#, ) .unwrap(); client.write_all(b"\n").unwrap(); client.flush().unwrap(); let running = Arc::new(AtomicBool::new(true)); let server_running = Arc::clone(&running); let event_hub = EventHub::default(); let (done_tx, done_rx) = std::sync::mpsc::channel(); let server_thread = std::thread::spawn(move || { let result = handle_connection(server, &api_tx, &event_hub, &server_running, None); done_tx.send(result).unwrap(); }); let ack = read_line(&mut client); let ack: serde_json::Value = serde_json::from_str(&ack).unwrap(); assert_eq!(ack["result"]["type"], "subscription_started"); drop(client); let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); assert!(result.is_ok()); server_thread.join().unwrap(); } #[test] fn subscriptions_stop_when_server_shuts_down() { let (api_tx, _api_rx) = mpsc::unbounded_channel::(); let (mut client, server) = UnixStream::pair().unwrap(); client .write_all( br#"{"id":"sub_2","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"}]}}"#, ) .unwrap(); client.write_all(b"\n").unwrap(); client.flush().unwrap(); let running = Arc::new(AtomicBool::new(true)); let server_running = Arc::clone(&running); let event_hub = EventHub::default(); let (done_tx, done_rx) = std::sync::mpsc::channel(); let server_thread = std::thread::spawn(move || { let result = handle_connection(server, &api_tx, &event_hub, &server_running, None); done_tx.send(result).unwrap(); }); let ack = read_line(&mut client); let ack: serde_json::Value = serde_json::from_str(&ack).unwrap(); assert_eq!(ack["result"]["type"], "subscription_started"); running.store(false, Ordering::Relaxed); let result = done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); assert!(result.is_ok()); server_thread.join().unwrap(); } }