aboutsummaryrefslogtreecommitdiff
path: root/src/client/endpoint_commands.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/endpoint_commands.rs')
-rw-r--r--src/client/endpoint_commands.rs224
1 files changed, 224 insertions, 0 deletions
diff --git a/src/client/endpoint_commands.rs b/src/client/endpoint_commands.rs
new file mode 100644
index 00000000..decb8522
--- /dev/null
+++ b/src/client/endpoint_commands.rs
@@ -0,0 +1,224 @@
+use std::collections::VecDeque;
+use std::io;
+
+use crate::api::client::ApiClientError;
+use crate::api::schema::{Request, ResponseResult};
+use crate::ipc::LocalStream;
+use crate::protocol::ClientMessage;
+
+use super::shell::ClientShellEndpointError;
+
+struct InFlightCommand {
+ boot_id: String,
+ request_id: String,
+ response: Vec<u8>,
+}
+
+pub(super) struct EndpointCommandResult {
+ pub(super) boot_id: String,
+ pub(super) request_id: String,
+ pub(super) result: Result<ResponseResult, ClientShellEndpointError>,
+}
+
+#[derive(Default)]
+pub(super) struct EndpointCommands {
+ queued: VecDeque<(String, Box<Request>)>,
+ in_flight: Option<InFlightCommand>,
+}
+
+impl EndpointCommands {
+ pub(super) fn enqueue(&mut self, boot_id: String, request: Box<Request>) {
+ self.queued.push_back((boot_id, request));
+ }
+
+ pub(super) fn send_next(&mut self, stream: &mut LocalStream) -> io::Result<()> {
+ if self.in_flight.is_some() {
+ return Ok(());
+ }
+ let Some((boot_id, request)) = self.queued.pop_front() else {
+ return Ok(());
+ };
+ let request_id = request.id.clone();
+ let request = serde_json::to_string(&request)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
+ super::write_to_server(
+ stream,
+ &ClientMessage::ClientShellEndpointRequest {
+ boot_id: boot_id.clone(),
+ request,
+ },
+ )?;
+ self.in_flight = Some(InFlightCommand {
+ boot_id,
+ request_id,
+ response: Vec::new(),
+ });
+ Ok(())
+ }
+
+ pub(super) fn receive_chunk(
+ &mut self,
+ response_boot_id: &str,
+ response_request_id: &str,
+ final_chunk: bool,
+ data: Vec<u8>,
+ ) -> io::Result<Option<EndpointCommandResult>> {
+ let Some(in_flight) = self.in_flight.as_mut() else {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "endpoint response arrived without an in-flight command",
+ ));
+ };
+ if response_boot_id != in_flight.boot_id || response_request_id != in_flight.request_id {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "endpoint response correlation did not match the in-flight command",
+ ));
+ }
+ in_flight.response.extend(data);
+ if !final_chunk {
+ return Ok(None);
+ }
+
+ let in_flight = self.in_flight.take().expect("checked in-flight command");
+ let response = String::from_utf8(in_flight.response)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
+ let result = parse_response(&in_flight.request_id, &response);
+ Ok(Some(EndpointCommandResult {
+ boot_id: in_flight.boot_id,
+ request_id: in_flight.request_id,
+ result,
+ }))
+ }
+}
+
+fn parse_response(
+ expected_id: &str,
+ response: &str,
+) -> Result<ResponseResult, ClientShellEndpointError> {
+ let value = serde_json::from_str(response).map_err(|error| ClientShellEndpointError {
+ code: None,
+ message: format!("invalid endpoint response: {error}"),
+ })?;
+ match crate::api::client::parse_response_value(value) {
+ Ok(response) if response.id == expected_id => Ok(response.result),
+ Ok(response) => Err(ClientShellEndpointError {
+ code: None,
+ message: format!(
+ "endpoint response id {:?} did not match {expected_id:?}",
+ response.id
+ ),
+ }),
+ Err(ApiClientError::ErrorResponse(response)) if response.id == expected_id => {
+ Err(ClientShellEndpointError {
+ code: Some(response.error.code),
+ message: response.error.message,
+ })
+ }
+ Err(ApiClientError::ErrorResponse(response)) => Err(ClientShellEndpointError {
+ code: None,
+ message: format!(
+ "endpoint error id {:?} did not match {expected_id:?}",
+ response.id
+ ),
+ }),
+ Err(error) => Err(ClientShellEndpointError {
+ code: None,
+ message: error.to_string(),
+ }),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::api::schema::{ResponseResult, SuccessResponse};
+
+ fn commands_with_in_flight() -> EndpointCommands {
+ EndpointCommands {
+ in_flight: Some(InFlightCommand {
+ boot_id: "boot-a".into(),
+ request_id: "request-a".into(),
+ response: Vec::new(),
+ }),
+ ..EndpointCommands::default()
+ }
+ }
+
+ #[test]
+ fn chunked_response_completion_is_correlated_and_clears_the_lane() {
+ let mut commands = commands_with_in_flight();
+ let response = serde_json::to_string(&SuccessResponse {
+ id: "request-a".into(),
+ result: ResponseResult::Ok {},
+ })
+ .unwrap();
+ let split = response.len() / 2;
+
+ assert!(commands
+ .receive_chunk(
+ "boot-a",
+ "request-a",
+ false,
+ response.as_bytes()[..split].to_vec(),
+ )
+ .unwrap()
+ .is_none());
+ let completed = commands
+ .receive_chunk(
+ "boot-a",
+ "request-a",
+ true,
+ response.as_bytes()[split..].to_vec(),
+ )
+ .unwrap()
+ .unwrap();
+
+ assert_eq!(completed.boot_id, "boot-a");
+ assert_eq!(completed.request_id, "request-a");
+ assert!(matches!(completed.result, Ok(ResponseResult::Ok {})));
+ assert!(commands.in_flight.is_none());
+ }
+
+ #[test]
+ fn large_selection_response_reassembles_without_truncation() {
+ let mut commands = commands_with_in_flight();
+ let selection = "selected".repeat(160_000);
+ let response = serde_json::to_vec(&SuccessResponse {
+ id: "request-a".into(),
+ result: ResponseResult::PaneSelection {
+ pane_id: "w1:p1".into(),
+ text: selection.clone(),
+ },
+ })
+ .unwrap();
+ let chunk_count = response.len().div_ceil(128 * 1024);
+ let mut completed = None;
+ for (index, chunk) in response.chunks(128 * 1024).enumerate() {
+ completed = commands
+ .receive_chunk(
+ "boot-a",
+ "request-a",
+ index + 1 == chunk_count,
+ chunk.to_vec(),
+ )
+ .unwrap();
+ }
+
+ assert!(matches!(
+ completed.expect("final selection response").result,
+ Ok(ResponseResult::PaneSelection { text, .. }) if text == selection
+ ));
+ }
+
+ #[test]
+ fn response_from_another_boot_is_rejected() {
+ let mut commands = commands_with_in_flight();
+
+ let Err(error) = commands.receive_chunk("boot-b", "request-a", true, b"{}".to_vec()) else {
+ panic!("mismatched boot should fail");
+ };
+
+ assert_eq!(error.kind(), io::ErrorKind::InvalidData);
+ }
+}