diff --git a/libs/clipboard/src/platform/unix/fuse/cs.rs b/libs/clipboard/src/platform/unix/fuse/cs.rs index fa1dea71d..307c2fb16 100644 --- a/libs/clipboard/src/platform/unix/fuse/cs.rs +++ b/libs/clipboard/src/platform/unix/fuse/cs.rs @@ -533,7 +533,7 @@ impl FuseServer { offset: i64, size: u32, ) -> Result, std::io::Error> { - // todo: async and concurrent read, generate stream_id per request + let request_stream_id = rand::random(); let cb_requested = unsafe { // convert `size` from u32 to i32 // yet with same bit representation @@ -543,7 +543,7 @@ impl FuseServer { let (n_position_high, n_position_low) = ((offset >> 32) as i32, (offset & (u32::MAX as i64)) as i32); let request = ClipboardFile::FileContentsRequest { - stream_id: node.stream_id, + stream_id: request_stream_id, list_index: node.index as i32, dw_flags: 2, n_position_low, @@ -573,7 +573,7 @@ impl FuseServer { stream_id, requested_data, } => { - if stream_id != node.stream_id { + if stream_id != request_stream_id { log::debug!("stream id mismatch, ignore"); continue; } @@ -611,11 +611,6 @@ struct FuseNode { /// connection id pub conn_id: i32, - // todo: use stream_id to identify a FileContents request-reply - // instead of a whole file - /// stream id - pub stream_id: i32, - /// file index in peer's file list /// NOTE: /// it is NOT the same as inode, this is the index in the file list @@ -639,7 +634,6 @@ impl FuseNode { pub fn from_description(inode: Inode, desc: FileDescription) -> Self { Self { conn_id: desc.conn_id, - stream_id: rand::random(), index: inode as usize - 2, name: desc .name @@ -656,7 +650,6 @@ impl FuseNode { pub fn new_root() -> Self { Self { conn_id: 0, - stream_id: rand::random(), index: 0, name: String::from("/"), parent: None, diff --git a/libs/clipboard/src/platform/unix/fuse/mod.rs b/libs/clipboard/src/platform/unix/fuse/mod.rs index f0acef6da..a1ad2a4a5 100644 --- a/libs/clipboard/src/platform/unix/fuse/mod.rs +++ b/libs/clipboard/src/platform/unix/fuse/mod.rs @@ -7,7 +7,8 @@ use fuser::MountOption; use hbb_common::{config::Config, log}; use parking_lot::Mutex; use std::{ - path::PathBuf, + io, + path::{Path, PathBuf}, sync::{mpsc::Sender, Arc}, time::Duration, }; @@ -31,6 +32,14 @@ lazy_static::lazy_static! { static FUSE_TIMEOUT: Duration = Duration::from_secs(3); +#[derive(Debug, PartialEq, Eq)] +enum MountPointState { + HealthyMount, + NotMounted, + StaleMount, + Unknown, +} + fn fuse_mount_point(name: &str) -> String { let mut path = PathBuf::from(Config::ipc_path("")); path.pop(); @@ -60,8 +69,27 @@ pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> { } else { FUSE_CONTEXT_SERVER.lock() }; - if fuse_context_lock.is_some() { - return Ok(()); + if let Some(ctx) = fuse_context_lock.as_ref() { + match inspect_mount_point_state(&ctx.mount_point) { + MountPointState::HealthyMount => return Ok(()), + MountPointState::StaleMount | MountPointState::NotMounted => { + log::warn!( + "clipboard FUSE mount {} is disconnected, remounting", + ctx.mount_point.display() + ); + let stale_context = fuse_context_lock.take(); + drop(fuse_context_lock); + drop(stale_context); + return init_fuse_context(is_client); + } + MountPointState::Unknown => { + log::warn!( + "failed to verify clipboard FUSE mount {}", + ctx.mount_point.display() + ); + return Err(CliprdrError::CliprdrInit); + } + } } let mount_point = if is_client { FUSE_MOUNT_POINT_CLIENT.clone() @@ -70,6 +98,28 @@ pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> { }; let mount_point = std::path::PathBuf::from(&*mount_point); + match inspect_mount_point_state(&mount_point) { + MountPointState::HealthyMount => { + log::warn!( + "clipboard FUSE mount {} is already active in another context", + mount_point.display() + ); + return Err(CliprdrError::ClipboardOccupied); + } + MountPointState::StaleMount => { + log::warn!( + "clipboard FUSE mount {} is stale, cleaning up before remount", + mount_point.display() + ); + unmount_fuse_mount_point(&mount_point); + validate_mount_state_after_stale_cleanup( + &mount_point, + inspect_mount_point_state(&mount_point), + )?; + } + MountPointState::Unknown => return Err(CliprdrError::CliprdrInit), + MountPointState::NotMounted => {} + } let (server, tx) = FuseServer::new(FUSE_TIMEOUT); let server = Arc::new(Mutex::new(server)); @@ -172,18 +222,23 @@ fn prepare_fuse_mount_point(mount_point: &PathBuf) -> Result<(), CliprdrError> { os::unix::prelude::PermissionsExt, }; + if let Some(parent) = mount_point.parent() { + reject_symlink_path(parent)?; + if let Err(e) = fs::create_dir_all(parent) { + log::warn!("failed to create FUSE mount parent {:?}: {:?}", parent, e); + return Err(CliprdrError::CliprdrInit); + } + } + + reject_symlink_path(mount_point)?; + let recovered_stale_mount = if let Err(e) = fs::create_dir_all(mount_point) { log::warn!( "failed to create clipboard FUSE mount point {}, trying stale mount cleanup: {:?}", mount_point.display(), e ); - if let Err(e) = std::process::Command::new("umount") - .arg(mount_point) - .status() - { - log::warn!("umount {:?} may fail: {:?}", mount_point, e); - } + unmount_fuse_mount_point(mount_point); fs::create_dir_all(mount_point).map_err(|e| { log::error!( "failed to create clipboard FUSE mount point {} after cleanup: {:?}", @@ -205,22 +260,193 @@ fn prepare_fuse_mount_point(mount_point: &PathBuf) -> Result<(), CliprdrError> { } if !recovered_stale_mount { - if let Err(e) = std::process::Command::new("umount") - .arg(mount_point) - .status() - { - log::warn!("umount {:?} may fail: {:?}", mount_point, e); - } + unmount_fuse_mount_point(mount_point); } Ok(()) } -fn uninit_fuse_context_(is_client: bool) { - if is_client { - let _ = FUSE_CONTEXT_CLIENT.lock().take(); - } else { - let _ = FUSE_CONTEXT_SERVER.lock().take(); +fn inspect_mount_point_state(mount_point: &Path) -> MountPointState { + if ensure_mount_point_path_is_safe(mount_point).is_err() { + return MountPointState::Unknown; } + inspect_mount_point_state_with( + mount_point, + std::fs::metadata(mount_point), + std::fs::read_to_string("/proc/self/mountinfo"), + ) +} + +fn validate_mount_state_after_stale_cleanup( + mount_point: &Path, + mount_state: MountPointState, +) -> Result<(), CliprdrError> { + match mount_state { + MountPointState::NotMounted => Ok(()), + MountPointState::HealthyMount => { + log::warn!( + "clipboard FUSE mount {} is still active after stale cleanup", + mount_point.display() + ); + Err(CliprdrError::ClipboardOccupied) + } + MountPointState::StaleMount => { + log::warn!( + "clipboard FUSE mount {} is still stale after cleanup", + mount_point.display() + ); + Err(CliprdrError::CliprdrInit) + } + MountPointState::Unknown => { + log::warn!( + "failed to verify clipboard FUSE mount {} after cleanup", + mount_point.display() + ); + Err(CliprdrError::CliprdrInit) + } + } +} + +fn inspect_mount_point_state_with( + mount_point: &Path, + metadata_result: io::Result, + mountinfo_result: io::Result, +) -> MountPointState { + match metadata_result { + Ok(_) => match mountinfo_result { + Ok(mountinfo) => { + if is_mount_point_listed_in_mountinfo(mount_point, &mountinfo) { + MountPointState::HealthyMount + } else { + MountPointState::NotMounted + } + } + Err(e) => { + log::warn!("failed to read mountinfo for {:?}: {:?}", mount_point, e); + MountPointState::Unknown + } + }, + Err(e) if e.raw_os_error() == Some(libc::ENOTCONN) => MountPointState::StaleMount, + Err(e) if e.kind() == io::ErrorKind::NotFound => MountPointState::NotMounted, + Err(e) => { + log::warn!("failed to inspect FUSE mount {:?}: {:?}", mount_point, e); + MountPointState::Unknown + } + } +} + +fn is_mount_point_listed_in_mountinfo(mount_point: &Path, mountinfo: &str) -> bool { + let mount_point = mount_point.to_string_lossy(); + mountinfo.lines().any(|line| { + let mut fields = line.split_whitespace(); + let _mount_id = fields.next(); + let _parent_id = fields.next(); + let _major_minor = fields.next(); + let _root = fields.next(); + let mount_path = fields.next(); + mount_path == Some(mount_point.as_ref()) + }) +} + +fn reject_symlink_metadata_result( + path: &Path, + metadata_result: io::Result, + allow_disconnected_mount: bool, +) -> Result<(), CliprdrError> { + match metadata_result { + Ok(metadata) if metadata.file_type().is_symlink() => { + log::warn!("refusing to use symlinked FUSE path {:?}", path); + Err(CliprdrError::CliprdrInit) + } + Ok(_) => Ok(()), + Err(e) if allow_disconnected_mount && e.raw_os_error() == Some(libc::ENOTCONN) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => { + log::warn!("failed to inspect FUSE path {:?}: {:?}", path, e); + Err(CliprdrError::CliprdrInit) + } + } +} + +fn reject_symlink_path(path: &Path) -> Result<(), CliprdrError> { + reject_symlink_metadata_result(path, std::fs::symlink_metadata(path), false) +} + +fn ensure_mount_point_path_is_safe(mount_point: &Path) -> Result<(), CliprdrError> { + if let Some(parent) = mount_point.parent() { + reject_symlink_path(parent)?; + } + reject_symlink_metadata_result(mount_point, std::fs::symlink_metadata(mount_point), true) +} + +fn unmount_fuse_mount_point(mount_point: &Path) { + if ensure_mount_point_path_is_safe(mount_point).is_err() { + log::warn!( + "refusing to unmount unsafe clipboard FUSE mount point {:?}", + mount_point + ); + return; + } + if inspect_mount_point_state_with( + mount_point, + std::fs::metadata(mount_point), + std::fs::read_to_string("/proc/self/mountinfo"), + ) == MountPointState::NotMounted + { + return; + } + for (program, args) in unmount_command_candidates() { + if run_unmount_command(program, args, mount_point) { + return; + } + } + log::warn!( + "failed to unmount clipboard FUSE mount point {:?}", + mount_point + ); +} + +fn unmount_command_candidates() -> [(&'static str, &'static [&'static str]); 3] { + [ + ("fusermount3", &["-uz"]), + ("fusermount", &["-uz"]), + ("umount", &["-l"]), + ] +} + +fn run_unmount_command(program: &str, args: &[&str], mount_point: &Path) -> bool { + match std::process::Command::new(program) + .args(args) + .arg(mount_point) + .status() + { + Ok(status) if status.success() => {} + Ok(status) => { + log::debug!( + "{} {:?} exited with status {:?}", + program, + mount_point, + status.code() + ); + return false; + } + Err(e) => { + log::debug!("failed to run {} for {:?}: {:?}", program, mount_point, e); + return false; + } + } + true +} + +fn uninit_fuse_context_(is_client: bool) { + let ctx = { + let mut fuse_context_lock = if is_client { + FUSE_CONTEXT_CLIENT.lock() + } else { + FUSE_CONTEXT_SERVER.lock() + }; + fuse_context_lock.take() + }; + drop(ctx); } impl Drop for FuseContext { @@ -265,3 +491,66 @@ impl FuseContext { .collect()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, io}; + + #[cfg(target_family = "unix")] + use std::os::unix::fs::symlink; + + #[test] + fn classifies_mount_point_state_from_metadata_and_mountinfo() { + let mount_point = std::env::temp_dir().join(format!( + "rustdesk-fuse-mount-state-test-{}-{}", + std::process::id(), + line!() + )); + let mountinfo = format!( + "123 1 0:45 / {} rw,nosuid,nodev - fuse.rustdesk rustdesk rw\n", + mount_point.display() + ); + + assert_eq!( + inspect_mount_point_state_with(&mount_point, Ok(()), Ok(mountinfo)), + MountPointState::HealthyMount + ); + assert_eq!( + inspect_mount_point_state_with(&mount_point, Ok(()), Ok(String::new())), + MountPointState::NotMounted + ); + + let disconnected_metadata: io::Result<()> = + Err(io::Error::from_raw_os_error(libc::ENOTCONN)); + assert_eq!( + inspect_mount_point_state_with(&mount_point, disconnected_metadata, Ok(String::new())), + MountPointState::StaleMount + ); + } + + #[test] + #[cfg(target_family = "unix")] + fn rejects_symlink_mount_point() { + let base = std::env::temp_dir().join(format!( + "rustdesk-fuse-symlink-test-{}-{}", + std::process::id(), + line!() + )); + let mount_parent = base.join("parent"); + let mount_point = mount_parent.join("cliprdr-client"); + let symlink_target = base.join("symlink-target"); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&base).unwrap(); + fs::create_dir_all(&mount_parent).unwrap(); + fs::create_dir_all(&symlink_target).unwrap(); + symlink(&symlink_target, &mount_point).unwrap(); + + assert!(matches!( + prepare_fuse_mount_point(&mount_point), + Err(CliprdrError::CliprdrInit) + )); + + let _ = fs::remove_dir_all(&base); + } +} diff --git a/libs/clipboard/src/platform/unix/local_file.rs b/libs/clipboard/src/platform/unix/local_file.rs index 11d62cad8..50c67b68f 100644 --- a/libs/clipboard/src/platform/unix/local_file.rs +++ b/libs/clipboard/src/platform/unix/local_file.rs @@ -192,20 +192,16 @@ impl LocalFile { }); }; - if offset != self.offset.load(Ordering::Relaxed) { + let read_result = if offset != self.offset.load(Ordering::Relaxed) { handle .seek(std::io::SeekFrom::Start(offset)) - .map_err(|e| CliprdrError::FileError { - path: self.path.to_string_lossy().to_string(), - err: e, - })?; + .and_then(|_| handle.read_exact(buf)) + } else { + handle.read_exact(buf) + }; + if let Err(e) = read_result { + return Err(self.invalidate_handle(e)); } - handle - .read_exact(buf) - .map_err(|e| CliprdrError::FileError { - path: self.path.to_string_lossy().to_string(), - err: e, - })?; let new_offset = offset + (buf.len() as u64); self.offset.store(new_offset, Ordering::Relaxed); @@ -217,6 +213,15 @@ impl LocalFile { Ok(()) } + + fn invalidate_handle(&mut self, err: std::io::Error) -> CliprdrError { + self.offset.store(0, Ordering::Relaxed); + self.handle = None; + CliprdrError::FileError { + path: self.path.to_string_lossy().to_string(), + err, + } + } } pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result, CliprdrError> { @@ -278,7 +283,10 @@ pub(super) fn construct_file_list(paths: &[PathBuf]) -> Result, C #[cfg(test)] mod file_list_test { - use std::{path::PathBuf, sync::atomic::AtomicU64}; + use std::{ + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + }; use hbb_common::bytes::{BufMut, BytesMut}; @@ -384,4 +392,30 @@ mod file_list_test { as_bin_parse_test("/test")?; Ok(()) } + + #[test] + fn read_exact_at_reopens_after_read_failure() -> Result<(), Box> { + let file_path = std::env::temp_dir().join(format!( + "rustdesk-clipboard-local-file-{}", + std::process::id() + )); + std::fs::write(&file_path, b"")?; + + let mut file = LocalFile::try_open(&std::env::temp_dir(), &file_path)?; + file.size = 1; + + let mut buf = [0u8; 1]; + assert!(file.read_exact_at(&mut buf, 0).is_err()); + assert!(file.handle.is_none()); + assert_eq!(file.offset.load(Ordering::Relaxed), 0); + + std::fs::write(&file_path, [42u8])?; + + file.read_exact_at(&mut buf, 0)?; + assert_eq!(buf, [42u8]); + assert!(file.handle.is_none()); + + std::fs::remove_file(file_path)?; + Ok(()) + } } diff --git a/src/client.rs b/src/client.rs index 87792d408..680ed1bec 100644 --- a/src/client.rs +++ b/src/client.rs @@ -941,21 +941,23 @@ impl Client { #[cfg(not(target_os = "ios"))] fn try_stop_clipboard() { - // There's a bug here. - // If session is closed by the peer, `has_sessions_running()` will always return true. - // It's better to check if the active session number. - // But it's not a problem, because the clipboard thread does not consume CPU. - // - // If we want to fix it, we can add a flag to indicate if session is active. - // But I think it's not necessary to introduce complexity at this point. + // Disconnected Flutter sessions may keep UI handlers alive, so only connected sessions + // should block clipboard cleanup. #[cfg(feature = "flutter")] - if crate::flutter::sessions::has_sessions_running(ConnType::DEFAULT_CONN) { + if crate::flutter::sessions::has_connected_sessions_running(ConnType::DEFAULT_CONN) { return; } #[cfg(not(target_os = "android"))] clipboard_listener::unsubscribe(Self::CLIENT_CLIPBOARD_NAME); CLIPBOARD_STATE.lock().unwrap().running = false; #[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))] + if let Err(e) = crate::clipboard::try_empty_clipboard_files_sync( + crate::clipboard::ClipboardSide::Client, + 0, + ) { + log::error!("Failed to empty client clipboard files: {}", e); + } + #[cfg(all(feature = "unix-file-copy-paste", target_os = "linux"))] clipboard::platform::unix::fuse::uninit_fuse_context(true); } diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 012107f57..68cd69700 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -360,6 +360,8 @@ impl Remote { #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] if self.handler.is_default() && _set_disconnected_ok { + // Linux client cleanup runs synchronously in try_stop_clipboard() before FUSE is + // unmounted. Keep this async path for other file-clipboard platforms. crate::clipboard::try_empty_clipboard_files(ClipboardSide::Client, self.client_conn_id); } } diff --git a/src/clipboard.rs b/src/clipboard.rs index d2a8a6653..01dc0c9ed 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -151,41 +151,50 @@ pub fn update_clipboard_files(files: Vec, side: ClipboardSide) { #[cfg(feature = "unix-file-copy-paste")] pub fn try_empty_clipboard_files(_side: ClipboardSide, _conn_id: i32) { std::thread::spawn(move || { - let mut ctx = CLIPBOARD_CTX.lock().unwrap(); - if ctx.is_none() { - match ClipboardContext::new() { - Ok(x) => { - *ctx = Some(x); - } - Err(e) => { - log::error!("Failed to create clipboard context: {}", e); - return; - } - } - } - #[allow(unused_mut)] - if let Some(mut ctx) = ctx.as_mut() { - #[cfg(target_os = "linux")] - { - use clipboard::platform::unix; - if unix::fuse::empty_local_files(_side == ClipboardSide::Client, _conn_id) { - ctx.try_empty_clipboard_files(_side); - } - } - #[cfg(target_os = "macos")] - { - ctx.try_empty_clipboard_files(_side); - // No need to make sure the context is enabled. - clipboard::ContextSend::proc(|context| -> ResultType<()> { - context.empty_clipboard(_conn_id).ok(); - Ok(()) - }) - .ok(); - } + if let Err(e) = try_empty_clipboard_files_sync(_side, _conn_id) { + log::error!("Failed to empty clipboard files: {}", e); } }); } +#[cfg(feature = "unix-file-copy-paste")] +pub fn try_empty_clipboard_files_sync(_side: ClipboardSide, _conn_id: i32) -> ResultType<()> { + let mut ctx = CLIPBOARD_CTX.lock().unwrap(); + if ctx.is_none() { + match ClipboardContext::new() { + Ok(x) => { + *ctx = Some(x); + } + Err(e) => { + log::error!("Failed to create clipboard context: {}", e); + bail!("Failed to create clipboard context: {}", e); + } + } + } + #[allow(unused_mut)] + if let Some(mut ctx) = ctx.as_mut() { + #[cfg(target_os = "linux")] + { + use clipboard::platform::unix; + if unix::fuse::empty_local_files(_side == ClipboardSide::Client, _conn_id) { + ctx.try_empty_clipboard_files(_side); + } + } + #[cfg(target_os = "macos")] + { + ctx.try_empty_clipboard_files(_side); + // No need to make sure the context is enabled. + clipboard::ContextSend::proc(|context| -> ResultType<()> { + if !context.empty_clipboard(_conn_id)? { + bail!("Failed to empty clipboard files for conn_id {}", _conn_id); + } + Ok(()) + })?; + } + } + Ok(()) +} + #[cfg(target_os = "windows")] pub fn try_empty_clipboard_files(side: ClipboardSide, conn_id: i32) { log::debug!("try to empty {} cliprdr for conn_id {}", side, conn_id); diff --git a/src/clipboard_file.rs b/src/clipboard_file.rs index 724d8aea9..4fa64e26f 100644 --- a/src/clipboard_file.rs +++ b/src/clipboard_file.rs @@ -332,12 +332,16 @@ pub mod unix_file_clip { log::debug!("format data response: msg_flags: {}", msg_flags); if msg_flags != 0x1 { - // return failure message? + log::error!( + "peer reported clipboard format data failure: {}", + msg_flags + ); + return vec![]; } log::debug!("parsing file descriptors"); - if fuse::init_fuse_context(true).is_ok() { - match fuse::format_data_response_to_urls( + match fuse::init_fuse_context(side == ClipboardSide::Client) { + Ok(()) => match fuse::format_data_response_to_urls( side == ClipboardSide::Client, format_data, conn_id, @@ -348,9 +352,10 @@ pub mod unix_file_clip { Err(e) => { log::error!("failed to parse file descriptors: {:?}", e); } + }, + Err(e) => { + log::error!("failed to initialize clipboard FUSE context: {:?}", e); } - } else { - // send error message to server } } ClipboardFile::FileContentsRequest { @@ -386,6 +391,7 @@ pub mod unix_file_clip { ClipboardFile::FileContentsResponse { msg_flags, stream_id, + requested_data, .. } => { log::debug!( @@ -393,13 +399,15 @@ pub mod unix_file_clip { msg_flags, stream_id, ); - if fuse::init_fuse_context(true).is_ok() { - hbb_common::allow_err!(fuse::handle_file_content_response( - side == ClipboardSide::Client, - clip - )); - } else { - // send error message to server + let response = ClipboardFile::FileContentsResponse { + msg_flags, + stream_id, + requested_data, + }; + if let Err(e) = + fuse::handle_file_content_response(side == ClipboardSide::Client, response) + { + log::error!("failed to handle file contents response: {:?}", e); } } ClipboardFile::NotifyCallback { diff --git a/src/flutter.rs b/src/flutter.rs index f8b04bf6c..73f2dbde3 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -2297,6 +2297,16 @@ pub mod sessions { *r#type == conn_type && s.session_handlers.read().unwrap().len() != 0 }) } + + #[inline] + #[cfg(not(target_os = "ios"))] + pub fn has_connected_sessions_running(conn_type: ConnType) -> bool { + SESSIONS.read().unwrap().iter().any(|((_, r#type), s)| { + *r#type == conn_type + && s.session_handlers.read().unwrap().len() != 0 + && s.connection_round_state.lock().unwrap().is_connected() + }) + } } pub(super) mod async_tasks { diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 33d7611c1..1e35672ec 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -128,6 +128,10 @@ impl ConnectionRoundState { true } } + + pub fn is_connected(&self) -> bool { + matches!(self.state, ConnectionState::Connected) + } } impl Default for ConnectionRoundState {