diff --git a/src/asynch/clients/websocket/_std.rs b/src/asynch/clients/websocket/_std.rs index 42b31596..5bba7e99 100644 --- a/src/asynch/clients/websocket/_std.rs +++ b/src/asynch/clients/websocket/_std.rs @@ -7,6 +7,7 @@ use crate::asynch::clients::SingleExecutorMutex; use crate::models::requests::{Request, XRPLRequest}; use crate::models::results::XRPLResponse; +use alloc::format; use alloc::string::{String, ToString}; use alloc::sync::Arc; use core::marker::PhantomData; @@ -23,6 +24,15 @@ use tokio_tungstenite::connect_async as tokio_tungstenite_connect_async; type TokioTungsteniteMaybeTlsStream = WebSocketStream>; +/// Render the reason carried by a WebSocket Close frame so it is preserved in +/// the surfaced error instead of being discarded. +fn close_reason(frame: Option<&tungstenite::protocol::CloseFrame<'_>>) -> String { + match frame { + Some(frame) => format!("code {}: {}", u16::from(frame.code), frame.reason), + None => "no close reason provided".to_string(), + } +} + pub struct AsyncWebSocketClient where M: RawMutex, @@ -111,8 +121,14 @@ where }; Poll::Ready(Some(Ok(response_string))) } - tungstenite::Message::Close(_) => { - Poll::Ready(Some(Err(XRPLWebSocketException::Disconnected.into()))) + tungstenite::Message::Close(frame) => { + // tokio-tungstenite queues the Close echo internally; + // preserve the peer's reason in the surfaced error + // instead of discarding it. + let reason = close_reason(frame.as_ref()); + Poll::Ready(Some(Err( + XRPLWebSocketException::ConnectionClosed(reason).into() + ))) } _ => Poll::Ready(Some(Err( XRPLWebSocketException::UnexpectedMessageType.into() @@ -255,8 +271,12 @@ where Err(error) => return Err(error.into()), } } - Some(Ok(tungstenite::Message::Close(_))) => { - return Err(XRPLWebSocketException::Disconnected.into()); + Some(Ok(tungstenite::Message::Close(frame))) => { + let reason = close_reason(frame.as_ref()); + // Reply with a Close frame to complete the RFC 6455 closing + // handshake before surfacing the error. + let _ = websocket.send(tungstenite::Message::Close(None)).await; + return Err(XRPLWebSocketException::ConnectionClosed(reason).into()); } Some(Ok(_)) => { return Err(XRPLWebSocketException::UnexpectedMessageType.into()); @@ -267,3 +287,24 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + use tungstenite::protocol::frame::coding::CloseCode; + use tungstenite::protocol::CloseFrame; + + #[test] + fn close_reason_preserves_peer_detail() { + let frame = CloseFrame { + code: CloseCode::Normal, + reason: "going away".into(), + }; + assert_eq!(close_reason(Some(&frame)), "code 1000: going away"); + } + + #[test] + fn close_reason_handles_missing_frame() { + assert_eq!(close_reason(None), "no close reason provided"); + } +} diff --git a/src/asynch/clients/websocket/exceptions.rs b/src/asynch/clients/websocket/exceptions.rs index 2d65beaf..d5f8f5ef 100644 --- a/src/asynch/clients/websocket/exceptions.rs +++ b/src/asynch/clients/websocket/exceptions.rs @@ -25,6 +25,8 @@ pub enum XRPLWebSocketException { WebSocket(embedded_websocket_embedded_io::Error), #[error("Disconnected")] Disconnected, + #[error("Connection closed by peer: {0}")] + ConnectionClosed(String), #[error("Read buffer is too small (size: {0:?})")] RxBufferTooSmall(usize), #[error("Unexpected message type")]