856 lines
30 KiB
Rust
856 lines
30 KiB
Rust
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<AtomicBool>,
|
|
}
|
|
|
|
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<ServerHandle> {
|
|
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<ServerCapabilities>,
|
|
) -> std::io::Result<ServerHandle> {
|
|
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<AtomicBool>,
|
|
capabilities: Option<ServerCapabilities>,
|
|
) -> 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::<Request>(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<ServerCapabilities>,
|
|
) -> 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::<serde_json::Value>(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<Option<String>> {
|
|
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<AtomicBool>,
|
|
) -> 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<T: serde::Serialize>(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<T: serde::Serialize>(
|
|
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<AtomicBool>,
|
|
) -> std::io::Result<bool> {
|
|
if !running.load(Ordering::Relaxed) {
|
|
return Ok(true);
|
|
}
|
|
|
|
probe_stream_closed(stream)
|
|
}
|
|
|
|
fn probe_stream_closed(stream: &mut UnixStream) -> std::io::Result<bool> {
|
|
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<Duration>,
|
|
) -> 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<Mutex<()>> = 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::<ApiRequestMessage>();
|
|
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::<ApiRequestMessage>();
|
|
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::<ApiRequestMessage>();
|
|
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();
|
|
}
|
|
}
|