aboutsummaryrefslogtreecommitdiff
path: root/src/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/api')
-rw-r--r--src/api/server.rs186
-rw-r--r--src/api/wait.rs121
2 files changed, 268 insertions, 39 deletions
diff --git a/src/api/server.rs b/src/api/server.rs
index 37b464ac..df0f8fc3 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -14,11 +14,11 @@ 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::wait::{wait_for_event, wait_for_output};
use crate::api::{request_changes_ui, socket_path, ApiRequestMessage, ApiRequestSender, EventHub};
use crate::ipc::{
- bind_local_listener, remove_socket_file_if_owned, socket_file_identity, LocalStream,
- SocketFileIdentity,
+ bind_local_listener, is_connection_closed_error, local_stream_peer_closed,
+ remove_socket_file_if_owned, socket_file_identity, LocalStream, SocketFileIdentity,
};
const SOCKET_PERMISSION_MODE: u32 = 0o600;
@@ -197,6 +197,38 @@ fn handle_connection(
}
result
}
+ Method::EventsWait(params) => {
+ let Some(response) = wait_for_event(
+ request_id.clone(),
+ params,
+ &mut stream,
+ api_tx,
+ event_hub,
+ 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::PaneWaitForOutput(params) => {
let Some(response) =
wait_for_output(request_id.clone(), params, &mut stream, api_tx, running)?
@@ -515,40 +547,7 @@ pub(super) fn should_stop_connection(
return Ok(true);
}
- probe_stream_closed(stream)
-}
-
-fn probe_stream_closed(stream: &mut LocalStream) -> 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
- )
+ local_stream_peer_closed(stream)
}
fn dispatch_to_app(request: Request, api_tx: &ApiRequestSender) -> String {
@@ -620,6 +619,7 @@ fn error_response_json(id: String, code: &str, message: String) -> String {
mod tests {
use super::*;
use interprocess::local_socket::traits::Listener as _;
+ use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixListener;
@@ -654,6 +654,64 @@ mod tests {
(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,
+ display_agent: None,
+ agent_status,
+ custom_status: None,
+ state_labels: HashMap::new(),
+ agent_session: 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::<ApiRequestMessage>();
+ 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();
@@ -784,6 +842,60 @@ mod tests {
}
#[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 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();
diff --git a/src/api/wait.rs b/src/api/wait.rs
index 3ed08d70..e70472c3 100644
--- a/src/api/wait.rs
+++ b/src/api/wait.rs
@@ -4,14 +4,17 @@ use std::sync::Arc;
use regex::Regex;
use crate::api::schema::{
- ErrorBody, ErrorResponse, Method, Request, ResponseResult, SuccessResponse,
+ ErrorBody, ErrorResponse, EventData, EventEnvelope, EventKind, EventMatch, EventsWaitParams,
+ Method, Request, ResponseResult, Subscription, SubscriptionEventData,
+ SubscriptionEventEnvelope, SuccessResponse,
};
use crate::api::server::{
dispatch_to_app_with_timeout, should_stop_connection, APP_RESPONSE_TIMEOUT,
CONNECTION_POLL_INTERVAL,
};
+use crate::api::subscriptions::ActiveSubscription;
use crate::api::subscriptions::{match_output, output_match_read_source};
-use crate::api::ApiRequestSender;
+use crate::api::{ApiRequestSender, EventHub};
use crate::ipc::LocalStream;
pub(super) fn wait_for_output(
@@ -122,3 +125,117 @@ pub(super) fn wait_for_output(
std::thread::sleep(CONNECTION_POLL_INTERVAL);
}
}
+
+pub(super) fn wait_for_event(
+ request_id: String,
+ params: EventsWaitParams,
+ stream: &mut LocalStream,
+ api_tx: &ApiRequestSender,
+ event_hub: &EventHub,
+ running: &Arc<AtomicBool>,
+) -> std::io::Result<Option<String>> {
+ let deadline = params
+ .timeout_ms
+ .map(|ms| std::time::Instant::now() + std::time::Duration::from_millis(ms));
+
+ let subscription = match event_match_subscription(&request_id, params.match_event) {
+ Ok(subscription) => subscription,
+ Err(response) => return Ok(Some(serde_json::to_string(&response).unwrap())),
+ };
+ let mut active = match ActiveSubscription::new(subscription, &request_id, 0, api_tx, event_hub)
+ {
+ Ok(active) => active,
+ Err(response) => return Ok(Some(serde_json::to_string(&response).unwrap())),
+ };
+
+ loop {
+ if should_stop_connection(stream, running)? {
+ return Ok(None);
+ }
+
+ if let Some(event) = active.poll(api_tx, event_hub) {
+ return Ok(Some(wait_matched_response(&request_id, event)));
+ }
+
+ if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
+ return Ok(Some(
+ serde_json::to_string(&ErrorResponse {
+ id: request_id,
+ error: ErrorBody {
+ code: "timeout".into(),
+ message: "timed out waiting for event match".into(),
+ },
+ })
+ .unwrap(),
+ ));
+ }
+
+ std::thread::sleep(CONNECTION_POLL_INTERVAL);
+ }
+}
+
+fn event_match_subscription(
+ request_id: &str,
+ match_event: EventMatch,
+) -> Result<Subscription, ErrorResponse> {
+ match match_event {
+ EventMatch::PaneAgentStatusChanged {
+ pane_id,
+ agent_status,
+ } => Ok(Subscription::PaneAgentStatusChanged {
+ pane_id,
+ agent_status: Some(agent_status),
+ }),
+ _ => Err(ErrorResponse {
+ id: request_id.into(),
+ error: ErrorBody {
+ code: "unsupported_event_wait_match".into(),
+ message: "events.wait currently supports pane agent status matches".into(),
+ },
+ }),
+ }
+}
+
+fn wait_matched_response(request_id: &str, event: serde_json::Value) -> String {
+ let Ok(event) = serde_json::from_value::<SubscriptionEventEnvelope>(event) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request_id.into(),
+ error: ErrorBody {
+ code: "internal_error".into(),
+ message: "failed to decode matched event".into(),
+ },
+ })
+ .unwrap();
+ };
+
+ let SubscriptionEventData::PaneAgentStatusChanged(data) = event.data else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request_id.into(),
+ error: ErrorBody {
+ code: "unsupported_event_wait_match".into(),
+ message: "events.wait currently supports pane agent status matches".into(),
+ },
+ })
+ .unwrap();
+ };
+
+ serde_json::to_string(&SuccessResponse {
+ id: request_id.into(),
+ result: ResponseResult::WaitMatched {
+ event: EventEnvelope {
+ event: EventKind::PaneAgentStatusChanged,
+ data: EventData::PaneAgentStatusChanged {
+ pane_id: data.pane_id,
+ workspace_id: data.workspace_id,
+ agent_status: data.agent_status,
+ agent: data.agent,
+ title: data.title,
+ display_agent: data.display_agent,
+ custom_status: data.custom_status,
+ state_labels: data.state_labels,
+ },
+ },
+ },
+ })
+ .unwrap()
+}