use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use interprocess::local_socket::traits::{ListenerExt as _, Stream as _}; use tracing::{debug, error, info, warn}; #[cfg(all(test, unix))] use std::fs; use crate::api::schema::{ ErrorBody, ErrorResponse, Method, Request, ResponseResult, ServerCapabilities, SuccessResponse, }; use crate::api::subscriptions::ActiveSubscription; use crate::api::wait::{prompt_agent, wait_for_agent, wait_for_event, wait_for_output}; use crate::api::{request_changes_ui, socket_path, ApiRequestMessage, ApiRequestSender, EventHub}; use crate::ipc::{ bind_local_listener, is_connection_closed_error, local_stream_peer_closed, poll_local_stream_read, remove_socket_file_if_owned, set_local_stream_polling, socket_file_identity, LocalStream, LocalStreamRead, SocketFileIdentity, }; mod pane_graphics_stream; 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(crate) fn start_server_with_stop_control( api_tx: ApiRequestSender, event_hub: EventHub, server_stop: Arc, ) -> std::io::Result { start_server_inner(api_tx, event_hub, default_capabilities(), Some(server_stop)) } fn default_capabilities() -> Option { Some(ServerCapabilities { live_handoff: crate::platform::capabilities().live_handoff, detached_server_daemon: crate::platform::current_process_is_detached_server_daemon(), endpoint_protocol_generation: Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION), surface_interest: true, health_check: true, }) } fn start_server_inner( api_tx: ApiRequestSender, event_hub: EventHub, capabilities: Option, server_stop: Option>, ) -> std::io::Result { let path = socket_path(); prepare_socket_path(&path)?; let listener = bind_local_listener(&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 server_stop = server_stop.clone(); let connection_running = Arc::clone(&listener_running); std::thread::spawn(move || { if let Err(err) = handle_connection_with_stop( stream, &api_tx, &event_hub, &connection_running, capabilities, server_stop.as_ref(), ) { 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) } #[cfg(test)] fn handle_connection( stream: LocalStream, api_tx: &ApiRequestSender, event_hub: &EventHub, running: &Arc, capabilities: Option, ) -> std::io::Result<()> { handle_connection_with_stop(stream, api_tx, event_hub, running, capabilities, None) } fn handle_connection_with_stop( mut stream: LocalStream, api_tx: &ApiRequestSender, event_hub: &EventHub, running: &Arc, capabilities: Option, server_stop: Option<&Arc>, ) -> std::io::Result<()> { if let Err(err) = stream.set_send_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(request_error) => { // Recover correlation without relaxing typed request validation or accepting // ambiguous duplicate IDs. Invalid JSON and non-string IDs stay uncorrelated. #[derive(serde::Deserialize)] struct RequestId { id: String, } let id = if line.starts_with('{') { serde_json::from_str::(line) .map(|request| request.id) .unwrap_or_default() } else { String::new() }; write_json_line_allow_disconnect( &mut stream, &ErrorResponse { id, error: ErrorBody { code: "invalid_request".into(), message: format!("invalid request: {request_error}"), }, }, )?; 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::PaneGraphicsStream(params) => { let result = pane_graphics_stream::serve(stream, request_id.clone(), params, api_tx, 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::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::EventsWait(params) => { let response = wait_for_event( request_id.clone(), params, &mut stream, api_tx, event_hub, running, )?; finish_wait_response(&mut stream, response, &request_id, method, changes_ui) } Method::AgentPrompt(params) => { let response = prompt_agent( request_id.clone(), params, &mut stream, api_tx, event_hub, running, )?; finish_wait_response(&mut stream, response, &request_id, method, changes_ui) } Method::AgentWait(params) => { let response = wait_for_agent( request_id.clone(), params, &mut stream, api_tx, event_hub, running, )?; finish_wait_response(&mut stream, response, &request_id, method, changes_ui) } Method::PaneWaitForOutput(params) => { let response = wait_for_output(request_id.clone(), params, &mut stream, api_tx, running)?; finish_wait_response(&mut stream, response, &request_id, method, changes_ui) } method_body => { let (response_write_tx, response_write_rx) = std::sync::mpsc::channel(); let response = handle_request( Request { id: request_id.clone(), method: method_body, }, api_tx, capabilities, server_stop, Some(response_write_rx), ); let result = write_text_line_allow_disconnect(&mut stream, &response); let _ = response_write_tx.send(()); 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 finish_wait_response( stream: &mut LocalStream, response: Option, request_id: &str, method: &'static str, changes_ui: bool, ) -> std::io::Result<()> { let Some(response) = response else { crate::logging::api_request_completed( request_id, method, "client_disconnected", changes_ui, ); return Ok(()); }; let result = write_text_line_allow_disconnect(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, server_stop: Option<&Arc>, response_write_complete: Option>, ) -> String { if matches!(&request.method, Method::Ping(_)) { return serde_json::to_string(&SuccessResponse { id: request.id, result: ResponseResult::Pong { version: crate::build_info::version(), protocol: crate::protocol::PROTOCOL_VERSION, capabilities, }, }) .unwrap_or_else(|_| { r#"{"id":"","error":{"code":"internal_error","message":"failed to encode response"}}"# .to_string() }); } if matches!(&request.method, Method::ClientShellSurfaceSet(_)) { return error_response_json( request.id, "connection_local_only", "client_shell.surface.set is only available through a client shell endpoint".into(), ); } if matches!(&request.method, Method::ServerStop(_)) { if let Some(server_stop) = server_stop { server_stop.store(true, Ordering::Release); return serde_json::to_string(&SuccessResponse { id: request.id, result: ResponseResult::Ok {}, }) .unwrap_or_else(|_| "{}".to_string()); } } else if server_stop.is_some_and(|stop| stop.load(Ordering::Acquire)) { return error_response_json( request.id, "server_unavailable", "server is shutting down".into(), ); } dispatch_to_app(request, api_tx, None, response_write_complete, None, None) } pub(crate) 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::ServerAgentManifests(_) => "server.agent_manifests", Method::ServerReloadAgentManifests(_) => "server.reload_agent_manifests", Method::NotificationShow(_) => "notification.show", Method::ProductAnnouncementDismiss(_) => "product_announcement.dismiss", Method::ReleaseNotesDismiss(_) => "release_notes.dismiss", Method::CommandInvoke(_) => "command.invoke", Method::ClientWindowTitleSet(_) => "client.window_title.set", Method::ClientWindowTitleClear(_) => "client.window_title.clear", Method::ClientShellSurfaceSet(_) => "client_shell.surface.set", Method::SessionSnapshot(_) => "session.snapshot", Method::WorkspaceCreate(_) => "workspace.create", Method::WorkspaceList(_) => "workspace.list", Method::WorkspaceGet(_) => "workspace.get", Method::WorkspaceFocus(_) => "workspace.focus", Method::WorkspaceRename(_) => "workspace.rename", Method::WorkspaceMove(_) => "workspace.move", Method::WorkspaceMoveBlock(_) => "workspace.move_block", Method::WorkspaceReportMetadata(_) => "workspace.report_metadata", 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::TabMove(_) => "tab.move", Method::TabClose(_) => "tab.close", Method::AgentList(_) => "agent.list", Method::AgentGet(_) => "agent.get", Method::AgentRead(_) => "agent.read", Method::AgentExplain(_) => "agent.explain", Method::AgentSendKeys(_) => "agent.send_keys", Method::AgentRename(_) => "agent.rename", Method::AgentViewSet(_) => "agent.view.set", Method::AgentViewClear(_) => "agent.view.clear", Method::AgentFocus(_) => "agent.focus", Method::AgentStart(_) => "agent.start", Method::AgentPrompt(_) => "agent.prompt", Method::AgentWait(_) => "agent.wait", Method::PaneSplit(_) => "pane.split", Method::PaneSwap(_) => "pane.swap", Method::PaneMove(_) => "pane.move", Method::PaneZoom(_) => "pane.zoom", Method::PaneLayout(_) => "pane.layout", Method::PaneProcessInfo(_) => "pane.process_info", Method::LayoutExport(_) => "layout.export", Method::LayoutApply(_) => "layout.apply", Method::LayoutSetSplitRatio(_) => "layout.set_split_ratio", Method::PaneNeighbor(_) => "pane.neighbor", Method::PaneEdges(_) => "pane.edges", Method::PaneFocusDirection(_) => "pane.focus_direction", Method::PaneResize(_) => "pane.resize", Method::PaneScroll(_) => "pane.scroll", Method::PaneClear(_) => "pane.clear", Method::PaneEditScrollback(_) => "pane.edit_scrollback", Method::PaneSelectionRead(_) => "pane.selection.read", Method::PaneCopyMotion(_) => "pane.copy_motion", Method::PaneCopySearch(_) => "pane.copy_search", Method::PaneList(_) => "pane.list", Method::PaneCurrent(_) => "pane.current", Method::PaneGet(_) => "pane.get", Method::PaneFocus(_) => "pane.focus", Method::PaneInputSet(_) => "pane.input.set", Method::PaneLinkActivate(_) => "pane.link.activate", Method::PaneLinkResolve(_) => "pane.link.resolve", Method::PaneRename(_) => "pane.rename", Method::PaneSendText(_) => "pane.send_text", Method::PaneSendKeys(_) => "pane.send_keys", Method::PaneSendInput(_) => "pane.send_input", Method::PaneRead(_) => "pane.read", Method::PaneGraphicsSet(_) => "pane.graphics.set", Method::PaneGraphicsClear(_) => "pane.graphics.clear", Method::PaneGraphicsInfo(_) => "pane.graphics.info", Method::PaneGraphicsStream(_) => "pane.graphics.stream", Method::PaneGraphicsStreamSet(_) => "pane.graphics.stream.set", Method::PaneGraphicsStreamDirect(_) => "pane.graphics.stream.direct", Method::PaneGraphicsStreamOpen(_) => "pane.graphics.stream.open", Method::PaneGraphicsStreamClose(_) => "pane.graphics.stream.close", 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::PopupClose(_) => "popup.close", Method::EventsSubscribe(_) => "events.subscribe", Method::EventsWait(_) => "events.wait", Method::PaneWaitForOutput(_) => "pane.wait_for_output", Method::IntegrationList(_) => "integration.list", Method::IntegrationInstall(_) => "integration.install", Method::IntegrationUninstall(_) => "integration.uninstall", Method::PluginLink(_) => "plugin.link", Method::PluginList(_) => "plugin.list", Method::PluginUnlink(_) => "plugin.unlink", Method::PluginEnable(_) => "plugin.enable", Method::PluginDisable(_) => "plugin.disable", Method::PluginActionList(_) => "plugin.action.list", Method::PluginActionInvoke(_) => "plugin.action.invoke", Method::PluginLogList(_) => "plugin.log.list", Method::PluginPaneOpen(_) => "plugin.pane.open", Method::PluginPaneFocus(_) => "plugin.pane.focus", Method::PluginPaneClose(_) => "plugin.pane.close", } } 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 LocalStream) -> std::io::Result> { read_initial_request_line_with_timeout(stream, INITIAL_REQUEST_TIMEOUT) } fn read_initial_request_line_with_timeout( stream: &mut LocalStream, timeout: Duration, ) -> std::io::Result> { read_initial_request_line_with_limits(stream, timeout, MAX_INITIAL_REQUEST_BYTES) } fn read_initial_request_line_with_limits( stream: &mut LocalStream, timeout: Duration, max_bytes: usize, ) -> std::io::Result> { set_local_stream_polling(stream, true)?; let deadline = Instant::now() + timeout; let mut bytes = Vec::new(); let mut byte = [0u8; 1]; let result = loop { let read = match poll_local_stream_read(stream, &mut byte) { Ok(read) => read, Err(err) => break Err(err), }; match read { LocalStreamRead::Closed => break Ok(None), LocalStreamRead::Data => { bytes.push(byte[0]); if byte[0] == b'\n' { break String::from_utf8(bytes) .map(Some) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)); } if bytes.len() > max_bytes { break Err(io::Error::new( io::ErrorKind::InvalidData, "api request line is too large", )); } } LocalStreamRead::Pending => { if Instant::now() >= deadline { break Err(io::Error::new( io::ErrorKind::TimedOut, "timed out reading api request", )); } std::thread::sleep(CONNECTION_POLL_INTERVAL); } } }; set_local_stream_polling(stream, false)?; result } #[cfg(all(test, windows))] mod windows_tests { use super::*; use interprocess::local_socket::traits::Listener as _; use std::io::{BufRead, BufReader}; use std::sync::mpsc::{self, Receiver}; fn local_stream_pair(name: &str) -> (LocalStream, LocalStream, PathBuf) { let path = std::env::temp_dir().join(format!( "herdr-api-{name}-{}-{}.sock", std::process::id(), Instant::now().elapsed().as_nanos() )); 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) } fn spawn_connection( server: LocalStream, ) -> (Receiver>, std::thread::JoinHandle<()>) { let (done_tx, done_rx) = mpsc::channel(); let thread = std::thread::spawn(move || { let (api_tx, _api_rx) = tokio::sync::mpsc::unbounded_channel(); let result = handle_connection( server, &api_tx, &EventHub::default(), &Arc::new(AtomicBool::new(true)), None, ); done_tx.send(result).unwrap(); }); (done_rx, thread) } #[test] fn windows_delayed_partial_initial_request_returns_pong() { let (mut client, server, path) = local_stream_pair("delayed-request"); let (done_rx, server_thread) = spawn_connection(server); std::thread::sleep(Duration::from_millis(300)); assert!( done_rx.try_recv().is_err(), "idle connected client must not be treated as closed" ); client .write_all(br#"{"id":"delayed","method":"ping","params":{}}"#) .unwrap(); client.flush().unwrap(); std::thread::sleep(Duration::from_millis(150)); assert!( done_rx.try_recv().is_err(), "partial request must wait for its newline" ); client.write_all(b"\n").unwrap(); client.flush().unwrap(); let mut response = String::new(); BufReader::new(&mut client) .read_line(&mut response) .unwrap(); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["id"], "delayed"); assert_eq!(response["result"]["type"], "pong"); done_rx .recv_timeout(Duration::from_secs(2)) .unwrap() .unwrap(); server_thread.join().unwrap(); let _ = std::fs::remove_file(path); } #[test] fn windows_disconnected_initial_request_returns_promptly() { let (client, server, path) = local_stream_pair("disconnected-request"); let (done_rx, server_thread) = spawn_connection(server); drop(client); done_rx .recv_timeout(Duration::from_secs(2)) .expect("disconnected connection handler must finish promptly") .unwrap(); server_thread.join().unwrap(); let _ = std::fs::remove_file(path); } #[test] fn windows_idle_initial_request_honors_timeout() { let (_client, mut server, path) = local_stream_pair("request-timeout"); let err = read_initial_request_line_with_timeout(&mut server, Duration::from_millis(50)) .unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::TimedOut); let _ = std::fs::remove_file(path); } #[test] fn windows_initial_request_enforces_size_limit() { let (mut client, mut server, path) = local_stream_pair("request-size-limit"); client.write_all(b"12345").unwrap(); client.flush().unwrap(); let err = read_initial_request_line_with_limits(&mut server, Duration::from_secs(1), 4) .unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidData); assert_eq!(err.to_string(), "api request line is too large"); let _ = std::fs::remove_file(path); } #[test] fn windows_initial_request_rejects_invalid_utf8() { let (mut client, mut server, path) = local_stream_pair("request-invalid-utf8"); client.write_all(&[0xff, b'\n']).unwrap(); client.flush().unwrap(); let err = read_initial_request_line_with_timeout(&mut server, Duration::from_secs(1)) .unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidData); let _ = std::fs::remove_file(path); } } fn stream_subscriptions( mut stream: LocalStream, request_id: String, params: crate::api::schema::EventsSubscribeParams, api_tx: &ApiRequestSender, event_hub: &EventHub, running: &Arc, ) -> std::io::Result<()> { let event_start_sequence = event_hub.current_sequence(); 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, event_start_sequence, ) { Ok(active) => active, Err(mut response) => { response.id = request_id; 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 LocalStream, 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 LocalStream, 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 LocalStream, 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 LocalStream, 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 LocalStream, running: &Arc, ) -> std::io::Result { if !running.load(Ordering::Relaxed) { return Ok(true); } local_stream_peer_closed(stream) } pub(super) fn dispatch_to_app_with_timeout( request: Request, api_tx: &ApiRequestSender, timeout: Option, ) -> String { dispatch_to_app(request, api_tx, timeout, None, None, None) } pub(super) fn dispatch_to_app_with_caller_timeout( request: Request, api_tx: &ApiRequestSender, timeout: Option, ) -> String { dispatch_to_app( request, api_tx, timeout, None, None, Some(("timeout", "timed out waiting for agent status")), ) } pub(super) fn dispatch_stream_open( request: Request, api_tx: &ApiRequestSender, timeout: Duration, active: Arc, ) -> String { dispatch_to_app(request, api_tx, Some(timeout), None, Some(active), None) } pub(super) fn dispatch_stream_frame( request: Request, api_tx: &ApiRequestSender, active: Arc, ) -> String { dispatch_to_app( request, api_tx, Some(crate::app::pane_graphics::DIRECT_OUTER_TIMEOUT), None, Some(active), None, ) } fn dispatch_to_app( request: Request, api_tx: &ApiRequestSender, timeout: Option, response_write_complete: Option>, stream_active: Option>, timeout_response: Option<(&str, &str)>, ) -> String { let request_id = request.id.clone(); let request_active = stream_active.clone(); let (respond_to, response_rx) = std::sync::mpsc::channel(); if let Err(err) = api_tx.send(ApiRequestMessage { request, respond_to, response_write_complete, stream_active, }) { if let Some(active) = request_active { active.store(false, Ordering::Release); } 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) => { if let Some(active) = request_active { active.store(false, Ordering::Release); } if err.kind() == std::io::ErrorKind::TimedOut { if let Some((code, message)) = timeout_response { return error_response_json(request_id, code, message.into()); } } error_response_json( request_id, "server_unavailable", format!("request handling failed: {err}"), ) } } } #[cfg(test)] #[test] fn caller_timeout_dispatch_uses_timeout_error() { let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let response = dispatch_to_app_with_caller_timeout( Request { id: "prompt-timeout".into(), method: Method::AgentPrompt(crate::api::schema::AgentPromptParams { target: "reviewer".into(), text: "review this".into(), wait: None, }), }, &tx, Some(Duration::ZERO), ); let error: ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(error.error.code, "timeout"); } 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(all(test, unix))] mod tests { use super::*; use interprocess::local_socket::traits::Listener as _; use std::collections::HashMap; use std::io::{BufRead, BufReader, Read}; use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener; 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 LocalStream) -> String { let mut reader = BufReader::new(stream); let mut line = String::new(); reader.read_line(&mut line).unwrap(); line } fn local_stream_pair(name: &str) -> (LocalStream, LocalStream, 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) } fn pane_info( pane_id: &str, agent_status: crate::api::schema::AgentStatus, ) -> crate::api::schema::PaneInfo { crate::api::schema::PaneInfo { pane_id: pane_id.into(), terminal_id: "term_1".into(), workspace_id: "ws_1".into(), tab_id: "tab_1".into(), focused: true, cwd: None, foreground_cwd: None, label: None, agent: Some("pi".into()), title: None, terminal_title: None, terminal_title_stripped: None, display_agent: None, agent_status, state_labels: HashMap::new(), tokens: HashMap::new(), agent_session: None, scroll: None, revision: 0, } } fn spawn_pane_get_responder( agent_status: crate::api::schema::AgentStatus, ) -> (ApiRequestSender, std::thread::JoinHandle<()>) { let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); let responder = std::thread::spawn(move || { while let Some(msg) = api_rx.blocking_recv() { match msg.request.method { Method::PaneGet(_) => msg .respond_to .send( serde_json::to_string(&SuccessResponse { id: msg.request.id, result: ResponseResult::PaneInfo { pane: pane_info("pane_1", agent_status), }, }) .unwrap(), ) .unwrap(), Method::EventsWait(_) => msg .respond_to .send(error_response_json( msg.request.id, "unexpected_dispatch", "events.wait should be handled by the api server".into(), )) .unwrap(), other => panic!("unexpected request: {other:?}"), } } }); (api_tx, responder) } #[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, detached_server_daemon: true, endpoint_protocol_generation: Some( crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION, ), surface_interest: true, health_check: true, }), None, None, ); let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(parsed.id, "req_1"); assert!(matches!(parsed.result, ResponseResult::Pong { .. })); } #[test] fn server_stop_control_bypasses_app_channel() { let (tx, mut rx) = mpsc::unbounded_channel(); let stop = Arc::new(AtomicBool::new(false)); let response = handle_request( Request { id: "priority_stop".into(), method: Method::ServerStop(crate::api::schema::EmptyParams::default()), }, &tx, None, Some(&stop), None, ); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["id"], "priority_stop"); assert_eq!(response["result"]["type"], "ok"); assert!(stop.load(Ordering::Acquire)); let rejected = handle_request( Request { id: "after_stop".into(), method: Method::WorkspaceList(crate::api::schema::EmptyParams::default()), }, &tx, None, Some(&stop), None, ); let rejected: serde_json::Value = serde_json::from_str(&rejected).unwrap(); assert_eq!(rejected["error"]["code"], "server_unavailable"); assert!(rx.try_recv().is_err()); } #[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, None, 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 dispatched_request_reports_response_write_completion() { let (api_tx, mut api_rx) = mpsc::unbounded_channel(); let (mut client, server, _path) = local_stream_pair("write-ack"); client .write_all(br#"{"id":"req_write","method":"workspace.list","params":{}}"#) .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 server_thread = std::thread::spawn(move || { handle_connection(server, &api_tx, &event_hub, &server_running, None) }); let msg = api_rx.blocking_recv().unwrap(); let response_write_complete = msg .response_write_complete .expect("socket-dispatched requests include write completion"); msg.respond_to .send( serde_json::to_string(&SuccessResponse { id: msg.request.id, result: ResponseResult::Ok {}, }) .unwrap(), ) .unwrap(); response_write_complete .recv_timeout(Duration::from_secs(1)) .expect("response write completion"); let response: SuccessResponse = serde_json::from_str(&read_line(&mut client)).unwrap(); assert_eq!(response.id, "req_write"); server_thread.join().unwrap().unwrap(); } #[test] fn events_wait_agent_status_returns_initial_match() { let (api_tx, responder) = spawn_pane_get_responder(crate::api::schema::AgentStatus::Blocked); let (mut client, server, _path) = local_stream_pair("api-events-wait-initial"); client .write_all(br#"{"id":"wait_1","method":"events.wait","params":{"match_event":{"event":"pane_agent_status_changed","pane_id":"pane_1","agent_status":"blocked"},"timeout_ms":1000}}"#) .unwrap(); client.write_all(b"\n").unwrap(); client.flush().unwrap(); let running = Arc::new(AtomicBool::new(true)); let event_hub = EventHub::default(); handle_connection(server, &api_tx, &event_hub, &running, None).unwrap(); let response: serde_json::Value = serde_json::from_str(&read_line(&mut client)).unwrap(); assert_eq!(response["id"], "wait_1"); assert_eq!(response["result"]["type"], "wait_matched"); assert_eq!( response["result"]["event"]["data"]["agent_status"], "blocked" ); drop(api_tx); responder.join().unwrap(); } #[test] fn events_wait_agent_status_times_out_server_side() { let (api_tx, responder) = spawn_pane_get_responder(crate::api::schema::AgentStatus::Unknown); let (mut client, server, _path) = local_stream_pair("api-events-wait-timeout"); client .write_all(br#"{"id":"wait_2","method":"events.wait","params":{"match_event":{"event":"pane_agent_status_changed","pane_id":"pane_1","agent_status":"blocked"},"timeout_ms":30}}"#) .unwrap(); client.write_all(b"\n").unwrap(); client.flush().unwrap(); let running = Arc::new(AtomicBool::new(true)); let event_hub = EventHub::default(); handle_connection(server, &api_tx, &event_hub, &running, None).unwrap(); let response: serde_json::Value = serde_json::from_str(&read_line(&mut client)).unwrap(); assert_eq!(response["id"], "wait_2"); assert_eq!(response["error"]["code"], "timeout"); assert_eq!( response["error"]["message"], "timed out waiting for event match" ); drop(api_tx); responder.join().unwrap(); } #[test] fn events_wait_agent_status_returns_not_found_when_pane_closes() { let event_hub = EventHub::default(); let responder_event_hub = event_hub.clone(); let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); let responder = std::thread::spawn(move || { let mut pane_get_count = 0; while let Some(msg) = api_rx.blocking_recv() { let Method::PaneGet(_) = msg.request.method else { panic!("unexpected request: {:?}", msg.request.method); }; pane_get_count += 1; let response = if pane_get_count == 1 { serde_json::to_string(&SuccessResponse { id: msg.request.id, result: ResponseResult::PaneInfo { pane: pane_info("pane_1", crate::api::schema::AgentStatus::Unknown), }, }) .unwrap() } else { if pane_get_count == 2 { responder_event_hub.push(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneClosed, data: crate::api::schema::EventData::PaneClosed { pane_id: "pane_1".into(), workspace_id: "ws_1".into(), }, }); } error_response_json( msg.request.id, "pane_not_found", "pane pane_1 not found".into(), ) }; msg.respond_to.send(response).unwrap(); } }); let (mut client, server, _path) = local_stream_pair("wait-close"); client .write_all(br#"{"id":"wait_close","method":"events.wait","params":{"match_event":{"event":"pane_agent_status_changed","pane_id":"pane_1","agent_status":"done"},"timeout_ms":500}}"#) .unwrap(); client.write_all(b"\n").unwrap(); client.flush().unwrap(); let running = Arc::new(AtomicBool::new(true)); handle_connection(server, &api_tx, &event_hub, &running, None).unwrap(); let response: serde_json::Value = serde_json::from_str(&read_line(&mut client)).unwrap(); assert_eq!(response["id"], "wait_close"); assert_eq!(response["error"]["code"], "pane_not_found"); assert_eq!(response["error"]["message"], "pane pane_1 not found"); drop(api_tx); responder.join().unwrap(); } #[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, _path) = local_stream_pair("api-wait-disconnect"); 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 invalid_requests_preserve_only_unambiguous_string_ids() { let cases = [ ( r#"{"id":"mine","method":"pane.report_agent","params":{"pane_id":"w1:p1","status":"working","source":"x"}}"#, "mine", ), (r#"{"id":"escaped\"id","method":"unknown"}"#, "escaped\"id"), (r#"{"method":"unknown","params":{"id":"nested"}}"#, ""), (r#"{"id":123,"method":"unknown"}"#, ""), ( r#"{"id":"first","id":"second","method":"ping","params":{}}"#, "", ), (r#"{"id":"truncated","method":"ping""#, ""), (r#"["not-an-object"]"#, ""), ]; for (request, expected_id) in cases { let (api_tx, mut api_rx) = mpsc::unbounded_channel(); let (mut client, server, path) = local_stream_pair("invalid-request-id"); writeln!(client, "{request}").unwrap(); let running = Arc::new(AtomicBool::new(true)); handle_connection(server, &api_tx, &EventHub::default(), &running, None).unwrap(); let mut response = String::new(); BufReader::new(client) .read_to_string(&mut response) .unwrap(); let response: ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(response.id, expected_id, "{request}"); assert_eq!(response.error.code, "invalid_request"); assert!(response.error.message.starts_with("invalid request: ")); assert!( api_rx.try_recv().is_err(), "invalid requests must not dispatch" ); fs::remove_file(path).unwrap(); } } #[test] fn subscription_setup_errors_preserve_request_id_and_reject_entire_stream() { let (api_tx, mut api_rx) = mpsc::unbounded_channel::(); let event_hub = EventHub::default(); let responder_event_hub = event_hub.clone(); let responder = std::thread::spawn(move || { let msg = api_rx.blocking_recv().unwrap(); let Method::PaneGet(params) = msg.request.method else { panic!("unexpected request: {:?}", msg.request.method); }; assert_eq!(params.pane_id, "w999:p9"); responder_event_hub.push(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneClosed, data: crate::api::schema::EventData::PaneClosed { pane_id: "w999:p9".into(), workspace_id: "w999".into(), }, }); msg.respond_to .send(error_response_json( msg.request.id, "pane_not_found", "pane w999:p9 not found".into(), )) .unwrap(); assert!( api_rx.blocking_recv().is_none(), "rejection must not start polling" ); }); let (mut client, server, path) = local_stream_pair("subscription-error-id"); let request = r#"{"id":"panefold:events","method":"events.subscribe","params":{"subscriptions":[{"type":"workspace.created"},{"type":"pane.closed"},{"type":"pane.agent_status_changed","pane_id":"w999:p9"}]}}"#; writeln!(client, "{request}").unwrap(); let running = Arc::new(AtomicBool::new(true)); handle_connection(server, &api_tx, &event_hub, &running, None).unwrap(); drop(api_tx); responder.join().unwrap(); let mut response = String::new(); BufReader::new(client) .read_to_string(&mut response) .unwrap(); let response: ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(response.id, "panefold:events"); assert_eq!(response.error.code, "pane_not_found"); assert_eq!(response.error.message, "pane w999:p9 not found"); fs::remove_file(path).unwrap(); } #[test] fn subscriptions_stop_when_client_disconnects() { let (api_tx, _api_rx) = mpsc::unbounded_channel::(); let (mut client, server, _path) = local_stream_pair("api-sub-disconnect"); 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"); assert_eq!(ack["id"], "sub_1"); 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, _path) = local_stream_pair("api-sub-shutdown"); 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(); } } #[cfg(test)] mod pane_graphics_request_tests { use super::*; use base64::Engine as _; #[test] fn maximum_public_graphics_request_fits_initial_json_line() { let request = Request { id: "graphics-max".into(), method: Method::PaneGraphicsSet(crate::api::schema::PaneGraphicsSetParams { pane_id: "pane_1".into(), layer_id: None, z_index: 0, owner: String::new(), format: crate::api::schema::PaneGraphicsFormat::Png, image_width: 1, image_height: 1, data_base64: base64::engine::general_purpose::STANDARD .encode(vec![1_u8; crate::api::schema::PANE_GRAPHICS_SET_MAX_BYTES]), data: None, placement: crate::api::schema::PaneGraphicsPlacementParams::default(), }), }; let encoded = serde_json::to_vec(&request).unwrap(); assert!(encoded.len() < MAX_INITIAL_REQUEST_BYTES); } #[test] fn duplicate_method_cannot_be_reinterpreted_as_graphics_stream() { let encoded = r#"{"id":"duplicate","method":"ping","method":"pane.graphics.stream","params":{"pane_id":"pane_1"}}"#; assert!(serde_json::from_str::(encoded).is_err()); } }