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 if is_stream_complete_sentinel(text) {
606 pending.lock().await.remove(&request_id);
607 return;
608 }
609
610 let sse_line = format!("data: {text}\n\n");
613 if streaming_tx
614 .send(Ok(hyper::body::Bytes::from(sse_line)))
615 .await
616 .is_err()
617 {
618 pending.lock().await.remove(&request_id);
620 return;
621 }
622
623 if is_stream_terminal(text) {
629 pending.lock().await.remove(&request_id);
630 }
631}
632
633fn extract_jsonrpc_id(text: &str) -> Option<String> {
635 let v: serde_json::Value = serde_json::from_str(text).ok()?;
636 match v.get("id") {
637 Some(serde_json::Value::String(s)) => Some(s.clone()),
638 Some(serde_json::Value::Number(n)) => Some(n.to_string()),
639 _ => None,
640 }
641}
642
643fn task_state_str_is_terminal(state: &str) -> bool {
655 serde_json::from_value::<a2a_protocol_types::TaskState>(serde_json::Value::String(
656 state.to_owned(),
657 ))
658 .is_ok_and(a2a_protocol_types::TaskState::is_terminal)
659}
660
661fn is_stream_complete_sentinel(text: &str) -> bool {
689 let Ok(frame) = serde_json::from_str::<serde_json::Value>(text) else {
690 return false;
691 };
692 let Some(r) = frame.get("result") else {
693 return false;
694 };
695 r.get("stream_complete").is_some()
696 || r.get("status").and_then(|s| s.as_str()) == Some("stream_complete")
697}
698
699fn is_stream_terminal(text: &str) -> bool {
707 let Ok(frame) = serde_json::from_str::<serde_json::Value>(text) else {
708 return false;
709 };
710
711 let has_terminal_state = |obj: &serde_json::Value| -> bool {
714 if let Some(status_update) = obj.get("statusUpdate") {
716 if let Some(status) = status_update.get("status") {
717 if let Some(state) = status.get("state").and_then(|s| s.as_str()) {
718 return task_state_str_is_terminal(state);
719 }
720 }
721 }
722 if let Some(status) = obj.get("status") {
724 if let Some(state) = status.get("state").and_then(|s| s.as_str()) {
725 return task_state_str_is_terminal(state);
726 }
727 }
728 false
729 };
730
731 if let Some(r) = frame.get("result") {
733 if r.get("stream_complete").is_some() {
737 return true;
738 }
739 if r.get("status").and_then(|s| s.as_str()) == Some("stream_complete") {
740 return true;
741 }
742 return has_terminal_state(r);
743 }
744
745 has_terminal_state(&frame)
748}
749
750fn build_rpc_request(method: &str, params: serde_json::Value) -> JsonRpcRequest {
751 let id = serde_json::Value::String(Uuid::new_v4().to_string());
752 JsonRpcRequest::with_params(id, method, params)
753}
754
755fn validate_ws_url(url: &str) -> ClientResult<()> {
756 if url.is_empty() {
757 return Err(ClientError::InvalidEndpoint("URL must not be empty".into()));
758 }
759 if !url.starts_with("ws://") && !url.starts_with("wss://") {
760 return Err(ClientError::InvalidEndpoint(format!(
761 "WebSocket URL must start with ws:// or wss://: {url}"
762 )));
763 }
764 Ok(())
765}
766
767#[cfg(test)]
770mod tests {
771 use super::*;
772
773 #[test]
774 fn validate_ws_url_rejects_empty() {
775 assert!(validate_ws_url("").is_err());
776 }
777
778 #[test]
779 fn with_extra_headers_sets_the_headers() {
780 let mut headers = HashMap::new();
783 headers.insert("authorization".to_string(), "Bearer tok".to_string());
784 headers.insert("x-custom".to_string(), "v".to_string());
785 let config = WebSocketTransportConfig::default().with_extra_headers(headers.clone());
786 assert_eq!(config.extra_headers, headers);
787 assert_eq!(
788 config
789 .extra_headers
790 .get("authorization")
791 .map(String::as_str),
792 Some("Bearer tok")
793 );
794 }
795
796 #[test]
797 fn validate_ws_url_rejects_http() {
798 assert!(validate_ws_url("http://localhost:8080").is_err());
799 }
800
801 #[test]
802 fn validate_ws_url_accepts_ws() {
803 assert!(validate_ws_url("ws://localhost:8080").is_ok());
804 }
805
806 #[test]
807 fn validate_ws_url_accepts_wss() {
808 assert!(validate_ws_url("wss://agent.example.com/a2a").is_ok());
809 }
810
811 #[test]
812 fn is_stream_terminal_completed_status() {
813 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"completed"}}}}"#;
814 assert!(is_stream_terminal(frame));
815 }
816
817 #[test]
818 fn is_stream_terminal_failed_status() {
819 let frame =
820 r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"failed"}}}}"#;
821 assert!(is_stream_terminal(frame));
822 }
823
824 #[test]
825 fn is_stream_terminal_working_is_not_terminal() {
826 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"working"}}}}"#;
827 assert!(!is_stream_terminal(frame));
828 }
829
830 #[test]
831 fn stream_complete_sentinel_is_recognized_in_both_spellings() {
832 assert!(is_stream_complete_sentinel(
833 r#"{"jsonrpc":"2.0","id":"1","result":{"status":"stream_complete"}}"#
834 ));
835 assert!(is_stream_complete_sentinel(
836 r#"{"jsonrpc":"2.0","id":"1","result":{"stream_complete":true}}"#
837 ));
838 }
839
840 #[test]
845 fn real_events_are_not_mistaken_for_the_sentinel() {
846 for frame in [
847 r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"TASK_STATE_COMPLETED"}}}}"#,
848 r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"TASK_STATE_INPUT_REQUIRED"}}}}"#,
849 r#"{"jsonrpc":"2.0","id":"1","result":{"task":{"id":"t1"}}}"#,
850 r#"{"jsonrpc":"2.0","id":"1","result":{"artifactUpdate":{"taskId":"t1"}}}"#,
851 r#"{"jsonrpc":"2.0","id":"1","result":{"task":{"id":"stream_complete"}}}"#,
853 ] {
854 assert!(
855 !is_stream_complete_sentinel(frame),
856 "wrongly treated as the end-of-stream sentinel: {frame}"
857 );
858 }
859 }
860
861 #[test]
864 fn the_sentinel_cannot_deserialize_as_a_stream_response() {
865 let result = serde_json::from_str::<a2a_protocol_types::events::StreamResponse>(
866 r#"{"status":"stream_complete"}"#,
867 );
868 let err = result.expect_err("the sentinel must not parse as a StreamResponse");
869 assert!(
870 err.to_string().contains("unknown variant"),
871 "expected an unknown-variant error, got: {err}"
872 );
873 }
874
875 #[test]
876 fn is_stream_terminal_stream_complete_sentinel() {
877 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"stream_complete":true}}"#;
878 assert!(is_stream_terminal(frame));
879 }
880
881 #[test]
882 fn is_stream_terminal_artifact_not_terminal() {
883 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"artifactUpdate":{"artifact":{"id":"a1","parts":[]}}}}"#;
884 assert!(!is_stream_terminal(frame));
885 }
886
887 #[test]
888 fn is_stream_terminal_payload_containing_word_not_terminal() {
889 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"artifactUpdate":{"artifact":{"id":"a1","parts":[{"text":"task completed successfully"}]}}}}"#;
891 assert!(!is_stream_terminal(frame));
892 }
893
894 #[test]
895 fn build_rpc_request_has_method() {
896 let req = build_rpc_request("TestMethod", serde_json::json!({"key": "val"}));
897 assert_eq!(req.method, "TestMethod");
898 let params = req.params.expect("params should be present");
899 assert_eq!(params["key"], "val");
900 let id = req.id.as_value().expect("id should be present");
902 assert!(id.is_string(), "id should be a string UUID");
903 assert!(!id.as_str().unwrap().is_empty(), "id should not be empty");
904 }
905
906 #[test]
907 fn is_stream_terminal_invalid_json() {
908 assert!(!is_stream_terminal("not json"));
909 }
910
911 #[test]
912 fn is_stream_terminal_no_result() {
913 assert!(!is_stream_terminal(r#"{"jsonrpc":"2.0","id":"1"}"#));
914 }
915
916 #[test]
917 fn is_stream_terminal_task_level_completed() {
918 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"status":{"state":"completed"}}}"#;
919 assert!(is_stream_terminal(frame));
920 }
921
922 #[test]
923 fn is_stream_terminal_canceled() {
924 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"canceled"}}}}"#;
925 assert!(is_stream_terminal(frame));
926 }
927
928 #[test]
929 fn is_stream_terminal_rejected() {
930 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"statusUpdate":{"status":{"state":"rejected"}}}}"#;
931 assert!(is_stream_terminal(frame));
932 }
933
934 #[test]
935 fn is_stream_terminal_task_level_failed() {
936 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"status":{"state":"failed"}}}"#;
937 assert!(is_stream_terminal(frame));
938 }
939
940 #[test]
941 fn is_stream_terminal_non_string_state() {
942 let frame = r#"{"jsonrpc":"2.0","id":"1","result":{"status":{"state":42}}}"#;
943 assert!(!is_stream_terminal(frame));
944 }
945
946 #[test]
951 fn is_stream_terminal_canonical_screaming_snake_case() {
952 for state in [
953 "TASK_STATE_COMPLETED",
954 "TASK_STATE_FAILED",
955 "TASK_STATE_CANCELED",
956 "TASK_STATE_REJECTED",
957 ] {
958 let frame = format!(
959 r#"{{"jsonrpc":"2.0","id":"1","result":{{"statusUpdate":{{"status":{{"state":"{state}"}}}}}}}}"#
960 );
961 assert!(
962 is_stream_terminal(&frame),
963 "canonical terminal state {state} not detected"
964 );
965 }
966 }
967
968 #[test]
970 fn is_stream_terminal_canonical_non_terminal() {
971 for state in ["TASK_STATE_WORKING", "TASK_STATE_SUBMITTED", "working"] {
972 let frame = format!(
973 r#"{{"jsonrpc":"2.0","id":"1","result":{{"status":{{"state":"{state}"}}}}}}"#
974 );
975 assert!(
976 !is_stream_terminal(&frame),
977 "non-terminal state {state} wrongly detected as terminal"
978 );
979 }
980 }
981
982 #[test]
983 fn validate_ws_url_rejects_https() {
984 assert!(validate_ws_url("https://example.com").is_err());
985 }
986
987 #[test]
988 fn validate_ws_url_error_message_contains_url() {
989 let err = validate_ws_url("http://bad").unwrap_err();
990 let msg = format!("{err}");
991 assert!(msg.contains("http://bad") || msg.contains("ws://"));
992 }
993
994 #[test]
995 fn extract_jsonrpc_id_string() {
996 let id = extract_jsonrpc_id(r#"{"jsonrpc":"2.0","id":"abc","result":{}}"#);
997 assert_eq!(id.as_deref(), Some("abc"));
998 }
999
1000 #[test]
1001 fn extract_jsonrpc_id_number() {
1002 let id = extract_jsonrpc_id(r#"{"jsonrpc":"2.0","id":42,"result":{}}"#);
1003 assert_eq!(id.as_deref(), Some("42"));
1004 }
1005
1006 #[test]
1007 fn extract_jsonrpc_id_null_returns_none() {
1008 let id = extract_jsonrpc_id(r#"{"jsonrpc":"2.0","id":null,"result":{}}"#);
1009 assert!(id.is_none());
1010 }
1011
1012 #[test]
1013 fn extract_jsonrpc_id_missing_returns_none() {
1014 let id = extract_jsonrpc_id(r#"{"jsonrpc":"2.0","result":{}}"#);
1015 assert!(id.is_none());
1016 }
1017
1018 #[tokio::test]
1023 async fn timed_out_request_is_removed_from_pending_map() {
1024 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1027 let addr = listener.local_addr().unwrap();
1028 tokio::spawn(async move {
1029 while let Ok((stream, _)) = listener.accept().await {
1030 tokio::spawn(async move {
1031 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
1032 return;
1033 };
1034 while let Some(Ok(_)) = ws.next().await {}
1035 });
1036 }
1037 });
1038
1039 let transport = WebSocketTransport::connect_with_timeout(
1040 format!("ws://{addr}"),
1041 Duration::from_millis(100),
1042 )
1043 .await
1044 .expect("connect");
1045
1046 let err = transport
1047 .send_request("GetTask", serde_json::json!({"id": "t1"}), &HashMap::new())
1048 .await
1049 .expect_err("request must time out");
1050 assert!(
1051 matches!(err, ClientError::Timeout(_)),
1052 "expected timeout, got: {err:?}"
1053 );
1054
1055 assert!(
1056 transport.inner.pending.lock().await.is_empty(),
1057 "pending map must not retain timed-out requests"
1058 );
1059 }
1060
1061 async fn spawn_raw_ws_server<F, Fut>(per_conn: F) -> std::net::SocketAddr
1064 where
1065 F: Fn(tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> Fut
1066 + Send
1067 + Sync
1068 + 'static,
1069 Fut: std::future::Future<Output = ()> + Send + 'static,
1070 {
1071 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1072 let addr = listener.local_addr().unwrap();
1073 let per_conn = Arc::new(per_conn);
1074 tokio::spawn(async move {
1075 while let Ok((stream, _)) = listener.accept().await {
1076 let per_conn = Arc::clone(&per_conn);
1077 tokio::spawn(async move {
1078 if let Ok(ws) = tokio_tungstenite::accept_async(stream).await {
1079 per_conn(ws).await;
1080 }
1081 });
1082 }
1083 });
1084 addr
1085 }
1086
1087 #[tokio::test]
1091 async fn dropping_transport_closes_connection() {
1092 let (closed_tx, mut closed_rx) = mpsc::channel::<()>(1);
1093 let closed_tx = Arc::new(closed_tx);
1094 let addr = spawn_raw_ws_server(move |mut ws| {
1095 let closed_tx = Arc::clone(&closed_tx);
1096 async move {
1097 while let Some(Ok(_)) = ws.next().await {}
1099 let _ = closed_tx.send(()).await;
1100 }
1101 })
1102 .await;
1103
1104 let transport = WebSocketTransport::connect(format!("ws://{addr}"))
1105 .await
1106 .expect("connect");
1107 drop(transport);
1108
1109 tokio::time::timeout(Duration::from_secs(5), closed_rx.recv())
1110 .await
1111 .expect("server must observe the connection closing after drop")
1112 .expect("channel open");
1113 }
1114
1115 #[tokio::test]
1119 async fn server_close_fails_pending_request_fast() {
1120 let addr = spawn_raw_ws_server(|mut ws| async move {
1121 let _ = ws.next().await;
1123 let _ = ws.close(None).await;
1124 })
1125 .await;
1126
1127 let transport = WebSocketTransport::connect_with_timeout(
1128 format!("ws://{addr}"),
1129 Duration::from_secs(30),
1130 )
1131 .await
1132 .expect("connect");
1133
1134 let start = std::time::Instant::now();
1135 let err = transport
1136 .send_request("GetTask", serde_json::json!({"id": "t1"}), &HashMap::new())
1137 .await
1138 .expect_err("request must fail when the server closes");
1139 assert!(
1140 matches!(err, ClientError::Transport(_)),
1141 "expected transport error, got: {err:?}"
1142 );
1143 assert!(
1144 start.elapsed() < Duration::from_secs(10),
1145 "failure must be prompt, took {:?} against a 30s request timeout",
1146 start.elapsed()
1147 );
1148
1149 let err = transport
1152 .send_request("GetTask", serde_json::json!({"id": "t2"}), &HashMap::new())
1153 .await
1154 .expect_err("dead transport must reject new requests");
1155 assert!(
1156 matches!(err, ClientError::Transport(_)),
1157 "expected transport error, got: {err:?}"
1158 );
1159 }
1160
1161 #[tokio::test]
1166 async fn oversized_incoming_frame_is_rejected() {
1167 let addr = spawn_raw_ws_server(|mut ws| async move {
1168 if let Some(Ok(_)) = ws.next().await {
1170 let big = "x".repeat(64 * 1024);
1171 let _ = ws
1172 .send(tokio_tungstenite::tungstenite::Message::Text(big.into()))
1173 .await;
1174 }
1175 while let Some(Ok(_)) = ws.next().await {}
1176 })
1177 .await;
1178
1179 let transport = WebSocketTransport::connect_with_config(
1180 format!("ws://{addr}"),
1181 WebSocketTransportConfig::default()
1182 .with_request_timeout(Duration::from_secs(30))
1183 .with_max_message_size(16 * 1024),
1184 )
1185 .await
1186 .expect("connect");
1187
1188 let start = std::time::Instant::now();
1189 let err = transport
1190 .send_request("GetTask", serde_json::json!({"id": "t1"}), &HashMap::new())
1191 .await
1192 .expect_err("oversized frame must fail the request");
1193 assert!(
1194 matches!(err, ClientError::Transport(_)),
1195 "expected transport error, got: {err:?}"
1196 );
1197 assert!(
1198 start.elapsed() < Duration::from_secs(10),
1199 "rejection must be prompt, took {:?}",
1200 start.elapsed()
1201 );
1202 }
1203
1204 #[cfg(feature = "tracing")]
1210 #[test]
1211 fn warn_dropped_per_request_headers_warns_iff_headers_present() {
1212 use std::sync::atomic::{AtomicUsize, Ordering};
1213 use std::sync::Arc;
1214
1215 struct CountingSubscriber(Arc<AtomicUsize>);
1217 impl tracing::Subscriber for CountingSubscriber {
1218 fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
1219 true
1220 }
1221 fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1222 tracing::span::Id::from_u64(1)
1223 }
1224 fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
1225 fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
1226 fn event(&self, _: &tracing::Event<'_>) {
1227 self.0.fetch_add(1, Ordering::SeqCst);
1228 }
1229 fn enter(&self, _: &tracing::span::Id) {}
1230 fn exit(&self, _: &tracing::span::Id) {}
1231 }
1232
1233 let count = Arc::new(AtomicUsize::new(0));
1234 tracing::subscriber::with_default(CountingSubscriber(Arc::clone(&count)), || {
1235 warn_dropped_per_request_headers("SendMessage", &HashMap::new());
1237 assert_eq!(
1238 count.load(Ordering::SeqCst),
1239 0,
1240 "must not warn when there are no per-request headers to drop"
1241 );
1242
1243 let mut headers = HashMap::new();
1245 headers.insert("authorization".to_owned(), "Bearer secret".to_owned());
1246 warn_dropped_per_request_headers("SendMessage", &headers);
1247 assert_eq!(
1248 count.load(Ordering::SeqCst),
1249 1,
1250 "dropping a per-request header must emit a warning (never silent)"
1251 );
1252 });
1253 }
1254}