1use std::collections::HashMap;
49use std::future::Future;
50use std::pin::Pin;
51use std::sync::atomic::{AtomicBool, Ordering};
52use std::sync::Arc;
53use std::time::Duration;
54
55use futures_util::{SinkExt, StreamExt};
56use tokio::sync::{mpsc, oneshot, Mutex};
57use tokio_tungstenite::tungstenite::client::IntoClientRequest;
58use tokio_tungstenite::tungstenite::Message as WsMessage;
59use uuid::Uuid;
60
61use a2a_protocol_types::{JsonRpcRequest, JsonRpcResponse};
62
63use crate::error::{ClientError, ClientResult};
64use crate::streaming::EventStream;
65use crate::transport::Transport;
66
67enum PendingRequest {
71 Unary(oneshot::Sender<Result<String, ClientError>>),
73 Streaming(mpsc::Sender<crate::streaming::event_stream::BodyChunk>),
75}
76
77struct WriteCommand {
79 text: String,
80 request_id: String,
81 pending: PendingRequest,
82}
83
84#[derive(Debug, Clone)]
88pub struct WebSocketTransportConfig {
89 pub request_timeout: Duration,
92 pub extra_headers: HashMap<String, String>,
96 pub max_message_size: usize,
101}
102
103impl Default for WebSocketTransportConfig {
104 fn default() -> Self {
105 Self {
106 request_timeout: Duration::from_secs(30),
107 extra_headers: HashMap::new(),
108 max_message_size: crate::transport::DEFAULT_MAX_RESPONSE_SIZE,
109 }
110 }
111}
112
113impl WebSocketTransportConfig {
114 #[must_use]
116 pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
117 self.request_timeout = timeout;
118 self
119 }
120
121 #[must_use]
123 pub fn with_extra_headers(mut self, headers: HashMap<String, String>) -> Self {
124 self.extra_headers = headers;
125 self
126 }
127
128 #[must_use]
130 pub const fn with_max_message_size(mut self, max_bytes: usize) -> Self {
131 self.max_message_size = max_bytes;
132 self
133 }
134}
135
136pub struct WebSocketTransport {
151 inner: Arc<Inner>,
152}
153
154struct Inner {
155 write_tx: mpsc::Sender<WriteCommand>,
157 pending: Arc<Mutex<HashMap<String, PendingRequest>>>,
161 closed: Arc<AtomicBool>,
165 endpoint: String,
166 request_timeout: Duration,
167 reader_handle: tokio::task::JoinHandle<()>,
169 writer_handle: tokio::task::JoinHandle<()>,
171}
172
173impl Drop for Inner {
174 fn drop(&mut self) {
175 self.reader_handle.abort();
179 self.writer_handle.abort();
180 }
181}
182
183impl WebSocketTransport {
184 pub async fn connect(endpoint: impl Into<String>) -> ClientResult<Self> {
192 Self::connect_with_options(endpoint, Duration::from_secs(30), &HashMap::new()).await
193 }
194
195 pub async fn connect_with_timeout(
201 endpoint: impl Into<String>,
202 request_timeout: Duration,
203 ) -> ClientResult<Self> {
204 Self::connect_with_options(endpoint, request_timeout, &HashMap::new()).await
205 }
206
207 pub async fn connect_with_options(
218 endpoint: impl Into<String>,
219 request_timeout: Duration,
220 extra_headers: &HashMap<String, String>,
221 ) -> ClientResult<Self> {
222 Self::connect_with_config(
223 endpoint,
224 WebSocketTransportConfig::default()
225 .with_request_timeout(request_timeout)
226 .with_extra_headers(extra_headers.clone()),
227 )
228 .await
229 }
230
231 #[allow(clippy::too_many_lines)]
237 pub async fn connect_with_config(
238 endpoint: impl Into<String>,
239 config: WebSocketTransportConfig,
240 ) -> ClientResult<Self> {
241 let endpoint = endpoint.into();
242 validate_ws_url(&endpoint)?;
243
244 let mut ws_request = endpoint
248 .as_str()
249 .into_client_request()
250 .map_err(|e| ClientError::Transport(format!("WebSocket request build failed: {e}")))?;
251 ws_request.headers_mut().insert(
255 a2a_protocol_types::A2A_VERSION_HEADER,
256 tokio_tungstenite::tungstenite::http::HeaderValue::from_static(
257 a2a_protocol_types::A2A_VERSION,
258 ),
259 );
260 for (k, v) in &config.extra_headers {
261 let name = k
266 .parse::<tokio_tungstenite::tungstenite::http::HeaderName>()
267 .map_err(|e| {
268 ClientError::Transport(format!("invalid WebSocket header name {k:?}: {e}"))
269 })?;
270 let val = v
271 .parse::<tokio_tungstenite::tungstenite::http::HeaderValue>()
272 .map_err(|_| {
273 ClientError::Transport(format!("invalid WebSocket header value for {k:?}"))
274 })?;
275 ws_request.headers_mut().insert(name, val);
276 }
277
278 let ws_config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
283 .max_message_size(Some(config.max_message_size))
284 .max_frame_size(Some(config.max_message_size));
285
286 let (ws_stream, _resp) =
287 tokio_tungstenite::connect_async_with_config(ws_request, Some(ws_config), true)
288 .await
289 .map_err(|e| ClientError::Transport(format!("WebSocket connect failed: {e}")))?;
290
291 let (ws_writer, ws_reader) = ws_stream.split();
292
293 let pending: Arc<Mutex<HashMap<String, PendingRequest>>> =
295 Arc::new(Mutex::new(HashMap::new()));
296 let closed = Arc::new(AtomicBool::new(false));
297
298 let (write_tx, mut write_rx) = mpsc::channel::<WriteCommand>(64);
300
301 let pending_for_writer = Arc::clone(&pending);
304 let closed_for_writer = Arc::clone(&closed);
305 let writer_handle = tokio::spawn(async move {
306 let mut ws_writer = ws_writer;
307 while let Some(cmd) = write_rx.recv().await {
308 {
310 let mut map = pending_for_writer.lock().await;
311 map.insert(cmd.request_id, cmd.pending);
312 }
313 if ws_writer
314 .send(WsMessage::Text(cmd.text.into()))
315 .await
316 .is_err()
317 {
318 fail_all_pending(&pending_for_writer, &closed_for_writer).await;
322 break;
323 }
324 }
325 });
326
327 let pending_for_reader = Arc::clone(&pending);
330 let closed_for_reader = Arc::clone(&closed);
331 let reader_handle = tokio::spawn(async move {
332 let mut ws_reader = ws_reader;
333 loop {
334 match ws_reader.next().await {
335 Some(Ok(WsMessage::Text(text))) => {
336 route_frame(&pending_for_reader, text.as_str()).await;
337 }
338 Some(Ok(WsMessage::Close(_)) | Err(_)) | None => break,
343 Some(Ok(_)) => {}
345 }
346 }
347 fail_all_pending(&pending_for_reader, &closed_for_reader).await;
348 });
349
350 Ok(Self {
351 inner: Arc::new(Inner {
352 write_tx,
353 pending,
354 closed,
355 endpoint,
356 request_timeout: config.request_timeout,
357 reader_handle,
358 writer_handle,
359 }),
360 })
361 }
362
363 #[must_use]
365 pub fn endpoint(&self) -> &str {
366 &self.inner.endpoint
367 }
368
369 async fn execute_request(
371 &self,
372 method: &str,
373 params: serde_json::Value,
374 extra_headers: &HashMap<String, String>,
375 ) -> ClientResult<serde_json::Value> {
376 self.check_open()?;
377 warn_dropped_per_request_headers(method, extra_headers);
378 trace_info!(method, endpoint = %self.inner.endpoint, "sending WebSocket JSON-RPC request");
379
380 let rpc_req = build_rpc_request(method, params);
381 let request_id = rpc_req
382 .id
383 .as_value()
384 .and_then(|v| v.as_str())
385 .unwrap_or("")
386 .to_owned();
387 let body = serde_json::to_string(&rpc_req).map_err(ClientError::Serialization)?;
388
389 let (tx, rx) = oneshot::channel();
390
391 self.inner
392 .write_tx
393 .send(WriteCommand {
394 text: body,
395 request_id: request_id.clone(),
396 pending: PendingRequest::Unary(tx),
397 })
398 .await
399 .map_err(|_| ClientError::Transport("WebSocket writer task closed".into()))?;
400
401 let response_text = match tokio::time::timeout(self.inner.request_timeout, rx).await {
402 Ok(received) => received
403 .map_err(|_| ClientError::Transport("WebSocket reader task closed".into()))??,
404 Err(_elapsed) => {
405 self.inner.pending.lock().await.remove(&request_id);
408 return Err(ClientError::Timeout("WebSocket response timed out".into()));
409 }
410 };
411
412 let envelope: JsonRpcResponse<serde_json::Value> =
413 serde_json::from_str(&response_text).map_err(ClientError::Serialization)?;
414
415 match envelope {
416 JsonRpcResponse::Success(ok) => {
417 trace_info!(method, "WebSocket request succeeded");
418 Ok(ok.result)
419 }
420 JsonRpcResponse::Error(err) => {
421 trace_warn!(
422 method,
423 code = err.error.code,
424 "JSON-RPC error over WebSocket"
425 );
426 let a2a = crate::transport::map_jsonrpc_error(
427 err.error.code,
428 err.error.message,
429 err.error.data,
430 );
431 Err(ClientError::Protocol(a2a))
432 }
433 }
434 }
435
436 fn check_open(&self) -> ClientResult<()> {
438 if self.inner.closed.load(Ordering::Acquire) {
439 return Err(ClientError::Transport("WebSocket connection closed".into()));
440 }
441 Ok(())
442 }
443
444 async fn execute_streaming_request(
446 &self,
447 method: &str,
448 params: serde_json::Value,
449 extra_headers: &HashMap<String, String>,
450 ) -> ClientResult<EventStream> {
451 self.check_open()?;
452 warn_dropped_per_request_headers(method, extra_headers);
453 trace_info!(method, endpoint = %self.inner.endpoint, "opening WebSocket stream");
454
455 let rpc_req = build_rpc_request(method, params);
456 let request_id = rpc_req
457 .id
458 .as_value()
459 .and_then(|v| v.as_str())
460 .unwrap_or("")
461 .to_owned();
462 let body = serde_json::to_string(&rpc_req).map_err(ClientError::Serialization)?;
463
464 let (tx, rx) = mpsc::channel::<crate::streaming::event_stream::BodyChunk>(64);
466
467 self.inner
468 .write_tx
469 .send(WriteCommand {
470 text: body,
471 request_id,
472 pending: PendingRequest::Streaming(tx),
473 })
474 .await
475 .map_err(|_| ClientError::Transport("WebSocket writer task closed".into()))?;
476
477 Ok(EventStream::new(rx).with_first_event_timeout(self.inner.request_timeout))
482 }
483}
484
485impl Transport for WebSocketTransport {
486 fn send_request<'a>(
487 &'a self,
488 method: &'a str,
489 params: serde_json::Value,
490 extra_headers: &'a HashMap<String, String>,
491 ) -> Pin<Box<dyn Future<Output = ClientResult<serde_json::Value>> + Send + 'a>> {
492 Box::pin(self.execute_request(method, params, extra_headers))
493 }
494
495 fn send_streaming_request<'a>(
496 &'a self,
497 method: &'a str,
498 params: serde_json::Value,
499 extra_headers: &'a HashMap<String, String>,
500 ) -> Pin<Box<dyn Future<Output = ClientResult<EventStream>> + Send + 'a>> {
501 Box::pin(self.execute_streaming_request(method, params, extra_headers))
502 }
503}
504
505impl std::fmt::Debug for WebSocketTransport {
506 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507 f.debug_struct("WebSocketTransport")
508 .field("endpoint", &self.inner.endpoint)
509 .finish()
510 }
511}
512
513#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
522fn warn_dropped_per_request_headers(method: &str, extra_headers: &HashMap<String, String>) {
523 if !extra_headers.is_empty() {
524 trace_warn!(
525 method,
526 header_count = extra_headers.len(),
527 "per-request headers are not sent over an established WebSocket connection; \
528 supply credentials at connect time via WebSocketTransport::connect_with_options"
529 );
530 }
531}
532
533async fn fail_all_pending(pending: &Mutex<HashMap<String, PendingRequest>>, closed: &AtomicBool) {
540 closed.store(true, Ordering::Release);
541 let entries: Vec<PendingRequest> = {
542 let mut map = pending.lock().await;
543 map.drain().map(|(_, v)| v).collect()
544 };
545 for entry in entries {
546 match entry {
547 PendingRequest::Unary(tx) => {
548 let _ = tx.send(Err(ClientError::Transport(
549 "WebSocket connection closed".into(),
550 )));
551 }
552 PendingRequest::Streaming(tx) => {
553 let _ = tx.try_send(Err(ClientError::Transport(
558 "WebSocket connection closed".into(),
559 )));
560 }
561 }
562 }
563}
564
565async fn route_frame(pending: &Arc<Mutex<HashMap<String, PendingRequest>>>, text: &str) {
572 let Some(request_id) = extract_jsonrpc_id(text) else {
574 return;
577 };
578
579 let streaming_tx = {
587 let mut map = pending.lock().await;
588 let tx = match map.get(&request_id) {
589 Some(PendingRequest::Unary(_)) => {
590 if let Some(PendingRequest::Unary(tx)) = map.remove(&request_id) {
591 let _ = tx.send(Ok(text.to_owned()));
592 }
593 return;
594 }
595 Some(PendingRequest::Streaming(tx)) => tx.clone(),
596 None => return,
597 };
598 drop(map);
599 tx
600 };
601
602 let sse_line = format!("data: {text}\n\n");
605 if streaming_tx
606 .send(Ok(hyper::body::Bytes::from(sse_line)))
607 .await
608 .is_err()
609 {
610 pending.lock().await.remove(&request_id);
612 return;
613 }
614
615 if is_stream_terminal(text) {
621 pending.lock().await.remove(&request_id);
622 }
623}
624
625fn extract_jsonrpc_id(text: &str) -> Option<String> {
627 let v: serde_json::Value = serde_json::from_str(text).ok()?;
628 match v.get("id") {
629 Some(serde_json::Value::String(s)) => Some(s.clone()),
630 Some(serde_json::Value::Number(n)) => Some(n.to_string()),
631 _ => None,
632 }
633}
634
635fn task_state_str_is_terminal(state: &str) -> bool {
647 serde_json::from_value::<a2a_protocol_types::TaskState>(serde_json::Value::String(
648 state.to_owned(),
649 ))
650 .is_ok_and(a2a_protocol_types::TaskState::is_terminal)
651}
652
653fn is_stream_terminal(text: &str) -> bool {
661 let Ok(frame) = serde_json::from_str::<serde_json::Value>(text) else {
662 return false;
663 };
664
665 let has_terminal_state = |obj: &serde_json::Value| -> bool {
668 if let Some(status_update) = obj.get("statusUpdate") {
670 if let Some(status) = status_update.get("status") {
671 if let Some(state) = status.get("state").and_then(|s| s.as_str()) {
672 return task_state_str_is_terminal(state);
673 }
674 }
675 }
676 if let Some(status) = obj.get("status") {
678 if let Some(state) = status.get("state").and_then(|s| s.as_str()) {
679 return task_state_str_is_terminal(state);
680 }
681 }
682 false
683 };
684
685 if let Some(r) = frame.get("result") {
687 if r.get("stream_complete").is_some() {
691 return true;
692 }
693 if r.get("status").and_then(|s| s.as_str()) == Some("stream_complete") {
694 return true;
695 }
696 return has_terminal_state(r);
697 }
698
699 has_terminal_state(&frame)
702}
703
704fn build_rpc_request(method: &str, params: serde_json::Value) -> JsonRpcRequest {
705 let id = serde_json::Value::String(Uuid::new_v4().to_string());
706 JsonRpcRequest::with_params(id, method, params)
707}
708
709fn validate_ws_url(url: &str) -> ClientResult<()> {
710 if url.is_empty() {
711 return Err(ClientError::InvalidEndpoint("URL must not be empty".into()));
712 }
713 if !url.starts_with("ws://") && !url.starts_with("wss://") {
714 return Err(ClientError::InvalidEndpoint(format!(
715 "WebSocket URL must start with ws:// or wss://: {url}"
716 )));
717 }
718 Ok(())
719}
720
721#[cfg(test)]
724mod tests {
725 use super::*;
726
727 #[test]
728 fn validate_ws_url_rejects_empty() {
729 assert!(validate_ws_url("").is_err());
730 }
731
732 #[test]
733 fn with_extra_headers_sets_the_headers() {
734 let mut headers = HashMap::new();
737 headers.insert("authorization".to_string(), "Bearer tok".to_string());
738 headers.insert("x-custom".to_string(), "v".to_string());
739 let config = WebSocketTransportConfig::default().with_extra_headers(headers.clone());
740 assert_eq!(config.extra_headers, headers);
741 assert_eq!(
742 config
743 .extra_headers
744 .get("authorization")
745 .map(String::as_str),
746 Some("Bearer tok")
747 );
748 }
749
750 #[test]
751 fn validate_ws_url_rejects_http() {
752 assert!(validate_ws_url("http://localhost:8080").is_err());
753 }
754
755 #[test]
756 fn validate_ws_url_accepts_ws() {
757 assert!(validate_ws_url("ws://localhost:8080").is_ok());
758 }
759
760 #[test]
761 fn validate_ws_url_accepts_wss() {
762 assert!(validate_ws_url("wss://agent.example.com/a2a").is_ok());
763 }
764
765 #[test]
766 fn is_stream_terminal_completed_status() {
767 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"completed"}}}}"#;
768 assert!(is_stream_terminal(frame));
769 }
770
771 #[test]
772 fn is_stream_terminal_failed_status() {
773 let frame =
774 r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"failed"}}}}"#;
775 assert!(is_stream_terminal(frame));
776 }
777
778 #[test]
779 fn is_stream_terminal_working_is_not_terminal() {
780 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"working"}}}}"#;
781 assert!(!is_stream_terminal(frame));
782 }
783
784 #[test]
785 fn is_stream_terminal_stream_complete_sentinel() {
786 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"stream_complete":true}}"#;
787 assert!(is_stream_terminal(frame));
788 }
789
790 #[test]
791 fn is_stream_terminal_artifact_not_terminal() {
792 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"artifactUpdate":{"artifact":{"id":"a1","parts":[]}}}}"#;
793 assert!(!is_stream_terminal(frame));
794 }
795
796 #[test]
797 fn is_stream_terminal_payload_containing_word_not_terminal() {
798 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"artifactUpdate":{"artifact":{"id":"a1","parts":[{"text":"task completed successfully"}]}}}}"#;
800 assert!(!is_stream_terminal(frame));
801 }
802
803 #[test]
804 fn build_rpc_request_has_method() {
805 let req = build_rpc_request("TestMethod", serde_json::json!({"key": "val"}));
806 assert_eq!(req.method, "TestMethod");
807 let params = req.params.expect("params should be present");
808 assert_eq!(params["key"], "val");
809 let id = req.id.as_value().expect("id should be present");
811 assert!(id.is_string(), "id should be a string UUID");
812 assert!(!id.as_str().unwrap().is_empty(), "id should not be empty");
813 }
814
815 #[test]
816 fn is_stream_terminal_invalid_json() {
817 assert!(!is_stream_terminal("not json"));
818 }
819
820 #[test]
821 fn is_stream_terminal_no_result() {
822 assert!(!is_stream_terminal(r#"{"jsonrpc":"2.0","id":"1"}"#));
823 }
824
825 #[test]
826 fn is_stream_terminal_task_level_completed() {
827 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"status":{"state":"completed"}}}"#;
828 assert!(is_stream_terminal(frame));
829 }
830
831 #[test]
832 fn is_stream_terminal_canceled() {
833 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"canceled"}}}}"#;
834 assert!(is_stream_terminal(frame));
835 }
836
837 #[test]
838 fn is_stream_terminal_rejected() {
839 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"rejected"}}}}"#;
840 assert!(is_stream_terminal(frame));
841 }
842
843 #[test]
844 fn is_stream_terminal_task_level_failed() {
845 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"status":{"state":"failed"}}}"#;
846 assert!(is_stream_terminal(frame));
847 }
848
849 #[test]
850 fn is_stream_terminal_non_string_state() {
851 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"status":{"state":42}}}"#;
852 assert!(!is_stream_terminal(frame));
853 }
854
855 #[test]
860 fn is_stream_terminal_canonical_screaming_snake_case() {
861 for state in [
862 "TASK_STATE_COMPLETED",
863 "TASK_STATE_FAILED",
864 "TASK_STATE_CANCELED",
865 "TASK_STATE_REJECTED",
866 ] {
867 let frame = format!(
868 r#"{{"jsonrpc":"2.0","id":"1","result":{{"statusUpdate":{{"status":{{"state":"{state}"}}}}}}}}"#
869 );
870 assert!(
871 is_stream_terminal(&frame),
872 "canonical terminal state {state} not detected"
873 );
874 }
875 }
876
877 #[test]
879 fn is_stream_terminal_canonical_non_terminal() {
880 for state in ["TASK_STATE_WORKING", "TASK_STATE_SUBMITTED", "working"] {
881 let frame = format!(
882 r#"{{"jsonrpc":"2.0","id":"1","result":{{"status":{{"state":"{state}"}}}}}}"#
883 );
884 assert!(
885 !is_stream_terminal(&frame),
886 "non-terminal state {state} wrongly detected as terminal"
887 );
888 }
889 }
890
891 #[test]
892 fn validate_ws_url_rejects_https() {
893 assert!(validate_ws_url("https://example.com").is_err());
894 }
895
896 #[test]
897 fn validate_ws_url_error_message_contains_url() {
898 let err = validate_ws_url("http://bad").unwrap_err();
899 let msg = format!("{err}");
900 assert!(msg.contains("http://bad") || msg.contains("ws://"));
901 }
902
903 #[test]
904 fn extract_jsonrpc_id_string() {
905 let id = extract_jsonrpc_id(r#"{"jsonrpc":"2.0","id":"abc","result":{}}"#);
906 assert_eq!(id.as_deref(), Some("abc"));
907 }
908
909 #[test]
910 fn extract_jsonrpc_id_number() {
911 let id = extract_jsonrpc_id(r#"{"jsonrpc":"2.0","id":42,"result":{}}"#);
912 assert_eq!(id.as_deref(), Some("42"));
913 }
914
915 #[test]
916 fn extract_jsonrpc_id_null_returns_none() {
917 let id = extract_jsonrpc_id(r#"{"jsonrpc":"2.0","id":null,"result":{}}"#);
918 assert!(id.is_none());
919 }
920
921 #[test]
922 fn extract_jsonrpc_id_missing_returns_none() {
923 let id = extract_jsonrpc_id(r#"{"jsonrpc":"2.0","result":{}}"#);
924 assert!(id.is_none());
925 }
926
927 #[tokio::test]
932 async fn timed_out_request_is_removed_from_pending_map() {
933 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
936 let addr = listener.local_addr().unwrap();
937 tokio::spawn(async move {
938 while let Ok((stream, _)) = listener.accept().await {
939 tokio::spawn(async move {
940 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
941 return;
942 };
943 while let Some(Ok(_)) = ws.next().await {}
944 });
945 }
946 });
947
948 let transport = WebSocketTransport::connect_with_timeout(
949 format!("ws://{addr}"),
950 Duration::from_millis(100),
951 )
952 .await
953 .expect("connect");
954
955 let err = transport
956 .send_request("GetTask", serde_json::json!({"id": "t1"}), &HashMap::new())
957 .await
958 .expect_err("request must time out");
959 assert!(
960 matches!(err, ClientError::Timeout(_)),
961 "expected timeout, got: {err:?}"
962 );
963
964 assert!(
965 transport.inner.pending.lock().await.is_empty(),
966 "pending map must not retain timed-out requests"
967 );
968 }
969
970 async fn spawn_raw_ws_server<F, Fut>(per_conn: F) -> std::net::SocketAddr
973 where
974 F: Fn(tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> Fut
975 + Send
976 + Sync
977 + 'static,
978 Fut: std::future::Future<Output = ()> + Send + 'static,
979 {
980 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
981 let addr = listener.local_addr().unwrap();
982 let per_conn = Arc::new(per_conn);
983 tokio::spawn(async move {
984 while let Ok((stream, _)) = listener.accept().await {
985 let per_conn = Arc::clone(&per_conn);
986 tokio::spawn(async move {
987 if let Ok(ws) = tokio_tungstenite::accept_async(stream).await {
988 per_conn(ws).await;
989 }
990 });
991 }
992 });
993 addr
994 }
995
996 #[tokio::test]
1000 async fn dropping_transport_closes_connection() {
1001 let (closed_tx, mut closed_rx) = mpsc::channel::<()>(1);
1002 let closed_tx = Arc::new(closed_tx);
1003 let addr = spawn_raw_ws_server(move |mut ws| {
1004 let closed_tx = Arc::clone(&closed_tx);
1005 async move {
1006 while let Some(Ok(_)) = ws.next().await {}
1008 let _ = closed_tx.send(()).await;
1009 }
1010 })
1011 .await;
1012
1013 let transport = WebSocketTransport::connect(format!("ws://{addr}"))
1014 .await
1015 .expect("connect");
1016 drop(transport);
1017
1018 tokio::time::timeout(Duration::from_secs(5), closed_rx.recv())
1019 .await
1020 .expect("server must observe the connection closing after drop")
1021 .expect("channel open");
1022 }
1023
1024 #[tokio::test]
1028 async fn server_close_fails_pending_request_fast() {
1029 let addr = spawn_raw_ws_server(|mut ws| async move {
1030 let _ = ws.next().await;
1032 let _ = ws.close(None).await;
1033 })
1034 .await;
1035
1036 let transport = WebSocketTransport::connect_with_timeout(
1037 format!("ws://{addr}"),
1038 Duration::from_secs(30),
1039 )
1040 .await
1041 .expect("connect");
1042
1043 let start = std::time::Instant::now();
1044 let err = transport
1045 .send_request("GetTask", serde_json::json!({"id": "t1"}), &HashMap::new())
1046 .await
1047 .expect_err("request must fail when the server closes");
1048 assert!(
1049 matches!(err, ClientError::Transport(_)),
1050 "expected transport error, got: {err:?}"
1051 );
1052 assert!(
1053 start.elapsed() < Duration::from_secs(10),
1054 "failure must be prompt, took {:?} against a 30s request timeout",
1055 start.elapsed()
1056 );
1057
1058 let err = transport
1061 .send_request("GetTask", serde_json::json!({"id": "t2"}), &HashMap::new())
1062 .await
1063 .expect_err("dead transport must reject new requests");
1064 assert!(
1065 matches!(err, ClientError::Transport(_)),
1066 "expected transport error, got: {err:?}"
1067 );
1068 }
1069
1070 #[tokio::test]
1075 async fn oversized_incoming_frame_is_rejected() {
1076 let addr = spawn_raw_ws_server(|mut ws| async move {
1077 if let Some(Ok(_)) = ws.next().await {
1079 let big = "x".repeat(64 * 1024);
1080 let _ = ws
1081 .send(tokio_tungstenite::tungstenite::Message::Text(big.into()))
1082 .await;
1083 }
1084 while let Some(Ok(_)) = ws.next().await {}
1085 })
1086 .await;
1087
1088 let transport = WebSocketTransport::connect_with_config(
1089 format!("ws://{addr}"),
1090 WebSocketTransportConfig::default()
1091 .with_request_timeout(Duration::from_secs(30))
1092 .with_max_message_size(16 * 1024),
1093 )
1094 .await
1095 .expect("connect");
1096
1097 let start = std::time::Instant::now();
1098 let err = transport
1099 .send_request("GetTask", serde_json::json!({"id": "t1"}), &HashMap::new())
1100 .await
1101 .expect_err("oversized frame must fail the request");
1102 assert!(
1103 matches!(err, ClientError::Transport(_)),
1104 "expected transport error, got: {err:?}"
1105 );
1106 assert!(
1107 start.elapsed() < Duration::from_secs(10),
1108 "rejection must be prompt, took {:?}",
1109 start.elapsed()
1110 );
1111 }
1112
1113 #[cfg(feature = "tracing")]
1119 #[test]
1120 fn warn_dropped_per_request_headers_warns_iff_headers_present() {
1121 use std::sync::atomic::{AtomicUsize, Ordering};
1122 use std::sync::Arc;
1123
1124 struct CountingSubscriber(Arc<AtomicUsize>);
1126 impl tracing::Subscriber for CountingSubscriber {
1127 fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
1128 true
1129 }
1130 fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1131 tracing::span::Id::from_u64(1)
1132 }
1133 fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
1134 fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
1135 fn event(&self, _: &tracing::Event<'_>) {
1136 self.0.fetch_add(1, Ordering::SeqCst);
1137 }
1138 fn enter(&self, _: &tracing::span::Id) {}
1139 fn exit(&self, _: &tracing::span::Id) {}
1140 }
1141
1142 let count = Arc::new(AtomicUsize::new(0));
1143 tracing::subscriber::with_default(CountingSubscriber(Arc::clone(&count)), || {
1144 warn_dropped_per_request_headers("SendMessage", &HashMap::new());
1146 assert_eq!(
1147 count.load(Ordering::SeqCst),
1148 0,
1149 "must not warn when there are no per-request headers to drop"
1150 );
1151
1152 let mut headers = HashMap::new();
1154 headers.insert("authorization".to_owned(), "Bearer secret".to_owned());
1155 warn_dropped_per_request_headers("SendMessage", &headers);
1156 assert_eq!(
1157 count.load(Ordering::SeqCst),
1158 1,
1159 "dropping a per-request header must emit a warning (never silent)"
1160 );
1161 });
1162 }
1163}